legion-gaia 0.9.59 → 0.9.60

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bcc0656f2d4922688542c1a7a9af486aea087a887f7faf929aa29c75c6ed1b9d
4
- data.tar.gz: 27af3cc6e41f056d98c69f7ec83fa8ea7a2e379e4d7a517913a7d71f719ab6bc
3
+ metadata.gz: 2fc298cf66a402057db523e07a53e4bde4a30a7b3e8090b819b8b1f64569c589
4
+ data.tar.gz: 6226b64c0fc7ace2122f014784b21f074fae19ebfc214360b5288cfc7d9e80d0
5
5
  SHA512:
6
- metadata.gz: ea478d48bf2e25902220796b962fd89ebc6c1925453164d4da5902a10af9b08bfbfa6aa5cdc4a062a8b5be189315c739d6ad2cbad87ceb6d8624a54a501da320
7
- data.tar.gz: f49728af3557e2a11266dbabd3cd8e8c56878a5bec7ccabcc25ffe115d02c05e0308df9b2fc29b9c89e2ed79000d2855c8cdd7e825eaa1d68f11ee5340e509c0
6
+ metadata.gz: d74b4bc1634a51f8759771f8c77711a58c92627487ca3c98e2af22c2244ab1c3efc63de5364656179d3232aa8d9ee9a0faa48f7a35d28dfa34aef21e493351bb
7
+ data.tar.gz: 1d4f2872dc1b9e332160f34676dc1f5950dd6a13b2d72a050c4c65b693546e8743309ba34148cdc3d18cb103127279c5dd8d055832ad2db41e182dcb0adf17d5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.60] - 2026-07-16
4
+ ### Added
5
+ - H2: BehavioralSynapse store with vendored Confidence math (lifecycle: crystallize, lazy decay with intensity resistance, grade via record_outcome, pain at 3 consecutive failures → dampened + event emission)
6
+ - `record_response_applied` public method — attribution facade for legion-llm's final-delivery hook; persists to Apollo Local with self-knowledge/attribution tags
7
+ - Grading path in `evaluate_calibration`: reaction_score >= 0.6 → :success, <= 0.4 → :failure; maps each applied synapse_id via BehavioralSynapse.record_outcome scaled by imprint multiplier
8
+ - TrackerPersistence integration for BehavioralSynapse: Tracker wrapper, hydrate at boot alongside bonds, flush_dirty/flush_all on heartbeat/shutdown
9
+
3
10
  ## [0.9.59] - 2026-07-16
4
11
  ### Added
