lex-apollo 0.3.4 → 0.3.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 +13 -0
- data/lib/legion/extensions/apollo/helpers/confidence.rb +1 -1
- data/lib/legion/extensions/apollo/helpers/graph_query.rb +4 -2
- data/lib/legion/extensions/apollo/runners/knowledge.rb +135 -19
- data/lib/legion/extensions/apollo/runners/maintenance.rb +25 -9
- data/lib/legion/extensions/apollo/version.rb +1 -1
- data/spec/legion/extensions/apollo/helpers/confidence_spec.rb +2 -2
- data/spec/legion/extensions/apollo/runners/decay_cycle_spec.rb +3 -4
- data/spec/legion/extensions/apollo/runners/knowledge_spec.rb +253 -0
- data/spec/legion/extensions/apollo/runners/maintenance_spec.rb +80 -0
- data/spec/spec_helper.rb +6 -2
- 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: 98960e6e7d1261ea36708f77b302dfa5011a3f7ea5ce4a1db66198887656fd4e
|
|
4
|
+
data.tar.gz: 9263a29e03175cfcdf7431138d05ec4bb362f29113ccb4f9d5d35e1f28d0fe85
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d99ea64a8ead8d08d4915607fc31d999f2000d5934c0519428c6a6eea6e4c13d9981050472e5ebbbdcd9771ab796a85f608e0c0d6b1949d28bece52f26c7a963
|
|
7
|
+
data.tar.gz: 884ec51ff3eb5850ae19a68c3eec31f6931bfccc9283a93c705cd4ba771faf1c1db2136958e4628b54f563ba7dfc6d19e79807176f39da28995181678c19ce52
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.6] - 2026-03-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Time-aware power-law decay in batch cycle (alpha=0.5 per Murre & Dros 2015)
|
|
7
|
+
- Source channel diversity enforcement in corroboration paths
|
|
8
|
+
- Right-to-erasure propagation via handle_erasure_request
|
|
9
|
+
- Knowledge domain namespaces with query filtering
|
|
10
|
+
- Domain-aware mesh propagation filtering (prepare_mesh_export)
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- POWER_LAW_ALPHA updated from 0.1 to 0.5
|
|
14
|
+
- run_decay_cycle uses time-aware SQL instead of flat multiplier
|
|
15
|
+
|
|
3
16
|
## [0.3.4] - 2026-03-20
|
|
4
17
|
|
|
5
18
|
### Added
|
|
@@ -44,7 +44,7 @@ module Legion
|
|
|
44
44
|
SQL
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
def build_semantic_search_sql(limit: 10, min_confidence: 0.3, statuses: nil, tags: nil, **)
|
|
47
|
+
def build_semantic_search_sql(limit: 10, min_confidence: 0.3, statuses: nil, tags: nil, domain: nil, **)
|
|
48
48
|
conditions = ["e.confidence >= #{min_confidence}"]
|
|
49
49
|
|
|
50
50
|
if statuses&.any?
|
|
@@ -57,11 +57,13 @@ module Legion
|
|
|
57
57
|
conditions << "e.tags && ARRAY[#{tag_list}]::text[]"
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
conditions << "e.knowledge_domain = '#{domain}'" if domain
|
|
61
|
+
|
|
60
62
|
where_clause = conditions.join(' AND ')
|
|
61
63
|
|
|
62
64
|
<<~SQL
|
|
63
65
|
SELECT e.id, e.content, e.content_type, e.confidence, e.tags, e.source_agent,
|
|
64
|
-
e.access_count, e.created_at,
|
|
66
|
+
e.access_count, e.created_at, e.knowledge_domain,
|
|
65
67
|
(e.embedding <=> $embedding) AS distance
|
|
66
68
|
FROM apollo_entries e
|
|
67
69
|
WHERE #{where_clause}
|
|
@@ -9,6 +9,12 @@ module Legion
|
|
|
9
9
|
module Apollo
|
|
10
10
|
module Runners
|
|
11
11
|
module Knowledge
|
|
12
|
+
DOMAIN_ISOLATION = {
|
|
13
|
+
'claims_optimization' => ['claims_optimization'],
|
|
14
|
+
'clinical_care' => %w[clinical_care general],
|
|
15
|
+
'general' => :all
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
12
18
|
def store_knowledge(content:, content_type:, tags: [], source_agent: nil, context: {}, **)
|
|
13
19
|
content_type = content_type.to_sym
|
|
14
20
|
unless Helpers::Confidence::CONTENT_TYPES.include?(content_type)
|
|
@@ -53,31 +59,34 @@ module Legion
|
|
|
53
59
|
}
|
|
54
60
|
end
|
|
55
61
|
|
|
56
|
-
def handle_ingest(content:, content_type:, tags: [], source_agent: 'unknown', source_provider: nil, context: {}, **) # rubocop:disable Metrics/ParameterLists
|
|
62
|
+
def handle_ingest(content:, content_type:, tags: [], source_agent: 'unknown', source_provider: nil, source_channel: nil, knowledge_domain: nil, context: {}, **) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
57
63
|
return { success: false, error: 'apollo_data_not_available' } unless defined?(Legion::Data::Model::ApolloEntry)
|
|
58
64
|
|
|
59
65
|
embedding = Helpers::Embedding.generate(text: content)
|
|
60
66
|
content_type_sym = content_type.to_s
|
|
61
67
|
tag_array = Array(tags)
|
|
68
|
+
domain = knowledge_domain || tag_array.first || 'general'
|
|
62
69
|
|
|
63
|
-
corroborated, existing_id = find_corroboration(embedding, content_type_sym, source_agent)
|
|
70
|
+
corroborated, existing_id = find_corroboration(embedding, content_type_sym, source_agent, source_channel)
|
|
64
71
|
|
|
65
72
|
unless corroborated
|
|
66
73
|
new_entry = Legion::Data::Model::ApolloEntry.create(
|
|
67
|
-
content:
|
|
68
|
-
content_type:
|
|
69
|
-
confidence:
|
|
70
|
-
source_agent:
|
|
71
|
-
source_provider:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
content: content,
|
|
75
|
+
content_type: content_type_sym,
|
|
76
|
+
confidence: Helpers::Confidence::INITIAL_CONFIDENCE,
|
|
77
|
+
source_agent: source_agent,
|
|
78
|
+
source_provider: source_provider || derive_provider_from_agent(source_agent),
|
|
79
|
+
source_channel: source_channel,
|
|
80
|
+
source_context: ::JSON.dump(context.is_a?(Hash) ? context : {}),
|
|
81
|
+
tags: Sequel.pg_array(tag_array),
|
|
82
|
+
status: 'candidate',
|
|
83
|
+
knowledge_domain: domain,
|
|
84
|
+
embedding: Sequel.lit("'[#{embedding.join(',')}]'::vector")
|
|
76
85
|
)
|
|
77
86
|
existing_id = new_entry.id
|
|
78
87
|
end
|
|
79
88
|
|
|
80
|
-
upsert_expertise(source_agent: source_agent, domain:
|
|
89
|
+
upsert_expertise(source_agent: source_agent, domain: domain)
|
|
81
90
|
|
|
82
91
|
Legion::Data::Model::ApolloAccessLog.create(
|
|
83
92
|
entry_id: existing_id, agent_id: source_agent, action: 'ingest'
|
|
@@ -91,13 +100,13 @@ module Legion
|
|
|
91
100
|
{ success: false, error: e.message }
|
|
92
101
|
end
|
|
93
102
|
|
|
94
|
-
def handle_query(query:, limit: 10, min_confidence: 0.3, status: [:confirmed], tags: nil, agent_id: 'unknown', **) # rubocop:disable Metrics/ParameterLists
|
|
103
|
+
def handle_query(query:, limit: 10, min_confidence: 0.3, status: [:confirmed], tags: nil, domain: nil, agent_id: 'unknown', **) # rubocop:disable Metrics/ParameterLists
|
|
95
104
|
return { success: false, error: 'apollo_data_not_available' } unless defined?(Legion::Data::Model::ApolloEntry)
|
|
96
105
|
|
|
97
106
|
embedding = Helpers::Embedding.generate(text: query)
|
|
98
107
|
sql = Helpers::GraphQuery.build_semantic_search_sql(
|
|
99
108
|
limit: limit, min_confidence: min_confidence,
|
|
100
|
-
statuses: Array(status).map(&:to_s), tags: tags
|
|
109
|
+
statuses: Array(status).map(&:to_s), tags: tags, domain: domain
|
|
101
110
|
)
|
|
102
111
|
|
|
103
112
|
db = Legion::Data::Model::ApolloEntry.db
|
|
@@ -122,7 +131,8 @@ module Legion
|
|
|
122
131
|
formatted = entries.map do |entry|
|
|
123
132
|
{ id: entry[:id], content: entry[:content], content_type: entry[:content_type],
|
|
124
133
|
confidence: entry[:confidence], distance: entry[:distance],
|
|
125
|
-
tags: entry[:tags], source_agent: entry[:source_agent]
|
|
134
|
+
tags: entry[:tags], source_agent: entry[:source_agent],
|
|
135
|
+
knowledge_domain: entry[:knowledge_domain] }
|
|
126
136
|
end
|
|
127
137
|
|
|
128
138
|
{ success: true, entries: formatted, count: formatted.size }
|
|
@@ -130,7 +140,40 @@ module Legion
|
|
|
130
140
|
{ success: false, error: e.message }
|
|
131
141
|
end
|
|
132
142
|
|
|
133
|
-
def
|
|
143
|
+
def redistribute_knowledge(agent_id:, min_confidence: 0.5, **)
|
|
144
|
+
return { success: false, error: 'apollo_data_not_available' } unless defined?(Legion::Data::Model::ApolloEntry)
|
|
145
|
+
|
|
146
|
+
entries = Legion::Data::Model::ApolloEntry
|
|
147
|
+
.where(source_agent: agent_id, status: 'confirmed')
|
|
148
|
+
.where { confidence > min_confidence }
|
|
149
|
+
.all
|
|
150
|
+
|
|
151
|
+
return { success: true, redistributed: 0 } if entries.empty?
|
|
152
|
+
|
|
153
|
+
store = (Legion::Extensions::Agentic::Memory::Trace.shared_store if defined?(Legion::Extensions::Agentic::Memory::Trace))
|
|
154
|
+
|
|
155
|
+
redistributed = 0
|
|
156
|
+
entries.each do |entry|
|
|
157
|
+
if store
|
|
158
|
+
trace = Legion::Extensions::Agentic::Memory::Trace::Helpers::Trace.new_trace(
|
|
159
|
+
type: :semantic,
|
|
160
|
+
content_payload: { content: entry.content, source_agent: agent_id,
|
|
161
|
+
content_type: entry.content_type, tags: Array(entry.tags) },
|
|
162
|
+
strength: entry.confidence.to_f,
|
|
163
|
+
domain_tag: Array(entry.tags).first || 'general'
|
|
164
|
+
)
|
|
165
|
+
store.store(trace)
|
|
166
|
+
end
|
|
167
|
+
redistributed += 1
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
Legion::Logging.info "[apollo] redistributed #{redistributed} entries from departing agent=#{agent_id}"
|
|
171
|
+
{ success: true, redistributed: redistributed, agent_id: agent_id }
|
|
172
|
+
rescue Sequel::Error => e
|
|
173
|
+
{ success: false, error: e.message }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def retrieve_relevant(query: nil, limit: 5, min_confidence: 0.3, tags: nil, domain: nil, skip: false, **) # rubocop:disable Metrics/ParameterLists
|
|
134
177
|
return { status: :skipped } if skip
|
|
135
178
|
|
|
136
179
|
return { success: false, error: 'apollo_data_not_available' } unless defined?(Legion::Data::Model::ApolloEntry)
|
|
@@ -140,7 +183,7 @@ module Legion
|
|
|
140
183
|
embedding = Helpers::Embedding.generate(text: query.to_s)
|
|
141
184
|
sql = Helpers::GraphQuery.build_semantic_search_sql(
|
|
142
185
|
limit: limit, min_confidence: min_confidence,
|
|
143
|
-
statuses: ['confirmed'], tags: tags
|
|
186
|
+
statuses: ['confirmed'], tags: tags, domain: domain
|
|
144
187
|
)
|
|
145
188
|
|
|
146
189
|
db = Legion::Data::Model::ApolloEntry.db
|
|
@@ -156,7 +199,8 @@ module Legion
|
|
|
156
199
|
formatted = entries.map do |entry|
|
|
157
200
|
{ id: entry[:id], content: entry[:content], content_type: entry[:content_type],
|
|
158
201
|
confidence: entry[:confidence], distance: entry[:distance],
|
|
159
|
-
tags: entry[:tags], source_agent: entry[:source_agent]
|
|
202
|
+
tags: entry[:tags], source_agent: entry[:source_agent],
|
|
203
|
+
knowledge_domain: entry[:knowledge_domain] }
|
|
160
204
|
end
|
|
161
205
|
|
|
162
206
|
{ success: true, entries: formatted, count: formatted.size }
|
|
@@ -164,8 +208,72 @@ module Legion
|
|
|
164
208
|
{ success: false, error: e.message }
|
|
165
209
|
end
|
|
166
210
|
|
|
211
|
+
def prepare_mesh_export(target_domain:, min_confidence: 0.5, limit: 100, **)
|
|
212
|
+
unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
|
|
213
|
+
return { success: false, error: 'apollo_data_not_available' }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
conn = Legion::Data.connection
|
|
217
|
+
allowed = allowed_domains_for(target_domain)
|
|
218
|
+
|
|
219
|
+
dataset = conn[:apollo_entries]
|
|
220
|
+
.where(status: 'confirmed')
|
|
221
|
+
.where { confidence >= min_confidence }
|
|
222
|
+
.limit(limit)
|
|
223
|
+
|
|
224
|
+
dataset = dataset.where(knowledge_domain: allowed) unless allowed == :all
|
|
225
|
+
|
|
226
|
+
entries = dataset.all
|
|
227
|
+
|
|
228
|
+
formatted = entries.map do |entry|
|
|
229
|
+
{ id: entry[:id], content: entry[:content], content_type: entry[:content_type],
|
|
230
|
+
confidence: entry[:confidence], knowledge_domain: entry[:knowledge_domain],
|
|
231
|
+
tags: entry[:tags], source_agent: entry[:source_agent] }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
{ success: true, entries: formatted, count: formatted.size, target_domain: target_domain }
|
|
235
|
+
rescue Sequel::Error => e
|
|
236
|
+
{ success: false, error: e.message }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def handle_erasure_request(agent_id:, **)
|
|
240
|
+
unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
|
|
241
|
+
return { deleted: 0, redacted: 0, error: 'apollo_data_not_available' }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
conn = Legion::Data.connection
|
|
245
|
+
|
|
246
|
+
# Delete entries solely from dead agent (not confirmed by others)
|
|
247
|
+
deleted = conn[:apollo_entries]
|
|
248
|
+
.where(source_agent: agent_id)
|
|
249
|
+
.exclude(status: 'confirmed')
|
|
250
|
+
.delete
|
|
251
|
+
|
|
252
|
+
# Redact attribution on confirmed entries (corroborated, retain knowledge)
|
|
253
|
+
redacted = conn[:apollo_entries]
|
|
254
|
+
.where(source_agent: agent_id, status: 'confirmed')
|
|
255
|
+
.update(source_agent: 'redacted', source_provider: nil, source_channel: nil)
|
|
256
|
+
|
|
257
|
+
{ deleted: deleted, redacted: redacted, agent_id: agent_id }
|
|
258
|
+
rescue Sequel::Error => e
|
|
259
|
+
{ deleted: 0, redacted: 0, error: e.message }
|
|
260
|
+
end
|
|
261
|
+
|
|
167
262
|
private
|
|
168
263
|
|
|
264
|
+
def allowed_domains_for(target_domain)
|
|
265
|
+
rules = if defined?(Legion::Settings) && Legion::Settings.dig(:apollo, :domain_isolation)
|
|
266
|
+
Legion::Settings.dig(:apollo, :domain_isolation)
|
|
267
|
+
else
|
|
268
|
+
DOMAIN_ISOLATION
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
allowed = rules[target_domain]
|
|
272
|
+
return :all if allowed == :all || allowed.nil?
|
|
273
|
+
|
|
274
|
+
Array(allowed)
|
|
275
|
+
end
|
|
276
|
+
|
|
169
277
|
def detect_contradictions(entry_id, embedding, content)
|
|
170
278
|
return [] unless embedding && defined?(Legion::Data::Model::ApolloEntry)
|
|
171
279
|
|
|
@@ -209,7 +317,7 @@ module Legion
|
|
|
209
317
|
false
|
|
210
318
|
end
|
|
211
319
|
|
|
212
|
-
def find_corroboration(embedding, content_type_sym, source_agent)
|
|
320
|
+
def find_corroboration(embedding, content_type_sym, source_agent, source_channel = nil)
|
|
213
321
|
existing = Legion::Data::Model::ApolloEntry
|
|
214
322
|
.where(content_type: content_type_sym)
|
|
215
323
|
.exclude(embedding: nil)
|
|
@@ -222,6 +330,14 @@ module Legion
|
|
|
222
330
|
next unless Helpers::Similarity.above_corroboration_threshold?(similarity: sim)
|
|
223
331
|
|
|
224
332
|
weight = same_source_provider?(source_agent, entry) ? 0.5 : 1.0
|
|
333
|
+
|
|
334
|
+
# Reject corroboration entirely if same channel (same data source)
|
|
335
|
+
if source_channel && entry.respond_to?(:source_channel)
|
|
336
|
+
existing_channel = entry.source_channel
|
|
337
|
+
weight = 0.0 if existing_channel && existing_channel == source_channel
|
|
338
|
+
end
|
|
339
|
+
next if weight.zero?
|
|
340
|
+
|
|
225
341
|
entry.update(
|
|
226
342
|
confidence: Helpers::Confidence.apply_corroboration_boost(confidence: entry.confidence, weight: weight),
|
|
227
343
|
updated_at: Time.now
|
|
@@ -21,28 +21,40 @@ module Legion
|
|
|
21
21
|
{ action: :resolve_dispute, entry_id: entry_id, resolution: resolution }
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def run_decay_cycle(
|
|
25
|
-
|
|
24
|
+
def run_decay_cycle(alpha: nil, min_confidence: nil, **)
|
|
25
|
+
alpha ||= decay_alpha
|
|
26
26
|
min_confidence ||= decay_threshold
|
|
27
27
|
|
|
28
28
|
return { decayed: 0, archived: 0 } unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
|
|
29
29
|
|
|
30
30
|
conn = Legion::Data.connection
|
|
31
|
+
|
|
32
|
+
# Power-law: per-cycle decay factor decreases as entries age
|
|
33
|
+
# Factor = (hours_old / (hours_old + 1)) ^ alpha
|
|
34
|
+
# Recent entries (small hours_old) get a factor closer to 0 (more decay)
|
|
35
|
+
# Old entries (large hours_old) get a factor closer to 1 (less decay)
|
|
36
|
+
hours_expr = Sequel.lit(
|
|
37
|
+
'GREATEST(EXTRACT(EPOCH FROM (NOW() - COALESCE(updated_at, created_at))) / 3600.0, 1.0)'
|
|
38
|
+
)
|
|
39
|
+
decay_factor = Sequel.lit(
|
|
40
|
+
'POWER(CAST(? AS double precision) / (CAST(? AS double precision) + 1.0), ?)', hours_expr, hours_expr, alpha
|
|
41
|
+
)
|
|
42
|
+
|
|
31
43
|
decayed = conn[:apollo_entries]
|
|
32
44
|
.exclude(status: 'archived')
|
|
33
|
-
.update(confidence: Sequel[:confidence] *
|
|
45
|
+
.update(confidence: Sequel[:confidence] * decay_factor)
|
|
34
46
|
|
|
35
47
|
archived = conn[:apollo_entries]
|
|
36
48
|
.where { confidence < min_confidence }
|
|
37
49
|
.exclude(status: 'archived')
|
|
38
50
|
.update(status: 'archived')
|
|
39
51
|
|
|
40
|
-
{ decayed: decayed, archived: archived,
|
|
52
|
+
{ decayed: decayed, archived: archived, alpha: alpha, threshold: min_confidence }
|
|
41
53
|
rescue Sequel::Error => e
|
|
42
54
|
{ decayed: 0, archived: 0, error: e.message }
|
|
43
55
|
end
|
|
44
56
|
|
|
45
|
-
def check_corroboration(**)
|
|
57
|
+
def check_corroboration(**) # rubocop:disable Metrics/CyclomaticComplexity
|
|
46
58
|
return { success: false, error: 'apollo_data_not_available' } unless defined?(Legion::Data::Model::ApolloEntry)
|
|
47
59
|
|
|
48
60
|
candidates = Legion::Data::Model::ApolloEntry.where(status: 'candidate').exclude(embedding: nil).all
|
|
@@ -67,6 +79,11 @@ module Legion
|
|
|
67
79
|
both_known = known_provider?(candidate_provider) && known_provider?(match_provider)
|
|
68
80
|
next if both_known && candidate_provider == match_provider
|
|
69
81
|
|
|
82
|
+
# Also reject if same source_channel (same data pipeline)
|
|
83
|
+
candidate_channel = candidate.respond_to?(:source_channel) ? candidate.source_channel : nil
|
|
84
|
+
match_channel = match.respond_to?(:source_channel) ? match.source_channel : nil
|
|
85
|
+
next if candidate_channel && match_channel && candidate_channel == match_channel
|
|
86
|
+
|
|
70
87
|
candidate.update(
|
|
71
88
|
status: 'confirmed',
|
|
72
89
|
confirmed_at: Time.now,
|
|
@@ -92,10 +109,9 @@ module Legion
|
|
|
92
109
|
|
|
93
110
|
private
|
|
94
111
|
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
1.0 / (1.0 + alpha)
|
|
112
|
+
def decay_alpha
|
|
113
|
+
(defined?(Legion::Settings) && Legion::Settings.dig(:apollo, :power_law_alpha)) ||
|
|
114
|
+
Helpers::Confidence::POWER_LAW_ALPHA
|
|
99
115
|
end
|
|
100
116
|
|
|
101
117
|
def known_provider?(provider)
|
|
@@ -18,7 +18,7 @@ RSpec.describe Legion::Extensions::Apollo::Helpers::Confidence do
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
it 'defines POWER_LAW_ALPHA' do
|
|
21
|
-
expect(described_class::POWER_LAW_ALPHA).to eq(0.
|
|
21
|
+
expect(described_class::POWER_LAW_ALPHA).to eq(0.5)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
it 'defines DECAY_THRESHOLD' do
|
|
@@ -45,7 +45,7 @@ RSpec.describe Legion::Extensions::Apollo::Helpers::Confidence do
|
|
|
45
45
|
describe '.apply_decay' do
|
|
46
46
|
it 'applies power-law decay with default alpha when no age given' do
|
|
47
47
|
result = described_class.apply_decay(confidence: 1.0)
|
|
48
|
-
expected = 1.0 / (1.0 + 0.
|
|
48
|
+
expected = 1.0 / (1.0 + 0.5) # ~0.6667
|
|
49
49
|
expect(result).to be_within(0.0001).of(expected)
|
|
50
50
|
end
|
|
51
51
|
|
|
@@ -12,10 +12,9 @@ RSpec.describe 'Apollo Decay Cycle' do
|
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
describe '#
|
|
16
|
-
it 'returns
|
|
17
|
-
|
|
18
|
-
expect(maintenance.send(:decay_rate)).to be_within(0.0001).of(expected)
|
|
15
|
+
describe '#decay_alpha' do
|
|
16
|
+
it 'returns POWER_LAW_ALPHA when settings unavailable' do
|
|
17
|
+
expect(maintenance.send(:decay_alpha)).to eq(0.5)
|
|
19
18
|
end
|
|
20
19
|
end
|
|
21
20
|
|
|
@@ -157,6 +157,37 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
157
157
|
)
|
|
158
158
|
host.handle_ingest(content: 'test', content_type: 'fact', source_agent: 'agent-1')
|
|
159
159
|
end
|
|
160
|
+
|
|
161
|
+
it 'passes source_channel to create' do
|
|
162
|
+
expect(mock_entry_class).to receive(:create).with(
|
|
163
|
+
hash_including(source_channel: 'slack-alerts')
|
|
164
|
+
).and_return(mock_entry)
|
|
165
|
+
host.handle_ingest(content: 'test', content_type: 'fact',
|
|
166
|
+
source_agent: 'agent-1', source_channel: 'slack-alerts')
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it 'passes knowledge_domain to create from explicit param' do
|
|
170
|
+
expect(mock_entry_class).to receive(:create).with(
|
|
171
|
+
hash_including(knowledge_domain: 'clinical')
|
|
172
|
+
).and_return(mock_entry)
|
|
173
|
+
host.handle_ingest(content: 'test', content_type: 'fact',
|
|
174
|
+
source_agent: 'agent-1', knowledge_domain: 'clinical')
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it 'defaults knowledge_domain to first tag' do
|
|
178
|
+
expect(mock_entry_class).to receive(:create).with(
|
|
179
|
+
hash_including(knowledge_domain: 'cardiology')
|
|
180
|
+
).and_return(mock_entry)
|
|
181
|
+
host.handle_ingest(content: 'test', content_type: 'fact',
|
|
182
|
+
tags: %w[cardiology treatment], source_agent: 'agent-1')
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it 'defaults knowledge_domain to general when no tags and no explicit domain' do
|
|
186
|
+
expect(mock_entry_class).to receive(:create).with(
|
|
187
|
+
hash_including(knowledge_domain: 'general')
|
|
188
|
+
).and_return(mock_entry)
|
|
189
|
+
host.handle_ingest(content: 'test', content_type: 'fact', source_agent: 'agent-1')
|
|
190
|
+
end
|
|
160
191
|
end
|
|
161
192
|
|
|
162
193
|
context 'when Sequel raises an error' do
|
|
@@ -305,6 +336,228 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
305
336
|
expect(result[:success]).to be true
|
|
306
337
|
expect(result[:count]).to eq(1)
|
|
307
338
|
end
|
|
339
|
+
|
|
340
|
+
it 'passes domain to graph query builder' do
|
|
341
|
+
expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_semantic_search_sql).with(
|
|
342
|
+
hash_including(domain: 'clinical')
|
|
343
|
+
).and_call_original
|
|
344
|
+
host.retrieve_relevant(query: 'treatment', domain: 'clinical')
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
describe '#redistribute_knowledge' do
|
|
350
|
+
let(:host) { Object.new.extend(described_class) }
|
|
351
|
+
|
|
352
|
+
context 'when Apollo data is not available' do
|
|
353
|
+
before { hide_const('Legion::Data::Model::ApolloEntry') if defined?(Legion::Data::Model::ApolloEntry) }
|
|
354
|
+
|
|
355
|
+
it 'returns a structured error' do
|
|
356
|
+
result = host.redistribute_knowledge(agent_id: 'agent-x')
|
|
357
|
+
expect(result[:success]).to be false
|
|
358
|
+
expect(result[:error]).to eq('apollo_data_not_available')
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
context 'when the departing agent has no confirmed entries' do
|
|
363
|
+
let(:mock_entry_class) { double('ApolloEntry') }
|
|
364
|
+
|
|
365
|
+
before do
|
|
366
|
+
stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
|
|
367
|
+
chain = double('chain')
|
|
368
|
+
allow(mock_entry_class).to receive(:where).and_return(chain)
|
|
369
|
+
allow(chain).to receive(:where).and_return(double(all: []))
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
it 'returns success with zero redistributed' do
|
|
373
|
+
result = host.redistribute_knowledge(agent_id: 'departed-1')
|
|
374
|
+
expect(result[:success]).to be true
|
|
375
|
+
expect(result[:redistributed]).to eq(0)
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
context 'when the departing agent has confirmed entries' do
|
|
380
|
+
let(:mock_entry_class) { double('ApolloEntry') }
|
|
381
|
+
let(:mock_entry) do
|
|
382
|
+
double('entry', content: 'Ruby is fast', content_type: 'fact',
|
|
383
|
+
confidence: 0.8, tags: ['ruby'])
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
before do
|
|
387
|
+
stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
|
|
388
|
+
chain = double('chain')
|
|
389
|
+
allow(mock_entry_class).to receive(:where).and_return(chain)
|
|
390
|
+
allow(chain).to receive(:where).and_return(double(all: [mock_entry]))
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
it 'returns the count of redistributed entries' do
|
|
394
|
+
result = host.redistribute_knowledge(agent_id: 'departed-2')
|
|
395
|
+
expect(result[:success]).to be true
|
|
396
|
+
expect(result[:redistributed]).to eq(1)
|
|
397
|
+
expect(result[:agent_id]).to eq('departed-2')
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
it 'stores into trace shared_store when Memory::Trace is available' do
|
|
401
|
+
mock_store = double('store')
|
|
402
|
+
mock_trace_helpers = Module.new do
|
|
403
|
+
def self.new_trace(type:, content_payload: nil, **kwargs) # rubocop:disable Lint/UnusedMethodArgument
|
|
404
|
+
{ trace_id: 'trace-abc', trace_type: type, strength: kwargs[:strength] || 0.5 }
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
stub_const('Legion::Extensions::Agentic::Memory::Trace', Module.new)
|
|
408
|
+
stub_const('Legion::Extensions::Agentic::Memory::Trace::Helpers::Trace', mock_trace_helpers)
|
|
409
|
+
allow(Legion::Extensions::Agentic::Memory::Trace).to receive(:shared_store).and_return(mock_store)
|
|
410
|
+
allow(mock_store).to receive(:store)
|
|
411
|
+
|
|
412
|
+
result = host.redistribute_knowledge(agent_id: 'departed-3')
|
|
413
|
+
expect(result[:redistributed]).to eq(1)
|
|
414
|
+
expect(mock_store).to have_received(:store).once
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
context 'when Sequel raises an error' do
|
|
419
|
+
before do
|
|
420
|
+
stub_const('Legion::Data::Model::ApolloEntry', Class.new)
|
|
421
|
+
allow(Legion::Data::Model::ApolloEntry).to receive(:where)
|
|
422
|
+
.and_raise(Sequel::Error, 'db error')
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
it 'returns a structured error' do
|
|
426
|
+
result = host.redistribute_knowledge(agent_id: 'agent-x')
|
|
427
|
+
expect(result[:success]).to be false
|
|
428
|
+
expect(result[:error]).to eq('db error')
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
describe '#prepare_mesh_export' do
|
|
434
|
+
let(:host) { Object.new.extend(described_class) }
|
|
435
|
+
|
|
436
|
+
context 'when Legion::Data is not available' do
|
|
437
|
+
before { hide_const('Legion::Data') if defined?(Legion::Data) }
|
|
438
|
+
|
|
439
|
+
it 'returns a structured error' do
|
|
440
|
+
result = host.prepare_mesh_export(target_domain: 'clinical_care')
|
|
441
|
+
expect(result[:success]).to be false
|
|
442
|
+
expect(result[:error]).to eq('apollo_data_not_available')
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
context 'when data is available' do
|
|
447
|
+
let(:mock_conn) { double('connection') }
|
|
448
|
+
let(:mock_dataset) { double('dataset') }
|
|
449
|
+
let(:clinical_entry) do
|
|
450
|
+
{ id: 'e1', content: 'treatment', content_type: 'fact',
|
|
451
|
+
confidence: 0.8, knowledge_domain: 'clinical_care',
|
|
452
|
+
tags: ['clinical'], source_agent: 'agent-1' }
|
|
453
|
+
end
|
|
454
|
+
let(:claims_entry) do
|
|
455
|
+
{ id: 'e2', content: 'denial', content_type: 'fact',
|
|
456
|
+
confidence: 0.7, knowledge_domain: 'claims_optimization',
|
|
457
|
+
tags: ['claims'], source_agent: 'agent-2' }
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
before do
|
|
461
|
+
data_mod = Module.new do
|
|
462
|
+
def self.connection; end
|
|
463
|
+
|
|
464
|
+
def self.respond_to?(method, *args)
|
|
465
|
+
method == :connection || super
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
stub_const('Legion::Data', data_mod)
|
|
469
|
+
allow(Legion::Data).to receive(:connection).and_return(mock_conn)
|
|
470
|
+
allow(mock_conn).to receive(:[]).with(:apollo_entries).and_return(mock_dataset)
|
|
471
|
+
allow(mock_dataset).to receive(:where).and_return(mock_dataset)
|
|
472
|
+
allow(mock_dataset).to receive(:limit).and_return(mock_dataset)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
it 'filters entries by domain compatibility for clinical_care' do
|
|
476
|
+
allow(mock_dataset).to receive(:all).and_return([clinical_entry])
|
|
477
|
+
result = host.prepare_mesh_export(target_domain: 'clinical_care')
|
|
478
|
+
expect(result[:success]).to be true
|
|
479
|
+
expect(result[:target_domain]).to eq('clinical_care')
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
it 'allows all domains when target is general' do
|
|
483
|
+
allow(mock_dataset).to receive(:all).and_return([clinical_entry, claims_entry])
|
|
484
|
+
result = host.prepare_mesh_export(target_domain: 'general')
|
|
485
|
+
expect(result[:success]).to be true
|
|
486
|
+
expect(result[:count]).to eq(2)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
it 'restricts claims_optimization to only claims entries' do
|
|
490
|
+
allow(mock_dataset).to receive(:all).and_return([claims_entry])
|
|
491
|
+
result = host.prepare_mesh_export(target_domain: 'claims_optimization')
|
|
492
|
+
expect(result[:success]).to be true
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
describe '#handle_erasure_request' do
|
|
498
|
+
let(:host) { Object.new.extend(described_class) }
|
|
499
|
+
|
|
500
|
+
context 'when Legion::Data is not available' do
|
|
501
|
+
before { hide_const('Legion::Data') if defined?(Legion::Data) }
|
|
502
|
+
|
|
503
|
+
it 'returns zero counts with error' do
|
|
504
|
+
result = host.handle_erasure_request(agent_id: 'agent-dead')
|
|
505
|
+
expect(result[:deleted]).to eq(0)
|
|
506
|
+
expect(result[:redacted]).to eq(0)
|
|
507
|
+
expect(result[:error]).to eq('apollo_data_not_available')
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
context 'when data is available' do
|
|
512
|
+
let(:mock_conn) { double('connection') }
|
|
513
|
+
let(:mock_dataset) { double('dataset') }
|
|
514
|
+
|
|
515
|
+
before do
|
|
516
|
+
data_mod = Module.new do
|
|
517
|
+
def self.connection; end
|
|
518
|
+
|
|
519
|
+
def self.respond_to?(method, *args)
|
|
520
|
+
method == :connection || super
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
stub_const('Legion::Data', data_mod)
|
|
524
|
+
allow(Legion::Data).to receive(:connection).and_return(mock_conn)
|
|
525
|
+
allow(mock_conn).to receive(:[]).with(:apollo_entries).and_return(mock_dataset)
|
|
526
|
+
allow(mock_dataset).to receive(:where).and_return(mock_dataset)
|
|
527
|
+
allow(mock_dataset).to receive(:exclude).and_return(mock_dataset)
|
|
528
|
+
allow(mock_dataset).to receive(:delete).and_return(3)
|
|
529
|
+
allow(mock_dataset).to receive(:update).and_return(2)
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
it 'deletes non-confirmed entries and redacts confirmed ones' do
|
|
533
|
+
result = host.handle_erasure_request(agent_id: 'agent-dead')
|
|
534
|
+
expect(result[:deleted]).to eq(3)
|
|
535
|
+
expect(result[:redacted]).to eq(2)
|
|
536
|
+
expect(result[:agent_id]).to eq('agent-dead')
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
context 'when Sequel raises an error' do
|
|
541
|
+
before do
|
|
542
|
+
data_mod = Module.new do
|
|
543
|
+
def self.connection; end
|
|
544
|
+
|
|
545
|
+
def self.respond_to?(method, *args)
|
|
546
|
+
method == :connection || super
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
stub_const('Legion::Data', data_mod)
|
|
550
|
+
allow(Legion::Data).to receive(:connection).and_return(double('conn').tap do |c|
|
|
551
|
+
allow(c).to receive(:[]).and_raise(Sequel::Error, 'db gone')
|
|
552
|
+
end)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
it 'returns zero counts with the error message' do
|
|
556
|
+
result = host.handle_erasure_request(agent_id: 'agent-dead')
|
|
557
|
+
expect(result[:deleted]).to eq(0)
|
|
558
|
+
expect(result[:redacted]).to eq(0)
|
|
559
|
+
expect(result[:error]).to eq('db gone')
|
|
560
|
+
end
|
|
308
561
|
end
|
|
309
562
|
end
|
|
310
563
|
end
|
|
@@ -37,6 +37,58 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Maintenance do
|
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
describe '#run_decay_cycle' do
|
|
41
|
+
let(:host) { Object.new.extend(described_class) }
|
|
42
|
+
|
|
43
|
+
context 'when Legion::Data is not available' do
|
|
44
|
+
before { hide_const('Legion::Data') if defined?(Legion::Data) }
|
|
45
|
+
|
|
46
|
+
it 'returns zero counts' do
|
|
47
|
+
result = host.run_decay_cycle
|
|
48
|
+
expect(result[:decayed]).to eq(0)
|
|
49
|
+
expect(result[:archived]).to eq(0)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
context 'when data is available' do
|
|
54
|
+
let(:mock_conn) { double('connection') }
|
|
55
|
+
let(:mock_dataset) { double('dataset') }
|
|
56
|
+
|
|
57
|
+
before do
|
|
58
|
+
data_mod = Module.new do
|
|
59
|
+
def self.connection; end
|
|
60
|
+
|
|
61
|
+
def self.respond_to?(method, *args)
|
|
62
|
+
method == :connection || super
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
stub_const('Legion::Data', data_mod)
|
|
66
|
+
allow(Legion::Data).to receive(:connection).and_return(mock_conn)
|
|
67
|
+
allow(mock_conn).to receive(:[]).with(:apollo_entries).and_return(mock_dataset)
|
|
68
|
+
allow(mock_dataset).to receive(:exclude).and_return(mock_dataset)
|
|
69
|
+
allow(mock_dataset).to receive(:where).and_return(mock_dataset)
|
|
70
|
+
allow(mock_dataset).to receive(:update).and_return(5)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'returns alpha in result hash' do
|
|
74
|
+
result = host.run_decay_cycle
|
|
75
|
+
expect(result[:alpha]).to eq(0.5)
|
|
76
|
+
expect(result).not_to have_key(:rate)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'returns decayed and archived counts' do
|
|
80
|
+
result = host.run_decay_cycle
|
|
81
|
+
expect(result[:decayed]).to eq(5)
|
|
82
|
+
expect(result[:archived]).to eq(5)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'accepts custom alpha parameter' do
|
|
86
|
+
result = host.run_decay_cycle(alpha: 0.3)
|
|
87
|
+
expect(result[:alpha]).to eq(0.3)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
40
92
|
describe '#check_corroboration' do
|
|
41
93
|
let(:host) { Object.new.extend(described_class) }
|
|
42
94
|
|
|
@@ -106,6 +158,34 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Maintenance do
|
|
|
106
158
|
end
|
|
107
159
|
end
|
|
108
160
|
|
|
161
|
+
context 'when candidate and confirmed share the same source_channel' do
|
|
162
|
+
let(:mock_entry_class) { double('ApolloEntry') }
|
|
163
|
+
let(:mock_relation_class) { double('ApolloRelation') }
|
|
164
|
+
let(:embedding) { Array.new(1536, 0.5) }
|
|
165
|
+
let(:candidate) do
|
|
166
|
+
double('candidate', id: 'c-1', content_type: 'fact', embedding: embedding,
|
|
167
|
+
confidence: 0.5, source_provider: 'openai', source_channel: 'slack-alerts')
|
|
168
|
+
end
|
|
169
|
+
let(:confirmed_entry) do
|
|
170
|
+
double('confirmed', id: 'f-1', content_type: 'fact', embedding: embedding,
|
|
171
|
+
source_provider: 'claude', source_channel: 'slack-alerts')
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
before do
|
|
175
|
+
stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
|
|
176
|
+
stub_const('Legion::Data::Model::ApolloRelation', mock_relation_class)
|
|
177
|
+
allow(mock_entry_class).to receive(:where).with(status: 'candidate')
|
|
178
|
+
.and_return(double(exclude: double(all: [candidate])))
|
|
179
|
+
allow(mock_entry_class).to receive(:where).with(status: 'confirmed')
|
|
180
|
+
.and_return(double(exclude: double(all: [confirmed_entry])))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'does not promote even with different providers' do
|
|
184
|
+
result = host.check_corroboration
|
|
185
|
+
expect(result[:promoted]).to eq(0)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
109
189
|
context 'when candidate has different content_type than confirmed' do
|
|
110
190
|
let(:mock_entry_class) { double('ApolloEntry') }
|
|
111
191
|
let(:embedding) { Array.new(1536, 0.5) }
|
data/spec/spec_helper.rb
CHANGED
|
@@ -17,9 +17,13 @@ unless defined?(Sequel)
|
|
|
17
17
|
class Error < StandardError; end
|
|
18
18
|
|
|
19
19
|
def self.pg_array(arr) = arr
|
|
20
|
-
def self.lit(str) = str
|
|
21
|
-
Expr = Struct.new(:value)
|
|
20
|
+
def self.lit(str, *) = str
|
|
21
|
+
Expr = Struct.new(:value) do
|
|
22
|
+
def +(other) = "#{value} + #{other}"
|
|
23
|
+
def *(other) = "#{value} * #{other}"
|
|
24
|
+
end
|
|
22
25
|
def self.expr(sym) = Expr.new(sym)
|
|
26
|
+
def self.[](sym) = Expr.new(sym)
|
|
23
27
|
end
|
|
24
28
|
end
|
|
25
29
|
|