lex-agentic-social 0.1.9 → 0.1.10
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 +9 -0
- data/lib/legion/extensions/agentic/social/attachment/runners/attachment.rb +96 -8
- data/lib/legion/extensions/agentic/social/calibration/runners/calibration.rb +18 -1
- data/lib/legion/extensions/agentic/social/governance/runners/shadow_ai.rb +4 -1
- data/lib/legion/extensions/agentic/social/version.rb +1 -1
- data/spec/legion/extensions/agentic/social/attachment/runners/attachment_spec.rb +232 -2
- data/spec/legion/extensions/agentic/social/calibration/runners/calibration_spec.rb +67 -0
- data/spec/legion/extensions/agentic/social/governance/runners/shadow_ai_spec.rb +14 -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: 0fc83af9a2de72c88452553af56512be4b8fd0ae78460677ede2acb8741b4717
|
|
4
|
+
data.tar.gz: c73bca2d3963eb2d3b565e5086e471e34a822bc5b51e4ea6e7beb40cf8a065f3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b49a7f8ff8a8c950c03fd88291efa84ccbdf73be77c3581572149fcfa99d4084d041584c63c6044da0fd275803ad75a2ef10cc930ac1c0f0561b624fab73190e
|
|
7
|
+
data.tar.gz: d37843a8af2d489580cb9f74923a82390e763939f8a81edc1870d28e42476b383e33d88074305df3c371bcf8ed4b8e01fa6f469ca5753b99cc71fcc1e15f1736
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.10] - 2026-03-31
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- `extract_style_signals`: replace hardcoded `frequency_variance: 0.0` and `reciprocity_imbalance: 0.0` with real computations (`compute_frequency_variance` from hourly bucket counts, `compute_reciprocity_imbalance` from agent/human direction ratio); `:anxious` attachment style is now reachable
|
|
7
|
+
- `reflect_on_bonds`: `narrative:` field now returns a mechanically generated sentence (stage, style, health, chapter, milestones) instead of always nil
|
|
8
|
+
- `reflect_on_bonds`: add `absence_exceeds_pattern:` field backed by `Legion::Extensions::Agentic::Memory::CommunicationPattern` when available, falling back to Apollo Local communication pattern data
|
|
9
|
+
- `retrieve_interaction_traces` in calibration: split into `retrieve_from_memory` + `retrieve_from_apollo_local` fallback; now returns data from Apollo Local when lex-agentic-memory is absent
|
|
10
|
+
- `check_airb_compliance` in shadow_ai: return `reason:` field alongside `source: :unavailable` so callers know why the check could not run; emit debug log entry
|
|
11
|
+
|
|
3
12
|
## [0.1.9] - 2026-03-31
|
|
4
13
|
|
|
5
14
|
### Added
|
|
@@ -39,12 +39,13 @@ module Legion
|
|
|
39
39
|
bonds_reflected: attachment_store.all_models.size,
|
|
40
40
|
partner_bond: if model
|
|
41
41
|
{
|
|
42
|
-
stage:
|
|
43
|
-
strength:
|
|
44
|
-
style:
|
|
45
|
-
health:
|
|
46
|
-
milestones_today:
|
|
47
|
-
narrative:
|
|
42
|
+
stage: model.bond_stage,
|
|
43
|
+
strength: model.attachment_strength,
|
|
44
|
+
style: model.attachment_style,
|
|
45
|
+
health: health,
|
|
46
|
+
milestones_today: arc_state[:milestones_today] || [],
|
|
47
|
+
narrative: build_narrative(model, health, arc_state),
|
|
48
|
+
absence_exceeds_pattern: absence_exceeds_pattern?(partner_id)
|
|
48
49
|
}
|
|
49
50
|
end
|
|
50
51
|
}
|
|
@@ -98,13 +99,37 @@ module Legion
|
|
|
98
99
|
obs = human_observations.select { |o| o[:agent_id].to_s == agent_id }
|
|
99
100
|
direct_count = obs.count { |o| o[:direct_address] }
|
|
100
101
|
{
|
|
101
|
-
frequency_variance:
|
|
102
|
-
reciprocity_imbalance:
|
|
102
|
+
frequency_variance: compute_frequency_variance(obs),
|
|
103
|
+
reciprocity_imbalance: compute_reciprocity_imbalance(obs),
|
|
103
104
|
frequency: obs.size.clamp(0, 10) / 10.0,
|
|
104
105
|
direct_address_ratio: obs.empty? ? 0.0 : direct_count.to_f / obs.size
|
|
105
106
|
}
|
|
106
107
|
end
|
|
107
108
|
|
|
109
|
+
def compute_frequency_variance(observations)
|
|
110
|
+
return 0.0 if observations.size < 3
|
|
111
|
+
|
|
112
|
+
timestamps = observations.filter_map { |o| o[:observed_at] || o[:timestamp] }
|
|
113
|
+
return 0.0 if timestamps.size < 3
|
|
114
|
+
|
|
115
|
+
buckets = timestamps.group_by { |t| t.to_i / 3600 }
|
|
116
|
+
counts = buckets.values.map { |b| b.size.to_f }
|
|
117
|
+
mean = counts.sum / counts.size
|
|
118
|
+
variance = counts.sum { |c| (c - mean)**2 } / counts.size
|
|
119
|
+
[variance / [mean, 1.0].max, 1.0].min
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def compute_reciprocity_imbalance(observations)
|
|
123
|
+
return 0.0 if observations.empty?
|
|
124
|
+
|
|
125
|
+
initiated = observations.count { |o| o[:initiated_by] == :agent || o[:direction] == :outgoing }
|
|
126
|
+
received = observations.size - initiated
|
|
127
|
+
total = observations.size.to_f
|
|
128
|
+
return 0.0 if total.zero?
|
|
129
|
+
|
|
130
|
+
((initiated - received).abs / total).clamp(0.0, 1.0)
|
|
131
|
+
end
|
|
132
|
+
|
|
108
133
|
def resolve_partner_id
|
|
109
134
|
if defined?(Legion::Gaia::BondRegistry)
|
|
110
135
|
bond = Legion::Gaia::BondRegistry.all_bonds.find { |b| b[:role] == :partner }
|
|
@@ -154,6 +179,69 @@ module Legion
|
|
|
154
179
|
(strength_component + reciprocity_component + consistency_component).clamp(0.0, 1.0)
|
|
155
180
|
end
|
|
156
181
|
|
|
182
|
+
def build_narrative(model, health, arc_state)
|
|
183
|
+
return nil unless model
|
|
184
|
+
|
|
185
|
+
stage = model.bond_stage
|
|
186
|
+
style = model.attachment_style
|
|
187
|
+
chapter = arc_state[:current_chapter]
|
|
188
|
+
parts = ["Bond is #{stage} (#{style} style)"]
|
|
189
|
+
parts << "health: #{format('%.1f', health)}" if health
|
|
190
|
+
parts << "chapter: #{chapter}" if chapter
|
|
191
|
+
milestones = arc_state[:milestones_today]
|
|
192
|
+
parts << "#{milestones.size} milestone(s) today" if milestones&.any?
|
|
193
|
+
"#{parts.join(', ')}."
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def absence_exceeds_pattern?(agent_id)
|
|
197
|
+
return false unless agent_id
|
|
198
|
+
|
|
199
|
+
if defined?(Legion::Extensions::Agentic::Memory::CommunicationPattern::Runners::CommunicationPattern)
|
|
200
|
+
begin
|
|
201
|
+
runner = Object.new
|
|
202
|
+
runner.extend(Legion::Extensions::Agentic::Memory::CommunicationPattern::Runners::CommunicationPattern)
|
|
203
|
+
stats = runner.partner_stats(agent_id: agent_id)
|
|
204
|
+
return false unless stats.is_a?(Hash)
|
|
205
|
+
|
|
206
|
+
avg_gap = stats[:average_gap_seconds] || stats[:avg_gap]
|
|
207
|
+
return false unless avg_gap&.positive?
|
|
208
|
+
|
|
209
|
+
last_interaction = stats[:last_interaction_at]
|
|
210
|
+
return false unless last_interaction
|
|
211
|
+
|
|
212
|
+
current_gap = Time.now - Time.parse(last_interaction.to_s)
|
|
213
|
+
return current_gap > (avg_gap * 2.0)
|
|
214
|
+
rescue StandardError => _e
|
|
215
|
+
return false
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
absence_exceeds_pattern_from_observations?(agent_id)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def absence_exceeds_pattern_from_observations?(agent_id)
|
|
223
|
+
store = apollo_local_store
|
|
224
|
+
return false unless store
|
|
225
|
+
|
|
226
|
+
result = store.query(text: 'communication_pattern',
|
|
227
|
+
tags: ['bond', 'communication_pattern', agent_id.to_s])
|
|
228
|
+
return false unless result[:success] && result[:results]&.any?
|
|
229
|
+
|
|
230
|
+
data = deserialize(result[:results].first[:content])
|
|
231
|
+
return false unless data.is_a?(Hash)
|
|
232
|
+
|
|
233
|
+
avg_gap = (data[:average_gap_seconds] || data[:avg_gap])&.to_f
|
|
234
|
+
return false unless avg_gap&.positive?
|
|
235
|
+
|
|
236
|
+
last_str = data[:last_interaction_at]
|
|
237
|
+
return false unless last_str
|
|
238
|
+
|
|
239
|
+
current_gap = Time.now - Time.parse(last_str.to_s)
|
|
240
|
+
current_gap > (avg_gap * 2.0)
|
|
241
|
+
rescue StandardError => _e
|
|
242
|
+
false
|
|
243
|
+
end
|
|
244
|
+
|
|
157
245
|
def deserialize(content)
|
|
158
246
|
parsed = defined?(Legion::JSON) ? Legion::JSON.parse(content) : ::JSON.parse(content, symbolize_names: true)
|
|
159
247
|
parsed.is_a?(Hash) ? parsed.transform_keys(&:to_sym) : {}
|
|
@@ -113,6 +113,13 @@ module Legion
|
|
|
113
113
|
end
|
|
114
114
|
|
|
115
115
|
def retrieve_interaction_traces
|
|
116
|
+
traces = retrieve_from_memory
|
|
117
|
+
return traces if traces.any?
|
|
118
|
+
|
|
119
|
+
retrieve_from_apollo_local
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def retrieve_from_memory
|
|
116
123
|
return [] unless defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
|
|
117
124
|
|
|
118
125
|
runner = Object.new
|
|
@@ -122,7 +129,17 @@ module Legion
|
|
|
122
129
|
|
|
123
130
|
result[:traces] || []
|
|
124
131
|
rescue StandardError => e
|
|
125
|
-
Legion::Logging.warn("[calibration]
|
|
132
|
+
Legion::Logging.warn("[calibration] retrieve_from_memory error: #{e.message}")
|
|
133
|
+
[]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def retrieve_from_apollo_local
|
|
137
|
+
return [] unless defined?(Legion::Apollo::Local) && Legion::Apollo::Local.started?
|
|
138
|
+
|
|
139
|
+
entries = Legion::Apollo::Local.query_by_tags(tags: ['partner_interaction'], limit: 50)
|
|
140
|
+
entries.map { |e| { content: e[:content], tags: e[:tags], confidence: e[:confidence] } }
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
Legion::Logging.warn("[calibration] retrieve_from_apollo_local error: #{e.message}")
|
|
126
143
|
[]
|
|
127
144
|
end
|
|
128
145
|
|
|
@@ -25,7 +25,10 @@ module Legion
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def check_airb_compliance(**)
|
|
28
|
-
|
|
28
|
+
unless defined?(Legion::Data::Model::DigitalWorker)
|
|
29
|
+
Legion::Logging.debug('[governance:shadow_ai] AIRB compliance check unavailable — DigitalWorker model not loaded')
|
|
30
|
+
return { checked: 0, source: :unavailable, reason: 'DigitalWorker model not loaded' }
|
|
31
|
+
end
|
|
29
32
|
|
|
30
33
|
workers = Legion::Data::Model::DigitalWorker.where(lifecycle_state: 'active').all
|
|
31
34
|
non_compliant = workers.select do |w|
|
|
@@ -97,8 +97,8 @@ RSpec.describe Legion::Extensions::Agentic::Social::Attachment::Runners::Attachm
|
|
|
97
97
|
|
|
98
98
|
it 'reads communication patterns from Apollo Local' do
|
|
99
99
|
host.reflect_on_bonds(tick_results: {}, bond_summary: {})
|
|
100
|
-
expect(mock_store).to have_received(:query)
|
|
101
|
-
|
|
100
|
+
expect(mock_store).to have_received(:query).at_least(:once)
|
|
101
|
+
.with(hash_including(tags: array_including('bond', 'communication_pattern')))
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
it 'reads relationship arc from Apollo Local' do
|
|
@@ -127,4 +127,234 @@ RSpec.describe Legion::Extensions::Agentic::Social::Attachment::Runners::Attachm
|
|
|
127
127
|
expect(result).to have_key(:partner_bond)
|
|
128
128
|
end
|
|
129
129
|
end
|
|
130
|
+
|
|
131
|
+
describe '#compute_frequency_variance' do
|
|
132
|
+
it 'returns 0.0 for fewer than 3 observations' do
|
|
133
|
+
obs = [{ timestamp: Time.now }, { timestamp: Time.now }]
|
|
134
|
+
expect(host.send(:compute_frequency_variance, obs)).to eq(0.0)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'returns 0.0 when observations have no timestamps' do
|
|
138
|
+
obs = [{ agent_id: 'x' }, { agent_id: 'x' }, { agent_id: 'x' }]
|
|
139
|
+
expect(host.send(:compute_frequency_variance, obs)).to eq(0.0)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'returns a positive value for unevenly distributed timestamps' do
|
|
143
|
+
base = Time.now
|
|
144
|
+
# Cluster 6 in one bucket and 1 in another — high variance
|
|
145
|
+
ts = Array.new(6) { base } + [base + 7200]
|
|
146
|
+
obs = ts.map { |t| { timestamp: t } }
|
|
147
|
+
result = host.send(:compute_frequency_variance, obs)
|
|
148
|
+
expect(result).to be > 0.0
|
|
149
|
+
expect(result).to be_between(0.0, 1.0)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it 'returns low variance for evenly distributed timestamps' do
|
|
153
|
+
base = Time.now
|
|
154
|
+
# One per hour — perfectly even
|
|
155
|
+
ts = (0..4).map { |i| base + (i * 3600) }
|
|
156
|
+
obs = ts.map { |t| { timestamp: t } }
|
|
157
|
+
result = host.send(:compute_frequency_variance, obs)
|
|
158
|
+
expect(result).to eq(0.0)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'uses :observed_at field when present' do
|
|
162
|
+
base = Time.now
|
|
163
|
+
ts = Array.new(5) { base } + [base + 7200]
|
|
164
|
+
obs = ts.map { |t| { observed_at: t } }
|
|
165
|
+
result = host.send(:compute_frequency_variance, obs)
|
|
166
|
+
expect(result).to be > 0.0
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
describe '#compute_reciprocity_imbalance' do
|
|
171
|
+
it 'returns 0.0 for empty observations' do
|
|
172
|
+
expect(host.send(:compute_reciprocity_imbalance, [])).to eq(0.0)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it 'returns 0.0 for balanced initiated/received' do
|
|
176
|
+
obs = [
|
|
177
|
+
{ direction: :outgoing },
|
|
178
|
+
{ direction: :incoming }
|
|
179
|
+
]
|
|
180
|
+
expect(host.send(:compute_reciprocity_imbalance, obs)).to eq(0.0)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'returns 1.0 when all interactions are agent-initiated' do
|
|
184
|
+
obs = Array.new(4) { { initiated_by: :agent } }
|
|
185
|
+
expect(host.send(:compute_reciprocity_imbalance, obs)).to eq(1.0)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it 'returns 1.0 when all interactions are received' do
|
|
189
|
+
obs = Array.new(4) { { direction: :incoming } }
|
|
190
|
+
expect(host.send(:compute_reciprocity_imbalance, obs)).to eq(1.0)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it 'computes partial imbalance correctly' do
|
|
194
|
+
# 3 outgoing, 1 incoming => |3-1|/4 = 0.5
|
|
195
|
+
obs = Array.new(3) { { direction: :outgoing } } + [{ direction: :incoming }]
|
|
196
|
+
expect(host.send(:compute_reciprocity_imbalance, obs)).to be_within(0.001).of(0.5)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
describe '#extract_style_signals — anxious style reachability' do
|
|
201
|
+
it 'produces frequency_variance and reciprocity_imbalance values that can trigger anxious style' do
|
|
202
|
+
base = Time.now
|
|
203
|
+
# 6 in one bucket + 1 isolated — high frequency variance
|
|
204
|
+
timestamps = Array.new(6) { base } + [base + 7200]
|
|
205
|
+
# All agent-initiated — max reciprocity imbalance
|
|
206
|
+
obs = timestamps.map.with_index do |t, i|
|
|
207
|
+
{ agent_id: 'partner-1', timestamp: t, initiated_by: :agent, direction: :outgoing,
|
|
208
|
+
direct_address: i.even? }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
signals = host.send(:extract_style_signals, 'partner-1', obs)
|
|
212
|
+
expect(signals[:frequency_variance]).to be > 0.4
|
|
213
|
+
expect(signals[:reciprocity_imbalance]).to be > 0.3
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it 'actually drives derive_style! to :anxious with high-variance, high-imbalance signals' do
|
|
217
|
+
model = Legion::Extensions::Agentic::Social::Attachment::Helpers::AttachmentModel.new(agent_id: 'p')
|
|
218
|
+
model.derive_style!(
|
|
219
|
+
frequency_variance: 0.5,
|
|
220
|
+
reciprocity_imbalance: 0.4,
|
|
221
|
+
frequency: 0.6,
|
|
222
|
+
direct_address_ratio: 0.3
|
|
223
|
+
)
|
|
224
|
+
expect(model.attachment_style).to eq(:anxious)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
describe '#build_narrative' do
|
|
229
|
+
let(:model) do
|
|
230
|
+
m = Legion::Extensions::Agentic::Social::Attachment::Helpers::AttachmentModel.new(agent_id: 'p')
|
|
231
|
+
m.instance_variable_set(:@bond_stage, :established)
|
|
232
|
+
m.instance_variable_set(:@attachment_style, :secure)
|
|
233
|
+
m
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it 'returns nil when model is nil' do
|
|
237
|
+
expect(host.send(:build_narrative, nil, 0.5, {})).to be_nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it 'returns a non-empty string' do
|
|
241
|
+
result = host.send(:build_narrative, model, 0.75, {})
|
|
242
|
+
expect(result).to be_a(String)
|
|
243
|
+
expect(result).not_to be_empty
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
it 'includes stage and style' do
|
|
247
|
+
result = host.send(:build_narrative, model, 0.75, {})
|
|
248
|
+
expect(result).to include('established')
|
|
249
|
+
expect(result).to include('secure')
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it 'includes health value' do
|
|
253
|
+
result = host.send(:build_narrative, model, 0.75, {})
|
|
254
|
+
expect(result).to include('0.8') # format('%.1f', 0.75) => '0.8'
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it 'includes chapter when present in arc_state' do
|
|
258
|
+
result = host.send(:build_narrative, model, 0.5, { current_chapter: 'discovery' })
|
|
259
|
+
expect(result).to include('discovery')
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
it 'includes milestone count when milestones present' do
|
|
263
|
+
arc = { milestones_today: %w[first_conflict repair] }
|
|
264
|
+
result = host.send(:build_narrative, model, 0.5, arc)
|
|
265
|
+
expect(result).to include('2 milestone(s) today')
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
it 'does not include milestone text when milestones empty' do
|
|
269
|
+
result = host.send(:build_narrative, model, 0.5, { milestones_today: [] })
|
|
270
|
+
expect(result).not_to include('milestone')
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
describe '#absence_exceeds_pattern?' do
|
|
275
|
+
it 'returns false when agent_id is nil' do
|
|
276
|
+
expect(host.send(:absence_exceeds_pattern?, nil)).to be false
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it 'returns false when CommunicationPattern not defined and no apollo store' do
|
|
280
|
+
allow(host).to receive(:apollo_local_store).and_return(nil)
|
|
281
|
+
expect(host.send(:absence_exceeds_pattern?, 'partner-1')).to be false
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
it 'returns false when apollo store returns no results' do
|
|
285
|
+
mock_store = double('apollo_local')
|
|
286
|
+
allow(host).to receive(:apollo_local_store).and_return(mock_store)
|
|
287
|
+
allow(mock_store).to receive(:query).and_return({ success: true, results: [] })
|
|
288
|
+
expect(host.send(:absence_exceeds_pattern?, 'partner-1')).to be false
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
it 'returns false when apollo data has no avg_gap' do
|
|
292
|
+
mock_store = double('apollo_local')
|
|
293
|
+
allow(host).to receive(:apollo_local_store).and_return(mock_store)
|
|
294
|
+
content = Legion::JSON.dump({ last_interaction_at: (Time.now - 3600).to_s })
|
|
295
|
+
allow(mock_store).to receive(:query).and_return({
|
|
296
|
+
success: true,
|
|
297
|
+
results: [{ content: content }]
|
|
298
|
+
})
|
|
299
|
+
expect(host.send(:absence_exceeds_pattern?, 'partner-1')).to be false
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
it 'returns true when current gap exceeds 2x average gap' do
|
|
303
|
+
mock_store = double('apollo_local')
|
|
304
|
+
allow(host).to receive(:apollo_local_store).and_return(mock_store)
|
|
305
|
+
last_seen = Time.now - 7200 # 2 hours ago
|
|
306
|
+
content = Legion::JSON.dump({
|
|
307
|
+
average_gap_seconds: 1800.0, # normal = 30 min
|
|
308
|
+
last_interaction_at: last_seen.to_s
|
|
309
|
+
})
|
|
310
|
+
allow(mock_store).to receive(:query).and_return({
|
|
311
|
+
success: true,
|
|
312
|
+
results: [{ content: content }]
|
|
313
|
+
})
|
|
314
|
+
expect(host.send(:absence_exceeds_pattern?, 'partner-1')).to be true
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
it 'returns false when current gap is within normal range' do
|
|
318
|
+
mock_store = double('apollo_local')
|
|
319
|
+
allow(host).to receive(:apollo_local_store).and_return(mock_store)
|
|
320
|
+
last_seen = Time.now - 600 # 10 minutes ago
|
|
321
|
+
content = Legion::JSON.dump({
|
|
322
|
+
average_gap_seconds: 1800.0, # normal = 30 min
|
|
323
|
+
last_interaction_at: last_seen.to_s
|
|
324
|
+
})
|
|
325
|
+
allow(mock_store).to receive(:query).and_return({
|
|
326
|
+
success: true,
|
|
327
|
+
results: [{ content: content }]
|
|
328
|
+
})
|
|
329
|
+
expect(host.send(:absence_exceeds_pattern?, 'partner-1')).to be false
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
describe '#reflect_on_bonds — narrative and absence fields' do
|
|
334
|
+
let(:mock_store) { double('apollo_local') }
|
|
335
|
+
|
|
336
|
+
before do
|
|
337
|
+
allow(host).to receive(:apollo_local_store).and_return(mock_store)
|
|
338
|
+
allow(mock_store).to receive(:query).and_return({ success: true, results: [] })
|
|
339
|
+
|
|
340
|
+
store = host.send(:attachment_store)
|
|
341
|
+
model = store.get_or_create('partner-1')
|
|
342
|
+
model.update_from_signals(frequency_score: 0.6, reciprocity_score: 0.5,
|
|
343
|
+
prediction_accuracy: 0.7, direct_address_ratio: 0.4,
|
|
344
|
+
channel_consistency: 0.8)
|
|
345
|
+
model.instance_variable_set(:@interaction_count, 55)
|
|
346
|
+
model.instance_variable_set(:@bond_stage, :established)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
it 'includes a non-nil narrative in partner_bond' do
|
|
350
|
+
result = host.reflect_on_bonds(tick_results: {}, bond_summary: {})
|
|
351
|
+
expect(result[:partner_bond][:narrative]).not_to be_nil
|
|
352
|
+
expect(result[:partner_bond][:narrative]).to be_a(String)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
it 'includes absence_exceeds_pattern in partner_bond' do
|
|
356
|
+
result = host.reflect_on_bonds(tick_results: {}, bond_summary: {})
|
|
357
|
+
expect(result[:partner_bond]).to have_key(:absence_exceeds_pattern)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
130
360
|
end
|
|
@@ -72,6 +72,73 @@ RSpec.describe Legion::Extensions::Agentic::Social::Calibration::Runners::Calibr
|
|
|
72
72
|
end
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
describe '#retrieve_from_memory' do
|
|
76
|
+
it 'returns empty array when lex-agentic-memory is not loaded' do
|
|
77
|
+
result = client.send(:retrieve_from_memory)
|
|
78
|
+
expect(result).to eq([])
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
describe '#retrieve_from_apollo_local' do
|
|
83
|
+
it 'returns empty array when Apollo Local is not available' do
|
|
84
|
+
result = client.send(:retrieve_from_apollo_local)
|
|
85
|
+
expect(result).to eq([])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'maps Apollo Local entries to trace-compatible hashes when available' do
|
|
89
|
+
mock_local = double('apollo_local')
|
|
90
|
+
stub_const('Legion::Apollo', Module.new)
|
|
91
|
+
stub_const('Legion::Apollo::Local', mock_local)
|
|
92
|
+
allow(mock_local).to receive(:started?).and_return(true)
|
|
93
|
+
allow(mock_local).to receive(:query_by_tags).with(tags: ['partner_interaction'], limit: 50).and_return([
|
|
94
|
+
{
|
|
95
|
+
content: 'hello partner',
|
|
96
|
+
tags: ['partner_interaction'],
|
|
97
|
+
confidence: 0.8
|
|
98
|
+
}
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
result = client.send(:retrieve_from_apollo_local)
|
|
102
|
+
expect(result).to be_an(Array)
|
|
103
|
+
expect(result.size).to eq(1)
|
|
104
|
+
expect(result.first[:content]).to eq('hello partner')
|
|
105
|
+
expect(result.first[:confidence]).to eq(0.8)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'returns empty array and logs warning when Apollo Local raises' do
|
|
109
|
+
mock_local = double('apollo_local')
|
|
110
|
+
stub_const('Legion::Apollo', Module.new)
|
|
111
|
+
stub_const('Legion::Apollo::Local', mock_local)
|
|
112
|
+
allow(mock_local).to receive(:started?).and_return(true)
|
|
113
|
+
allow(mock_local).to receive(:query_by_tags).and_raise(StandardError, 'db error')
|
|
114
|
+
allow(Legion::Logging).to receive(:warn)
|
|
115
|
+
|
|
116
|
+
result = client.send(:retrieve_from_apollo_local)
|
|
117
|
+
expect(result).to eq([])
|
|
118
|
+
expect(Legion::Logging).to have_received(:warn).with(a_string_including('retrieve_from_apollo_local'))
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
describe '#retrieve_interaction_traces' do
|
|
123
|
+
it 'falls through to Apollo Local when memory returns no traces' do
|
|
124
|
+
allow(client).to receive(:retrieve_from_memory).and_return([])
|
|
125
|
+
allow(client).to receive(:retrieve_from_apollo_local).and_return([{ content: 'trace', tags: [], confidence: 0.5 }])
|
|
126
|
+
|
|
127
|
+
result = client.send(:retrieve_interaction_traces)
|
|
128
|
+
expect(result).not_to be_empty
|
|
129
|
+
expect(result.first[:content]).to eq('trace')
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'returns memory traces directly without calling Apollo Local when memory has results' do
|
|
133
|
+
memory_trace = { content: 'memory trace', tags: ['partner_interaction'], recorded_at: Time.now }
|
|
134
|
+
allow(client).to receive(:retrieve_from_memory).and_return([memory_trace])
|
|
135
|
+
expect(client).not_to receive(:retrieve_from_apollo_local)
|
|
136
|
+
|
|
137
|
+
result = client.send(:retrieve_interaction_traces)
|
|
138
|
+
expect(result).to eq([memory_trace])
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
75
142
|
describe '#promote_partner_knowledge' do
|
|
76
143
|
it 'skips when Apollo Local unavailable' do
|
|
77
144
|
result = client.promote_partner_knowledge
|
|
@@ -43,6 +43,20 @@ RSpec.describe Legion::Extensions::Agentic::Social::Governance::Runners::ShadowA
|
|
|
43
43
|
result = host.check_airb_compliance
|
|
44
44
|
expect(result[:source]).to eq(:unavailable)
|
|
45
45
|
end
|
|
46
|
+
|
|
47
|
+
it 'includes a reason field when unavailable' do
|
|
48
|
+
result = host.check_airb_compliance
|
|
49
|
+
expect(result).to have_key(:reason)
|
|
50
|
+
expect(result[:reason]).to be_a(String)
|
|
51
|
+
expect(result[:reason]).not_to be_empty
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'logs a debug message when DigitalWorker model is not loaded' do
|
|
55
|
+
allow(Legion::Logging).to receive(:debug)
|
|
56
|
+
host.check_airb_compliance
|
|
57
|
+
expect(Legion::Logging).to have_received(:debug)
|
|
58
|
+
.with(a_string_including('AIRB compliance check unavailable'))
|
|
59
|
+
end
|
|
46
60
|
end
|
|
47
61
|
|
|
48
62
|
describe '#full_scan' do
|