5
12
  - H0: Partner identity as first-class store dimension — `record_interaction_trace` adds `partner:<identity>` domain tag; `SessionStore#erase_partner!(identity:)`; `AuditObserver` tool patterns keyed per-identity with `tool_patterns_for(identity)` and `erase_partner!(identity:)`; `TrackerPersistence.register_tracker` is idempotent (re-registration is a no-op, safe for partial-boot resume); fail-closed: no partner state written to shared Postgres when Apollo local is unavailable
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent/hash'
4
+ require 'securerandom'
5
+ require 'legion/json'
6
+ require 'legion/logging/helper'
7
+
8
+ module Legion
9
+ module Gaia
10
+ module BehavioralSynapse # rubocop:disable Metrics/ModuleLength
11
+ extend Legion::Logging::Helper
12
+
13
+ TO_APOLLO_TAGS = %w[self-knowledge behavior].freeze
14
+
15
+ # Vendored confidence math — identical constants to Legion::Extensions::Synapse::Helpers::Confidence.
16
+ # At runtime we delegate to the real module if it is loaded.
17
+ module Math
18
+ STARTING_SCORES = { explicit: 0.7, emergent: 0.3, seeded: 0.5 }.freeze
19
+ ADJUSTMENTS = {
20
+ success: 0.02,
21
+ failure: -0.05,
22
+ validation_failure: -0.03,
23
+ consecutive_bonus: 0.05
24
+ }.freeze
25
+ DECAY_RATE = 0.998
26
+ CONSECUTIVE_BONUS_THRESHOLD = 50
27
+ AUTONOMY_RANGES = {
28
+ observe: 0.0..0.3,
29
+ filter: 0.3..0.6,
30
+ transform: 0.6..0.8,
31
+ autonomous: 0.8..1.0
32
+ }.freeze
33
+ E_WEIGHT = 3.0
34
+
35
+ module_function
36
+
37
+ def starting_score(origin)
38
+ STARTING_SCORES.fetch(origin.to_sym, 0.3)
39
+ end
40
+
41
+ def adjust(confidence, event, consecutive_successes: 0)
42
+ delta = ADJUSTMENTS.fetch(event.to_sym, 0.0)
43
+ delta += ADJUSTMENTS[:consecutive_bonus] if consecutive_successes >= CONSECUTIVE_BONUS_THRESHOLD
44
+ (confidence + delta).clamp(0.0, 1.0)
45
+ end
46
+
47
+ def decay(confidence, hours: 1)
48
+ (confidence * (DECAY_RATE**hours)).clamp(0.0, 1.0)
49
+ end
50
+
51
+ def autonomy_mode(confidence)
52
+ AUTONOMY_RANGES.find { |_mode, range| range.cover?(confidence) }&.first || :observe
53
+ end
54
+ end
55
+
56
+ @store = Concurrent::Hash.new
57
+ @dirty = false
58
+ @mutex = Mutex.new
59
+
60
+ module_function
61
+
62
+ # --- Confidence math delegation ---
63
+
64
+ def starting_score(origin)
65
+ if defined?(Legion::Extensions::Synapse::Helpers::Confidence)
66
+ Legion::Extensions::Synapse::Helpers::Confidence.starting_score(origin)
67
+ else
68
+ Math.starting_score(origin)
69
+ end
70
+ end
71
+
72
+ def adjust(confidence, event, consecutive_successes: 0)
73
+ if defined?(Legion::Extensions::Synapse::Helpers::Confidence)
74
+ Legion::Extensions::Synapse::Helpers::Confidence.adjust(confidence, event,
75
+ consecutive_successes: consecutive_successes)
76
+ else
77
+ Math.adjust(confidence, event, consecutive_successes: consecutive_successes)
78
+ end
79
+ end
80
+
81
+ def decay_confidence(confidence, hours: 1)
82
+ if defined?(Legion::Extensions::Synapse::Helpers::Confidence)
83
+ Legion::Extensions::Synapse::Helpers::Confidence.decay(confidence, hours: hours)
84
+ else
85
+ Math.decay(confidence, hours: hours)
86
+ end
87
+ end
88
+
89
+ def e_weight
90
+ if defined?(Legion::Extensions::Synapse::Helpers::Confidence)
91
+ Legion::Extensions::Synapse::Helpers::Confidence::E_WEIGHT
92
+ else
93
+ Math::E_WEIGHT
94
+ end
95
+ end
96
+
97
+ # --- Public API ---
98
+
99
+ def for(identity:, domain:)
100
+ key = store_key(identity, domain)
101
+ entry = @store[key]
102
+ return nil unless entry
103
+
104
+ apply_lazy_decay(entry)
105
+ end
106
+
107
+ def crystallize(identity:, domain:, directive:, evidence_trace_ids: [], origin: 'emergent')
108
+ key = store_key(identity, domain)
109
+ @mutex.synchronize do
110
+ return @store[key] if @store[key]
111
+
112
+ conf = starting_score(origin.to_sym)
113
+ now = Time.now.utc
114
+ entry = {
115
+ id: SecureRandom.uuid,
116
+ identity: identity.to_s,
117
+ domain: domain.to_s,
118
+ origin: origin.to_s,
119
+ confidence: conf,
120
+ emotional_valence: 0.0,
121
+ emotional_intensity: 0.0,
122
+ consecutive_failures: 0,
123
+ consecutive_successes: 0,
124
+ directive: directive,
125
+ evidence_trace_ids: Array(evidence_trace_ids),
126
+ status: 'active',
127
+ last_applied_at: nil,
128
+ last_reinforced_at: now,
129
+ created_at: now
130
+ }
131
+ @store[key] = entry
132
+ @dirty = true
133
+ log.info("[gaia] synapse crystallized identity=#{identity} domain=#{domain} origin=#{origin} conf=#{conf}")
134
+ entry
135
+ end
136
+ end
137
+
138
+ def record_outcome(id:, outcome:, multiplier: 1.0)
139
+ @mutex.synchronize do
140
+ entry = find_by_id(id)
141
+ return { found: false } unless entry
142
+
143
+ conf = entry[:confidence]
144
+ base_event = outcome.to_sym
145
+ new_conf = adjust(conf, base_event, consecutive_successes: entry[:consecutive_successes])
146
+
147
+ # Apply multiplier to delta (scale the delta, not the raw confidence)
148
+ raw_delta = new_conf - conf
149
+ scaled_conf = (conf + (raw_delta * multiplier)).clamp(0.0, 1.0)
150
+
151
+ if base_event == :success
152
+ entry[:consecutive_successes] = entry[:consecutive_successes].to_i + 1
153
+ entry[:consecutive_failures] = 0
154
+ elsif base_event == :failure
155
+ entry[:consecutive_failures] = entry[:consecutive_failures].to_i + 1
156
+ entry[:consecutive_successes] = 0
157
+ end
158
+
159
+ entry[:confidence] = scaled_conf
160
+ entry[:last_reinforced_at] = Time.now.utc
161
+ @dirty = true
162
+
163
+ apply_pain(entry) if entry[:consecutive_failures].to_i >= 3
164
+
165
+ entry
166
+ end
167
+ end
168
+
169
+ def all_for(identity:)
170
+ prefix = "#{identity}:"
171
+ @store.select { |key, _| key.start_with?(prefix) }.values
172
+ end
173
+
174
+ def erase_partner!(identity:)
175
+ prefix = "#{identity}:"
176
+ keys = @store.keys.select { |key| key.start_with?(prefix) }
177
+ keys.each { |key| @store.delete(key) }
178
+ @mutex.synchronize { @dirty = true } unless keys.empty?
179
+ log.info("[gaia] BehavioralSynapse erased identity=#{identity} count=#{keys.size}")
180
+ keys.size
181
+ end
182
+
183
+ def store
184
+ @store
185
+ end
186
+
187
+ def reset!
188
+ @mutex.synchronize do
189
+ @store = Concurrent::Hash.new
190
+ @dirty = false
191
+ end
192
+ end
193
+
194
+ # --- TrackerPersistence support ---
195
+
196
+ def dirty?
197
+ @dirty == true
198
+ end
199
+
200
+ def mark_clean!
201
+ @dirty = false
202
+ end
203
+
204
+ def to_apollo_entries
205
+ @store.values.map do |entry|
206
+ identity = entry[:identity]
207
+ {
208
+ content: Legion::JSON.dump(entry),
209
+ tags: TO_APOLLO_TAGS + ["partner:#{identity}"],
210
+ confidence: entry[:confidence] || 0.3,
211
+ access_scope: 'local'
212
+ }
213
+ end
214
+ end
215
+
216
+ def from_apollo(store: nil)
217
+ return unless store
218
+
219
+ result = store.query(text: 'behavior', tags: TO_APOLLO_TAGS)
220
+ return unless result.is_a?(Hash) && result[:success]
221
+
222
+ count = 0
223
+ result[:results]&.each do |entry|
224
+ hydrate_apollo_entry(entry)
225
+ count += 1
226
+ rescue StandardError => e
227
+ log.debug("[gaia] BehavioralSynapse skipped unparseable Apollo entry: #{e.message}")
228
+ end
229
+ log.info("[gaia] BehavioralSynapse hydrated from Apollo count=#{count}")
230
+ rescue StandardError => e
231
+ handle_exception(e, level: :warn, operation: 'gaia.behavioral_synapse.from_apollo')
232
+ end
233
+
234
+ private
235
+
236
+ def store_key(identity, domain)
237
+ "#{identity}:#{domain}"
238
+ end
239
+
240
+ def find_by_id(id)
241
+ @store.values.find { |entry| entry[:id] == id }
242
+ end
243
+
244
+ def apply_lazy_decay(entry)
245
+ return entry unless entry[:last_reinforced_at]
246
+
247
+ hours = (Time.now.utc - entry[:last_reinforced_at]) / 3600.0
248
+ intensity = entry[:emotional_intensity].to_f
249
+ effective_hours = hours / (1 + (intensity * e_weight))
250
+ return entry if effective_hours < 0.001
251
+
252
+ new_conf = decay_confidence(entry[:confidence], hours: effective_hours)
253
+ @mutex.synchronize do
254
+ entry[:confidence] = new_conf
255
+ entry[:last_reinforced_at] = Time.now.utc
256
+ @dirty = true
257
+ end
258
+ entry
259
+ end
260
+
261
+ def apply_pain(entry)
262
+ entry[:status] = 'dampened'
263
+ entry[:confidence] = 0.29
264
+ entry[:consecutive_failures] = 0
265
+ entry[:consecutive_successes] = 0
266
+ log.warn("[gaia] synapse pain id=#{entry[:id]} domain=#{entry[:domain]} identity=#{entry[:identity]}")
267
+
268
+ if defined?(Legion::Events) && Legion::Events.respond_to?(:emit)
269
+ Legion::Events.emit('gaia.behavior.reverted',
270
+ id: entry[:id], identity: entry[:identity], domain: entry[:domain])
271
+ end
272
+ rescue StandardError => e
273
+ handle_exception(e, level: :warn, operation: 'gaia.behavioral_synapse.apply_pain', id: entry[:id])
274
+ end
275
+
276
+ def hydrate_apollo_entry(raw_entry)
277
+ return unless raw_entry[:content].to_s.start_with?('{')
278
+
279
+ parsed = Legion::JSON.load(raw_entry[:content])
280
+ return unless parsed[:id] && parsed[:identity] && parsed[:domain]
281
+
282
+ key = store_key(parsed[:identity], parsed[:domain])
283
+ @mutex.synchronize do
284
+ @store[key] = {
285
+ id: parsed[:id],
286
+ identity: parsed[:identity].to_s,
287
+ domain: parsed[:domain].to_s,
288
+ origin: parsed[:origin].to_s,
289
+ confidence: parsed[:confidence].to_f,
290
+ emotional_valence: parsed[:emotional_valence].to_f,
291
+ emotional_intensity: parsed[:emotional_intensity].to_f,
292
+ consecutive_failures: parsed[:consecutive_failures].to_i,
293
+ consecutive_successes: parsed[:consecutive_successes].to_i,
294
+ directive: parsed[:directive],
295
+ evidence_trace_ids: Array(parsed[:evidence_trace_ids]),
296
+ status: parsed[:status].to_s,
297
+ last_applied_at: parsed[:last_applied_at],
298
+ last_reinforced_at: parse_time(parsed[:last_reinforced_at]),
299
+ created_at: parse_time(parsed[:created_at])
300
+ }
301
+ end
302
+ end
303
+
304
+ def parse_time(value)
305
+ return value if value.is_a?(Time)
306
+ return nil if value.nil?
307
+
308
+ Time.parse(value.to_s)
309
+ rescue ArgumentError, TypeError
310
+ nil
311
+ end
312
+
313
+ module_function :store_key, :find_by_id, :apply_lazy_decay, :apply_pain, :hydrate_apollo_entry, :parse_time
314
+
315
+ # Wrapper class that satisfies TrackerPersistence interface
316
+ class Tracker
317
+ def dirty?
318
+ BehavioralSynapse.dirty?
319
+ end
320
+
321
+ def mark_clean!
322
+ BehavioralSynapse.mark_clean!
323
+ end
324
+
325
+ def to_apollo_entries
326
+ BehavioralSynapse.to_apollo_entries
327
+ end
328
+
329
+ def from_apollo(store: nil)
330
+ BehavioralSynapse.from_apollo(store: store)
331
+ end
332
+ end
333
+ end
334
+ end
335
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Gaia
5
- VERSION = '0.9.59'
5
+ VERSION = '0.9.60'
6
6
  end
