lex-agentic-affect 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/Gemfile +5 -0
  4. data/LICENSE +21 -0
  5. data/README.md +13 -0
  6. data/lex-agentic-affect.gemspec +30 -0
  7. data/lib/legion/extensions/agentic/affect/appraisal/client.rb +20 -0
  8. data/lib/legion/extensions/agentic/affect/appraisal/helpers/appraisal.rb +112 -0
  9. data/lib/legion/extensions/agentic/affect/appraisal/helpers/appraisal_engine.rb +129 -0
  10. data/lib/legion/extensions/agentic/affect/appraisal/helpers/constants.rb +43 -0
  11. data/lib/legion/extensions/agentic/affect/appraisal/runners/appraisal.rb +105 -0
  12. data/lib/legion/extensions/agentic/affect/appraisal/version.rb +13 -0
  13. data/lib/legion/extensions/agentic/affect/appraisal.rb +19 -0
  14. data/lib/legion/extensions/agentic/affect/cognitive_empathy/client.rb +19 -0
  15. data/lib/legion/extensions/agentic/affect/cognitive_empathy/helpers/constants.rb +37 -0
  16. data/lib/legion/extensions/agentic/affect/cognitive_empathy/helpers/empathy_engine.rb +151 -0
  17. data/lib/legion/extensions/agentic/affect/cognitive_empathy/helpers/perspective.rb +92 -0
  18. data/lib/legion/extensions/agentic/affect/cognitive_empathy/runners/cognitive_empathy.rb +93 -0
  19. data/lib/legion/extensions/agentic/affect/cognitive_empathy/version.rb +13 -0
  20. data/lib/legion/extensions/agentic/affect/cognitive_empathy.rb +20 -0
  21. data/lib/legion/extensions/agentic/affect/contagion/client.rb +28 -0
  22. data/lib/legion/extensions/agentic/affect/contagion/helpers/constants.rb +34 -0
  23. data/lib/legion/extensions/agentic/affect/contagion/helpers/contagion_engine.rb +184 -0
  24. data/lib/legion/extensions/agentic/affect/contagion/helpers/meme.rb +97 -0
  25. data/lib/legion/extensions/agentic/affect/contagion/runners/cognitive_contagion.rb +125 -0
  26. data/lib/legion/extensions/agentic/affect/contagion/version.rb +13 -0
  27. data/lib/legion/extensions/agentic/affect/contagion.rb +19 -0
  28. data/lib/legion/extensions/agentic/affect/defusion/client.rb +28 -0
  29. data/lib/legion/extensions/agentic/affect/defusion/helpers/constants.rb +64 -0
  30. data/lib/legion/extensions/agentic/affect/defusion/helpers/defusion_engine.rb +167 -0
  31. data/lib/legion/extensions/agentic/affect/defusion/helpers/thought.rb +92 -0
  32. data/lib/legion/extensions/agentic/affect/defusion/runners/cognitive_defusion.rb +127 -0
  33. data/lib/legion/extensions/agentic/affect/defusion/version.rb +13 -0
  34. data/lib/legion/extensions/agentic/affect/defusion.rb +19 -0
  35. data/lib/legion/extensions/agentic/affect/emotion/actors/momentum_decay.rb +45 -0
  36. data/lib/legion/extensions/agentic/affect/emotion/client.rb +36 -0
  37. data/lib/legion/extensions/agentic/affect/emotion/helpers/baseline.rb +52 -0
  38. data/lib/legion/extensions/agentic/affect/emotion/helpers/momentum.rb +52 -0
  39. data/lib/legion/extensions/agentic/affect/emotion/helpers/valence.rb +92 -0
  40. data/lib/legion/extensions/agentic/affect/emotion/runners/gut.rb +102 -0
  41. data/lib/legion/extensions/agentic/affect/emotion/runners/valence.rb +120 -0
  42. data/lib/legion/extensions/agentic/affect/emotion/version.rb +13 -0
  43. data/lib/legion/extensions/agentic/affect/emotion.rb +20 -0
  44. data/lib/legion/extensions/agentic/affect/empathy/client.rb +21 -0
  45. data/lib/legion/extensions/agentic/affect/empathy/helpers/constants.rb +54 -0
  46. data/lib/legion/extensions/agentic/affect/empathy/helpers/mental_model.rb +185 -0
  47. data/lib/legion/extensions/agentic/affect/empathy/helpers/model_store.rb +88 -0
  48. data/lib/legion/extensions/agentic/affect/empathy/runners/empathy.rb +173 -0
  49. data/lib/legion/extensions/agentic/affect/empathy/version.rb +13 -0
  50. data/lib/legion/extensions/agentic/affect/empathy.rb +20 -0
  51. data/lib/legion/extensions/agentic/affect/fatigue/client.rb +26 -0
  52. data/lib/legion/extensions/agentic/affect/fatigue/helpers/constants.rb +54 -0
  53. data/lib/legion/extensions/agentic/affect/fatigue/helpers/energy_model.rb +181 -0
  54. data/lib/legion/extensions/agentic/affect/fatigue/helpers/fatigue_store.rb +146 -0
  55. data/lib/legion/extensions/agentic/affect/fatigue/runners/fatigue.rb +89 -0
  56. data/lib/legion/extensions/agentic/affect/fatigue/version.rb +13 -0
  57. data/lib/legion/extensions/agentic/affect/fatigue.rb +19 -0
  58. data/lib/legion/extensions/agentic/affect/flow/client.rb +25 -0
  59. data/lib/legion/extensions/agentic/affect/flow/helpers/constants.rb +84 -0
  60. data/lib/legion/extensions/agentic/affect/flow/helpers/flow_detector.rb +166 -0
  61. data/lib/legion/extensions/agentic/affect/flow/runners/flow.rb +129 -0
  62. data/lib/legion/extensions/agentic/affect/flow/version.rb +13 -0
  63. data/lib/legion/extensions/agentic/affect/flow.rb +18 -0
  64. data/lib/legion/extensions/agentic/affect/interoception/actors/decay.rb +45 -0
  65. data/lib/legion/extensions/agentic/affect/interoception/client.rb +28 -0
  66. data/lib/legion/extensions/agentic/affect/interoception/helpers/body_budget.rb +152 -0
  67. data/lib/legion/extensions/agentic/affect/interoception/helpers/constants.rb +68 -0
  68. data/lib/legion/extensions/agentic/affect/interoception/helpers/somatic_marker.rb +75 -0
  69. data/lib/legion/extensions/agentic/affect/interoception/runners/interoception.rb +101 -0
  70. data/lib/legion/extensions/agentic/affect/interoception/version.rb +13 -0
  71. data/lib/legion/extensions/agentic/affect/interoception.rb +20 -0
  72. data/lib/legion/extensions/agentic/affect/mood/client.rb +21 -0
  73. data/lib/legion/extensions/agentic/affect/mood/helpers/constants.rb +78 -0
  74. data/lib/legion/extensions/agentic/affect/mood/helpers/mood_state.rb +154 -0
  75. data/lib/legion/extensions/agentic/affect/mood/runners/mood.rb +122 -0
  76. data/lib/legion/extensions/agentic/affect/mood/version.rb +13 -0
  77. data/lib/legion/extensions/agentic/affect/mood.rb +18 -0
  78. data/lib/legion/extensions/agentic/affect/motivation/client.rb +26 -0
  79. data/lib/legion/extensions/agentic/affect/motivation/helpers/constants.rb +48 -0
  80. data/lib/legion/extensions/agentic/affect/motivation/helpers/drive_state.rb +98 -0
  81. data/lib/legion/extensions/agentic/affect/motivation/helpers/motivation_store.rb +106 -0
  82. data/lib/legion/extensions/agentic/affect/motivation/runners/motivation.rb +165 -0
  83. data/lib/legion/extensions/agentic/affect/motivation/version.rb +13 -0
  84. data/lib/legion/extensions/agentic/affect/motivation.rb +19 -0
  85. data/lib/legion/extensions/agentic/affect/reappraisal/actors/auto_regulate.rb +45 -0
  86. data/lib/legion/extensions/agentic/affect/reappraisal/client.rb +28 -0
  87. data/lib/legion/extensions/agentic/affect/reappraisal/helpers/constants.rb +82 -0
  88. data/lib/legion/extensions/agentic/affect/reappraisal/helpers/emotional_event.rb +98 -0
  89. data/lib/legion/extensions/agentic/affect/reappraisal/helpers/llm_enhancer.rb +88 -0
  90. data/lib/legion/extensions/agentic/affect/reappraisal/helpers/reappraisal_engine.rb +153 -0
  91. data/lib/legion/extensions/agentic/affect/reappraisal/runners/cognitive_reappraisal.rb +164 -0
  92. data/lib/legion/extensions/agentic/affect/reappraisal/version.rb +13 -0
  93. data/lib/legion/extensions/agentic/affect/reappraisal.rb +20 -0
  94. data/lib/legion/extensions/agentic/affect/regulation/client.rb +25 -0
  95. data/lib/legion/extensions/agentic/affect/regulation/helpers/constants.rb +71 -0
  96. data/lib/legion/extensions/agentic/affect/regulation/helpers/regulation_model.rb +175 -0
  97. data/lib/legion/extensions/agentic/affect/regulation/runners/emotional_regulation.rb +127 -0
  98. data/lib/legion/extensions/agentic/affect/regulation/version.rb +13 -0
  99. data/lib/legion/extensions/agentic/affect/regulation.rb +18 -0
  100. data/lib/legion/extensions/agentic/affect/resilience/client.rb +27 -0
  101. data/lib/legion/extensions/agentic/affect/resilience/helpers/adversity_tracker.rb +130 -0
  102. data/lib/legion/extensions/agentic/affect/resilience/helpers/constants.rb +79 -0
  103. data/lib/legion/extensions/agentic/affect/resilience/helpers/resilience_model.rb +165 -0
  104. data/lib/legion/extensions/agentic/affect/resilience/runners/resilience.rb +150 -0
  105. data/lib/legion/extensions/agentic/affect/resilience/version.rb +13 -0
  106. data/lib/legion/extensions/agentic/affect/resilience.rb +19 -0
  107. data/lib/legion/extensions/agentic/affect/resonance/client.rb +24 -0
  108. data/lib/legion/extensions/agentic/affect/resonance/helpers/category.rb +75 -0
  109. data/lib/legion/extensions/agentic/affect/resonance/helpers/constants.rb +47 -0
  110. data/lib/legion/extensions/agentic/affect/resonance/helpers/resonance_engine.rb +115 -0
  111. data/lib/legion/extensions/agentic/affect/resonance/runners/cognitive_resonance.rb +94 -0
  112. data/lib/legion/extensions/agentic/affect/resonance/version.rb +13 -0
  113. data/lib/legion/extensions/agentic/affect/resonance.rb +19 -0
  114. data/lib/legion/extensions/agentic/affect/reward/client.rb +26 -0
  115. data/lib/legion/extensions/agentic/affect/reward/helpers/constants.rb +67 -0
  116. data/lib/legion/extensions/agentic/affect/reward/helpers/reward_signal.rb +178 -0
  117. data/lib/legion/extensions/agentic/affect/reward/helpers/reward_store.rb +142 -0
  118. data/lib/legion/extensions/agentic/affect/reward/runners/reward.rb +92 -0
  119. data/lib/legion/extensions/agentic/affect/reward/version.rb +13 -0
  120. data/lib/legion/extensions/agentic/affect/reward.rb +19 -0
  121. data/lib/legion/extensions/agentic/affect/somatic_marker/actors/decay.rb +45 -0
  122. data/lib/legion/extensions/agentic/affect/somatic_marker/client.rb +29 -0
  123. data/lib/legion/extensions/agentic/affect/somatic_marker/helpers/body_state.rb +69 -0
  124. data/lib/legion/extensions/agentic/affect/somatic_marker/helpers/constants.rb +43 -0
  125. data/lib/legion/extensions/agentic/affect/somatic_marker/helpers/marker_store.rb +160 -0
  126. data/lib/legion/extensions/agentic/affect/somatic_marker/helpers/somatic_marker.rb +74 -0
  127. data/lib/legion/extensions/agentic/affect/somatic_marker/runners/somatic_marker.rb +132 -0
  128. data/lib/legion/extensions/agentic/affect/somatic_marker/version.rb +13 -0
  129. data/lib/legion/extensions/agentic/affect/somatic_marker.rb +20 -0
  130. data/lib/legion/extensions/agentic/affect/version.rb +11 -0
  131. data/lib/legion/extensions/agentic/affect.rb +34 -0
  132. data/spec/legion/extensions/agentic/affect/appraisal/client_spec.rb +52 -0
  133. data/spec/legion/extensions/agentic/affect/appraisal/helpers/appraisal_engine_spec.rb +161 -0
  134. data/spec/legion/extensions/agentic/affect/appraisal/helpers/appraisal_spec.rb +175 -0
  135. data/spec/legion/extensions/agentic/affect/appraisal/helpers/constants_spec.rb +49 -0
  136. data/spec/legion/extensions/agentic/affect/appraisal/runners/appraisal_spec.rb +116 -0
  137. data/spec/legion/extensions/agentic/affect/cognitive_empathy/client_spec.rb +62 -0
  138. data/spec/legion/extensions/agentic/affect/cognitive_empathy/helpers/empathy_engine_spec.rb +316 -0
  139. data/spec/legion/extensions/agentic/affect/cognitive_empathy/helpers/perspective_spec.rb +132 -0
  140. data/spec/legion/extensions/agentic/affect/cognitive_empathy/runners/cognitive_empathy_spec.rb +200 -0
  141. data/spec/legion/extensions/agentic/affect/contagion/client_spec.rb +63 -0
  142. data/spec/legion/extensions/agentic/affect/contagion/helpers/constants_spec.rb +86 -0
  143. data/spec/legion/extensions/agentic/affect/contagion/helpers/contagion_engine_spec.rb +241 -0
  144. data/spec/legion/extensions/agentic/affect/contagion/helpers/meme_spec.rb +160 -0
  145. data/spec/legion/extensions/agentic/affect/contagion/runners/cognitive_contagion_spec.rb +211 -0
  146. data/spec/legion/extensions/agentic/affect/defusion/client_spec.rb +80 -0
  147. data/spec/legion/extensions/agentic/affect/defusion/helpers/constants_spec.rb +84 -0
  148. data/spec/legion/extensions/agentic/affect/defusion/helpers/defusion_engine_spec.rb +250 -0
  149. data/spec/legion/extensions/agentic/affect/defusion/helpers/thought_spec.rb +178 -0
  150. data/spec/legion/extensions/agentic/affect/defusion/runners/cognitive_defusion_spec.rb +185 -0
  151. data/spec/legion/extensions/agentic/affect/emotion/actors/momentum_decay_spec.rb +46 -0
  152. data/spec/legion/extensions/agentic/affect/emotion/client_spec.rb +46 -0
  153. data/spec/legion/extensions/agentic/affect/emotion/helpers/baseline_spec.rb +48 -0
  154. data/spec/legion/extensions/agentic/affect/emotion/helpers/momentum_spec.rb +45 -0
  155. data/spec/legion/extensions/agentic/affect/emotion/helpers/valence_spec.rb +91 -0
  156. data/spec/legion/extensions/agentic/affect/emotion/runners/gut_spec.rb +73 -0
  157. data/spec/legion/extensions/agentic/affect/emotion/runners/valence_spec.rb +67 -0
  158. data/spec/legion/extensions/agentic/affect/empathy/client_spec.rb +20 -0
  159. data/spec/legion/extensions/agentic/affect/empathy/helpers/constants_spec.rb +23 -0
  160. data/spec/legion/extensions/agentic/affect/empathy/helpers/mental_model_spec.rb +150 -0
  161. data/spec/legion/extensions/agentic/affect/empathy/helpers/model_store_spec.rb +94 -0
  162. data/spec/legion/extensions/agentic/affect/empathy/runners/empathy_spec.rb +127 -0
  163. data/spec/legion/extensions/agentic/affect/fatigue/client_spec.rb +66 -0
  164. data/spec/legion/extensions/agentic/affect/fatigue/helpers/constants_spec.rb +130 -0
  165. data/spec/legion/extensions/agentic/affect/fatigue/helpers/energy_model_spec.rb +281 -0
  166. data/spec/legion/extensions/agentic/affect/fatigue/helpers/fatigue_store_spec.rb +157 -0
  167. data/spec/legion/extensions/agentic/affect/fatigue/runners/fatigue_spec.rb +127 -0
  168. data/spec/legion/extensions/agentic/affect/flow/client_spec.rb +58 -0
  169. data/spec/legion/extensions/agentic/affect/flow/helpers/constants_spec.rb +112 -0
  170. data/spec/legion/extensions/agentic/affect/flow/helpers/flow_detector_spec.rb +268 -0
  171. data/spec/legion/extensions/agentic/affect/flow/runners/flow_spec.rb +222 -0
  172. data/spec/legion/extensions/agentic/affect/interoception/client_spec.rb +52 -0
  173. data/spec/legion/extensions/agentic/affect/interoception/helpers/body_budget_spec.rb +178 -0
  174. data/spec/legion/extensions/agentic/affect/interoception/helpers/somatic_marker_spec.rb +120 -0
  175. data/spec/legion/extensions/agentic/affect/interoception/runners/interoception_spec.rb +108 -0
  176. data/spec/legion/extensions/agentic/affect/mood/client_spec.rb +20 -0
  177. data/spec/legion/extensions/agentic/affect/mood/helpers/constants_spec.rb +29 -0
  178. data/spec/legion/extensions/agentic/affect/mood/helpers/mood_state_spec.rb +94 -0
  179. data/spec/legion/extensions/agentic/affect/mood/runners/mood_spec.rb +71 -0
  180. data/spec/legion/extensions/agentic/affect/motivation/client_spec.rb +35 -0
  181. data/spec/legion/extensions/agentic/affect/motivation/helpers/constants_spec.rb +111 -0
  182. data/spec/legion/extensions/agentic/affect/motivation/helpers/drive_state_spec.rb +183 -0
  183. data/spec/legion/extensions/agentic/affect/motivation/helpers/motivation_store_spec.rb +185 -0
  184. data/spec/legion/extensions/agentic/affect/motivation/runners/motivation_spec.rb +248 -0
  185. data/spec/legion/extensions/agentic/affect/reappraisal/actors/auto_regulate_spec.rb +46 -0
  186. data/spec/legion/extensions/agentic/affect/reappraisal/client_spec.rb +64 -0
  187. data/spec/legion/extensions/agentic/affect/reappraisal/helpers/constants_spec.rb +102 -0
  188. data/spec/legion/extensions/agentic/affect/reappraisal/helpers/emotional_event_spec.rb +177 -0
  189. data/spec/legion/extensions/agentic/affect/reappraisal/helpers/llm_enhancer_spec.rb +161 -0
  190. data/spec/legion/extensions/agentic/affect/reappraisal/helpers/reappraisal_engine_spec.rb +211 -0
  191. data/spec/legion/extensions/agentic/affect/reappraisal/runners/cognitive_reappraisal_spec.rb +312 -0
  192. data/spec/legion/extensions/agentic/affect/regulation/client_spec.rb +61 -0
  193. data/spec/legion/extensions/agentic/affect/regulation/helpers/constants_spec.rb +108 -0
  194. data/spec/legion/extensions/agentic/affect/regulation/helpers/regulation_model_spec.rb +200 -0
  195. data/spec/legion/extensions/agentic/affect/regulation/runners/emotional_regulation_spec.rb +190 -0
  196. data/spec/legion/extensions/agentic/affect/resilience/client_spec.rb +36 -0
  197. data/spec/legion/extensions/agentic/affect/resilience/helpers/adversity_tracker_spec.rb +164 -0
  198. data/spec/legion/extensions/agentic/affect/resilience/helpers/constants_spec.rb +78 -0
  199. data/spec/legion/extensions/agentic/affect/resilience/helpers/resilience_model_spec.rb +133 -0
  200. data/spec/legion/extensions/agentic/affect/resilience/runners/resilience_spec.rb +150 -0
  201. data/spec/legion/extensions/agentic/affect/resonance/client_spec.rb +66 -0
  202. data/spec/legion/extensions/agentic/affect/resonance/cognitive_resonance_spec.rb +27 -0
  203. data/spec/legion/extensions/agentic/affect/resonance/helpers/category_spec.rb +146 -0
  204. data/spec/legion/extensions/agentic/affect/resonance/helpers/constants_spec.rb +104 -0
  205. data/spec/legion/extensions/agentic/affect/resonance/helpers/resonance_engine_spec.rb +189 -0
  206. data/spec/legion/extensions/agentic/affect/resonance/runners/cognitive_resonance_spec.rb +197 -0
  207. data/spec/legion/extensions/agentic/affect/reward/client_spec.rb +42 -0
  208. data/spec/legion/extensions/agentic/affect/reward/helpers/constants_spec.rb +91 -0
  209. data/spec/legion/extensions/agentic/affect/reward/helpers/reward_signal_spec.rb +296 -0
  210. data/spec/legion/extensions/agentic/affect/reward/helpers/reward_store_spec.rb +167 -0
  211. data/spec/legion/extensions/agentic/affect/reward/runners/reward_spec.rb +149 -0
  212. data/spec/legion/extensions/agentic/affect/somatic_marker/client_spec.rb +83 -0
  213. data/spec/legion/extensions/agentic/affect/somatic_marker/helpers/body_state_spec.rb +155 -0
  214. data/spec/legion/extensions/agentic/affect/somatic_marker/helpers/marker_store_spec.rb +233 -0
  215. data/spec/legion/extensions/agentic/affect/somatic_marker/helpers/somatic_marker_spec.rb +172 -0
  216. data/spec/legion/extensions/agentic/affect/somatic_marker/runners/somatic_marker_spec.rb +181 -0
  217. data/spec/spec_helper.rb +46 -0
  218. metadata +302 -0
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/agentic/affect/somatic_marker/client'
4
+
5
+ RSpec.describe Legion::Extensions::Agentic::Affect::SomaticMarker::Client do
6
+ let(:client) { described_class.new }
7
+
8
+ it 'responds to all runner methods' do
9
+ expect(client).to respond_to(:register_marker)
10
+ expect(client).to respond_to(:evaluate_option)
11
+ expect(client).to respond_to(:make_decision)
12
+ expect(client).to respond_to(:reinforce)
13
+ expect(client).to respond_to(:update_body)
14
+ expect(client).to respond_to(:body_state)
15
+ expect(client).to respond_to(:markers_for_action)
16
+ expect(client).to respond_to(:recent_decisions)
17
+ expect(client).to respond_to(:update_somatic_markers)
18
+ expect(client).to respond_to(:somatic_marker_stats)
19
+ end
20
+
21
+ it 'maintains isolated state per instance' do
22
+ client_a = described_class.new
23
+ client_b = described_class.new
24
+
25
+ client_a.register_marker(action: :deploy, domain: :ops, valence: 0.9)
26
+ result_b = client_b.evaluate_option(action: :deploy, domain: :ops)
27
+
28
+ expect(client_a.somatic_marker_stats[:marker_count]).to eq(1)
29
+ expect(client_b.somatic_marker_stats[:marker_count]).to eq(0)
30
+ expect(result_b[:signal]).to eq(:neutral)
31
+ end
32
+
33
+ it 'runs a full decision cycle' do
34
+ # Register markers from past experience
35
+ client.register_marker(action: :merge, domain: :git, valence: 0.7, source: :experience)
36
+ client.register_marker(action: :revert, domain: :git, valence: -0.6, source: :experience)
37
+ client.register_marker(action: :hotfix, domain: :git, valence: 0.3, source: :inference)
38
+
39
+ # Make a decision
40
+ result = client.make_decision(options: %i[merge revert hotfix], domain: :git)
41
+ expect(result[:success]).to be true
42
+ expect(result[:decision][:ranked].first[:action]).to eq(:merge)
43
+
44
+ # Reinforce with negative outcome (merge caused issues)
45
+ marker_id = client.markers_for_action(action: :merge, domain: :git)[:markers].first[:id]
46
+ client.reinforce(marker_id: marker_id, outcome_valence: -0.8)
47
+
48
+ # After reinforcement, evaluate again
49
+ eval_result = client.evaluate_option(action: :merge, domain: :git)
50
+ # Valence should have moved toward negative
51
+ expect(eval_result[:valence]).to be < 0.7
52
+ end
53
+
54
+ it 'body state affects decision context' do
55
+ client.register_marker(action: :take_risk, domain: :strategy, valence: 0.1)
56
+
57
+ # Under stress, body state should be surfaced
58
+ client.update_body(tension: 0.9, comfort: 0.1)
59
+ result = client.make_decision(options: %i[take_risk play_safe], domain: :strategy)
60
+ expect(result[:decision][:body_contribution][:stressed]).to be true
61
+ end
62
+
63
+ it 'decay removes faded markers over time' do
64
+ client.register_marker(action: :old_action, domain: :history, valence: 0.0)
65
+
66
+ # Run enough decay cycles to fade the marker
67
+ 60.times { client.update_somatic_markers }
68
+
69
+ stats = client.somatic_marker_stats
70
+ expect(stats[:marker_count]).to eq(0)
71
+ end
72
+
73
+ it 'tracks multiple domains independently' do
74
+ client.register_marker(action: :approve, domain: :finance, valence: 0.8)
75
+ client.register_marker(action: :approve, domain: :security, valence: -0.7)
76
+
77
+ finance_result = client.evaluate_option(action: :approve, domain: :finance)
78
+ security_result = client.evaluate_option(action: :approve, domain: :security)
79
+
80
+ expect(finance_result[:signal]).to eq(:approach)
81
+ expect(security_result[:signal]).to eq(:avoid)
82
+ end
83
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Affect::SomaticMarker::Helpers::BodyState do
4
+ subject(:state) { described_class.new }
5
+
6
+ describe '#initialize' do
7
+ it 'sets neutral defaults' do
8
+ expect(state.arousal).to eq(0.5)
9
+ expect(state.tension).to eq(0.5)
10
+ expect(state.comfort).to eq(0.5)
11
+ expect(state.gut_signal).to eq(0.0)
12
+ end
13
+
14
+ it 'accepts custom values' do
15
+ s = described_class.new(arousal: 0.8, tension: 0.2, comfort: 0.9, gut_signal: 0.4)
16
+ expect(s.arousal).to eq(0.8)
17
+ expect(s.tension).to eq(0.2)
18
+ expect(s.comfort).to eq(0.9)
19
+ expect(s.gut_signal).to eq(0.4)
20
+ end
21
+
22
+ it 'clamps values to valid ranges' do
23
+ s = described_class.new(arousal: 2.0, tension: -1.0, comfort: 1.5, gut_signal: -2.0)
24
+ expect(s.arousal).to eq(1.0)
25
+ expect(s.tension).to eq(0.0)
26
+ expect(s.comfort).to eq(1.0)
27
+ expect(s.gut_signal).to eq(-1.0)
28
+ end
29
+ end
30
+
31
+ describe '#composite_valence' do
32
+ it 'returns a value in reasonable range for neutral state' do
33
+ # neutral: comfort=0.5, tension=0.5, gut=0.0
34
+ # => (0.5*0.4) + ((1-0.5)*0.3) + (0.0*0.3) = 0.2 + 0.15 + 0.0 = 0.35
35
+ val = state.composite_valence
36
+ expect(val).to be_within(0.001).of(0.35)
37
+ end
38
+
39
+ it 'returns higher value for comfortable low-tension state' do
40
+ high = described_class.new(comfort: 1.0, tension: 0.0, gut_signal: 1.0)
41
+ low = described_class.new(comfort: 0.0, tension: 1.0, gut_signal: -1.0)
42
+ expect(high.composite_valence).to be > low.composite_valence
43
+ end
44
+
45
+ it 'weights comfort at 0.4' do
46
+ s = described_class.new(comfort: 1.0, tension: 0.5, gut_signal: 0.0)
47
+ neutral = described_class.new(comfort: 0.0, tension: 0.5, gut_signal: 0.0)
48
+ diff = s.composite_valence - neutral.composite_valence
49
+ expect(diff).to be_within(0.001).of(0.4)
50
+ end
51
+
52
+ it 'weights tension at 0.3 (inverted)' do
53
+ low_tension = described_class.new(comfort: 0.5, tension: 0.0, gut_signal: 0.0)
54
+ high_tension = described_class.new(comfort: 0.5, tension: 1.0, gut_signal: 0.0)
55
+ diff = low_tension.composite_valence - high_tension.composite_valence
56
+ expect(diff).to be_within(0.001).of(0.3)
57
+ end
58
+
59
+ it 'weights gut_signal at 0.3' do
60
+ pos = described_class.new(comfort: 0.5, tension: 0.5, gut_signal: 1.0)
61
+ neg = described_class.new(comfort: 0.5, tension: 0.5, gut_signal: -1.0)
62
+ diff = pos.composite_valence - neg.composite_valence
63
+ expect(diff).to be_within(0.001).of(0.6)
64
+ end
65
+ end
66
+
67
+ describe '#update' do
68
+ it 'updates individual fields' do
69
+ state.update(arousal: 0.9)
70
+ expect(state.arousal).to eq(0.9)
71
+ expect(state.tension).to eq(0.5)
72
+ end
73
+
74
+ it 'clamps updated values' do
75
+ state.update(tension: 1.5)
76
+ expect(state.tension).to eq(1.0)
77
+ end
78
+
79
+ it 'ignores nil fields' do
80
+ state.update(comfort: nil)
81
+ expect(state.comfort).to eq(0.5)
82
+ end
83
+ end
84
+
85
+ describe '#decay' do
86
+ it 'drifts arousal toward 0.5' do
87
+ high = described_class.new(arousal: 0.9)
88
+ high.decay
89
+ expect(high.arousal).to be < 0.9
90
+ expect(high.arousal).to be >= 0.5
91
+ end
92
+
93
+ it 'drifts tension toward 0.5' do
94
+ low = described_class.new(tension: 0.1)
95
+ low.decay
96
+ expect(low.tension).to be > 0.1
97
+ expect(low.tension).to be <= 0.5
98
+ end
99
+
100
+ it 'drifts gut_signal toward 0.0' do
101
+ pos = described_class.new(gut_signal: 0.8)
102
+ pos.decay
103
+ expect(pos.gut_signal).to be < 0.8
104
+ expect(pos.gut_signal).to be >= 0.0
105
+ end
106
+
107
+ it 'decays negative gut_signal toward 0.0' do
108
+ neg = described_class.new(gut_signal: -0.8)
109
+ neg.decay
110
+ expect(neg.gut_signal).to be > -0.8
111
+ expect(neg.gut_signal).to be <= 0.0
112
+ end
113
+
114
+ it 'does not overshoot neutral targets' do
115
+ high_arousal = described_class.new(arousal: 0.53)
116
+ high_arousal.decay
117
+ expect(high_arousal.arousal).to be >= 0.5
118
+ end
119
+ end
120
+
121
+ describe '#stressed?' do
122
+ it 'returns true when tension > 0.7 and comfort < 0.3' do
123
+ stressed = described_class.new(tension: 0.8, comfort: 0.2)
124
+ expect(stressed.stressed?).to be true
125
+ end
126
+
127
+ it 'returns false when tension is moderate' do
128
+ relaxed = described_class.new(tension: 0.5, comfort: 0.2)
129
+ expect(relaxed.stressed?).to be false
130
+ end
131
+
132
+ it 'returns false when comfort is moderate' do
133
+ not_stressed = described_class.new(tension: 0.8, comfort: 0.5)
134
+ expect(not_stressed.stressed?).to be false
135
+ end
136
+
137
+ it 'returns false for default state' do
138
+ expect(state.stressed?).to be false
139
+ end
140
+ end
141
+
142
+ describe '#to_h' do
143
+ it 'includes all state keys' do
144
+ h = state.to_h
145
+ expect(h).to include(:arousal, :tension, :comfort, :gut_signal, :composite_valence, :stressed)
146
+ end
147
+
148
+ it 'reflects current values' do
149
+ s = described_class.new(arousal: 0.7, tension: 0.8, comfort: 0.2, gut_signal: -0.5)
150
+ h = s.to_h
151
+ expect(h[:arousal]).to eq(0.7)
152
+ expect(h[:stressed]).to be true
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Affect::SomaticMarker::Helpers::MarkerStore do
4
+ subject(:store) { described_class.new }
5
+
6
+ describe '#register_marker' do
7
+ it 'creates a new marker' do
8
+ marker = store.register_marker(action: :deploy, domain: :ops, valence: 0.7)
9
+ expect(marker).to be_a(Legion::Extensions::Agentic::Affect::SomaticMarker::Helpers::SomaticMarker)
10
+ expect(marker.action).to eq(:deploy)
11
+ expect(marker.domain).to eq(:ops)
12
+ expect(marker.valence).to be_within(0.001).of(0.7)
13
+ end
14
+
15
+ it 'stores the marker in the store' do
16
+ store.register_marker(action: :deploy, domain: :ops, valence: 0.5)
17
+ expect(store.markers.size).to eq(1)
18
+ end
19
+
20
+ it 'assigns sequential ids' do
21
+ m1 = store.register_marker(action: :first, domain: :d, valence: 0.1)
22
+ m2 = store.register_marker(action: :second, domain: :d, valence: 0.2)
23
+ expect(m1.id).to eq('sm_1')
24
+ expect(m2.id).to eq('sm_2')
25
+ end
26
+
27
+ it 'accepts custom source' do
28
+ marker = store.register_marker(action: :deploy, domain: :ops, valence: 0.5, source: :instruction)
29
+ expect(marker.source).to eq(:instruction)
30
+ end
31
+
32
+ it 'evicts weakest marker when at MAX_MARKERS capacity' do
33
+ max = Legion::Extensions::Agentic::Affect::SomaticMarker::Helpers::Constants::MAX_MARKERS
34
+
35
+ # Fill store with mid-strength markers
36
+ max.times do |i|
37
+ store.register_marker(action: :"action_#{i}", domain: :d, valence: 0.0)
38
+ end
39
+
40
+ # Manually weaken one
41
+ weakest = store.markers.values.first
42
+ 50.times { weakest.decay }
43
+
44
+ initial_count = store.markers.size
45
+ store.register_marker(action: :overflow, domain: :d, valence: 0.5)
46
+ expect(store.markers.size).to eq(initial_count)
47
+ end
48
+ end
49
+
50
+ describe '#evaluate_option' do
51
+ it 'returns neutral signal with no markers' do
52
+ result = store.evaluate_option(action: :deploy, domain: :ops)
53
+ expect(result[:signal]).to eq(:neutral)
54
+ expect(result[:marker_count]).to eq(0)
55
+ end
56
+
57
+ it 'returns approach signal for positive markers' do
58
+ store.register_marker(action: :deploy, domain: :ops, valence: 0.9)
59
+ result = store.evaluate_option(action: :deploy, domain: :ops)
60
+ expect(result[:signal]).to eq(:approach)
61
+ end
62
+
63
+ it 'returns avoid signal for negative markers' do
64
+ store.register_marker(action: :deploy, domain: :ops, valence: -0.9)
65
+ result = store.evaluate_option(action: :deploy, domain: :ops)
66
+ expect(result[:signal]).to eq(:avoid)
67
+ end
68
+
69
+ it 'weighs markers by strength' do
70
+ store.register_marker(action: :deploy, domain: :ops, valence: 0.9)
71
+ # Weaken the positive marker heavily
72
+ marker = store.markers.values.first
73
+ 40.times { marker.decay }
74
+
75
+ store.register_marker(action: :deploy, domain: :ops, valence: -0.9)
76
+
77
+ result = store.evaluate_option(action: :deploy, domain: :ops)
78
+ # Strong negative should outweigh weak positive
79
+ expect(result[:valence]).to be < 0
80
+ end
81
+
82
+ it 'only considers markers matching action and domain' do
83
+ store.register_marker(action: :deploy, domain: :ops, valence: 0.9)
84
+ store.register_marker(action: :rollback, domain: :ops, valence: -0.9)
85
+ store.register_marker(action: :deploy, domain: :dev, valence: -0.9)
86
+
87
+ result = store.evaluate_option(action: :deploy, domain: :ops)
88
+ expect(result[:signal]).to eq(:approach)
89
+ expect(result[:marker_count]).to eq(1)
90
+ end
91
+ end
92
+
93
+ describe '#decide' do
94
+ it 'returns ranked options' do
95
+ store.register_marker(action: :approve, domain: :risk, valence: 0.8)
96
+ store.register_marker(action: :reject, domain: :risk, valence: -0.8)
97
+
98
+ result = store.decide(options: %i[approve reject], domain: :risk)
99
+ expect(result[:ranked].first[:action]).to eq(:approve)
100
+ expect(result[:ranked].last[:action]).to eq(:reject)
101
+ end
102
+
103
+ it 'records decision in history' do
104
+ store.decide(options: %i[go stop], domain: :ops)
105
+ expect(store.decision_history.size).to eq(1)
106
+ end
107
+
108
+ it 'caps options at MAX_OPTIONS_PER_DECISION' do
109
+ max = Legion::Extensions::Agentic::Affect::SomaticMarker::Helpers::Constants::MAX_OPTIONS_PER_DECISION
110
+ options = (max + 5).times.map { |i| :"option_#{i}" }
111
+ result = store.decide(options: options, domain: :ops)
112
+ expect(result[:ranked].size).to eq(max)
113
+ end
114
+
115
+ it 'includes body_contribution in result' do
116
+ result = store.decide(options: %i[act wait], domain: :ops)
117
+ expect(result).to have_key(:body_contribution)
118
+ end
119
+
120
+ it 'caps decision history at MAX_DECISION_HISTORY' do
121
+ max = Legion::Extensions::Agentic::Affect::SomaticMarker::Helpers::Constants::MAX_DECISION_HISTORY
122
+ (max + 5).times { store.decide(options: %i[go stop], domain: :ops) }
123
+ expect(store.decision_history(limit: max + 10).size).to eq(max)
124
+ end
125
+ end
126
+
127
+ describe '#reinforce_marker' do
128
+ it 'reinforces an existing marker' do
129
+ m = store.register_marker(action: :deploy, domain: :ops, valence: 0.0)
130
+ result = store.reinforce_marker(marker_id: m.id, outcome_valence: 1.0)
131
+ expect(result).to eq(m)
132
+ expect(m.valence).to be > 0.0
133
+ end
134
+
135
+ it 'returns nil for unknown marker id' do
136
+ result = store.reinforce_marker(marker_id: 'nonexistent', outcome_valence: 0.5)
137
+ expect(result).to be_nil
138
+ end
139
+ end
140
+
141
+ describe '#update_body_state' do
142
+ it 'updates the body state' do
143
+ store.update_body_state(tension: 0.9, comfort: 0.1)
144
+ expect(store.body_state.tension).to eq(0.9)
145
+ expect(store.body_state.comfort).to eq(0.1)
146
+ end
147
+
148
+ it 'returns the updated body state' do
149
+ result = store.update_body_state(gut_signal: 0.5)
150
+ expect(result).to be_a(Legion::Extensions::Agentic::Affect::SomaticMarker::Helpers::BodyState)
151
+ end
152
+ end
153
+
154
+ describe '#markers_for' do
155
+ it 'returns markers matching action and domain' do
156
+ store.register_marker(action: :send, domain: :email, valence: 0.3)
157
+ store.register_marker(action: :send, domain: :email, valence: 0.7)
158
+ store.register_marker(action: :receive, domain: :email, valence: 0.5)
159
+
160
+ found = store.markers_for(action: :send, domain: :email)
161
+ expect(found.size).to eq(2)
162
+ found.each { |m| expect(m.action).to eq(:send) }
163
+ end
164
+
165
+ it 'returns empty array when no matching markers' do
166
+ expect(store.markers_for(action: :unknown, domain: :unknown)).to be_empty
167
+ end
168
+ end
169
+
170
+ describe '#body_influence' do
171
+ it 'returns composite valence and stressed flag' do
172
+ result = store.body_influence
173
+ expect(result).to have_key(:composite_valence)
174
+ expect(result).to have_key(:stressed)
175
+ end
176
+ end
177
+
178
+ describe '#decay_all' do
179
+ it 'decays all markers' do
180
+ store.register_marker(action: :deploy, domain: :ops, valence: 0.5)
181
+ before_strength = store.markers.values.first.strength
182
+ store.decay_all
183
+ expect(store.markers.values.first.strength).to be < before_strength
184
+ end
185
+
186
+ it 'removes faded markers' do
187
+ m = store.register_marker(action: :deploy, domain: :ops, valence: 0.0)
188
+ # Force marker to nearly faded state
189
+ 50.times { m.decay }
190
+ store.decay_all
191
+ expect(store.markers).not_to have_key(m.id)
192
+ end
193
+
194
+ it 'returns decay stats' do
195
+ store.register_marker(action: :deploy, domain: :ops, valence: 0.5)
196
+ result = store.decay_all
197
+ expect(result).to have_key(:markers_decayed)
198
+ expect(result).to have_key(:markers_removed)
199
+ end
200
+
201
+ it 'decays body state' do
202
+ store.update_body_state(arousal: 0.9)
203
+ before = store.body_state.arousal
204
+ store.decay_all
205
+ expect(store.body_state.arousal).to be < before
206
+ end
207
+ end
208
+
209
+ describe '#decision_history' do
210
+ it 'returns up to limit recent decisions' do
211
+ 5.times { store.decide(options: %i[act wait], domain: :ops) }
212
+ expect(store.decision_history(limit: 3).size).to eq(3)
213
+ end
214
+
215
+ it 'returns all decisions when under limit' do
216
+ 2.times { store.decide(options: %i[act wait], domain: :ops) }
217
+ expect(store.decision_history(limit: 10).size).to eq(2)
218
+ end
219
+ end
220
+
221
+ describe '#to_h' do
222
+ it 'returns summary stats' do
223
+ store.register_marker(action: :deploy, domain: :ops, valence: 0.5)
224
+ store.decide(options: %i[go stop], domain: :ops)
225
+
226
+ h = store.to_h
227
+ expect(h[:marker_count]).to eq(1)
228
+ expect(h[:decision_count]).to eq(1)
229
+ expect(h).to have_key(:body_state)
230
+ expect(h).to have_key(:stressed)
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Affect::SomaticMarker::Helpers::SomaticMarker do
4
+ subject(:marker) do
5
+ described_class.new(id: 'sm_1', action: :deploy, domain: :ops, valence: 0.5)
6
+ end
7
+
8
+ describe '#initialize' do
9
+ it 'stores all attributes' do
10
+ expect(marker.id).to eq('sm_1')
11
+ expect(marker.action).to eq(:deploy)
12
+ expect(marker.domain).to eq(:ops)
13
+ expect(marker.strength).to eq(0.5)
14
+ expect(marker.source).to eq(:experience)
15
+ end
16
+
17
+ it 'clamps valence to [-1, 1]' do
18
+ high = described_class.new(id: 'sm_2', action: :act, domain: :d, valence: 2.0)
19
+ low = described_class.new(id: 'sm_3', action: :act, domain: :d, valence: -2.0)
20
+ expect(high.valence).to eq(1.0)
21
+ expect(low.valence).to eq(-1.0)
22
+ end
23
+
24
+ it 'clamps strength to [0, 1]' do
25
+ over = described_class.new(id: 'sm_4', action: :act, domain: :d, valence: 0.0, strength: 1.5)
26
+ under = described_class.new(id: 'sm_5', action: :act, domain: :d, valence: 0.0, strength: -0.5)
27
+ expect(over.strength).to eq(1.0)
28
+ expect(under.strength).to eq(0.0)
29
+ end
30
+
31
+ it 'sets created_at' do
32
+ expect(marker.created_at).to be_a(Time)
33
+ end
34
+ end
35
+
36
+ describe '#signal' do
37
+ it 'returns :approach when valence > POSITIVE_BIAS' do
38
+ m = described_class.new(id: 'sm_6', action: :act, domain: :d, valence: 0.8)
39
+ expect(m.signal).to eq(:approach)
40
+ end
41
+
42
+ it 'returns :avoid when valence < NEGATIVE_BIAS' do
43
+ m = described_class.new(id: 'sm_7', action: :act, domain: :d, valence: -0.8)
44
+ expect(m.signal).to eq(:avoid)
45
+ end
46
+
47
+ it 'returns :neutral for mid-range valence' do
48
+ m = described_class.new(id: 'sm_8', action: :act, domain: :d, valence: 0.0)
49
+ expect(m.signal).to eq(:neutral)
50
+ end
51
+
52
+ it 'returns :neutral at exactly POSITIVE_BIAS boundary' do
53
+ m = described_class.new(id: 'sm_9', action: :act, domain: :d, valence: 0.6)
54
+ expect(m.signal).to eq(:neutral)
55
+ end
56
+
57
+ it 'returns :neutral at exactly NEGATIVE_BIAS boundary' do
58
+ m = described_class.new(id: 'sm_10', action: :act, domain: :d, valence: -0.6)
59
+ expect(m.signal).to eq(:neutral)
60
+ end
61
+ end
62
+
63
+ describe '#reinforce' do
64
+ it 'moves valence toward positive outcome' do
65
+ m = described_class.new(id: 'sm_11', action: :act, domain: :d, valence: 0.0)
66
+ 25.times { m.reinforce(outcome_valence: 1.0) }
67
+ expect(m.valence).to be > 0.5
68
+ end
69
+
70
+ it 'moves valence toward negative outcome' do
71
+ m = described_class.new(id: 'sm_12', action: :act, domain: :d, valence: 0.0)
72
+ 25.times { m.reinforce(outcome_valence: -1.0) }
73
+ expect(m.valence).to be < -0.5
74
+ end
75
+
76
+ it 'boosts strength on reinforce' do
77
+ m = described_class.new(id: 'sm_13', action: :act, domain: :d, valence: 0.0, strength: 0.3)
78
+ m.reinforce(outcome_valence: 0.5)
79
+ expect(m.strength).to be > 0.3
80
+ end
81
+
82
+ it 'clamps valence at 1.0' do
83
+ m = described_class.new(id: 'sm_14', action: :act, domain: :d, valence: 0.99)
84
+ 50.times { m.reinforce(outcome_valence: 1.0) }
85
+ expect(m.valence).to be <= 1.0
86
+ end
87
+
88
+ it 'clamps valence at -1.0' do
89
+ m = described_class.new(id: 'sm_15', action: :act, domain: :d, valence: -0.99)
90
+ 50.times { m.reinforce(outcome_valence: -1.0) }
91
+ expect(m.valence).to be >= -1.0
92
+ end
93
+
94
+ it 'clamps strength at 1.0' do
95
+ m = described_class.new(id: 'sm_16', action: :act, domain: :d, valence: 0.0, strength: 0.95)
96
+ m.reinforce(outcome_valence: 0.5)
97
+ expect(m.strength).to be <= 1.0
98
+ end
99
+ end
100
+
101
+ describe '#decay' do
102
+ it 'reduces strength by MARKER_DECAY' do
103
+ m = described_class.new(id: 'sm_17', action: :act, domain: :d, valence: 0.0, strength: 0.5)
104
+ before = m.strength
105
+ m.decay
106
+ expect(m.strength).to be_within(0.001).of(before - Legion::Extensions::Agentic::Affect::SomaticMarker::Helpers::Constants::MARKER_DECAY)
107
+ end
108
+
109
+ it 'floors strength at 0.0' do
110
+ m = described_class.new(id: 'sm_18', action: :act, domain: :d, valence: 0.0, strength: 0.005)
111
+ m.decay
112
+ expect(m.strength).to eq(0.0)
113
+ end
114
+ end
115
+
116
+ describe '#faded?' do
117
+ it 'returns false when strength is above floor' do
118
+ m = described_class.new(id: 'sm_19', action: :act, domain: :d, valence: 0.0, strength: 0.5)
119
+ expect(m.faded?).to be false
120
+ end
121
+
122
+ it 'returns true when strength is at or below floor' do
123
+ m = described_class.new(id: 'sm_20', action: :act, domain: :d, valence: 0.0, strength: 0.05)
124
+ expect(m.faded?).to be true
125
+ end
126
+
127
+ it 'returns true after enough decay cycles' do
128
+ m = described_class.new(id: 'sm_21', action: :act, domain: :d, valence: 0.0, strength: 0.2)
129
+ 20.times { m.decay }
130
+ expect(m.faded?).to be true
131
+ end
132
+ end
133
+
134
+ describe '#valence_label' do
135
+ it 'returns :strongly_negative for very negative valence' do
136
+ m = described_class.new(id: 'sm_22', action: :act, domain: :d, valence: -0.9)
137
+ expect(m.valence_label).to eq(:strongly_negative)
138
+ end
139
+
140
+ it 'returns :negative for moderately negative valence' do
141
+ m = described_class.new(id: 'sm_23', action: :act, domain: :d, valence: -0.4)
142
+ expect(m.valence_label).to eq(:negative)
143
+ end
144
+
145
+ it 'returns :neutral for near-zero valence' do
146
+ m = described_class.new(id: 'sm_24', action: :act, domain: :d, valence: 0.0)
147
+ expect(m.valence_label).to eq(:neutral)
148
+ end
149
+
150
+ it 'returns :positive for moderately positive valence' do
151
+ m = described_class.new(id: 'sm_25', action: :act, domain: :d, valence: 0.4)
152
+ expect(m.valence_label).to eq(:positive)
153
+ end
154
+
155
+ it 'returns :strongly_positive for very positive valence' do
156
+ m = described_class.new(id: 'sm_26', action: :act, domain: :d, valence: 0.9)
157
+ expect(m.valence_label).to eq(:strongly_positive)
158
+ end
159
+ end
160
+
161
+ describe '#to_h' do
162
+ it 'returns a hash with all expected keys' do
163
+ h = marker.to_h
164
+ expect(h).to include(:id, :action, :domain, :valence, :strength, :source, :signal, :label, :created_at)
165
+ end
166
+
167
+ it 'includes the computed signal' do
168
+ m = described_class.new(id: 'sm_27', action: :act, domain: :d, valence: 0.8)
169
+ expect(m.to_h[:signal]).to eq(:approach)
170
+ end
171
+ end
172
+ end