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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d139fd458759ac7941be893a1b5d8030e1923d366d1e8a79a796d18afee34044
|
|
4
|
+
data.tar.gz: 0ffb155f12fdae620c562849a81c1fbbcaf224e7b5b9ca4e81365615fe1dcd54
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 273656c152ceb7d137acbd0dbef32305d4acf57802347ec816f2e4f7e5a371b714a0b02ea88a4273b915458df85cc16cdbc2c1edbfdf925411c4b8d44b803cbb
|
|
7
|
+
data.tar.gz: a6924be1392b3adfbdab40357888e81b066c246f306781b6e512c21b7a437e9d3df2059b39cb9a2bf00be9d03861cd9e3155c8b0dfb9b5e19ba5aab81074dba1
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Iverson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# lex-somatic-marker
|
|
2
|
+
|
|
3
|
+
Damasio Somatic Marker Hypothesis implementation for LegionIO cognitive agents. Affective signals associated with past actions bias future decision-making toward approach or avoidance.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
`lex-somatic-marker` models how learned bodily signals (somatic markers) guide decisions. Each action the agent considers has an associated marker with a valence score. Positive valence produces an `:approach` signal; negative valence produces `:avoid`. A BodyState tracks global arousal, tension, comfort, and gut signal, providing the affective context in which decisions are made.
|
|
8
|
+
|
|
9
|
+
- **Markers**: action-associated valence scores, updated via EMA on each outcome
|
|
10
|
+
- **Signal**: `:approach` (valence > 0.6), `:avoid` (valence < -0.6), or `:neutral`
|
|
11
|
+
- **BodyState**: arousal, tension, comfort, gut_signal — composite valence = `comfort*0.4 + (1-tension)*0.3 + gut_signal*0.3`
|
|
12
|
+
- **Decision ranking**: `make_decision` evaluates multiple options and ranks by valence
|
|
13
|
+
- **Decay**: marker valence and body state drift toward neutral each tick
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require 'legion/extensions/somatic_marker'
|
|
19
|
+
|
|
20
|
+
client = Legion::Extensions::SomaticMarker::Client.new
|
|
21
|
+
|
|
22
|
+
# Register a marker for an action
|
|
23
|
+
result = client.register_marker(action: 'deploy_without_tests', domain: :engineering)
|
|
24
|
+
marker_id = result[:marker_id]
|
|
25
|
+
|
|
26
|
+
# Bad outcome — reinforce with negative valence
|
|
27
|
+
client.reinforce(marker_id: marker_id, outcome_valence: -0.9)
|
|
28
|
+
# => { valence: -0.11, signal: :neutral } (first update from 0.0)
|
|
29
|
+
|
|
30
|
+
# After repeated bad outcomes, signal becomes :avoid
|
|
31
|
+
client.reinforce(marker_id: marker_id, outcome_valence: -0.9)
|
|
32
|
+
# => { valence: -0.21, signal: :neutral }
|
|
33
|
+
|
|
34
|
+
# Evaluate an option
|
|
35
|
+
client.evaluate_option(action: 'deploy_without_tests')
|
|
36
|
+
# => { signal: :neutral, valence: -0.21, body_valence: 0.59 }
|
|
37
|
+
|
|
38
|
+
# Rank multiple options
|
|
39
|
+
client.make_decision(options: ['deploy_without_tests', 'add_tests_first', 'skip_deployment'])
|
|
40
|
+
# => { ranked_options: [...], recommended: 'add_tests_first' }
|
|
41
|
+
|
|
42
|
+
# Update body state (e.g., from emotion evaluation)
|
|
43
|
+
client.update_body(dimension: :tension, value: 0.8)
|
|
44
|
+
client.body_state
|
|
45
|
+
# => { arousal: 0.5, tension: 0.8, comfort: 0.7, gut_signal: 0.0, composite_valence: 0.46, stressed: false }
|
|
46
|
+
|
|
47
|
+
# Per-tick decay
|
|
48
|
+
client.update_somatic_markers
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Development
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bundle install
|
|
55
|
+
bundle exec rspec
|
|
56
|
+
bundle exec rubocop
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/somatic_marker/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-somatic-marker'
|
|
7
|
+
spec.version = Legion::Extensions::SomaticMarker::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Somatic Marker'
|
|
12
|
+
spec.description = "Damasio's Somatic Marker Hypothesis for brain-modeled agentic AI decision-making"
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-somatic-marker'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
spec.required_ruby_version = '>= 3.4'
|
|
16
|
+
|
|
17
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
18
|
+
spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-somatic-marker'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-somatic-marker'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-somatic-marker'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-somatic-marker/issues'
|
|
22
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
25
|
+
Dir.glob('{lib,spec}/**/*') + %w[lex-somatic-marker.gemspec Gemfile LICENSE README.md]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/actors/every'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module SomaticMarker
|
|
8
|
+
module Actor
|
|
9
|
+
class Decay < Legion::Extensions::Actors::Every
|
|
10
|
+
def runner_class
|
|
11
|
+
Legion::Extensions::SomaticMarker::Runners::SomaticMarker
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def runner_function
|
|
15
|
+
'update_somatic_markers'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def time
|
|
19
|
+
30
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run_now?
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def use_runner?
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def check_subtask?
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def generate_task?
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/somatic_marker/helpers/constants'
|
|
4
|
+
require 'legion/extensions/somatic_marker/helpers/somatic_marker'
|
|
5
|
+
require 'legion/extensions/somatic_marker/helpers/body_state'
|
|
6
|
+
require 'legion/extensions/somatic_marker/helpers/marker_store'
|
|
7
|
+
require 'legion/extensions/somatic_marker/runners/somatic_marker'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module SomaticMarker
|
|
12
|
+
class Client
|
|
13
|
+
include Runners::SomaticMarker
|
|
14
|
+
|
|
15
|
+
def initialize(**)
|
|
16
|
+
@store = Helpers::MarkerStore.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
attr_reader :store
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module SomaticMarker
|
|
6
|
+
module Helpers
|
|
7
|
+
class BodyState
|
|
8
|
+
include Constants
|
|
9
|
+
|
|
10
|
+
attr_reader :arousal, :tension, :comfort, :gut_signal
|
|
11
|
+
|
|
12
|
+
def initialize(arousal: 0.5, tension: 0.5, comfort: 0.5, gut_signal: 0.0)
|
|
13
|
+
@arousal = arousal.clamp(0.0, 1.0)
|
|
14
|
+
@tension = tension.clamp(0.0, 1.0)
|
|
15
|
+
@comfort = comfort.clamp(0.0, 1.0)
|
|
16
|
+
@gut_signal = gut_signal.clamp(-1.0, 1.0)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def update(arousal: nil, tension: nil, comfort: nil, gut_signal: nil)
|
|
20
|
+
@arousal = arousal.clamp(0.0, 1.0) if arousal
|
|
21
|
+
@tension = tension.clamp(0.0, 1.0) if tension
|
|
22
|
+
@comfort = comfort.clamp(0.0, 1.0) if comfort
|
|
23
|
+
@gut_signal = gut_signal.clamp(-1.0, 1.0) if gut_signal
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def composite_valence
|
|
27
|
+
(@comfort * 0.4) + ((1.0 - @tension) * 0.3) + (@gut_signal * 0.3)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def decay
|
|
31
|
+
@arousal = drift(@arousal, 0.5, BODY_STATE_DECAY)
|
|
32
|
+
@tension = drift(@tension, 0.5, BODY_STATE_DECAY)
|
|
33
|
+
@comfort = drift(@comfort, 0.5, BODY_STATE_DECAY)
|
|
34
|
+
@gut_signal = drift(@gut_signal, 0.0, BODY_STATE_DECAY)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stressed?
|
|
38
|
+
@tension > 0.7 && @comfort < 0.3
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_h
|
|
42
|
+
{
|
|
43
|
+
arousal: @arousal,
|
|
44
|
+
tension: @tension,
|
|
45
|
+
comfort: @comfort,
|
|
46
|
+
gut_signal: @gut_signal,
|
|
47
|
+
composite_valence: composite_valence,
|
|
48
|
+
stressed: stressed?
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def drift(value, target, rate)
|
|
55
|
+
if value > target
|
|
56
|
+
(value - rate).clamp(target, 1.0)
|
|
57
|
+
else
|
|
58
|
+
(value + rate).clamp(-1.0, target)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module SomaticMarker
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
MAX_MARKERS = 500
|
|
9
|
+
MAX_OPTIONS_PER_DECISION = 20
|
|
10
|
+
MAX_DECISION_HISTORY = 200
|
|
11
|
+
MARKER_DECAY = 0.01
|
|
12
|
+
MARKER_STRENGTH_FLOOR = 0.05
|
|
13
|
+
MARKER_ALPHA = 0.12
|
|
14
|
+
POSITIVE_BIAS = 0.6
|
|
15
|
+
NEGATIVE_BIAS = -0.6
|
|
16
|
+
DEFAULT_VALENCE = 0.0
|
|
17
|
+
REINFORCEMENT_BOOST = 0.15
|
|
18
|
+
PUNISHMENT_PENALTY = 0.2
|
|
19
|
+
BODY_STATE_DECAY = 0.03
|
|
20
|
+
MAX_BODY_STATES = 50
|
|
21
|
+
|
|
22
|
+
VALENCE_LABELS = {
|
|
23
|
+
(-1.0..-0.6) => :strongly_negative,
|
|
24
|
+
(-0.6..-0.2) => :negative,
|
|
25
|
+
(-0.2..0.2) => :neutral,
|
|
26
|
+
(0.2..0.6) => :positive,
|
|
27
|
+
(0.6..1.0) => :strongly_positive
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
SIGNAL_LABELS = {
|
|
31
|
+
approach: 'somatic signal favoring action',
|
|
32
|
+
avoid: 'somatic signal against action',
|
|
33
|
+
neutral: 'no clear somatic signal'
|
|
34
|
+
}.freeze
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module SomaticMarker
|
|
6
|
+
module Helpers
|
|
7
|
+
class MarkerStore
|
|
8
|
+
include Constants
|
|
9
|
+
|
|
10
|
+
attr_reader :markers, :body_state
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@markers = {}
|
|
14
|
+
@body_state = BodyState.new
|
|
15
|
+
@decision_history = []
|
|
16
|
+
@next_id = 1
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def register_marker(action:, domain:, valence:, source: :experience)
|
|
20
|
+
evict_weakest if @markers.size >= MAX_MARKERS
|
|
21
|
+
|
|
22
|
+
id = generate_id
|
|
23
|
+
marker = SomaticMarker.new(
|
|
24
|
+
id: id,
|
|
25
|
+
action: action,
|
|
26
|
+
domain: domain,
|
|
27
|
+
valence: valence,
|
|
28
|
+
source: source
|
|
29
|
+
)
|
|
30
|
+
@markers[id] = marker
|
|
31
|
+
marker
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def evaluate_option(action:, domain:)
|
|
35
|
+
relevant = markers_for(action: action, domain: domain)
|
|
36
|
+
return { signal: :neutral, valence: DEFAULT_VALENCE, marker_count: 0 } if relevant.empty?
|
|
37
|
+
|
|
38
|
+
weighted_valence = compute_weighted_valence(relevant)
|
|
39
|
+
signal = valence_to_signal(weighted_valence)
|
|
40
|
+
{ signal: signal, valence: weighted_valence, marker_count: relevant.size }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def decide(options:, domain:)
|
|
44
|
+
capped = options.first(MAX_OPTIONS_PER_DECISION)
|
|
45
|
+
ranked = capped.map do |option|
|
|
46
|
+
eval_result = evaluate_option(action: option, domain: domain)
|
|
47
|
+
{
|
|
48
|
+
action: option,
|
|
49
|
+
signal: eval_result[:signal],
|
|
50
|
+
valence: eval_result[:valence],
|
|
51
|
+
marker_count: eval_result[:marker_count]
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
ranked.sort_by! { |r| -r[:valence] }
|
|
56
|
+
|
|
57
|
+
body_contribution = body_influence
|
|
58
|
+
|
|
59
|
+
record = {
|
|
60
|
+
options: capped,
|
|
61
|
+
ranked: ranked,
|
|
62
|
+
domain: domain,
|
|
63
|
+
body_contribution: body_contribution,
|
|
64
|
+
decided_at: Time.now.utc
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@decision_history.shift while @decision_history.size >= MAX_DECISION_HISTORY
|
|
68
|
+
@decision_history << record
|
|
69
|
+
|
|
70
|
+
record
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reinforce_marker(marker_id:, outcome_valence:)
|
|
74
|
+
marker = @markers[marker_id]
|
|
75
|
+
return nil unless marker
|
|
76
|
+
|
|
77
|
+
marker.reinforce(outcome_valence: outcome_valence)
|
|
78
|
+
marker
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def update_body_state(arousal: nil, tension: nil, comfort: nil, gut_signal: nil)
|
|
82
|
+
@body_state.update(
|
|
83
|
+
arousal: arousal,
|
|
84
|
+
tension: tension,
|
|
85
|
+
comfort: comfort,
|
|
86
|
+
gut_signal: gut_signal
|
|
87
|
+
)
|
|
88
|
+
@body_state
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def markers_for(action:, domain:)
|
|
92
|
+
@markers.values.select { |m| m.action == action && m.domain == domain }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def body_influence
|
|
96
|
+
{
|
|
97
|
+
composite_valence: @body_state.composite_valence,
|
|
98
|
+
stressed: @body_state.stressed?
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def decay_all
|
|
103
|
+
@markers.each_value(&:decay)
|
|
104
|
+
faded_ids = @markers.select { |_id, m| m.faded? }.keys
|
|
105
|
+
faded_ids.each { |id| @markers.delete(id) }
|
|
106
|
+
@body_state.decay
|
|
107
|
+
{ markers_decayed: @markers.size, markers_removed: faded_ids.size }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def decision_history(limit: 10)
|
|
111
|
+
@decision_history.last(limit)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def to_h
|
|
115
|
+
{
|
|
116
|
+
marker_count: @markers.size,
|
|
117
|
+
decision_count: @decision_history.size,
|
|
118
|
+
body_state: @body_state.to_h,
|
|
119
|
+
stressed: @body_state.stressed?
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def compute_weighted_valence(relevant)
|
|
126
|
+
total_strength = relevant.sum(&:strength)
|
|
127
|
+
return DEFAULT_VALENCE unless total_strength.positive?
|
|
128
|
+
|
|
129
|
+
relevant.sum { |m| m.valence * m.strength } / total_strength
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def valence_to_signal(weighted_valence)
|
|
133
|
+
if weighted_valence > POSITIVE_BIAS
|
|
134
|
+
:approach
|
|
135
|
+
elsif weighted_valence < NEGATIVE_BIAS
|
|
136
|
+
:avoid
|
|
137
|
+
else
|
|
138
|
+
:neutral
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def generate_id
|
|
143
|
+
id = "sm_#{@next_id}"
|
|
144
|
+
@next_id += 1
|
|
145
|
+
id
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def evict_weakest
|
|
149
|
+
weakest_id = @markers.min_by { |_id, m| m.strength }&.first
|
|
150
|
+
@markers.delete(weakest_id) if weakest_id
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module SomaticMarker
|
|
6
|
+
module Helpers
|
|
7
|
+
class SomaticMarker
|
|
8
|
+
include Constants
|
|
9
|
+
|
|
10
|
+
attr_reader :id, :action, :domain, :valence, :strength, :source, :created_at
|
|
11
|
+
|
|
12
|
+
def initialize(id:, action:, domain:, valence:, strength: 0.5, source: :experience)
|
|
13
|
+
@id = id
|
|
14
|
+
@action = action
|
|
15
|
+
@domain = domain
|
|
16
|
+
@valence = valence.clamp(-1.0, 1.0)
|
|
17
|
+
@strength = strength.clamp(0.0, 1.0)
|
|
18
|
+
@source = source
|
|
19
|
+
@created_at = Time.now.utc
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def signal
|
|
23
|
+
if @valence > POSITIVE_BIAS
|
|
24
|
+
:approach
|
|
25
|
+
elsif @valence < NEGATIVE_BIAS
|
|
26
|
+
:avoid
|
|
27
|
+
else
|
|
28
|
+
:neutral
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reinforce(outcome_valence:)
|
|
33
|
+
@valence = (MARKER_ALPHA * outcome_valence) + ((1.0 - MARKER_ALPHA) * @valence)
|
|
34
|
+
@valence = @valence.clamp(-1.0, 1.0)
|
|
35
|
+
@strength = (@strength + REINFORCEMENT_BOOST).clamp(0.0, 1.0)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def decay
|
|
39
|
+
@strength = (@strength - MARKER_DECAY).clamp(0.0, 1.0)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def faded?
|
|
43
|
+
@strength <= MARKER_STRENGTH_FLOOR
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def valence_label
|
|
47
|
+
VALENCE_LABELS.each do |range, label|
|
|
48
|
+
return label if range.cover?(@valence)
|
|
49
|
+
end
|
|
50
|
+
:neutral
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_h
|
|
54
|
+
{
|
|
55
|
+
id: @id,
|
|
56
|
+
action: @action,
|
|
57
|
+
domain: @domain,
|
|
58
|
+
valence: @valence,
|
|
59
|
+
strength: @strength,
|
|
60
|
+
source: @source,
|
|
61
|
+
signal: signal,
|
|
62
|
+
label: valence_label,
|
|
63
|
+
created_at: @created_at
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module SomaticMarker
|
|
6
|
+
module Runners
|
|
7
|
+
module SomaticMarker
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def register_marker(action:, domain:, valence:, source: :experience, **)
|
|
12
|
+
marker = store.register_marker(action: action, domain: domain, valence: valence, source: source)
|
|
13
|
+
Legion::Logging.debug "[somatic_marker] register: action=#{action} domain=#{domain} " \
|
|
14
|
+
"valence=#{valence.round(3)} source=#{source} id=#{marker.id}"
|
|
15
|
+
{ success: true, marker: marker.to_h }
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
Legion::Logging.error "[somatic_marker] register failed: #{e.message}"
|
|
18
|
+
{ success: false, error: e.message }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def evaluate_option(action:, domain:, **)
|
|
22
|
+
result = store.evaluate_option(action: action, domain: domain)
|
|
23
|
+
Legion::Logging.debug "[somatic_marker] evaluate: action=#{action} domain=#{domain} " \
|
|
24
|
+
"signal=#{result[:signal]} valence=#{result[:valence].round(3)}"
|
|
25
|
+
{ success: true }.merge(result)
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
Legion::Logging.error "[somatic_marker] evaluate failed: #{e.message}"
|
|
28
|
+
{ success: false, error: e.message }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def make_decision(options:, domain:, **)
|
|
32
|
+
result = store.decide(options: options, domain: domain)
|
|
33
|
+
Legion::Logging.debug "[somatic_marker] decide: domain=#{domain} options=#{options.size} " \
|
|
34
|
+
"top=#{result[:ranked].first&.fetch(:action)}"
|
|
35
|
+
{ success: true, decision: result }
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
Legion::Logging.error "[somatic_marker] decide failed: #{e.message}"
|
|
38
|
+
{ success: false, error: e.message }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reinforce(marker_id:, outcome_valence:, **)
|
|
42
|
+
marker = store.reinforce_marker(marker_id: marker_id, outcome_valence: outcome_valence)
|
|
43
|
+
unless marker
|
|
44
|
+
Legion::Logging.debug "[somatic_marker] reinforce: marker_id=#{marker_id} not found"
|
|
45
|
+
return { success: false, error: 'marker not found' }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Legion::Logging.debug "[somatic_marker] reinforce: id=#{marker_id} " \
|
|
49
|
+
"outcome=#{outcome_valence.round(3)} new_valence=#{marker.valence.round(3)}"
|
|
50
|
+
{ success: true, marker: marker.to_h }
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
Legion::Logging.error "[somatic_marker] reinforce failed: #{e.message}"
|
|
53
|
+
{ success: false, error: e.message }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def update_body(arousal: nil, tension: nil, comfort: nil, gut_signal: nil, **)
|
|
57
|
+
state = store.update_body_state(
|
|
58
|
+
arousal: arousal,
|
|
59
|
+
tension: tension,
|
|
60
|
+
comfort: comfort,
|
|
61
|
+
gut_signal: gut_signal
|
|
62
|
+
)
|
|
63
|
+
Legion::Logging.debug "[somatic_marker] body_update: composite=#{state.composite_valence.round(3)} " \
|
|
64
|
+
"stressed=#{state.stressed?}"
|
|
65
|
+
{ success: true, body_state: state.to_h }
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
Legion::Logging.error "[somatic_marker] body update failed: #{e.message}"
|
|
68
|
+
{ success: false, error: e.message }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def body_state(**)
|
|
72
|
+
state = store.body_state
|
|
73
|
+
Legion::Logging.debug "[somatic_marker] body_state: composite=#{state.composite_valence.round(3)}"
|
|
74
|
+
{ success: true, body_state: state.to_h }
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
Legion::Logging.error "[somatic_marker] body_state failed: #{e.message}"
|
|
77
|
+
{ success: false, error: e.message }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def markers_for_action(action:, domain:, **)
|
|
81
|
+
markers = store.markers_for(action: action, domain: domain)
|
|
82
|
+
Legion::Logging.debug "[somatic_marker] markers_for: action=#{action} domain=#{domain} " \
|
|
83
|
+
"count=#{markers.size}"
|
|
84
|
+
{ success: true, markers: markers.map(&:to_h), count: markers.size }
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
Legion::Logging.error "[somatic_marker] markers_for_action failed: #{e.message}"
|
|
87
|
+
{ success: false, error: e.message }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def recent_decisions(limit: 10, **)
|
|
91
|
+
decisions = store.decision_history(limit: limit)
|
|
92
|
+
Legion::Logging.debug "[somatic_marker] recent_decisions: limit=#{limit} count=#{decisions.size}"
|
|
93
|
+
{ success: true, decisions: decisions, count: decisions.size }
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
Legion::Logging.error "[somatic_marker] recent_decisions failed: #{e.message}"
|
|
96
|
+
{ success: false, error: e.message }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def update_somatic_markers(**)
|
|
100
|
+
result = store.decay_all
|
|
101
|
+
Legion::Logging.debug "[somatic_marker] decay: remaining=#{result[:markers_decayed]} " \
|
|
102
|
+
"removed=#{result[:markers_removed]}"
|
|
103
|
+
{ success: true }.merge(result)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
Legion::Logging.error "[somatic_marker] decay failed: #{e.message}"
|
|
106
|
+
{ success: false, error: e.message }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def somatic_marker_stats(**)
|
|
110
|
+
stats = store.to_h
|
|
111
|
+
Legion::Logging.debug "[somatic_marker] stats: markers=#{stats[:marker_count]} " \
|
|
112
|
+
"decisions=#{stats[:decision_count]}"
|
|
113
|
+
{ success: true }.merge(stats)
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
Legion::Logging.error "[somatic_marker] stats failed: #{e.message}"
|
|
116
|
+
{ success: false, error: e.message }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def store
|
|
122
|
+
@store ||= Helpers::MarkerStore.new
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|