lex-apollo 0.3.5 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6156122baf8989f96a09685918e8ed6f6608730c8e19e442a632f5faa8853ee7
4
- data.tar.gz: 478d0003003a76abc6ffdc0c39a7be3b06d8e75f91e1c88a6edfa5e46a05634a
3
+ metadata.gz: 98960e6e7d1261ea36708f77b302dfa5011a3f7ea5ce4a1db66198887656fd4e
4
+ data.tar.gz: 9263a29e03175cfcdf7431138d05ec4bb362f29113ccb4f9d5d35e1f28d0fe85
5
5
  SHA512:
6
- metadata.gz: 898147c2dbe69430a0dc4606db21a82100de37a1865b9d56157499530c7e7a205e8cc44cd39f23410293a8f3874c9dadacf9c5aa47c89bd5b61a7a39b9067951
7
- data.tar.gz: 8ed9fe61c718b12abfd8b5b2c6a75e249e47491c6bf8068df984f6f18c8943ee5d3d56b3b2def551703b4cd7b0463942e20a4803a740ca7da65d25a87b90c344
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
@@ -8,7 +8,7 @@ module Legion
8
8
  INITIAL_CONFIDENCE = 0.5
9
9
  CORROBORATION_BOOST = 0.3
10
10
  RETRIEVAL_BOOST = 0.02
11
- POWER_LAW_ALPHA = 0.1
11
+ POWER_LAW_ALPHA = 0.5
12
12
  DECAY_THRESHOLD = 0.1
13
13
  CORROBORATION_SIMILARITY_THRESHOLD = 0.9
14
14
  WRITE_CONFIDENCE_GATE = 0.6
@@ -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: content,
68
- content_type: content_type_sym,
69
- confidence: Helpers::Confidence::INITIAL_CONFIDENCE,
70
- source_agent: source_agent,
71
- source_provider: source_provider || derive_provider_from_agent(source_agent),
72
- source_context: ::JSON.dump(context.is_a?(Hash) ? context : {}),
73
- tags: Sequel.pg_array(tag_array),
74
- status: 'candidate',
75
- embedding: Sequel.lit("'[#{embedding.join(',')}]'::vector")
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: tag_array.first || 'general')
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 }
@@ -163,7 +173,7 @@ module Legion
163
173
  { success: false, error: e.message }
164
174
  end
165
175
 
166
- def retrieve_relevant(query: nil, limit: 5, min_confidence: 0.3, tags: nil, skip: false, **)
176
+ def retrieve_relevant(query: nil, limit: 5, min_confidence: 0.3, tags: nil, domain: nil, skip: false, **) # rubocop:disable Metrics/ParameterLists
167
177
  return { status: :skipped } if skip
168
178
 
169
179
  return { success: false, error: 'apollo_data_not_available' } unless defined?(Legion::Data::Model::ApolloEntry)
@@ -173,7 +183,7 @@ module Legion
173
183
  embedding = Helpers::Embedding.generate(text: query.to_s)
174
184
  sql = Helpers::GraphQuery.build_semantic_search_sql(
175
185
  limit: limit, min_confidence: min_confidence,
176
- statuses: ['confirmed'], tags: tags
186
+ statuses: ['confirmed'], tags: tags, domain: domain
177
187
  )
178
188
 
179
189
  db = Legion::Data::Model::ApolloEntry.db
@@ -189,7 +199,8 @@ module Legion
189
199
  formatted = entries.map do |entry|
190
200
  { id: entry[:id], content: entry[:content], content_type: entry[:content_type],
191
201
  confidence: entry[:confidence], distance: entry[:distance],
192
- tags: entry[:tags], source_agent: entry[:source_agent] }
202
+ tags: entry[:tags], source_agent: entry[:source_agent],
203
+ knowledge_domain: entry[:knowledge_domain] }
193
204
  end
194
205
 
195
206
  { success: true, entries: formatted, count: formatted.size }
@@ -197,8 +208,72 @@ module Legion
197
208
  { success: false, error: e.message }
198
209
  end
199
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
+
200
262
  private
201
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
+
202
277
  def detect_contradictions(entry_id, embedding, content)
203
278
  return [] unless embedding && defined?(Legion::Data::Model::ApolloEntry)
204
279
 
@@ -242,7 +317,7 @@ module Legion
242
317
  false
243
318
  end
244
319
 
245
- def find_corroboration(embedding, content_type_sym, source_agent)
320
+ def find_corroboration(embedding, content_type_sym, source_agent, source_channel = nil)
246
321
  existing = Legion::Data::Model::ApolloEntry
247
322
  .where(content_type: content_type_sym)
248
323
  .exclude(embedding: nil)
@@ -255,6 +330,14 @@ module Legion
255
330
  next unless Helpers::Similarity.above_corroboration_threshold?(similarity: sim)
256
331
 
257
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
+
258
341
  entry.update(
259
342
  confidence: Helpers::Confidence.apply_corroboration_boost(confidence: entry.confidence, weight: weight),
260
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(rate: nil, min_confidence: nil, **)
25
- rate ||= decay_rate
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] * rate)
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, rate: rate, threshold: min_confidence }
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 decay_rate
96
- alpha = (defined?(Legion::Settings) && Legion::Settings.dig(:apollo, :power_law_alpha)) ||
97
- Helpers::Confidence::POWER_LAW_ALPHA
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)
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Apollo
6
- VERSION = '0.3.5'
6
+ VERSION = '0.3.6'
7
7
  end
8
8
  end
9
9
  end
@@ -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.1)
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.1) # ~0.909091
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 '#decay_rate' do
16
- it 'returns power-law derived rate when settings unavailable' do
17
- expected = 1.0 / (1.0 + 0.1) # ~0.909091
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,13 @@ 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
308
346
  end
309
347
  end
310
348
 
@@ -391,4 +429,135 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
391
429
  end
392
430
  end
393
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
561
+ end
562
+ end
394
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) { def +(other) = "#{value} + #{other}" }
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
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-apollo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 0.3.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity