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 +4 -4
- data/CHANGELOG.md +11 -0
- data/lib/legion/extensions/agentic/social/social/helpers/social_graph.rb +75 -2
- data/lib/legion/extensions/agentic/social/social/runners/social.rb +40 -5
- data/lib/legion/extensions/agentic/social/theory_of_mind/helpers/mental_state_tracker.rb +103 -0
- data/lib/legion/extensions/agentic/social/theory_of_mind/runners/theory_of_mind.rb +47 -1
- data/lib/legion/extensions/agentic/social/version.rb +1 -1
- data/spec/legion/extensions/agentic/social/social/helpers/social_graph_spec.rb +110 -0
- data/spec/legion/extensions/agentic/social/social/runners/social_spec.rb +72 -0
- data/spec/legion/extensions/agentic/social/theory_of_mind/helpers/mental_state_tracker_spec.rb +116 -0
- data/spec/legion/extensions/agentic/social/theory_of_mind/runners/theory_of_mind_spec.rb +46 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1e4e4cf74f2655992c34c4945a8da0de92c597fc5371ff6fa595e583a4cdd88a
|
|
4
|
+
data.tar.gz: b0696e9bc1f7b990fb8e3a153fd22755edf34e18a6fdce7ea7efe23eccaa5afb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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:
|
|
21
|
-
agents_tracked:
|
|
22
|
-
standing:
|
|
23
|
-
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)
|
|
@@ -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
|
data/spec/legion/extensions/agentic/social/theory_of_mind/helpers/mental_state_tracker_spec.rb
CHANGED
|
@@ -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
|