lex-surprise 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: 22083a1c0263ca15e137e1df847e5c4f7619a6148116adb37a3f587c9ad4a379
4
+ data.tar.gz: b0d6ae0f77e5574d2ea4d400c1df123e5a64f1af9e12e35b88f532b6b0658d0b
5
+ SHA512:
6
+ metadata.gz: 9477cfc33f2e546dd75d6ab8bceb71f52a1c468d4555a0f914922185909a03de7fb16fe95c46ef06413aeaaac9191b67f661dd74a6b26af5e7909814380b44a1
7
+ data.tar.gz: 981d88fe05c424d1a2ee6fa21afd09fbf7b1a770fc93fb5f2175c33f9ca4f32ba5ccfceff918a6eef69b87bbc048ee07afd82e3869e288dfa6948057769b8c3a
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Surprise
6
+ class Client
7
+ include Runners::Surprise
8
+
9
+ attr_reader :store, :habituation_model
10
+
11
+ def initialize(store: nil, habituation_model: nil, **)
12
+ @store = store || Helpers::SurpriseStore.new
13
+ @habituation_model = habituation_model || Helpers::HabituationModel.new
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Surprise
6
+ module Helpers
7
+ module Constants
8
+ SURPRISE_THRESHOLD = 0.4
9
+
10
+ HABITUATION_RATE = 0.05
11
+
12
+ SENSITIZATION_RATE = 0.02
13
+
14
+ SURPRISE_DECAY = 0.1
15
+
16
+ MAX_SURPRISE_HISTORY = 200
17
+
18
+ SURPRISE_ALPHA = 0.15
19
+
20
+ VALENCE_WEIGHTS = { positive: 0.6, negative: 1.0, neutral: 0.3 }.freeze
21
+
22
+ DOMAIN_HABITUATION_FLOOR = 0.1
23
+
24
+ MAX_DOMAINS = 50
25
+
26
+ ORIENTING_COOLDOWN = 3
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Surprise
6
+ module Helpers
7
+ class HabituationModel
8
+ def initialize
9
+ @levels = {}
10
+ end
11
+
12
+ def sensitivity_for(domain)
13
+ @levels.fetch(domain, 1.0)
14
+ end
15
+
16
+ def habituate(domain)
17
+ current = sensitivity_for(domain)
18
+ floor = Constants::DOMAIN_HABITUATION_FLOOR
19
+ updated = [current - Constants::HABITUATION_RATE, floor].max
20
+ @levels[domain] = updated
21
+ enforce_domain_limit
22
+ updated
23
+ end
24
+
25
+ def sensitize(domain)
26
+ current = sensitivity_for(domain)
27
+ updated = [current + Constants::SENSITIZATION_RATE, 1.0].min
28
+ @levels[domain] = updated
29
+ updated
30
+ end
31
+
32
+ def decay_all
33
+ @levels.each_key do |domain|
34
+ current = @levels[domain]
35
+ @levels[domain] = [current + (Constants::SENSITIZATION_RATE * 0.5), 1.0].min
36
+ end
37
+ end
38
+
39
+ def to_h
40
+ {
41
+ domains: @levels.size,
42
+ sensitivities: @levels.transform_values { |v| v.round(4) }
43
+ }
44
+ end
45
+
46
+ private
47
+
48
+ def enforce_domain_limit
49
+ return unless @levels.size > Constants::MAX_DOMAINS
50
+
51
+ oldest_key = @levels.keys.first
52
+ @levels.delete(oldest_key)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Surprise
8
+ module Helpers
9
+ class SurpriseEvent
10
+ attr_reader :id, :domain, :predicted, :actual, :magnitude, :valence, :timestamp, :orienting
11
+
12
+ def initialize(domain:, predicted:, actual:, magnitude:, valence:, orienting: false) # rubocop:disable Metrics/ParameterLists
13
+ @id = SecureRandom.uuid
14
+ @domain = domain
15
+ @predicted = predicted
16
+ @actual = actual
17
+ @magnitude = magnitude.clamp(0.0, 1.0)
18
+ @valence = valence
19
+ @orienting = orienting
20
+ @timestamp = Time.now.utc
21
+ end
22
+
23
+ def to_h
24
+ {
25
+ id: @id,
26
+ domain: @domain,
27
+ predicted: @predicted,
28
+ actual: @actual,
29
+ magnitude: @magnitude.round(4),
30
+ valence: @valence,
31
+ orienting: @orienting,
32
+ timestamp: @timestamp
33
+ }
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Surprise
6
+ module Helpers
7
+ class SurpriseStore
8
+ attr_reader :events
9
+
10
+ def initialize
11
+ @events = []
12
+ @baselines = {}
13
+ end
14
+
15
+ def record(event)
16
+ @events << event
17
+ update_baseline(event.domain, event.magnitude)
18
+ trim
19
+ event
20
+ end
21
+
22
+ def recent(count = 10)
23
+ @events.last(count)
24
+ end
25
+
26
+ def by_domain(domain)
27
+ @events.select { |e| e.domain == domain }
28
+ end
29
+
30
+ def most_surprising(count = 5)
31
+ @events.sort_by { |e| -e.magnitude }.first(count)
32
+ end
33
+
34
+ def baseline_for(domain)
35
+ @baselines.fetch(domain, 0.0)
36
+ end
37
+
38
+ def to_h
39
+ total = @events.size
40
+ domains = @events.map(&:domain).uniq
41
+ avg_mag = total.positive? ? (@events.sum(&:magnitude) / total).round(4) : 0.0
42
+ top = most_surprising(1).first
43
+
44
+ {
45
+ total_events: total,
46
+ domain_count: domains.size,
47
+ average_magnitude: avg_mag,
48
+ most_surprising_domain: top&.domain,
49
+ baselines: @baselines.transform_values { |v| v.round(4) }
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ def update_baseline(domain, magnitude)
56
+ prior = @baselines.fetch(domain, magnitude)
57
+ alpha = Constants::SURPRISE_ALPHA
58
+ @baselines[domain] = ((1 - alpha) * prior) + (alpha * magnitude)
59
+ end
60
+
61
+ def trim
62
+ return unless @events.size > Constants::MAX_SURPRISE_HISTORY
63
+
64
+ @events.shift(@events.size - Constants::MAX_SURPRISE_HISTORY)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Surprise
6
+ module Runners
7
+ module Surprise
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ # Evaluate a single prediction-outcome pair and compute surprise magnitude.
12
+ # magnitude = |predicted - actual| * sensitivity * valence_weight, clamped to [0,1]
13
+ def evaluate_surprise(domain:, predicted:, actual:, valence: :neutral, **)
14
+ sensitivity = habituation_model.sensitivity_for(domain)
15
+ valence_weight = Helpers::Constants::VALENCE_WEIGHTS.fetch(valence, 0.3)
16
+ raw_diff = (predicted.to_f - actual.to_f).abs
17
+ magnitude = (raw_diff * sensitivity * valence_weight).clamp(0.0, 1.0)
18
+
19
+ threshold = Helpers::Constants::SURPRISE_THRESHOLD
20
+ orienting = should_orient?(domain, magnitude, threshold)
21
+
22
+ event = Helpers::SurpriseEvent.new(
23
+ domain: domain,
24
+ predicted: predicted,
25
+ actual: actual,
26
+ magnitude: magnitude,
27
+ valence: valence,
28
+ orienting: orienting
29
+ )
30
+
31
+ store.record(event)
32
+ habituation_model.habituate(domain)
33
+
34
+ if orienting
35
+ record_cooldown(domain)
36
+ Legion::Logging.debug "[surprise] orienting response triggered: domain=#{domain} magnitude=#{magnitude.round(3)}"
37
+ else
38
+ Legion::Logging.debug "[surprise] surprise recorded: domain=#{domain} magnitude=#{magnitude.round(3)} orienting=false"
39
+ end
40
+
41
+ { success: true, surprise_event: event.to_h, orienting_triggered: orienting }
42
+ end
43
+
44
+ # Per-tick update: extract domain predictions from tick_result, compute surprise for each,
45
+ # decay the store's baseline tracking, and return a summary.
46
+ def update_surprise(tick_result: {}, **)
47
+ predictions = extract_predictions(tick_result)
48
+ events = []
49
+
50
+ predictions.each do |pred|
51
+ next unless pred[:domain] && !pred[:predicted].nil? && !pred[:actual].nil?
52
+
53
+ result = evaluate_surprise(
54
+ domain: pred[:domain],
55
+ predicted: pred[:predicted],
56
+ actual: pred[:actual],
57
+ valence: pred.fetch(:valence, :neutral)
58
+ )
59
+ events << result[:surprise_event] if result[:success]
60
+ end
61
+
62
+ habituation_model.decay_all
63
+ tick_cooldowns
64
+
65
+ orienting_count = events.count { |e| e[:orienting] }
66
+ Legion::Logging.debug "[surprise] tick update: evaluated=#{events.size} orienting=#{orienting_count}"
67
+
68
+ {
69
+ success: true,
70
+ evaluated: events.size,
71
+ orienting_count: orienting_count,
72
+ events: events
73
+ }
74
+ end
75
+
76
+ def surprise_stats(**)
77
+ stats = store.to_h
78
+ top = store.most_surprising(1).first
79
+ avg_mag = stats[:average_magnitude]
80
+
81
+ Legion::Logging.debug "[surprise] stats: total=#{stats[:total_events]} domains=#{stats[:domain_count]}"
82
+
83
+ {
84
+ success: true,
85
+ total_events: stats[:total_events],
86
+ domain_count: stats[:domain_count],
87
+ average_magnitude: avg_mag,
88
+ most_surprising_domain: stats[:most_surprising_domain],
89
+ top_surprise_magnitude: top&.magnitude&.round(4)
90
+ }
91
+ end
92
+
93
+ def domain_sensitivity(domain:, **)
94
+ sensitivity = habituation_model.sensitivity_for(domain)
95
+ baseline = store.baseline_for(domain)
96
+ domain_events = store.by_domain(domain)
97
+
98
+ Legion::Logging.debug "[surprise] domain_sensitivity: domain=#{domain} sensitivity=#{sensitivity.round(3)}"
99
+
100
+ {
101
+ success: true,
102
+ domain: domain,
103
+ sensitivity: sensitivity.round(4),
104
+ baseline: baseline.round(4),
105
+ event_count: domain_events.size
106
+ }
107
+ end
108
+
109
+ def recent_surprises(count: 10, **)
110
+ events = store.recent(count)
111
+ Legion::Logging.debug "[surprise] recent_surprises: count=#{events.size}"
112
+ { success: true, events: events.map(&:to_h), count: events.size }
113
+ end
114
+
115
+ def reset_habituation(domain:, **)
116
+ old_sensitivity = habituation_model.sensitivity_for(domain)
117
+ # Sensitize repeatedly to push back toward 1.0
118
+ steps = ((1.0 - old_sensitivity) / Helpers::Constants::SENSITIZATION_RATE).ceil
119
+ steps.times { habituation_model.sensitize(domain) }
120
+ new_sensitivity = habituation_model.sensitivity_for(domain)
121
+
122
+ Legion::Logging.debug "[surprise] reset_habituation: domain=#{domain} #{old_sensitivity.round(3)} -> #{new_sensitivity.round(3)}"
123
+
124
+ { success: true, domain: domain, old_sensitivity: old_sensitivity.round(4), new_sensitivity: new_sensitivity.round(4) }
125
+ end
126
+
127
+ private
128
+
129
+ def store
130
+ @store ||= Helpers::SurpriseStore.new
131
+ end
132
+
133
+ def habituation_model
134
+ @habituation_model ||= Helpers::HabituationModel.new
135
+ end
136
+
137
+ def cooldowns
138
+ @cooldowns ||= {}
139
+ end
140
+
141
+ def tick_cooldowns
142
+ cooldowns.each_key { |domain| cooldowns[domain] -= 1 }
143
+ cooldowns.reject! { |_, ticks_left| ticks_left <= 0 }
144
+ end
145
+
146
+ def record_cooldown(domain)
147
+ cooldowns[domain] = Helpers::Constants::ORIENTING_COOLDOWN
148
+ end
149
+
150
+ def on_cooldown?(domain)
151
+ (cooldowns[domain] || 0).positive?
152
+ end
153
+
154
+ def should_orient?(domain, magnitude, threshold)
155
+ return false if magnitude < threshold
156
+ return false if on_cooldown?(domain)
157
+
158
+ true
159
+ end
160
+
161
+ def extract_predictions(tick_result)
162
+ return [] unless tick_result.is_a?(Hash)
163
+
164
+ tick_result.fetch(:predictions, [])
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Surprise
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'surprise/version'
4
+ require_relative 'surprise/helpers/constants'
5
+ require_relative 'surprise/helpers/surprise_event'
6
+ require_relative 'surprise/helpers/habituation_model'
7
+ require_relative 'surprise/helpers/surprise_store'
8
+ require_relative 'surprise/runners/surprise'
9
+ require_relative 'surprise/client'
10
+
11
+ module Legion
12
+ module Extensions
13
+ module Surprise
14
+ extend Legion::Extensions::Core if defined?(Legion::Extensions::Core)
15
+ end
16
+ end
17
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-surprise
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Matthew Iverson
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: Detects when reality deviates from expectations using Bayesian surprise
27
+ (KL divergence). Tracks habituation, generates orienting signals, and adapts sensitivity
28
+ per domain.
29
+ email:
30
+ - matt@legionIO.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - lib/legion/extensions/surprise.rb
36
+ - lib/legion/extensions/surprise/client.rb
37
+ - lib/legion/extensions/surprise/helpers/constants.rb
38
+ - lib/legion/extensions/surprise/helpers/habituation_model.rb
39
+ - lib/legion/extensions/surprise/helpers/surprise_event.rb
40
+ - lib/legion/extensions/surprise/helpers/surprise_store.rb
41
+ - lib/legion/extensions/surprise/runners/surprise.rb
42
+ - lib/legion/extensions/surprise/version.rb
43
+ homepage: https://github.com/LegionIO/lex-surprise
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ rubygems_mfa_required: 'true'
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '3.4'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.6.9
63
+ specification_version: 4
64
+ summary: Orienting response and surprise detection for LegionIO cognitive agents
65
+ test_files: []