7
7
  end
data/lib/legion/gaia.rb CHANGED
@@ -32,6 +32,7 @@ require 'legion/gaia/proactive'
32
32
  require 'legion/gaia/offline_handler'
33
33
  require 'legion/gaia/proactive_dispatcher'
34
34
  require 'legion/gaia/bond_registry'
35
+ require 'legion/gaia/behavioral_synapse'
35
36
  require 'legion/gaia/tracker_persistence'
36
37
  require 'legion/gaia/router'
37
38
 
@@ -118,6 +119,7 @@ module Legion
118
119
  @absence_pattern_checked_at = nil
119
120
  @last_valences = nil
120
121
  @last_response_at = nil
122
+ @last_applied = nil
121
123
  @tick_history = nil
122
124
  @tick_count = nil
123
125
  @started_at = nil
@@ -258,6 +260,28 @@ module Legion
258
260
  handle_exception(e, level: :warn, operation: 'gaia.record_advisory_meta', advisory_id: advisory_id)
259
261
  end
260
262
 
263
+ def record_response_applied(advisory_id:, identity:, applied:)
264
+ return unless started?
265
+
266
+ synapse_count = Array(applied[:behavioral_synapse_ids]).size
267
+ log.info("[gaia] attribution advisory_id=#{advisory_id} identity=#{identity} synapses=#{synapse_count}")
268
+
269
+ store = apollo_local_store
270
+ store&.upsert(
271
+ content: Legion::JSON.dump(applied.merge(advisory_id: advisory_id, identity: identity)),
272
+ tags: %w[self-knowledge attribution] + ["partner:#{identity}"],
273
+ source_channel: 'gaia',
274
+ confidence: 0.9,
275
+ access_scope: 'local'
276
+ )
277
+
278
+ @last_applied = applied.merge(advisory_id: advisory_id, identity: identity)
279
+ { recorded: true, advisory_id: advisory_id }
280
+ rescue StandardError => e
281
+ handle_exception(e, level: :warn, operation: 'gaia.record_response_applied', advisory_id: advisory_id)
282
+ { recorded: false }
283
+ end
284
+
261
285
  def status
262
286
  return { started: false } unless started?
263
287
 
@@ -377,6 +401,7 @@ module Legion
377
401
  @registry.discover
378
402
  boot_channels
379
403
  boot_agent_bridge
404
+ register_behavioral_synapse_tracker
380
405
  hydrate_from_apollo_local
381
406
  register_provisional_partner_prior
382
407
  log.info('Legion::Gaia agent mode boot complete')
@@ -638,6 +663,8 @@ module Legion
638
663
  log.info("[gaia] calibration identity=#{observation[:identity]} " \
639
664
  "deltas=#{result[:deltas].keys.join(',')}")
640
665
  end
666
+
667
+ grade_behavioral_synapses(result) if result.is_a?(Hash) && result[:reaction_score]
641
668
  rescue StandardError => e
642
669
  handle_exception(e, level: :warn, operation: 'gaia.evaluate_calibration', identity: observation[:identity])
643
670
  end
@@ -655,6 +682,31 @@ module Legion
655
682
  @calibration_runner = runner
656
683
  end
657
684
 
685
+ def grade_behavioral_synapses(calibration_result)
686
+ return unless @last_applied.is_a?(Hash)
687
+
688
+ synapse_ids = Array(@last_applied[:behavioral_synapse_ids])
689
+ return if synapse_ids.empty?
690
+
691
+ reaction_score = calibration_result[:reaction_score].to_f
692
+ outcome = if reaction_score >= 0.6
693
+ :success
694
+ elsif reaction_score <= 0.4
695
+ :failure
696
+ end
697
+ return unless outcome
698
+
699
+ multiplier = fetch_imprint_multiplier
700
+ synapse_ids.each do |sid|
701
+ BehavioralSynapse.record_outcome(id: sid, outcome: outcome, multiplier: multiplier)
702
+ end
703
+ log.info("[gaia] graded synapses count=#{synapse_ids.size} outcome=#{outcome} " \
704
+ "reaction_score=#{reaction_score.round(3)}")
705
+ @last_applied = nil
706
+ rescue StandardError => e
707
+ handle_exception(e, level: :warn, operation: 'gaia.grade_behavioral_synapses')
708
+ end
709
+
658
710
  def fetch_imprint_multiplier
659
711
  # Soft-dep on lex-coldstart; returns settings default if unavailable
660
712
  return 1.0 unless defined?(Legion::Extensions::Coldstart) && Legion::Extensions::Coldstart.connected?
@@ -835,12 +887,21 @@ module Legion
835
887
  handle_exception(e, level: :debug, operation: 'gaia.flush_trackers_on_shutdown')
836
888
  end
837
889
 
890
+ def register_behavioral_synapse_tracker
891
+ TrackerPersistence.register_tracker(
892
+ :behavioral_synapse,
893
+ tracker: BehavioralSynapse::Tracker.new,
894
+ tags: %w[self-knowledge behavior]
895
+ )
896
+ end
897
+
838
898
  def hydrate_from_apollo_local
839
899
  store = apollo_local_store
840
900
  return unless store
841
901
 
842
902
  TrackerPersistence.hydrate_all(store: store)
843
903
  BondRegistry.hydrate_from_apollo(store: store)
904
+ BehavioralSynapse.from_apollo(store: store)
844
905
  log.info('Legion::Gaia hydrated trackers and bond registry from Apollo Local')
845
906
  rescue StandardError => e
846
907
  handle_exception(e, level: :warn, operation: 'gaia.hydrate_from_apollo_local')
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-gaia
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.59
4
+ version: 0.9.60
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -417,6 +417,7 @@ files:
417
417
  - lib/legion/gaia/actors/heartbeat.rb
418
418
  - lib/legion/gaia/advisory.rb
419
419
  - lib/legion/gaia/audit_observer.rb
420
+ - lib/legion/gaia/behavioral_synapse.rb
420
421
  - lib/legion/gaia/bond_registry.rb
421
422
  - lib/legion/gaia/bond_store.rb
422
423
  - lib/legion/gaia/bond_tracker.rb