lex-agentic-social 0.1.5 → 0.1.6

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: 3a01aa481409269257996188227484fe5ad8f1bd8e4c16abd0a8922ce3b312b8
4
- data.tar.gz: 4ecb04ebefef8d08763f840c3233da964592c58216597d42963da27f44f1d8a7
3
+ metadata.gz: 1e4e4cf74f2655992c34c4945a8da0de92c597fc5371ff6fa595e583a4cdd88a
4
+ data.tar.gz: b0696e9bc1f7b990fb8e3a153fd22755edf34e18a6fdce7ea7efe23eccaa5afb
5
5
  SHA512:
6
- metadata.gz: d2372a7bfab7f4f52b9389ef120e7f851e81755b71f07272ba63c572704424eef92045201815e93b55866cced472cfa3a06d00e2343c33b9523e5b0adc0a7732
7
- data.tar.gz: 50ef5117fe9183f54c6b02ef84df4f130c010701d266156820bed6e4654755009c7772202ab84a39588691879096fd68e9301ee63ce5b28bf471bb26e781c15d
6
+ metadata.gz: 362432beb980f6e62f4097db1c37f444929ce2edd84439daee258de6e15dae346b5c33b09e3bfab19b2ca5d38ecd75b8f65269099247586750c329e05f5e7728
7
+ data.tar.gz: 1309465c2e0655fe62b68b3bfb58cf4f07eb9bbd808215927eb64c240dd4a16575117eb1e3cf6c757d7cd40e586bf3416ce4ddc3ba7feb040d8b2589f6f34c9a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.6] - 2026-03-31
4
+
5
+ ### Added
6
+ - `update_social` accepts `human_observations:` kwarg; processes each observation into reputation signals (partner: 0.8 confidence, others: 0.5) and records communication reciprocity
7
+ - `SocialGraph#reputation_changes` array tracks dimension-level changes per update cycle; cleared at start of each `update_social` call
8
+ - `update_social` return hash includes `:reputation_updates` key with agent-level summary
9
+ - `update_theory_of_mind` accepts `human_observations:` kwarg; builds communication and channel-preference beliefs, infers engagement intent from direct-address observations, validates pending predictions
10
+ - `MentalStateTracker#pending_prediction(agent_id:)` returns most recent unvalidated prediction log entry
11
+ - Dirty tracking (`dirty?`, `mark_clean!`) on `SocialGraph` and `MentalStateTracker`
12
+ - Apollo Local persistence (`to_apollo_entries`, `from_apollo`, `mark_clean!`) on `SocialGraph` and `MentalStateTracker`; partner agents tagged with `'partner'` when `Legion::Gaia::BondRegistry` is present
13
+
3
14
  ## [0.1.5] - 2026-03-30
4
15
 
5
16
  ### Changed
@@ -7,12 +7,50 @@ module Legion
7
7
  module Social
8
8
  module Helpers
9
9
  class SocialGraph
10
- attr_reader :groups, :reputation_scores, :reciprocity_ledger
10
+ attr_reader :groups, :reputation_scores, :reciprocity_ledger, :reputation_changes
11
11
 
12
12
  def initialize
13
13
  @groups = {}
14
14
  @reputation_scores = {}
15
15
  @reciprocity_ledger = []
16
+ @reputation_changes = []
17
+ @dirty = false
18
+ end
19
+
20
+ def dirty?
21
+ @dirty
22
+ end
23
+
24
+ def mark_clean!
25
+ @dirty = false
26
+ self
27
+ end
28
+
29
+ def clear_reputation_changes!
30
+ @reputation_changes = []
31
+ end
32
+
33
+ def to_apollo_entries
34
+ @reputation_scores.map do |agent_id, scores|
35
+ tags = build_apollo_tags(agent_id)
36
+ content = Legion::JSON.dump({
37
+ agent_id: agent_id.to_s,
38
+ scores: scores,
39
+ updated_at: Time.now.utc.iso8601
40
+ })
41
+ { content: content, tags: tags }
42
+ end
43
+ end
44
+
45
+ def from_apollo(store:)
46
+ result = store.query(text: 'social_graph reputation', tags: %w[social_graph reputation])
47
+ return false unless result[:success] && result[:results]&.any?
48
+
49
+ result[:results].each { |entry| restore_from_entry(entry) }
50
+ true
51
+ rescue StandardError => e
52
+ Legion::Logging.warn("[social_graph] from_apollo error: #{e.message}")
53
+ false
16
54
  end
17
55
 
18
56
  def join_group(group_id:, role: :contributor, members: [])
@@ -25,6 +63,7 @@ module Legion
25
63
  violations: []
26
64
  }
27
65
  trim_groups
66
+ @dirty = true
28
67
  @groups[group_id]
29
68
  end
30
69
 
@@ -44,7 +83,11 @@ module Legion
44
83
 
45
84
  @reputation_scores[agent_id] ||= Constants::REPUTATION_DIMENSIONS.keys.to_h { |d| [d, 0.5] }
46
85
  current = @reputation_scores[agent_id][dimension]
47
- @reputation_scores[agent_id][dimension] = ema(current, signal.clamp(0.0, 1.0), Constants::REPUTATION_ALPHA)
86
+ new_score = ema(current, signal.clamp(0.0, 1.0), Constants::REPUTATION_ALPHA)
87
+ @reputation_scores[agent_id][dimension] = new_score
88
+ @reputation_changes << { agent_id: agent_id, dimension: dimension, score: new_score }
89
+ @dirty = true
90
+ new_score
48
91
  end
49
92
 
50
93
  def reputation_for(agent_id)
@@ -80,6 +123,7 @@ module Legion
80
123
  at: Time.now.utc
81
124
  }
82
125
  @reciprocity_ledger.shift while @reciprocity_ledger.size > Constants::RECIPROCITY_WINDOW
126
+ @dirty = true
83
127
  end
84
128
 
85
129
  def reciprocity_balance(agent_id)
@@ -163,6 +207,35 @@ module Legion
163
207
  oldest = @groups.keys.sort_by { |k| @groups[k][:joined_at] }
164
208
  oldest.first([@groups.size - Constants::MAX_GROUPS, 0].max).each { |k| @groups.delete(k) }
165
209
  end
210
+
211
+ def build_apollo_tags(agent_id)
212
+ tags = ['social_graph', 'reputation', agent_id.to_s]
213
+ tags << 'partner' if defined?(Legion::Gaia::BondRegistry) && partner_agent?(agent_id)
214
+ tags
215
+ end
216
+
217
+ def partner_agent?(agent_id)
218
+ Legion::Gaia::BondRegistry.partner?(agent_id.to_s)
219
+ rescue StandardError => e
220
+ Legion::Logging.debug("[social_graph] BondRegistry check failed: #{e.message}")
221
+ false
222
+ end
223
+
224
+ def restore_from_entry(entry)
225
+ data = Legion::JSON.parse(entry[:content])
226
+ agent_id = data['agent_id'] || data[:agent_id]
227
+ return unless agent_id
228
+
229
+ scores = data['scores'] || data[:scores] || {}
230
+ stored = scores.transform_keys(&:to_sym)
231
+ @reputation_scores[agent_id] ||= Constants::REPUTATION_DIMENSIONS.keys.to_h { |d| [d, 0.5] }
232
+ stored.each do |dim, val|
233
+ @reputation_scores[agent_id][dim] = val.to_f if Constants::REPUTATION_DIMENSIONS.key?(dim)
234
+ end
235
+ rescue StandardError => e
236
+ Legion::Logging.debug("[social_graph] restore entry failed: #{e.message}")
237
+ nil
238
+ end
166
239
  end
167
240
  end
168
241
  end
@@ -10,17 +10,20 @@ module Legion
10
10
  include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
11
11
  Legion::Extensions::Helpers.const_defined?(:Lex, false)
12
12
 
13
- def update_social(tick_results: {}, **)
13
+ def update_social(tick_results: {}, human_observations: [], **)
14
+ social_graph.clear_reputation_changes!
14
15
  extract_social_signals(tick_results)
16
+ process_human_observations(human_observations)
15
17
 
16
18
  log.debug "[social] groups=#{social_graph.group_count} " \
17
19
  "agents=#{social_graph.agents_tracked} standing=#{social_graph.social_standing}"
18
20
 
19
21
  {
20
- groups: social_graph.group_count,
21
- agents_tracked: social_graph.agents_tracked,
22
- standing: social_graph.social_standing,
23
- ledger_size: social_graph.reciprocity_ledger.size
22
+ groups: social_graph.group_count,
23
+ agents_tracked: social_graph.agents_tracked,
24
+ standing: social_graph.social_standing,
25
+ ledger_size: social_graph.reciprocity_ledger.size,
26
+ reputation_updates: build_reputation_updates
24
27
  }
25
28
  end
