lex-somatic-marker 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.
- checksums.yaml +7 -0
- data/Gemfile +12 -0
- data/LICENSE +21 -0
- data/README.md +61 -0
- data/lex-somatic-marker.gemspec +29 -0
- data/lib/legion/extensions/somatic_marker/actors/decay.rb +41 -0
- data/lib/legion/extensions/somatic_marker/client.rb +25 -0
- data/lib/legion/extensions/somatic_marker/helpers/body_state.rb +65 -0
- data/lib/legion/extensions/somatic_marker/helpers/constants.rb +39 -0
- data/lib/legion/extensions/somatic_marker/helpers/marker_store.rb +156 -0
- data/lib/legion/extensions/somatic_marker/helpers/somatic_marker.rb +70 -0
- data/lib/legion/extensions/somatic_marker/runners/somatic_marker.rb +128 -0
- data/lib/legion/extensions/somatic_marker/version.rb +9 -0
- data/lib/legion/extensions/somatic_marker.rb +16 -0
- data/spec/legion/extensions/somatic_marker/client_spec.rb +83 -0
- data/spec/legion/extensions/somatic_marker/helpers/body_state_spec.rb +155 -0
- data/spec/legion/extensions/somatic_marker/helpers/marker_store_spec.rb +233 -0
- data/spec/legion/extensions/somatic_marker/helpers/somatic_marker_spec.rb +172 -0
- data/spec/legion/extensions/somatic_marker/runners/somatic_marker_spec.rb +181 -0
- data/spec/spec_helper.rb +33 -0
- metadata +79 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/somatic_marker/version'
|
|
4
|
+
require 'legion/extensions/somatic_marker/helpers/constants'
|
|
5
|
+
require 'legion/extensions/somatic_marker/helpers/somatic_marker'
|
|
6
|
+
require 'legion/extensions/somatic_marker/helpers/body_state'
|
|
7
|
+
require 'legion/extensions/somatic_marker/helpers/marker_store'
|
|
8
|
+
require 'legion/extensions/somatic_marker/runners/somatic_marker'
|
|
9
|
+
|
|
10
|
+
module Legion
|
|
11
|
+
module Extensions
|
|
12
|
+
module SomaticMarker
|
|
13
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/somatic_marker/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::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::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::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::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::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::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::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::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::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::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
|