lex-interoception 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dace4cb240f23845d21abe89e16f97bb73d4bc993002ccfe4ceb94e01da4d72b
4
+ data.tar.gz: 3bcf0cbef4062bcf3fcfb58d93e5aa2c55b24c22ec545cd9a10a48b438d52f7e
5
+ SHA512:
6
+ metadata.gz: d352233f77c158c7e60869010f7f46ede5099c21eb839871ffc469df701c990e9d253d8dcf9c661155adeb8700cbb2eb0766f639e20ccfa3b77a19a068f36688
7
+ data.tar.gz: edee25c05cfb81956ec5052fa7e85177b8eab77ab70bf80d2a55512b7d4c0a2472b5d87d0a680f8a522d6f2a1fd3602153757b9b926457145d7d3baea7566f40
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+ gem 'rubocop-rspec', require: false
10
+
11
+ gem 'legion-gaia', path: '../../legion-gaia'
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/interoception/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-interoception'
7
+ spec.version = Legion::Extensions::Interoception::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Interoception'
12
+ spec.description = 'Somatic marker hypothesis for brain-modeled agentic AI — internal body-state ' \
13
+ 'monitoring, vital signal tracking, and somatic markers that bias decisions ' \
14
+ 'based on past outcomes associated with similar internal states.'
15
+ spec.homepage = 'https://github.com/LegionIO/lex-interoception'
16
+ spec.license = 'MIT'
17
+ spec.required_ruby_version = '>= 3.4'
18
+
19
+ spec.metadata['homepage_uri'] = spec.homepage
20
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-interoception'
21
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-interoception'
22
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-interoception'
23
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-interoception/issues'
24
+ spec.metadata['rubygems_mfa_required'] = 'true'
25
+
26
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
27
+ Dir.glob('{lib,spec}/**/*') + %w[lex-interoception.gemspec Gemfile]
28
+ end
29
+ spec.require_paths = ['lib']
30
+ spec.add_development_dependency 'legion-gaia'
31
+ 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 Interoception
8
+ module Actor
9
+ class Decay < Legion::Extensions::Actors::Every
10
+ def runner_class
11
+ Legion::Extensions::Interoception::Runners::Interoception
12
+ end
13
+
14
+ def runner_function
15
+ 'update_interoception'
16
+ end
17
+
18
+ def time
19
+ 60
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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/interoception/helpers/constants'
4
+ require 'legion/extensions/interoception/helpers/somatic_marker'
5
+ require 'legion/extensions/interoception/helpers/body_budget'
6
+ require 'legion/extensions/interoception/runners/interoception'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Interoception
11
+ class Client
12
+ include Runners::Interoception
13
+
14
+ def initialize(body_budget: nil, **)
15
+ @body_budget = body_budget || Helpers::BodyBudget.new
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :body_budget
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Interoception
6
+ module Helpers
7
+ class BodyBudget
8
+ include Constants
9
+
10
+ attr_reader :vitals, :baselines, :markers, :vital_history
11
+
12
+ def initialize
13
+ @vitals = {}
14
+ @baselines = {}
15
+ @markers = []
16
+ @vital_history = {}
17
+ end
18
+
19
+ # --- Vital Signal Tracking ---
20
+
21
+ def report_vital(channel:, value:)
22
+ channel = channel.to_sym
23
+ normalized = value.clamp(0.0, 1.0)
24
+ @baselines[channel] ||= DEFAULT_BASELINE
25
+ @vitals[channel] = if @vitals.key?(channel)
26
+ ema(@vitals[channel], normalized, VITAL_ALPHA)
27
+ else
28
+ normalized
29
+ end
30
+ record_vital_history(channel, @vitals[channel])
31
+ @baselines[channel] = ema(@baselines[channel], @vitals[channel], VITAL_ALPHA * 0.5)
32
+ @vitals[channel]
33
+ end
34
+
35
+ def vital_for(channel)
36
+ @vitals.fetch(channel.to_sym, DEFAULT_BASELINE)
37
+ end
38
+
39
+ def deviation_for(channel)
40
+ channel = channel.to_sym
41
+ current = @vitals.fetch(channel, DEFAULT_BASELINE)
42
+ baseline = @baselines.fetch(channel, DEFAULT_BASELINE)
43
+ current - baseline
44
+ end
45
+
46
+ def vital_label(channel)
47
+ health = vital_health(channel)
48
+ VITAL_LABELS.each { |range, lbl| return lbl if range.cover?(health) }
49
+ :nominal
50
+ end
51
+
52
+ def vital_health(channel)
53
+ val = vital_for(channel)
54
+ inverted_channels = %i[cpu_load memory_pressure queue_depth error_rate disk_usage gc_pressure]
55
+ inverted_channels.include?(channel.to_sym) ? 1.0 - val : val
56
+ end
57
+
58
+ def deviating_channels
59
+ @vitals.select { |ch, _| deviation_for(ch).abs >= DEVIATION_THRESHOLD }
60
+ .map { |ch, _| { channel: ch, deviation: deviation_for(ch).round(4), label: vital_label(ch) } }
61
+ end
62
+
63
+ # --- Somatic Markers ---
64
+
65
+ def create_marker(action:, domain:, valence:, strength: 1.0)
66
+ marker = SomaticMarker.new(action: action, domain: domain, valence: valence, strength: strength)
67
+ @markers << marker
68
+ prune_markers if @markers.size > MAX_MARKERS
69
+ marker
70
+ end
71
+
72
+ def markers_for(action:, domain: nil)
73
+ results = @markers.select { |m| m.action == action }
74
+ results = results.select { |m| m.domain == domain } if domain
75
+ results
76
+ end
77
+
78
+ def bias_for_action(action:, domain: nil)
79
+ relevant = markers_for(action: action, domain: domain)
80
+ return 0.0 if relevant.empty?
81
+
82
+ relevant.sum { |m| m.bias_for(action) } / relevant.size
83
+ end
84
+
85
+ def reinforce_markers(action:, domain: nil, amount: 0.1)
86
+ markers_for(action: action, domain: domain).each { |m| m.reinforce(amount: amount) }
87
+ end
88
+
89
+ def decay_markers
90
+ @markers.each(&:decay)
91
+ @markers.reject!(&:faded?)
92
+ end
93
+
94
+ # --- Body Budget Overview ---
95
+
96
+ def overall_health
97
+ return DEFAULT_BASELINE if @vitals.empty?
98
+
99
+ healths = @vitals.keys.map { |ch| vital_health(ch) }
100
+ healths.sum / healths.size
101
+ end
102
+
103
+ def body_budget_label
104
+ health = overall_health
105
+ BODY_BUDGET_LABELS.each { |range, lbl| return lbl if range.cover?(health) }
106
+ :comfortable
107
+ end
108
+
109
+ def channel_count
110
+ @vitals.size
111
+ end
112
+
113
+ def marker_count
114
+ @markers.size
115
+ end
116
+
117
+ def to_h
118
+ {
119
+ overall_health: overall_health.round(4),
120
+ body_budget_label: body_budget_label,
121
+ channels: channel_count,
122
+ markers: marker_count,
123
+ vitals: @vitals.transform_values { |v| v.round(4) },
124
+ deviations: deviating_channels
125
+ }
126
+ end
127
+
128
+ private
129
+
130
+ def ema(old_val, new_val, alpha)
131
+ old_val + (alpha * (new_val - old_val))
132
+ end
133
+
134
+ def record_vital_history(channel, value)
135
+ @vital_history[channel] ||= []
136
+ @vital_history[channel] << { value: value, at: Time.now.utc }
137
+ @vital_history[channel].shift while @vital_history[channel].size > MAX_VITAL_HISTORY
138
+ end
139
+
140
+ def prune_markers
141
+ @markers.sort_by!(&:strength)
142
+ @markers.shift while @markers.size > MAX_MARKERS
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Interoception
6
+ module Helpers
7
+ module Constants
8
+ # Vital signal channels the agent monitors
9
+ VITAL_CHANNELS = %i[
10
+ cpu_load memory_pressure queue_depth
11
+ response_latency error_rate connection_health
12
+ disk_usage thread_count gc_pressure
13
+ ].freeze
14
+
15
+ # Somatic marker valence thresholds
16
+ MARKER_POSITIVE_THRESHOLD = 0.3
17
+ MARKER_NEGATIVE_THRESHOLD = -0.3
18
+
19
+ # How strongly markers bias decisions (0..1)
20
+ MARKER_INFLUENCE = 0.4
21
+
22
+ # EMA alpha for vital signal smoothing
23
+ VITAL_ALPHA = 0.15
24
+
25
+ # Default baseline for vitals (normalized 0..1)
26
+ DEFAULT_BASELINE = 0.5
27
+
28
+ # Deviation from baseline that triggers a somatic marker
29
+ DEVIATION_THRESHOLD = 0.2
30
+
31
+ # Maximum stored somatic markers
32
+ MAX_MARKERS = 200
33
+
34
+ # Maximum stored vital snapshots per channel
35
+ MAX_VITAL_HISTORY = 100
36
+
37
+ # Marker decay per tick
38
+ MARKER_DECAY = 0.02
39
+
40
+ # Marker floor (below this, marker is pruned)
41
+ MARKER_FLOOR = 0.05
42
+
43
+ # Body budget labels based on overall vital health
44
+ BODY_BUDGET_LABELS = {
45
+ (0.8..) => :thriving,
46
+ (0.6...0.8) => :comfortable,
47
+ (0.4...0.6) => :strained,
48
+ (0.2...0.4) => :distressed,
49
+ (..0.2) => :critical
50
+ }.freeze
51
+
52
+ # Vital health labels
53
+ VITAL_LABELS = {
54
+ (0.8..) => :healthy,
55
+ (0.6...0.8) => :nominal,
56
+ (0.4...0.6) => :elevated,
57
+ (0.2...0.4) => :warning,
58
+ (..0.2) => :critical
59
+ }.freeze
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Interoception
6
+ module Helpers
7
+ class SomaticMarker
8
+ attr_reader :id, :action, :domain, :valence, :created_at
9
+ attr_accessor :strength
10
+
11
+ def initialize(action:, domain:, valence:, strength: 1.0)
12
+ @id = SecureRandom.uuid
13
+ @action = action
14
+ @domain = domain
15
+ @valence = valence.clamp(-1.0, 1.0)
16
+ @strength = strength.clamp(0.0, 1.0)
17
+ @created_at = Time.now.utc
18
+ end
19
+
20
+ def bias_for(candidate_action)
21
+ return 0.0 unless candidate_action == @action
22
+
23
+ @valence * @strength * Constants::MARKER_INFLUENCE
24
+ end
25
+
26
+ def reinforce(amount: 0.1)
27
+ @strength = [@strength + amount, 1.0].min
28
+ end
29
+
30
+ def decay
31
+ @strength = [@strength - Constants::MARKER_DECAY, Constants::MARKER_FLOOR].max
32
+ end
33
+
34
+ def faded?
35
+ @strength <= Constants::MARKER_FLOOR
36
+ end
37
+
38
+ def positive?
39
+ @valence >= Constants::MARKER_POSITIVE_THRESHOLD
40
+ end
41
+
42
+ def negative?
43
+ @valence <= Constants::MARKER_NEGATIVE_THRESHOLD
44
+ end
45
+
46
+ def label
47
+ if positive?
48
+ :approach
49
+ elsif negative?
50
+ :avoid
51
+ else
52
+ :neutral
53
+ end
54
+ end
55
+
56
+ def to_h
57
+ {
58
+ id: @id,
59
+ action: @action,
60
+ domain: @domain,
61
+ valence: @valence,
62
+ strength: @strength,
63
+ label: label,
64
+ created_at: @created_at
65
+ }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Interoception
6
+ module Runners
7
+ module Interoception
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def report_vital(channel:, value:, **)
12
+ smoothed = body_budget.report_vital(channel: channel, value: value.to_f)
13
+ deviation = body_budget.deviation_for(channel)
14
+ Legion::Logging.debug "[interoception] vital: channel=#{channel} raw=#{value} " \
15
+ "smoothed=#{smoothed.round(3)} deviation=#{deviation.round(3)}"
16
+ {
17
+ success: true,
18
+ channel: channel,
19
+ smoothed: smoothed.round(4),
20
+ deviation: deviation.round(4),
21
+ label: body_budget.vital_label(channel)
22
+ }
23
+ end
24
+
25
+ def create_somatic_marker(action:, domain:, valence:, strength: 1.0, **)
26
+ marker = body_budget.create_marker(action: action, domain: domain, valence: valence.to_f, strength: strength.to_f)
27
+ Legion::Logging.debug "[interoception] marker: action=#{action} domain=#{domain} " \
28
+ "valence=#{marker.valence.round(2)} label=#{marker.label}"
29
+ { success: true, marker: marker.to_h }
30
+ end
31
+
32
+ def query_bias(action:, domain: nil, **)
33
+ bias = body_budget.bias_for_action(action: action, domain: domain)
34
+ label = if bias > Helpers::Constants::MARKER_POSITIVE_THRESHOLD * Helpers::Constants::MARKER_INFLUENCE
35
+ :approach
36
+ elsif bias < Helpers::Constants::MARKER_NEGATIVE_THRESHOLD * Helpers::Constants::MARKER_INFLUENCE
37
+ :avoid
38
+ else
39
+ :neutral
40
+ end
41
+ Legion::Logging.debug "[interoception] bias: action=#{action} domain=#{domain} bias=#{bias.round(3)} label=#{label}"
42
+ { success: true, action: action, domain: domain, bias: bias.round(4), label: label }
43
+ end
44
+
45
+ def reinforce_somatic(action:, domain: nil, amount: 0.1, **)
46
+ body_budget.reinforce_markers(action: action, domain: domain, amount: amount.to_f)
47
+ Legion::Logging.debug "[interoception] reinforce: action=#{action} domain=#{domain} amount=#{amount}"
48
+ { success: true, action: action, domain: domain }
49
+ end
50
+
51
+ def deviating_vitals(**)
52
+ deviations = body_budget.deviating_channels
53
+ Legion::Logging.debug "[interoception] deviating: count=#{deviations.size}"
54
+ { success: true, deviations: deviations, count: deviations.size }
55
+ end
56
+
57
+ def body_status(**)
58
+ health = body_budget.overall_health
59
+ label = body_budget.body_budget_label
60
+ Legion::Logging.debug "[interoception] status: health=#{health.round(3)} label=#{label}"
61
+ {
62
+ success: true,
63
+ health: health.round(4),
64
+ label: label,
65
+ channels: body_budget.channel_count,
66
+ markers: body_budget.marker_count
67
+ }
68
+ end
69
+
70
+ def update_interoception(**)
71
+ body_budget.decay_markers
72
+ health = body_budget.overall_health
73
+ Legion::Logging.debug "[interoception] tick: health=#{health.round(3)} " \
74
+ "channels=#{body_budget.channel_count} markers=#{body_budget.marker_count}"
75
+ {
76
+ success: true,
77
+ health: health.round(4),
78
+ label: body_budget.body_budget_label,
79
+ channels: body_budget.channel_count,
80
+ markers: body_budget.marker_count
81
+ }
82
+ end
83
+
84
+ def interoception_stats(**)
85
+ { success: true, stats: body_budget.to_h }
86
+ end
87
+
88
+ private
89
+
90
+ def body_budget
91
+ @body_budget ||= Helpers::BodyBudget.new
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Interoception
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'legion/extensions/interoception/version'
5
+ require 'legion/extensions/interoception/helpers/constants'
6
+ require 'legion/extensions/interoception/helpers/somatic_marker'
7
+ require 'legion/extensions/interoception/helpers/body_budget'
8
+ require 'legion/extensions/interoception/runners/interoception'
9
+ require 'legion/extensions/interoception/client'
10
+
11
+ module Legion
12
+ module Extensions
13
+ module Interoception
14
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Interoception::Client do
4
+ subject(:client) { described_class.new }
5
+
6
+ it 'includes Runners::Interoception' do
7
+ expect(described_class.ancestors).to include(Legion::Extensions::Interoception::Runners::Interoception)
8
+ end
9
+
10
+ it 'responds to all runner methods' do
11
+ expect(client).to respond_to(:report_vital)
12
+ expect(client).to respond_to(:create_somatic_marker)
13
+ expect(client).to respond_to(:query_bias)
14
+ expect(client).to respond_to(:reinforce_somatic)
15
+ expect(client).to respond_to(:deviating_vitals)
16
+ expect(client).to respond_to(:body_status)
17
+ expect(client).to respond_to(:update_interoception)
18
+ expect(client).to respond_to(:interoception_stats)
19
+ end
20
+
21
+ it 'supports full somatic marker lifecycle' do
22
+ # Report some vitals
23
+ client.report_vital(channel: :cpu_load, value: 0.3)
24
+ client.report_vital(channel: :connection_health, value: 0.9)
25
+
26
+ # Create markers based on outcomes
27
+ client.create_somatic_marker(action: :deploy, domain: :prod, valence: 0.8)
28
+ client.create_somatic_marker(action: :risky_change, domain: :prod, valence: -0.6)
29
+
30
+ # Query bias should reflect markers
31
+ approach = client.query_bias(action: :deploy)
32
+ expect(approach[:bias]).to be > 0
33
+
34
+ avoid = client.query_bias(action: :risky_change)
35
+ expect(avoid[:bias]).to be < 0
36
+
37
+ # Reinforce good outcome
38
+ client.reinforce_somatic(action: :deploy)
39
+
40
+ # Tick decay
41
+ client.update_interoception
42
+
43
+ # Check overall status
44
+ status = client.body_status
45
+ expect(status[:success]).to be true
46
+ expect(status[:health]).to be > 0
47
+
48
+ stats = client.interoception_stats
49
+ expect(stats[:stats][:channels]).to eq(2)
50
+ expect(stats[:stats][:markers]).to be >= 1
51
+ end
52
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Interoception::Helpers::BodyBudget do
4
+ subject(:budget) { described_class.new }
5
+
6
+ describe '#report_vital' do
7
+ it 'stores and smooths a vital signal' do
8
+ result = budget.report_vital(channel: :cpu_load, value: 0.7)
9
+ expect(result).to be_a(Float)
10
+ expect(result).to be_between(0.0, 1.0)
11
+ end
12
+
13
+ it 'applies EMA smoothing on repeated reports' do
14
+ budget.report_vital(channel: :cpu_load, value: 0.2)
15
+ budget.report_vital(channel: :cpu_load, value: 0.8)
16
+ val = budget.vital_for(:cpu_load)
17
+ expect(val).to be_between(0.2, 0.8)
18
+ end
19
+
20
+ it 'clamps values to 0..1' do
21
+ budget.report_vital(channel: :cpu_load, value: 5.0)
22
+ expect(budget.vital_for(:cpu_load)).to be <= 1.0
23
+ end
24
+
25
+ it 'updates baseline over time' do
26
+ 10.times { budget.report_vital(channel: :cpu_load, value: 0.9) }
27
+ expect(budget.baselines[:cpu_load]).to be > 0.5
28
+ end
29
+ end
30
+
31
+ describe '#deviation_for' do
32
+ it 'returns 0 for new channels' do
33
+ expect(budget.deviation_for(:cpu_load)).to eq(0.0)
34
+ end
35
+
36
+ it 'detects deviation from baseline' do
37
+ 5.times { budget.report_vital(channel: :cpu_load, value: 0.3) }
38
+ budget.report_vital(channel: :cpu_load, value: 0.9)
39
+ expect(budget.deviation_for(:cpu_load).abs).to be > 0
40
+ end
41
+ end
42
+
43
+ describe '#vital_label' do
44
+ it 'returns :healthy for healthy vitals' do
45
+ budget.report_vital(channel: :connection_health, value: 0.9)
46
+ expect(budget.vital_label(:connection_health)).to eq(:healthy)
47
+ end
48
+
49
+ it 'returns :critical for bad inverted vitals' do
50
+ budget.report_vital(channel: :cpu_load, value: 0.95)
51
+ expect(budget.vital_label(:cpu_load)).to eq(:critical)
52
+ end
53
+ end
54
+
55
+ describe '#vital_health' do
56
+ it 'inverts cpu_load (high load = low health)' do
57
+ budget.report_vital(channel: :cpu_load, value: 0.9)
58
+ expect(budget.vital_health(:cpu_load)).to be < 0.2
59
+ end
60
+
61
+ it 'keeps connection_health direct (high = good)' do
62
+ budget.report_vital(channel: :connection_health, value: 0.9)
63
+ expect(budget.vital_health(:connection_health)).to be > 0.8
64
+ end
65
+ end
66
+
67
+ describe '#deviating_channels' do
68
+ it 'returns empty when no deviations' do
69
+ expect(budget.deviating_channels).to be_empty
70
+ end
71
+
72
+ it 'detects channels with significant deviation' do
73
+ 5.times { budget.report_vital(channel: :cpu_load, value: 0.2) }
74
+ # Force a big jump
75
+ budget.report_vital(channel: :cpu_load, value: 0.95)
76
+ budget.report_vital(channel: :cpu_load, value: 0.95)
77
+ budget.report_vital(channel: :cpu_load, value: 0.95)
78
+ deviations = budget.deviating_channels
79
+ expect(deviations.first[:channel]).to eq(:cpu_load) if deviations.any?
80
+ end
81
+ end
82
+
83
+ describe 'somatic markers' do
84
+ describe '#create_marker' do
85
+ it 'creates and stores a marker' do
86
+ marker = budget.create_marker(action: :deploy, domain: :prod, valence: 0.7)
87
+ expect(marker).to be_a(Legion::Extensions::Interoception::Helpers::SomaticMarker)
88
+ expect(budget.marker_count).to eq(1)
89
+ end
90
+ end
91
+
92
+ describe '#markers_for' do
93
+ it 'finds markers by action' do
94
+ budget.create_marker(action: :deploy, domain: :prod, valence: 0.5)
95
+ budget.create_marker(action: :deploy, domain: :staging, valence: 0.3)
96
+ budget.create_marker(action: :rollback, domain: :prod, valence: -0.5)
97
+ expect(budget.markers_for(action: :deploy).size).to eq(2)
98
+ end
99
+
100
+ it 'filters by domain' do
101
+ budget.create_marker(action: :deploy, domain: :prod, valence: 0.5)
102
+ budget.create_marker(action: :deploy, domain: :staging, valence: 0.3)
103
+ expect(budget.markers_for(action: :deploy, domain: :prod).size).to eq(1)
104
+ end
105
+ end
106
+
107
+ describe '#bias_for_action' do
108
+ it 'returns 0.0 with no markers' do
109
+ expect(budget.bias_for_action(action: :deploy)).to eq(0.0)
110
+ end
111
+
112
+ it 'returns positive bias for approach markers' do
113
+ budget.create_marker(action: :deploy, domain: :prod, valence: 0.8)
114
+ expect(budget.bias_for_action(action: :deploy)).to be > 0
115
+ end
116
+
117
+ it 'returns negative bias for avoid markers' do
118
+ budget.create_marker(action: :deploy, domain: :prod, valence: -0.8)
119
+ expect(budget.bias_for_action(action: :deploy)).to be < 0
120
+ end
121
+ end
122
+
123
+ describe '#reinforce_markers' do
124
+ it 'increases strength of matching markers' do
125
+ budget.create_marker(action: :deploy, domain: :prod, valence: 0.5, strength: 0.4)
126
+ budget.reinforce_markers(action: :deploy, amount: 0.2)
127
+ marker = budget.markers_for(action: :deploy).first
128
+ expect(marker.strength).to be_within(0.001).of(0.6)
129
+ end
130
+ end
131
+
132
+ describe '#decay_markers' do
133
+ it 'decays all markers' do
134
+ budget.create_marker(action: :deploy, domain: :prod, valence: 0.5)
135
+ before = budget.markers_for(action: :deploy).first.strength
136
+ budget.decay_markers
137
+ after = budget.markers_for(action: :deploy).first&.strength
138
+ expect(after).to be < before if after
139
+ end
140
+
141
+ it 'prunes faded markers' do
142
+ floor = Legion::Extensions::Interoception::Helpers::Constants::MARKER_FLOOR
143
+ budget.create_marker(action: :deploy, domain: :prod, valence: 0.5, strength: floor + 0.01)
144
+ budget.decay_markers
145
+ expect(budget.marker_count).to eq(0)
146
+ end
147
+ end
148
+ end
149
+
150
+ describe '#overall_health' do
151
+ it 'returns DEFAULT_BASELINE with no vitals' do
152
+ expect(budget.overall_health).to eq(Legion::Extensions::Interoception::Helpers::Constants::DEFAULT_BASELINE)
153
+ end
154
+
155
+ it 'computes average health across channels' do
156
+ budget.report_vital(channel: :connection_health, value: 0.9)
157
+ budget.report_vital(channel: :cpu_load, value: 0.1) # low load = healthy
158
+ expect(budget.overall_health).to be > 0.7
159
+ end
160
+ end
161
+
162
+ describe '#body_budget_label' do
163
+ it 'returns :comfortable for healthy vitals' do
164
+ budget.report_vital(channel: :connection_health, value: 0.7)
165
+ label = budget.body_budget_label
166
+ expect(%i[thriving comfortable]).to include(label)
167
+ end
168
+ end
169
+
170
+ describe '#to_h' do
171
+ it 'returns comprehensive stats' do
172
+ budget.report_vital(channel: :cpu_load, value: 0.3)
173
+ budget.create_marker(action: :deploy, domain: :prod, valence: 0.5)
174
+ h = budget.to_h
175
+ expect(h).to include(:overall_health, :body_budget_label, :channels, :markers, :vitals, :deviations)
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Interoception::Helpers::SomaticMarker do
4
+ subject(:marker) { described_class.new(action: :deploy, domain: :production, valence: 0.6) }
5
+
6
+ describe '#initialize' do
7
+ it 'assigns fields' do
8
+ expect(marker.action).to eq(:deploy)
9
+ expect(marker.domain).to eq(:production)
10
+ expect(marker.valence).to eq(0.6)
11
+ expect(marker.strength).to eq(1.0)
12
+ end
13
+
14
+ it 'assigns uuid and timestamp' do
15
+ expect(marker.id).to match(/\A[0-9a-f-]{36}\z/)
16
+ expect(marker.created_at).to be_a(Time)
17
+ end
18
+
19
+ it 'clamps valence to -1.0..1.0' do
20
+ high = described_class.new(action: :a, domain: :d, valence: 2.0)
21
+ low = described_class.new(action: :a, domain: :d, valence: -2.0)
22
+ expect(high.valence).to eq(1.0)
23
+ expect(low.valence).to eq(-1.0)
24
+ end
25
+
26
+ it 'clamps strength to 0.0..1.0' do
27
+ high = described_class.new(action: :a, domain: :d, valence: 0.5, strength: 5.0)
28
+ expect(high.strength).to eq(1.0)
29
+ end
30
+ end
31
+
32
+ describe '#bias_for' do
33
+ it 'returns positive bias for matching action with positive valence' do
34
+ bias = marker.bias_for(:deploy)
35
+ expect(bias).to be > 0
36
+ end
37
+
38
+ it 'returns 0 for non-matching action' do
39
+ expect(marker.bias_for(:rollback)).to eq(0.0)
40
+ end
41
+
42
+ it 'applies MARKER_INFLUENCE factor' do
43
+ influence = Legion::Extensions::Interoception::Helpers::Constants::MARKER_INFLUENCE
44
+ expected = marker.valence * marker.strength * influence
45
+ expect(marker.bias_for(:deploy)).to be_within(0.001).of(expected)
46
+ end
47
+ end
48
+
49
+ describe '#reinforce' do
50
+ it 'increases strength' do
51
+ marker.strength = 0.5
52
+ marker.reinforce(amount: 0.2)
53
+ expect(marker.strength).to be_within(0.001).of(0.7)
54
+ end
55
+
56
+ it 'caps at 1.0' do
57
+ marker.reinforce(amount: 0.5)
58
+ expect(marker.strength).to eq(1.0)
59
+ end
60
+ end
61
+
62
+ describe '#decay' do
63
+ it 'reduces strength by MARKER_DECAY' do
64
+ before = marker.strength
65
+ marker.decay
66
+ decay_amount = Legion::Extensions::Interoception::Helpers::Constants::MARKER_DECAY
67
+ expect(marker.strength).to be_within(0.001).of(before - decay_amount)
68
+ end
69
+
70
+ it 'does not drop below MARKER_FLOOR' do
71
+ 50.times { marker.decay }
72
+ expect(marker.strength).to be >= Legion::Extensions::Interoception::Helpers::Constants::MARKER_FLOOR
73
+ end
74
+ end
75
+
76
+ describe '#faded?' do
77
+ it 'returns false for strong markers' do
78
+ expect(marker.faded?).to be false
79
+ end
80
+
81
+ it 'returns true at or below floor' do
82
+ marker.strength = Legion::Extensions::Interoception::Helpers::Constants::MARKER_FLOOR
83
+ expect(marker.faded?).to be true
84
+
85
+ marker.strength = Legion::Extensions::Interoception::Helpers::Constants::MARKER_FLOOR + 0.01
86
+ expect(marker.faded?).to be false
87
+ end
88
+ end
89
+
90
+ describe '#positive? / #negative? / #label' do
91
+ it 'returns :approach for positive valence' do
92
+ pos = described_class.new(action: :a, domain: :d, valence: 0.5)
93
+ expect(pos.positive?).to be true
94
+ expect(pos.negative?).to be false
95
+ expect(pos.label).to eq(:approach)
96
+ end
97
+
98
+ it 'returns :avoid for negative valence' do
99
+ neg = described_class.new(action: :a, domain: :d, valence: -0.5)
100
+ expect(neg.positive?).to be false
101
+ expect(neg.negative?).to be true
102
+ expect(neg.label).to eq(:avoid)
103
+ end
104
+
105
+ it 'returns :neutral for middle valence' do
106
+ mid = described_class.new(action: :a, domain: :d, valence: 0.0)
107
+ expect(mid.positive?).to be false
108
+ expect(mid.negative?).to be false
109
+ expect(mid.label).to eq(:neutral)
110
+ end
111
+ end
112
+
113
+ describe '#to_h' do
114
+ it 'returns a hash with all fields' do
115
+ h = marker.to_h
116
+ expect(h).to include(:id, :action, :domain, :valence, :strength, :label, :created_at)
117
+ expect(h[:action]).to eq(:deploy)
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Interoception::Runners::Interoception do
4
+ let(:client) { Legion::Extensions::Interoception::Client.new }
5
+
6
+ describe '#report_vital' do
7
+ it 'reports and smooths a vital signal' do
8
+ result = client.report_vital(channel: :cpu_load, value: 0.6)
9
+ expect(result[:success]).to be true
10
+ expect(result[:channel]).to eq(:cpu_load)
11
+ expect(result[:smoothed]).to be_a(Float)
12
+ expect(result).to have_key(:deviation)
13
+ expect(result).to have_key(:label)
14
+ end
15
+
16
+ it 'tracks deviation from baseline' do
17
+ 5.times { client.report_vital(channel: :cpu_load, value: 0.2) }
18
+ result = client.report_vital(channel: :cpu_load, value: 0.9)
19
+ expect(result[:deviation].abs).to be > 0
20
+ end
21
+ end
22
+
23
+ describe '#create_somatic_marker' do
24
+ it 'creates a marker with correct fields' do
25
+ result = client.create_somatic_marker(action: :deploy, domain: :prod, valence: 0.7)
26
+ expect(result[:success]).to be true
27
+ expect(result[:marker][:action]).to eq(:deploy)
28
+ expect(result[:marker][:valence]).to eq(0.7)
29
+ expect(result[:marker][:label]).to eq(:approach)
30
+ end
31
+
32
+ it 'creates negative marker' do
33
+ result = client.create_somatic_marker(action: :delete_data, domain: :prod, valence: -0.8)
34
+ expect(result[:marker][:label]).to eq(:avoid)
35
+ end
36
+ end
37
+
38
+ describe '#query_bias' do
39
+ it 'returns neutral with no markers' do
40
+ result = client.query_bias(action: :deploy)
41
+ expect(result[:success]).to be true
42
+ expect(result[:bias]).to eq(0.0)
43
+ expect(result[:label]).to eq(:neutral)
44
+ end
45
+
46
+ it 'returns approach bias for positive markers' do
47
+ client.create_somatic_marker(action: :deploy, domain: :prod, valence: 0.8)
48
+ result = client.query_bias(action: :deploy)
49
+ expect(result[:bias]).to be > 0
50
+ end
51
+
52
+ it 'returns avoid bias for negative markers' do
53
+ client.create_somatic_marker(action: :risky_change, domain: :prod, valence: -0.9)
54
+ result = client.query_bias(action: :risky_change)
55
+ expect(result[:bias]).to be < 0
56
+ end
57
+ end
58
+
59
+ describe '#reinforce_somatic' do
60
+ it 'reinforces matching markers' do
61
+ client.create_somatic_marker(action: :deploy, domain: :prod, valence: 0.5, strength: 0.3)
62
+ result = client.reinforce_somatic(action: :deploy, amount: 0.2)
63
+ expect(result[:success]).to be true
64
+ end
65
+ end
66
+
67
+ describe '#deviating_vitals' do
68
+ it 'returns deviating channels' do
69
+ result = client.deviating_vitals
70
+ expect(result[:success]).to be true
71
+ expect(result[:deviations]).to be_an(Array)
72
+ end
73
+ end
74
+
75
+ describe '#body_status' do
76
+ it 'returns overall health status' do
77
+ client.report_vital(channel: :cpu_load, value: 0.2)
78
+ result = client.body_status
79
+ expect(result[:success]).to be true
80
+ expect(result[:health]).to be_a(Float)
81
+ expect(result).to have_key(:label)
82
+ expect(result).to have_key(:channels)
83
+ expect(result).to have_key(:markers)
84
+ end
85
+ end
86
+
87
+ describe '#update_interoception' do
88
+ it 'decays markers and returns status' do
89
+ client.create_somatic_marker(action: :deploy, domain: :prod, valence: 0.5)
90
+ result = client.update_interoception
91
+ expect(result[:success]).to be true
92
+ expect(result).to have_key(:health)
93
+ expect(result).to have_key(:label)
94
+ expect(result).to have_key(:channels)
95
+ expect(result).to have_key(:markers)
96
+ end
97
+ end
98
+
99
+ describe '#interoception_stats' do
100
+ it 'returns comprehensive stats' do
101
+ client.report_vital(channel: :cpu_load, value: 0.4)
102
+ client.create_somatic_marker(action: :deploy, domain: :prod, valence: 0.7)
103
+ result = client.interoception_stats
104
+ expect(result[:success]).to be true
105
+ expect(result[:stats]).to include(:overall_health, :body_budget_label, :channels, :markers)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ module Legion
6
+ module Logging
7
+ def self.debug(_msg); end
8
+ def self.info(_msg); end
9
+ def self.warn(_msg); end
10
+ def self.error(_msg); end
11
+ end
12
+ end
13
+
14
+ require 'legion/extensions/interoception'
15
+
16
+ RSpec.configure do |config|
17
+ config.example_status_persistence_file_path = '.rspec_status'
18
+ config.disable_monkey_patching!
19
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
20
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-interoception
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Esity
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: legion-gaia
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: Somatic marker hypothesis for brain-modeled agentic AI — internal body-state
27
+ monitoring, vital signal tracking, and somatic markers that bias decisions based
28
+ on past outcomes associated with similar internal states.
29
+ email:
30
+ - matthewdiverson@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - Gemfile
36
+ - lex-interoception.gemspec
37
+ - lib/legion/extensions/interoception.rb
38
+ - lib/legion/extensions/interoception/actors/decay.rb
39
+ - lib/legion/extensions/interoception/client.rb
40
+ - lib/legion/extensions/interoception/helpers/body_budget.rb
41
+ - lib/legion/extensions/interoception/helpers/constants.rb
42
+ - lib/legion/extensions/interoception/helpers/somatic_marker.rb
43
+ - lib/legion/extensions/interoception/runners/interoception.rb
44
+ - lib/legion/extensions/interoception/version.rb
45
+ - spec/legion/extensions/interoception/client_spec.rb
46
+ - spec/legion/extensions/interoception/helpers/body_budget_spec.rb
47
+ - spec/legion/extensions/interoception/helpers/somatic_marker_spec.rb
48
+ - spec/legion/extensions/interoception/runners/interoception_spec.rb
49
+ - spec/spec_helper.rb
50
+ homepage: https://github.com/LegionIO/lex-interoception
51
+ licenses:
52
+ - MIT
53
+ metadata:
54
+ homepage_uri: https://github.com/LegionIO/lex-interoception
55
+ source_code_uri: https://github.com/LegionIO/lex-interoception
56
+ documentation_uri: https://github.com/LegionIO/lex-interoception
57
+ changelog_uri: https://github.com/LegionIO/lex-interoception
58
+ bug_tracker_uri: https://github.com/LegionIO/lex-interoception/issues
59
+ rubygems_mfa_required: 'true'
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '3.4'
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 3.6.9
75
+ specification_version: 4
76
+ summary: LEX Interoception
77
+ test_files: []