26
29
 
@@ -111,6 +114,38 @@ module Legion
111
114
  @social_graph ||= Helpers::SocialGraph.new
112
115
  end
113
116
 
117
+ def process_human_observations(human_observations)
118
+ human_observations.each do |obs|
119
+ agent_id = obs[:identity].to_s
120
+ confidence = obs[:bond_role] == :partner ? 0.8 : 0.5
121
+
122
+ social_graph.update_reputation(
123
+ agent_id: agent_id,
124
+ dimension: :reliability,
125
+ signal: confidence
126
+ )
127
+ social_graph.record_reciprocity(
128
+ agent_id: agent_id,
129
+ action: obs[:content_type] || :text,
130
+ direction: :received
131
+ )
132
+ end
133
+ end
134
+
135
+ def build_reputation_updates
136
+ social_graph.reputation_changes
137
+ .group_by { |c| c[:agent_id] }
138
+ .map do |agent_id, changes|
139
+ rep = social_graph.reputation_for(agent_id)
140
+ {
141
+ agent_id: agent_id,
142
+ changes: changes,
143
+ composite: rep&.dig(:composite),
144
+ standing: rep&.dig(:standing)
145
+ }
146
+ end
147
+ end
148
+
114
149
  def extract_social_signals(tick_results)
115
150
  extract_trust_signals(tick_results)
116
151
  extract_mesh_signals(tick_results)
@@ -12,6 +12,40 @@ module Legion
12
12
  def initialize
13
13
  @agent_models = {}
14
14
  @prediction_log = []
15
+ @dirty = false
16
+ end
17
+
18
+ def dirty?
19
+ @dirty
20
+ end
21
+
22
+ def mark_clean!
23
+ @dirty = false
24
+ self
25
+ end
26
+
27
+ def to_apollo_entries
28
+ @agent_models.map do |agent_id, model|
29
+ tags = build_apollo_tags(agent_id)
30
+ content = Legion::JSON.dump({
31
+ agent_id: agent_id.to_s,
32
+ beliefs: serialize_beliefs(model.beliefs),
33
+ desires: model.desires,
34
+ intentions: model.intentions
35
+ })
36
+ { content: content, tags: tags }
37
+ end
38
+ end
39
+
40
+ def from_apollo(store:)
41
+ result = store.query(text: 'theory_of_mind agent_model', tags: %w[theory_of_mind agent_model])
42
+ return false unless result[:success] && result[:results]&.any?
43
+
44
+ result[:results].each { |entry| restore_from_entry(entry) }
45
+ true
46
+ rescue StandardError => e
47
+ Legion::Logging.warn("[mental_state_tracker] from_apollo error: #{e.message}")
48
+ false
15
49
  end
16
50
 
17
51
  def model_for(agent_id)
@@ -23,16 +57,19 @@ module Legion
23
57
  def update_belief(agent_id:, domain:, content:, confidence:, source: :inference)
24
58
  model = model_for(agent_id)
25
59
  model.update_belief(domain: domain, content: content, confidence: confidence, source: source)
60
+ @dirty = true
26
61
  end
27
62
 
28
63
  def update_desire(agent_id:, goal:, priority: :medium)
29
64
  model = model_for(agent_id)
30
65
  model.update_desire(goal: goal, priority: priority)
66
+ @dirty = true
31
67
  end
32
68
 
33
69
  def infer_intention(agent_id:, action:, confidence: :possible)
34
70
  model = model_for(agent_id)
35
71
  model.update_intention(action: action, confidence: confidence)
72
+ @dirty = true
36
73
  end
37
74
 
38
75
  def predict_behavior(agent_id:, context: {})
@@ -91,6 +128,10 @@ module Legion
91
128
  }
92
129
  end
93
130
 
131
+ def pending_prediction(agent_id:)
132
+ @prediction_log.reverse.find { |p| p[:agent_id] == agent_id }
133
+ end
134
+
94
135
  def decay_all
95
136
  @agent_models.each_value(&:decay_beliefs)
96
137
  @agent_models.reject! { |_, m| m.beliefs.empty? && m.desires.empty? && m.intentions.empty? }
@@ -160,6 +201,68 @@ module Legion
160
201
  def trim_prediction_log
161
202
  @prediction_log.shift while @prediction_log.size > 200
162
203
  end
