lex-apollo 0.3.5 → 0.3.7

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: 8f3802ccfc38ce2517807c30308ecf2e0c7257ff164fce30e83529e11ce9762e
4
+ data.tar.gz: f07b13160329615265956271795b41cbaaf898d1e0d0084f46bb7598e6f50733
5
5
  SHA512:
6
- metadata.gz: 898147c2dbe69430a0dc4606db21a82100de37a1865b9d56157499530c7e7a205e8cc44cd39f23410293a8f3874c9dadacf9c5aa47c89bd5b61a7a39b9067951
7
- data.tar.gz: 8ed9fe61c718b12abfd8b5b2c6a75e249e47491c6bf8068df984f6f18c8943ee5d3d56b3b2def551703b4cd7b0463942e20a4803a740ca7da65d25a87b90c344
6
+ metadata.gz: 7179a58756d5a9f6cfbdde00d06f809a99b5795ff2a7bf8aea599785d0fdc18d363228b844fc769f0cd459eabfdec416540f474e814a84713937eff06692d82b
7
+ data.tar.gz: 1eb71fa6a968770f8327e026f42a5cf3bce50ceb18c7ab65545e9bc3ecd325f550c9eb3af239e64f456a8e5263ba4cc3faf66729f2e96aa821e9caf3e317dc51
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.7] - 2026-03-22
4
+
5
+ ### Changed
6
+ - Add legion-cache, legion-crypt, legion-data, legion-json, legion-logging, legion-settings, and legion-transport as runtime dependencies
7
+ - Replace direct Legion::Logging calls with injected log helper in runners and actors
8
+ - Update spec_helper with real sub-gem helper stubs (Legion::Logging stub removed)
9
+
10
+ ## [0.3.6] - 2026-03-21
11
+
12
+ ### Added
13
+ - Time-aware power-law decay in batch cycle (alpha=0.5 per Murre & Dros 2015)
14
+ - Source channel diversity enforcement in corroboration paths
15
+ - Right-to-erasure propagation via handle_erasure_request
16
+ - Knowledge domain namespaces with query filtering
17
+ - Domain-aware mesh propagation filtering (prepare_mesh_export)
18
+
19
+ ### Changed
20
+ - POWER_LAW_ALPHA updated from 0.1 to 0.5
21
+ - run_decay_cycle uses time-aware SQL instead of flat multiplier
22
+
3
23
  ## [0.3.4] - 2026-03-20
4
24
 
5
25
  ### Added
@@ -52,10 +52,10 @@ module Legion
52
52
  end
53
53
  end
54
54
 
55
- log_debug("EntityWatchdog: ingested #{ingested} new entities from #{texts.size} log entries")
55
+ log.debug("EntityWatchdog: ingested #{ingested} new entities from #{texts.size} log entries")
56
56
  { success: true, ingested: ingested, logs_scanned: texts.size }
57
57
  rescue StandardError => e
58
- log_error("EntityWatchdog scan_and_ingest failed: #{e.message}")
58
+ log.error("EntityWatchdog scan_and_ingest failed: #{e.message}")
59
59
  { success: false, error: e.message }
60
60
  end
61
61
 
@@ -100,7 +100,7 @@ module Legion
100
100
  context: { entity_type: entity[:type], original_name: entity[:name] }
101
101
  ).publish
102
102
  rescue StandardError => e
103
- log_error("EntityWatchdog publish failed: #{e.message}")
103
+ log.error("EntityWatchdog publish failed: #{e.message}")
104
104
  end
105
105
 
106
106
  def entity_types
@@ -126,16 +126,6 @@ module Legion
126
126
  end
127
127
  DEDUP_THRESHOLD_DEFAULT
128
128
  end
129
-
130
- private
131
-
132
- def log_debug(message)
133
- Legion::Logging.debug(message) if defined?(Legion::Logging)
134
- end
135
-
136
- def log_error(message)
137
- Legion::Logging.error(message) if defined?(Legion::Logging)
138
- end
139
129
  end
140
130
  end
141
131
  end
@@ -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 }
@@ -157,13 +167,13 @@ module Legion
157
167
  redistributed += 1
158
168
  end
159
169
 
160
- Legion::Logging.info "[apollo] redistributed #{redistributed} entries from departing agent=#{agent_id}"
170
+ log.info "[apollo] redistributed #{redistributed} entries from departing agent=#{agent_id}"
161
171
  { success: true, redistributed: redistributed, agent_id: agent_id }
162
172
  rescue Sequel::Error => e
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.7'
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
@@ -1,15 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'bundler/setup'
4
-
5
- module Legion
6
- module Logging
7
- def self.debug(_msg); end
8
- def self.info(_msg); end
9
- def self.warn(_msg); end
10
- def self.error(_msg); end
11
- end
12
- end
4
+ require 'legion/logging'
5
+ require 'legion/settings'
6
+ require 'legion/cache/helper'
7
+ require 'legion/crypt/helper'
8
+ require 'legion/data/helper'
9
+ require 'legion/json/helper'
10
+ require 'legion/transport'
13
11
 
14
12
  # Sequel is a runtime dependency via legion-data; stub for specs
15
13
  unless defined?(Sequel)
@@ -17,9 +15,39 @@ unless defined?(Sequel)
17
15
  class Error < StandardError; end
18
16
 
19
17
  def self.pg_array(arr) = arr
20
- def self.lit(str) = str
21
- Expr = Struct.new(:value) { def +(other) = "#{value} + #{other}" }
18
+ def self.lit(str, *) = str
19
+ Expr = Struct.new(:value) do
20
+ def +(other) = "#{value} + #{other}"
21
+ def *(other) = "#{value} * #{other}"
22
+ end
22
23
  def self.expr(sym) = Expr.new(sym)
24
+ def self.[](sym) = Expr.new(sym)
25
+ end
26
+ end
27
+
28
+ module Legion
29
+ module Extensions
30
+ module Helpers
31
+ module Lex
32
+ include Legion::Logging::Helper
33
+ include Legion::Settings::Helper
34
+ include Legion::Cache::Helper
35
+ include Legion::Crypt::Helper
36
+ include Legion::Data::Helper
37
+ include Legion::JSON::Helper
38
+ include Legion::Transport::Helper
39
+ end
40
+ end
41
+
42
+ module Actors
43
+ class Every
44
+ include Helpers::Lex
45
+ end
46
+
47
+ class Subscription
48
+ include Helpers::Lex
49
+ end
50
+ end
23
51
  end
24
52
  end
25
53
 
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.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -9,6 +9,104 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: legion-cache
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 1.3.11
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 1.3.11
26
+ - !ruby/object:Gem::Dependency
27
+ name: legion-crypt
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 1.4.9
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 1.4.9
40
+ - !ruby/object:Gem::Dependency
41
+ name: legion-data
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 1.4.17
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 1.4.17
54
+ - !ruby/object:Gem::Dependency
55
+ name: legion-json
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 1.2.1
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 1.2.1
68
+ - !ruby/object:Gem::Dependency
69
+ name: legion-logging
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 1.3.2
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 1.3.2
82
+ - !ruby/object:Gem::Dependency
83
+ name: legion-settings
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 1.3.14
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 1.3.14
96
+ - !ruby/object:Gem::Dependency
97
+ name: legion-transport
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 1.3.9
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: 1.3.9
12
110
  - !ruby/object:Gem::Dependency
13
111
  name: rspec
14
112
  requirement: !ruby/object:Gem::Requirement