204
+
205
+ def build_apollo_tags(agent_id)
206
+ tags = ['theory_of_mind', 'agent_model', agent_id.to_s]
207
+ tags << 'partner' if defined?(Legion::Gaia::BondRegistry) && partner_agent?(agent_id)
208
+ tags
209
+ end
210
+
211
+ def partner_agent?(agent_id)
212
+ Legion::Gaia::BondRegistry.partner?(agent_id.to_s)
213
+ rescue StandardError => e
214
+ Legion::Logging.debug("[mental_state_tracker] BondRegistry check failed: #{e.message}")
215
+ false
216
+ end
217
+
218
+ def serialize_beliefs(beliefs)
219
+ beliefs.transform_keys(&:to_s).transform_values do |b|
220
+ b.merge(updated_at: b[:updated_at]&.iso8601)
221
+ end
222
+ end
223
+
224
+ def restore_from_entry(entry)
225
+ data = Legion::JSON.parse(entry[:content])
226
+ agent_id = data['agent_id'] || data[:agent_id]
227
+ return unless agent_id
228
+
229
+ model = model_for(agent_id)
230
+ restore_beliefs(model, data['beliefs'] || {})
231
+ restore_desires(model, data['desires'] || [])
232
+ restore_intentions(model, data['intentions'] || [])
233
+ rescue StandardError => e
234
+ Legion::Logging.debug("[mental_state_tracker] restore entry failed: #{e.message}")
235
+ nil
236
+ end
237
+
238
+ def restore_beliefs(model, beliefs_data)
239
+ beliefs_data.each do |domain, belief|
240
+ model.update_belief(
241
+ domain: domain.to_sym,
242
+ content: belief['content'],
243
+ confidence: belief['confidence'].to_f,
244
+ source: (belief['source'] || :restored).to_sym
245
+ )
246
+ end
247
+ end
248
+
249
+ def restore_desires(model, desires_data)
250
+ desires_data.each do |desire|
251
+ model.update_desire(
252
+ goal: desire['goal'],
253
+ priority: (desire['priority'] || :medium).to_sym
254
+ )
255
+ end
256
+ end
257
+
258
+ def restore_intentions(model, intentions_data)
259
+ intentions_data.each do |intention|
260
+ model.update_intention(
261
+ action: intention['action'].to_sym,
262
+ confidence: (intention['confidence'] || :possible).to_sym
263
+ )
264
+ end
265
+ end
163
266
  end
164
267
  end
165
268
  end
@@ -10,9 +10,10 @@ module Legion
10
10
  include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
11
11
  Legion::Extensions::Helpers.const_defined?(:Lex, false)
12
12
 
13
- def update_theory_of_mind(tick_results: {}, **)
13
+ def update_theory_of_mind(tick_results: {}, human_observations: [], **)
14
14
  extract_social_observations(tick_results)
15
15
  extract_mesh_observations(tick_results)
16
+ process_tom_human_observations(human_observations)
16
17
  tracker.decay_all
17
18
 
18
19
  log.debug "[tom] agents=#{tracker.agents_tracked} " \
@@ -121,6 +122,51 @@ module Legion
121
122
  tracker.infer_intention(agent_id: agent_id, action: obs[:action], confidence: obs[:action_confidence] || :possible)
122
123
  end
123
124
 
125
+ def process_tom_human_observations(human_observations)
126
+ human_observations.each do |obs|
127
+ agent_id = obs[:identity].to_s
128
+ validate_pending_prediction(agent_id)
129
+ build_communication_belief(agent_id, obs)
130
+ infer_engagement_intention(agent_id, obs) if obs[:direct_address]
131
+ infer_channel_preference(agent_id, obs)
132
+ end
133
+ end
134
+
135
+ def validate_pending_prediction(agent_id)
136
+ pending = tracker.pending_prediction(agent_id: agent_id)
137
+ return unless pending
138
+
139
+ tracker.record_prediction_outcome(agent_id: agent_id, outcome: :correct)
140
+ end
141
+
142
+ def build_communication_belief(agent_id, obs)
143
+ channel = obs[:channel] || :unknown
144
+ tracker.update_belief(
145
+ agent_id: agent_id,
146
+ domain: :communication,
147
+ content: channel,
148
+ confidence: 0.7,
149
+ source: :direct_observation
150
+ )
151
+ end
152
+
153
+ def infer_engagement_intention(agent_id, obs)
154
+ confidence = obs[:bond_role] == :partner ? :certain : :likely
155
+ tracker.infer_intention(agent_id: agent_id, action: :engage, confidence: confidence)
156
+ end
157
+
158
+ def infer_channel_preference(agent_id, obs)
159
+ return unless obs[:channel]
160
+
161
+ tracker.update_belief(
162
+ agent_id: agent_id,
163
+ domain: :channel_preference,
164
+ content: obs[:channel],
165
+ confidence: 0.6,
166
+ source: :direct_observation
167
+ )
168
+ end
169
+
124
170
  def extract_social_observations(tick_results)
125
171
  social = tick_results.dig(:social, :reputation_updates)
126
172
  return unless social.is_a?(Array)
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Social
7
- VERSION = '0.1.5'
7
+ VERSION = '0.1.6'
8
8
  end
9
9
  end
10
10
  end
@@ -319,4 +319,114 @@ RSpec.describe Legion::Extensions::Agentic::Social::Social::Helpers::SocialGraph
319
319
  expect(result).to have_key(:ledger_size)
320
320
  end
321
321
  end
322
+
323
+ describe '#dirty?' do
324
+ it 'starts clean' do
325
+ expect(graph.dirty?).to be false
326
+ end
327
+
328
+ it 'becomes dirty after reputation update' do
329
+ graph.update_reputation(agent_id: :a1, dimension: :reliability, signal: 0.8)
330
+ expect(graph.dirty?).to be true
331
+ end
332
+
333
+ it 'becomes dirty after joining a group' do
334
+ graph.join_group(group_id: :alpha)
335
+ expect(graph.dirty?).to be true
336
+ end
337
+
338
+ it 'becomes dirty after recording reciprocity' do
339
+ graph.record_reciprocity(agent_id: :a1, action: :helped, direction: :given)
340
+ expect(graph.dirty?).to be true
341
+ end
342
+
343
+ it 'becomes clean after mark_clean!' do
344
+ graph.update_reputation(agent_id: :a1, dimension: :reliability, signal: 0.8)
345
+ graph.mark_clean!
346
+ expect(graph.dirty?).to be false
347
+ end
348
+ end
349
+
350
+ describe '#to_apollo_entries' do
351
+ it 'returns empty array when no reputation scores' do
352
+ expect(graph.to_apollo_entries).to eq([])
353
+ end
354
+
355
+ it 'returns one entry per tracked agent' do
356
+ graph.update_reputation(agent_id: :a1, dimension: :reliability, signal: 0.8)
357
+ graph.update_reputation(agent_id: :a2, dimension: :competence, signal: 0.7)
358
+ expect(graph.to_apollo_entries.size).to eq(2)
359
+ end
360
+
361
+ it 'entry content is a JSON string' do
362
+ graph.update_reputation(agent_id: :a1, dimension: :reliability, signal: 0.8)
363
+ entry = graph.to_apollo_entries.first
364
+ parsed = JSON.parse(entry[:content])
365
+ expect(parsed).to have_key('agent_id')
366
+ end
367
+
368
+ it 'entry tags include social_graph, reputation, and agent_id' do
369
+ graph.update_reputation(agent_id: :a1, dimension: :reliability, signal: 0.8)
370
+ entry = graph.to_apollo_entries.first
371
+ expect(entry[:tags]).to include('social_graph', 'reputation')
372
+ expect(entry[:tags].any? { |t| t.to_s == 'a1' }).to be true
373
+ end
374
+ end
375
+
376
+ describe '#from_apollo' do
377
+ let(:mock_store) do
378
+ double('ApolloLocal').tap do |store|
379
+ allow(store).to receive(:query).and_return({ success: false, results: [] })
380
+ end
381
+ end
382
+
383
+ it 'returns false when store query fails or returns no results' do
384
+ result = graph.from_apollo(store: mock_store)
385
+ expect(result).to be false
386
+ end
387
+
388
+ it 'populates reputation_scores from stored JSON' do
389
+ scores = { reliability: 0.8, competence: 0.7, benevolence: 0.5, integrity: 0.5, influence: 0.5 }
390
+ content = JSON.dump({ agent_id: 'a1', scores: scores, updated_at: Time.now.utc.iso8601 })
391
+ allow(mock_store).to receive(:query).and_return(
392
+ { success: true, results: [{ content: content, tags: '["social_graph","reputation","a1"]' }] }
393
+ )
394
+ graph.from_apollo(store: mock_store)
395
+ expect(graph.reputation_scores['a1']).not_to be_nil
396
+ end
397
+ end
398
+
399
+ describe '#mark_clean!' do
400
+ it 'returns self' do
401
+ expect(graph.mark_clean!).to eq(graph)
402
+ end
403
+
404
+ it 'resets dirty flag' do
405
+ graph.update_reputation(agent_id: :a1, dimension: :reliability, signal: 0.5)
406
+ graph.mark_clean!
407
+ expect(graph.dirty?).to be false
408
+ end
409
+ end
410
+
411
+ describe '#reputation_changes' do
412
+ it 'starts empty' do
413
+ expect(graph.reputation_changes).to eq([])
414
+ end
415
+
416
+ it 'records a change when reputation is updated' do
417
+ graph.update_reputation(agent_id: :a1, dimension: :reliability, signal: 0.8)
418
+ expect(graph.reputation_changes).not_to be_empty
419
+ end
420
+
421
+ it 'records agent_id in the change entry' do
422
+ graph.update_reputation(agent_id: :a1, dimension: :reliability, signal: 0.8)
423
+ expect(graph.reputation_changes.first[:agent_id]).to eq(:a1)
424
+ end
425
+
426
+ it 'clears reputation_changes on clear_reputation_changes!' do
427
+ graph.update_reputation(agent_id: :a1, dimension: :reliability, signal: 0.8)
428
+ graph.clear_reputation_changes!
429
+ expect(graph.reputation_changes).to be_empty
430
+ end
431
+ end
322
432
  end
@@ -216,5 +216,77 @@ RSpec.describe Legion::Extensions::Agentic::Social::Social::Runners::Social do
216
216
  host.update_social(tick_results: tick)
217
217
  expect(graph.group_cohesion(:alpha)).to eq(initial_cohesion)
218
218
  end
219
+
220
+ it 'returns :reputation_updates key' do
221
+ result = host.update_social(tick_results: {})
222
+ expect(result).to have_key(:reputation_updates)
223
+ expect(result[:reputation_updates]).to be_an(Array)
224
+ end
225
+
226
+ it 'processes human_observations and creates agent reputation entries' do
227
+ obs = [
228
+ {
229
+ identity: 'esity',
230
+ bond_role: :partner,
231
+ channel: :cli,
232
+ content_type: :text,
233
+ content_length: 50,
234
+ direct_address: false,
235
+ timestamp: Time.now.utc
236
+ }
237
+ ]
238
+ host.update_social(tick_results: {}, human_observations: obs)
239
+ expect(graph.agents_tracked).to be >= 1
240
+ expect(graph.reputation_scores['esity']).not_to be_nil
241
+ end
242
+
243
+ it 'gives partner observations higher initial confidence than strangers' do
244
+ partner_obs = [
245
+ { identity: 'partner_agent', bond_role: :partner, channel: :cli,
246
+ content_type: :text, content_length: 10, direct_address: false, timestamp: Time.now.utc }
247
+ ]
248
+ stranger_obs = [
249
+ { identity: 'stranger_agent', bond_role: :unknown, channel: :cli,
250
+ content_type: :text, content_length: 10, direct_address: false, timestamp: Time.now.utc }
251
+ ]
252
+
253
+ graph2 = Legion::Extensions::Agentic::Social::Social::Helpers::SocialGraph.new
254
+ host2 = Object.new.tap do |obj|
255
+ obj.extend(described_class)
256
+ obj.instance_variable_set(:@social_graph, graph2)
257
+ end
258
+
259
+ host.update_social(tick_results: {}, human_observations: partner_obs)
260
+ host2.update_social(tick_results: {}, human_observations: stranger_obs)
261
+
262
+ partner_score = graph.reputation_scores['partner_agent']
263
+ stranger_score = graph2.reputation_scores['stranger_agent']
264
+
265
+ partner_composite = partner_score.values.sum / partner_score.size.to_f
266
+ stranger_composite = stranger_score.values.sum / stranger_score.size.to_f
267
+
268
+ expect(partner_composite).to be > stranger_composite
269
+ end
270
+
271
+ it 'records communication reciprocity for each observation' do
272
+ obs = [
273
+ { identity: 'alice', bond_role: :known, channel: :cli,
274
+ content_type: :text, content_length: 20, direct_address: true, timestamp: Time.now.utc }
275
+ ]
276
+ host.update_social(tick_results: {}, human_observations: obs)
277
+ balance = graph.reciprocity_balance('alice')
278
+ expect(balance[:received]).to eq(1)
279
+ end
280
+
281
+ it 'reputation_updates in result includes entries for processed observations' do
282
+ obs = [
283
+ { identity: 'bob', bond_role: :partner, channel: :cli,
284
+ content_type: :text, content_length: 30, direct_address: false, timestamp: Time.now.utc }
285
+ ]
286
+ result = host.update_social(tick_results: {}, human_observations: obs)
287
+ expect(result[:reputation_updates]).not_to be_empty
288
+ entry = result[:reputation_updates].first
289
+ expect(entry[:agent_id]).to eq('bob')
290
+ end
219
291
  end
220
292
  end
@@ -225,4 +225,120 @@ RSpec.describe Legion::Extensions::Agentic::Social::TheoryOfMind::Helpers::Menta
225
225
  expect(result).to have_key(:prediction_log_size)
226
226
  end
227
227
  end
228
+
229
+ describe '#dirty?' do
230
+ it 'starts clean' do
231
+ expect(tracker.dirty?).to be false
232
+ end
233
+
234
+ it 'becomes dirty after update_belief' do
235
+ tracker.update_belief(agent_id: :a1, domain: :test, content: 'v', confidence: 0.8)
236
+ expect(tracker.dirty?).to be true
237
+ end
238
+
239
+ it 'becomes dirty after update_desire' do
240
+ tracker.update_desire(agent_id: :a1, goal: 'deploy')
241
+ expect(tracker.dirty?).to be true
242
+ end
243
+
244
+ it 'becomes dirty after infer_intention' do
245
+ tracker.infer_intention(agent_id: :a1, action: :send)
246
+ expect(tracker.dirty?).to be true
247
+ end
248
+
249
+ it 'becomes clean after mark_clean!' do
250
+ tracker.update_belief(agent_id: :a1, domain: :test, content: 'v', confidence: 0.8)
251
+ tracker.mark_clean!
252
+ expect(tracker.dirty?).to be false
253
+ end
254
+ end
255
+
256
+ describe '#to_apollo_entries' do
257
+ it 'returns empty array when no agent models' do
258
+ expect(tracker.to_apollo_entries).to eq([])
259
+ end
260
+
261
+ it 'returns one entry per tracked agent' do
262
+ tracker.update_belief(agent_id: :a1, domain: :x, content: 'v', confidence: 0.9)
263
+ tracker.update_belief(agent_id: :a2, domain: :y, content: 'w', confidence: 0.8)
264
+ expect(tracker.to_apollo_entries.size).to eq(2)
265
+ end
266
+
267
+ it 'entry content is a JSON string with agent_id' do
268
+ tracker.update_belief(agent_id: :a1, domain: :x, content: 'v', confidence: 0.9)
269
+ entry = tracker.to_apollo_entries.first
270
+ parsed = JSON.parse(entry[:content])
271
+ expect(parsed).to have_key('agent_id')
272
+ end
273
+
274
+ it 'entry tags include theory_of_mind, agent_model, and agent_id' do
275
+ tracker.update_belief(agent_id: :a1, domain: :x, content: 'v', confidence: 0.9)
276
+ entry = tracker.to_apollo_entries.first
277
+ expect(entry[:tags]).to include('theory_of_mind', 'agent_model')
278
+ expect(entry[:tags].any? { |t| t.to_s == 'a1' }).to be true
279
+ end
280
+ end
281
+
282
+ describe '#from_apollo' do
283
+ let(:mock_store) do
284
+ double('ApolloLocal').tap do |store|
285
+ allow(store).to receive(:query).and_return({ success: false, results: [] })
286
+ end
287
+ end
288
+
289
+ it 'returns false when no results' do
290
+ expect(tracker.from_apollo(store: mock_store)).to be false
291
+ end
292
+
293
+ it 'populates agent_models from stored JSON' do
294
+ model_data = {
295
+ agent_id: 'agent_x',
296
+ beliefs: { task: { content: 'coding', confidence: 0.9, source: 'direct_observation',
297
+ updated_at: Time.now.utc.iso8601 } },
298
+ desires: [],
299
+ intentions: []
300
+ }
301
+ content = JSON.dump(model_data)
302
+ allow(mock_store).to receive(:query).and_return(
303
+ { success: true, results: [{ content: content, tags: '["theory_of_mind","agent_model","agent_x"]' }] }
304
+ )
305
+ tracker.from_apollo(store: mock_store)
306
+ expect(tracker.agent_models['agent_x']).not_to be_nil
307
+ end
308
+ end
309
+
310
+ describe '#mark_clean!' do
311
+ it 'returns self' do
312
+ expect(tracker.mark_clean!).to eq(tracker)
313
+ end
314
+
315
+ it 'resets dirty flag' do
316
+ tracker.update_belief(agent_id: :a1, domain: :test, content: 'v', confidence: 0.8)
317
+ tracker.mark_clean!
318
+ expect(tracker.dirty?).to be false
319
+ end
320
+ end
321
+
322
+ describe '#pending_prediction' do
323
+ it 'returns nil when no predictions for agent' do
324
+ expect(tracker.pending_prediction(agent_id: :unknown)).to be_nil
325
+ end
326
+
327
+ it 'returns the most recent prediction entry' do
328
+ tracker.update_desire(agent_id: :a1, goal: 'help', priority: :high)
329
+ tracker.infer_intention(agent_id: :a1, action: :respond, confidence: :likely)
330
+ tracker.predict_behavior(agent_id: :a1, context: {})
331
+ result = tracker.pending_prediction(agent_id: :a1)
332
+ expect(result).to be_a(Hash)
333
+ expect(result[:agent_id]).to eq(:a1)
334
+ end
335
+
336
+ it 'returns the most recent of multiple predictions' do
337
+ tracker.update_desire(agent_id: :a1, goal: 'help', priority: :high)
338
+ tracker.infer_intention(agent_id: :a1, action: :respond, confidence: :likely)
339
+ 2.times { tracker.predict_behavior(agent_id: :a1, context: {}) }
340
+ result = tracker.pending_prediction(agent_id: :a1)
341
+ expect(result[:predicted_at]).to eq(tracker.prediction_log.last[:predicted_at])
342
+ end
343
+ end
228
344
  end
@@ -217,5 +217,51 @@ RSpec.describe Legion::Extensions::Agentic::Social::TheoryOfMind::Runners::Theor
217
217
  host.update_theory_of_mind(tick_results: {})
218
218
  expect(mental_tracker.agent_models[:a1].beliefs[:test][:confidence]).to be < initial
219
219
  end
220
+
221
+ it 'accepts human_observations kwarg without error' do
222
+ obs = [
223
+ { identity: 'esity', bond_role: :partner, channel: :cli,
224
+ content_type: :text, content_length: 50, direct_address: true, timestamp: Time.now.utc }
225
+ ]
226
+ expect { host.update_theory_of_mind(tick_results: {}, human_observations: obs) }.not_to raise_error
227
+ end
228
+
229
+ it 'builds beliefs from human_observations communication domain' do
230
+ obs = [
231
+ { identity: 'esity', bond_role: :partner, channel: :cli,
232
+ content_type: :text, content_length: 50, direct_address: false, timestamp: Time.now.utc }
233
+ ]
234
+ host.update_theory_of_mind(tick_results: {}, human_observations: obs)
235
+ model = mental_tracker.agent_models['esity']
236
+ expect(model).not_to be_nil
237
+ expect(model.beliefs[:communication]).not_to be_nil
238
+ end
239
+
240
+ it 'records engagement intent from direct_address observations' do
241
+ obs = [
242
+ { identity: 'alice', bond_role: :known, channel: :cli,
243
+ content_type: :text, content_length: 30, direct_address: true, timestamp: Time.now.utc }
244
+ ]
245
+ host.update_theory_of_mind(tick_results: {}, human_observations: obs)
246
+ model = mental_tracker.agent_models['alice']
247
+ intention = model.intentions.find { |i| i[:action] == :engage }
248
+ expect(intention).not_to be_nil
249
+ end
250
+
251
+ it 'validates pending predictions when human_observations arrive' do
252
+ # First seed a prediction
253
+ mental_tracker.update_belief(agent_id: 'bob', domain: :task, content: 'coding', confidence: 0.8)
254
+ mental_tracker.infer_intention(agent_id: 'bob', action: :communicate, confidence: :likely)
255
+ mental_tracker.predict_behavior(agent_id: 'bob', context: {})
256
+ initial_log_size = mental_tracker.prediction_log.size
257
+
258
+ obs = [
259
+ { identity: 'bob', bond_role: :known, channel: :cli,
260
+ content_type: :text, content_length: 20, direct_address: false, timestamp: Time.now.utc }
261
+ ]
262
+ host.update_theory_of_mind(tick_results: {}, human_observations: obs)
263
+ # prediction log may grow or model accuracy may change; at minimum no error
264
+ expect(mental_tracker.prediction_log.size).to be >= initial_log_size
265
+ end
220
266
  end
221
267
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-agentic-social
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity