lex-apollo 0.3.9 → 0.4.0

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: e4b61ece5949747c3f9c63d527451975f1bae1193529322a2dfee5c99fe449f7
4
- data.tar.gz: 2a8ee392eda6931f18e0c8740ef3c7a57737fee787f85755fe4531bac44b1e11
3
+ metadata.gz: 8e6e95c43807f0f9a3eaaaaa0a0d4cadafdbf279acb4829fe0c0ef6ce909e6bc
4
+ data.tar.gz: c336ca5086ffc85995bf6a8759799d48d65edf905f12458de4aa63c24608d0fe
5
5
  SHA512:
6
- metadata.gz: beda1e6d911c593184fa43fb457881b7cc2c8c7052761c3172861afa0b6dd39f449ff51484a8a05fba9479957aff0509f27fb4d462c4e15bb979e30ca8a68d68
7
- data.tar.gz: d21a75d1bfc573144fab0f83cbf37c691a0fb1d5e9ac403ab6aa735ec04b2842d1cd9aeb431f10cd56f5f61f671b67a010257221aadf1e5844dd2435641ea43f
6
+ metadata.gz: 84bfc36365061507970f0fb99c084a2729702041b416211791c90309e1f9e3ce54f1ba2fb9986ebf4e5412d327822ce82f51f83274df6cff758e4564d4786f84
7
+ data.tar.gz: ede777694a9e166d0ce8b8862f444eb02c231204a89c265f850503ca78a8001b89bf3430995a1fb2ca324040467831dee73a5563caa81105a67956721507b505
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-03-23
4
+
5
+ ### Added
6
+ - `Runners::Gas`: 6-phase GAS (Generation-Augmented Storage) pipeline
7
+ - Phase 1 (Comprehend): LLM-based fact extraction via GaiaCaller, mechanical fallback
8
+ - Phase 2 (Extract): delegates to existing `EntityExtractor` runner
9
+ - Phase 3 (Relate): queries Apollo for similar entries, classifies 8 relationship types via LLM, confidence > 0.7 gate
10
+ - Phase 4 (Synthesize): generates derivative knowledge with geometric mean confidence, capped at 0.7
11
+ - Phase 5 (Deposit): atomic write of facts to Apollo via `Knowledge.handle_ingest`
12
+ - Phase 6 (Anticipate): generates follow-up questions, promotes to TBI PatternStore when available
13
+ - `Actor::GasSubscriber`: subscription actor binding to `llm.audit.complete` routing key
14
+ - `Transport::Queues::Gas`: durable queue for GAS audit event processing
15
+
3
16
  ## [0.3.9] - 2026-03-23
4
17
 
5
18
  ### Fixed
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/subscription' if defined?(Legion::Extensions::Actors::Subscription)
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Apollo
8
+ module Actor
9
+ class GasSubscriber < Legion::Extensions::Actors::Subscription
10
+ def runner_class = 'Legion::Extensions::Apollo::Runners::Gas'
11
+ def runner_function = 'process'
12
+ def check_subtask? = false
13
+ def generate_task? = false
14
+
15
+ def enabled?
16
+ defined?(Legion::Extensions::Apollo::Runners::Gas) &&
17
+ defined?(Legion::Transport)
18
+ rescue StandardError
19
+ false
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,375 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Apollo
6
+ module Runners
7
+ module Gas
8
+ module_function
9
+
10
+ def process(audit_event)
11
+ return { phases_completed: 0, reason: 'no content' } unless processable?(audit_event)
12
+
13
+ facts = phase_comprehend(audit_event)
14
+ entities = phase_extract(audit_event, facts)
15
+ relations = phase_relate(facts, entities)
16
+ synthesis = phase_synthesize(facts, relations)
17
+ deposit_result = phase_deposit(facts, entities, relations, synthesis, audit_event)
18
+ anticipations = phase_anticipate(facts, synthesis)
19
+
20
+ {
21
+ phases_completed: 6,
22
+ facts: facts.length,
23
+ entities: entities.length,
24
+ relations: relations.length,
25
+ synthesis: synthesis.length,
26
+ deposited: deposit_result,
27
+ anticipations: anticipations.length
28
+ }
29
+ rescue StandardError => e
30
+ Legion::Logging.warn("GAS pipeline error: #{e.message}") if defined?(Legion::Logging)
31
+ { phases_completed: 0, error: e.message }
32
+ end
33
+
34
+ def processable?(event)
35
+ event[:messages]&.any? == true && !event[:response_content].nil?
36
+ end
37
+
38
+ # Phase 1: Comprehend - extract typed facts from the exchange
39
+ def phase_comprehend(audit_event)
40
+ messages = audit_event[:messages]
41
+ response = audit_event[:response_content]
42
+
43
+ if llm_available?
44
+ llm_comprehend(messages, response)
45
+ else
46
+ mechanical_comprehend(messages, response)
47
+ end
48
+ end
49
+
50
+ # Phase 2: Extract - entity extraction (delegates to existing EntityExtractor)
51
+ def phase_extract(audit_event, _facts)
52
+ return [] unless defined?(Runners::EntityExtractor)
53
+
54
+ result = Runners::EntityExtractor.extract_entities(text: audit_event[:response_content])
55
+ result[:success] ? (result[:entities] || []) : []
56
+ rescue StandardError
57
+ []
58
+ end
59
+
60
+ RELATION_TYPES = %w[
61
+ similar_to contradicts depends_on causes
62
+ part_of supersedes supports_by extends
63
+ ].freeze
64
+
65
+ RELATE_CONFIDENCE_GATE = 0.7
66
+
67
+ # Phase 3: Relate - classify relationships between new and existing entries
68
+ def phase_relate(facts, _entities)
69
+ return [] unless defined?(Runners::Knowledge)
70
+
71
+ existing = fetch_similar_entries(facts)
72
+ return [] if existing.empty?
73
+
74
+ relations = []
75
+ facts.each do |fact|
76
+ existing.each do |entry|
77
+ relation = classify_relation(fact, entry)
78
+ relations << relation if relation
79
+ end
80
+ end
81
+ relations
82
+ end
83
+
84
+ SYNTHESIS_CONFIDENCE_CAP = 0.7
85
+
86
+ # Phase 4: Synthesize - generate derivative knowledge
87
+ def phase_synthesize(facts, _relations)
88
+ return [] if facts.length < 2
89
+ return [] unless llm_available?
90
+
91
+ llm_synthesize(facts)
92
+ rescue StandardError
93
+ []
94
+ end
95
+
96
+ # Phase 5: Deposit - atomic write to Apollo
97
+ def phase_deposit(facts, _entities, _relations, _synthesis, audit_event)
98
+ return { deposited: 0 } unless defined?(Runners::Knowledge)
99
+
100
+ deposited = 0
101
+ facts.each do |fact|
102
+ Runners::Knowledge.handle_ingest(
103
+ content: fact[:content],
104
+ content_type: fact[:content_type].to_s,
105
+ tags: %w[gas auto_extracted],
106
+ source_agent: 'gas_pipeline',
107
+ source_provider: audit_event.dig(:routing, :provider)&.to_s,
108
+ knowledge_domain: 'general',
109
+ context: { source_request_id: audit_event[:request_id] }
110
+ )
111
+ deposited += 1
112
+ rescue StandardError => e
113
+ Legion::Logging.warn("GAS deposit error: #{e.message}") if defined?(Legion::Logging)
114
+ end
115
+ { deposited: deposited }
116
+ end
117
+
118
+ MAX_ANTICIPATIONS = 3
119
+
120
+ # Phase 6: Anticipate - pre-cache likely follow-up questions
121
+ def phase_anticipate(facts, _synthesis)
122
+ return [] if facts.empty?
123
+ return [] unless llm_available?
124
+
125
+ llm_anticipate(facts)
126
+ rescue StandardError
127
+ []
128
+ end
129
+
130
+ def fetch_similar_entries(facts)
131
+ entries = []
132
+ facts.each do |fact|
133
+ result = Runners::Knowledge.retrieve_relevant(query: fact[:content], limit: 3, min_confidence: 0.3)
134
+ entries.concat(result[:entries]) if result[:success] && result[:entries]&.any?
135
+ rescue StandardError
136
+ next
137
+ end
138
+ entries.uniq { |e| e[:id] }
139
+ end
140
+
141
+ def classify_relation(fact, entry)
142
+ if llm_available?
143
+ llm_classify_relation(fact, entry)
144
+ else
145
+ { from_content: fact[:content], to_id: entry[:id], relation_type: 'similar_to', confidence: 0.5 }
146
+ end
147
+ rescue StandardError
148
+ { from_content: fact[:content], to_id: entry[:id], relation_type: 'similar_to', confidence: 0.5 }
149
+ end
150
+
151
+ def llm_classify_relation(fact, entry)
152
+ prompt = <<~PROMPT
153
+ Classify the relationship between these two knowledge entries.
154
+ Valid types: #{RELATION_TYPES.join(', ')}
155
+
156
+ Entry A (new): #{fact[:content]}
157
+ Entry B (existing): #{entry[:content]}
158
+
159
+ Return JSON with relation_type and confidence (0.0-1.0).
160
+ PROMPT
161
+
162
+ result = Legion::LLM::Pipeline::GaiaCaller.structured(
163
+ message: prompt.strip,
164
+ schema: {
165
+ type: :object,
166
+ properties: {
167
+ relations: {
168
+ type: :array,
169
+ items: {
170
+ type: :object,
171
+ properties: {
172
+ relation_type: { type: :string },
173
+ confidence: { type: :number }
174
+ },
175
+ required: %w[relation_type confidence]
176
+ }
177
+ }
178
+ },
179
+ required: ['relations']
180
+ },
181
+ phase: 'gas_relate'
182
+ )
183
+
184
+ content = result.respond_to?(:message) ? result.message[:content] : result.to_s
185
+ parsed = Legion::JSON.load(content)
186
+ rels = parsed.is_a?(Hash) ? (parsed[:relations] || parsed['relations'] || []) : []
187
+ best = rels.max_by { |r| r[:confidence] || r['confidence'] || 0 }
188
+
189
+ return fallback_relation(fact, entry) unless best
190
+
191
+ conf = best[:confidence] || best['confidence'] || 0
192
+ rtype = best[:relation_type] || best['relation_type']
193
+ return fallback_relation(fact, entry) if conf < RELATE_CONFIDENCE_GATE || !RELATION_TYPES.include?(rtype)
194
+
195
+ { from_content: fact[:content], to_id: entry[:id], relation_type: rtype, confidence: conf }
196
+ rescue StandardError
197
+ fallback_relation(fact, entry)
198
+ end
199
+
200
+ def fallback_relation(fact, entry)
201
+ { from_content: fact[:content], to_id: entry[:id], relation_type: 'similar_to', confidence: 0.5 }
202
+ end
203
+
204
+ def llm_synthesize(facts)
205
+ facts_text = facts.each_with_index.map { |f, i| "[#{i}] (#{f[:content_type]}) #{f[:content]}" }.join("\n")
206
+
207
+ prompt = <<~PROMPT
208
+ Given these knowledge entries, generate derivative insights (inferences, implications, or connections).
209
+ Each synthesis should combine information from multiple sources.
210
+
211
+ Entries:
212
+ #{facts_text}
213
+
214
+ Return JSON with a "synthesis" array where each item has: content (string), content_type (inference/implication/connection), source_indices (array of entry indices used).
215
+ PROMPT
216
+
217
+ result = Legion::LLM::Pipeline::GaiaCaller.structured(
218
+ message: prompt.strip,
219
+ schema: {
220
+ type: :object,
221
+ properties: {
222
+ synthesis: {
223
+ type: :array,
224
+ items: {
225
+ type: :object,
226
+ properties: {
227
+ content: { type: :string },
228
+ content_type: { type: :string },
229
+ source_indices: { type: :array, items: { type: :integer } }
230
+ },
231
+ required: %w[content content_type source_indices]
232
+ }
233
+ }
234
+ },
235
+ required: ['synthesis']
236
+ },
237
+ phase: 'gas_synthesize'
238
+ )
239
+
240
+ content = result.respond_to?(:message) ? result.message[:content] : result.to_s
241
+ parsed = Legion::JSON.load(content)
242
+ items = parsed.is_a?(Hash) ? (parsed[:synthesis] || parsed['synthesis'] || []) : []
243
+
244
+ items.map { |item| build_synthesis_entry(item, facts) }
245
+ rescue StandardError
246
+ []
247
+ end
248
+
249
+ def build_synthesis_entry(item, facts)
250
+ source_indices = item[:source_indices] || item['source_indices'] || []
251
+ source_confs = source_indices.filter_map { |i| facts[i]&.dig(:confidence) }
252
+ geo_mean = source_confs.empty? ? 0.5 : geometric_mean(source_confs)
253
+
254
+ {
255
+ content: item[:content] || item['content'],
256
+ content_type: (item[:content_type] || item['content_type'] || 'inference').to_sym,
257
+ status: :candidate,
258
+ confidence: [geo_mean, SYNTHESIS_CONFIDENCE_CAP].min,
259
+ source_indices: source_indices
260
+ }
261
+ end
262
+
263
+ def geometric_mean(values)
264
+ return 0.0 if values.empty?
265
+
266
+ product = values.reduce(1.0) { |acc, v| acc * v }
267
+ product**(1.0 / values.length)
268
+ end
269
+
270
+ def llm_anticipate(facts)
271
+ facts_text = facts.map { |f| "(#{f[:content_type]}) #{f[:content]}" }.join("\n")
272
+
273
+ prompt = <<~PROMPT
274
+ Given these knowledge entries, generate 1-3 likely follow-up questions a user might ask.
275
+
276
+ Knowledge:
277
+ #{facts_text}
278
+
279
+ Return JSON with a "questions" array of question strings.
280
+ PROMPT
281
+
282
+ result = Legion::LLM::Pipeline::GaiaCaller.structured(
283
+ message: prompt.strip,
284
+ schema: {
285
+ type: :object,
286
+ properties: {
287
+ questions: { type: :array, items: { type: :string } }
288
+ },
289
+ required: ['questions']
290
+ },
291
+ phase: 'gas_anticipate'
292
+ )
293
+
294
+ content = result.respond_to?(:message) ? result.message[:content] : result.to_s
295
+ parsed = Legion::JSON.load(content)
296
+ questions = parsed.is_a?(Hash) ? (parsed[:questions] || parsed['questions'] || []) : []
297
+ questions = questions.first(MAX_ANTICIPATIONS)
298
+
299
+ questions.map do |q|
300
+ promote_to_pattern_store(question: q, facts: facts)
301
+ { question: q }
302
+ end
303
+ rescue StandardError
304
+ []
305
+ end
306
+
307
+ def promote_to_pattern_store(question:, facts:)
308
+ return unless defined?(Legion::Extensions::Agentic::TBI::PatternStore)
309
+
310
+ Legion::Extensions::Agentic::TBI::PatternStore.promote_candidate(
311
+ intent: question,
312
+ resolution: { source: 'gas_anticipate', facts: facts.map { |f| f[:content] } },
313
+ confidence: 0.5
314
+ )
315
+ rescue StandardError
316
+ nil
317
+ end
318
+
319
+ def llm_available?
320
+ defined?(Legion::LLM::Pipeline::GaiaCaller)
321
+ rescue StandardError
322
+ false
323
+ end
324
+
325
+ def mechanical_comprehend(_messages, response)
326
+ [{ content: response, content_type: :observation }]
327
+ end
328
+
329
+ def llm_comprehend(messages, response)
330
+ prompt = <<~PROMPT
331
+ Extract distinct facts from this exchange. Return JSON array of {content:, content_type:} where content_type is one of: fact, concept, procedure, association.
332
+
333
+ User: #{messages.last&.dig(:content)}
334
+ Assistant: #{response}
335
+ PROMPT
336
+
337
+ result = Legion::LLM::Pipeline::GaiaCaller.structured(
338
+ message: prompt.strip,
339
+ schema: {
340
+ type: :object,
341
+ properties: {
342
+ facts: {
343
+ type: :array,
344
+ items: {
345
+ type: :object,
346
+ properties: {
347
+ content: { type: :string },
348
+ content_type: { type: :string }
349
+ },
350
+ required: %w[content content_type]
351
+ }
352
+ }
353
+ },
354
+ required: ['facts']
355
+ },
356
+ phase: 'gas_comprehend'
357
+ )
358
+
359
+ content = result.respond_to?(:message) ? result.message[:content] : result.to_s
360
+ parsed = Legion::JSON.load(content)
361
+ facts_array = parsed.is_a?(Hash) ? (parsed[:facts] || parsed['facts'] || []) : Array(parsed)
362
+ facts_array.map do |f|
363
+ {
364
+ content: f[:content] || f['content'],
365
+ content_type: (f[:content_type] || f['content_type'] || 'fact').to_sym
366
+ }
367
+ end
368
+ rescue StandardError
369
+ mechanical_comprehend(messages, response)
370
+ end
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/transport/queue' if defined?(Legion::Transport)
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Apollo
8
+ module Transport
9
+ module Queues
10
+ class Gas < Legion::Transport::Queue
11
+ def queue_name
12
+ 'apollo.gas'
13
+ end
14
+
15
+ def queue_options
16
+ { manual_ack: true, durable: true, arguments: { 'x-dead-letter-exchange': 'apollo.dlx' } }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Apollo
6
- VERSION = '0.3.9'
6
+ VERSION = '0.4.0'
7
7
  end
8
8
  end
9
9
  end
@@ -8,11 +8,13 @@ require 'legion/extensions/apollo/runners/knowledge'
8
8
  require 'legion/extensions/apollo/runners/expertise'
9
9
  require 'legion/extensions/apollo/runners/maintenance'
10
10
  require 'legion/extensions/apollo/runners/entity_extractor'
11
+ require 'legion/extensions/apollo/runners/gas'
11
12
 
12
13
  if defined?(Legion::Transport)
13
14
  require 'legion/extensions/apollo/transport/exchanges/apollo'
14
15
  require 'legion/extensions/apollo/transport/queues/ingest'
15
16
  require 'legion/extensions/apollo/transport/queues/query'
17
+ require 'legion/extensions/apollo/transport/queues/gas'
16
18
  require 'legion/extensions/apollo/transport/messages/ingest'
17
19
  require 'legion/extensions/apollo/transport/messages/query'
18
20
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ unless defined?(Legion::Extensions::Actors::Subscription)
6
+ module Legion
7
+ module Extensions
8
+ module Actors
9
+ class Subscription; end # rubocop:disable Lint/EmptyClass
10
+ end
11
+ end
12
+ end
13
+ end
14
+ $LOADED_FEATURES << 'legion/extensions/actors/subscription' unless $LOADED_FEATURES.include?('legion/extensions/actors/subscription')
15
+
16
+ require 'legion/extensions/apollo/runners/gas'
17
+ require 'legion/extensions/apollo/actors/gas_subscriber'
18
+
19
+ RSpec.describe Legion::Extensions::Apollo::Actor::GasSubscriber do
20
+ subject(:actor) { described_class.new }
21
+
22
+ it 'uses Gas runner_class as string' do
23
+ expect(actor.runner_class).to eq('Legion::Extensions::Apollo::Runners::Gas')
24
+ end
25
+
26
+ it 'runs process function' do
27
+ expect(actor.runner_function).to eq('process')
28
+ end
29
+
30
+ it 'does not generate tasks' do
31
+ expect(actor.generate_task?).to be false
32
+ end
33
+
34
+ it 'does not check subtasks' do
35
+ expect(actor.check_subtask?).to be false
36
+ end
37
+
38
+ describe '#enabled?' do
39
+ it 'returns truthy when Gas runner and Transport are defined' do
40
+ stub_const('Legion::Transport', Module.new)
41
+ expect(actor.enabled?).to be_truthy
42
+ end
43
+
44
+ it 'returns falsey when Transport is not defined' do
45
+ hide_const('Legion::Transport') if defined?(Legion::Transport)
46
+ expect(actor.enabled?).to be_falsey
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/apollo/runners/gas'
5
+
6
+ RSpec.describe Legion::Extensions::Apollo::Runners::Gas, '.phase_anticipate' do
7
+ let(:facts) do
8
+ [
9
+ { content: 'pgvector uses HNSW indexes', content_type: :fact },
10
+ { content: 'cosine distance measures similarity', content_type: :concept }
11
+ ]
12
+ end
13
+
14
+ let(:synthesis) do
15
+ [
16
+ { content: 'pgvector achieves fast search via HNSW', content_type: :inference, status: :candidate }
17
+ ]
18
+ end
19
+
20
+ context 'when GaiaCaller is unavailable' do
21
+ it 'returns empty array' do
22
+ result = described_class.phase_anticipate(facts, synthesis)
23
+ expect(result).to eq([])
24
+ end
25
+ end
26
+
27
+ context 'when GaiaCaller is available' do
28
+ let(:gaia_caller) { double('GaiaCaller') }
29
+ let(:mock_response) do
30
+ double(
31
+ 'Response',
32
+ message: {
33
+ content: '{"questions":["How fast is pgvector HNSW search?","What distance metrics does pgvector support?"]}'
34
+ }
35
+ )
36
+ end
37
+
38
+ before do
39
+ stub_const('Legion::LLM::Pipeline::GaiaCaller', gaia_caller)
40
+ allow(gaia_caller).to receive(:structured).and_return(mock_response)
41
+ allow(Legion::JSON).to receive(:load).and_return(
42
+ { 'questions' => ['How fast is pgvector HNSW search?', 'What distance metrics does pgvector support?'] }
43
+ )
44
+ end
45
+
46
+ it 'generates anticipated questions' do
47
+ result = described_class.phase_anticipate(facts, synthesis)
48
+ expect(result).not_to be_empty
49
+ expect(result.length).to be <= 3
50
+ end
51
+
52
+ it 'returns question strings' do
53
+ result = described_class.phase_anticipate(facts, synthesis)
54
+ result.each do |item|
55
+ expect(item[:question]).to be_a(String)
56
+ end
57
+ end
58
+
59
+ context 'when PatternStore is available' do
60
+ let(:pattern_store) { double('PatternStore') }
61
+
62
+ before do
63
+ stub_const('Legion::Extensions::Agentic::TBI::PatternStore', pattern_store)
64
+ allow(pattern_store).to receive(:promote_candidate)
65
+ end
66
+
67
+ it 'promotes candidates to PatternStore' do
68
+ described_class.phase_anticipate(facts, synthesis)
69
+ expect(pattern_store).to have_received(:promote_candidate).at_least(:once)
70
+ end
71
+ end
72
+
73
+ context 'when PatternStore is not available' do
74
+ it 'still returns questions without error' do
75
+ result = described_class.phase_anticipate(facts, synthesis)
76
+ expect(result).not_to be_empty
77
+ end
78
+ end
79
+ end
80
+
81
+ context 'when LLM call fails' do
82
+ let(:gaia_caller) { double('GaiaCaller') }
83
+
84
+ before do
85
+ stub_const('Legion::LLM::Pipeline::GaiaCaller', gaia_caller)
86
+ allow(gaia_caller).to receive(:structured).and_raise(StandardError, 'timeout')
87
+ end
88
+
89
+ it 'returns empty array on error' do
90
+ result = described_class.phase_anticipate(facts, synthesis)
91
+ expect(result).to eq([])
92
+ end
93
+ end
94
+
95
+ context 'with empty facts' do
96
+ it 'returns empty array' do
97
+ result = described_class.phase_anticipate([], [])
98
+ expect(result).to eq([])
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/apollo/runners/gas'
5
+
6
+ RSpec.describe Legion::Extensions::Apollo::Runners::Gas, '.phase_relate' do
7
+ let(:facts) do
8
+ [
9
+ { content: 'pgvector uses HNSW indexes', content_type: :fact },
10
+ { content: 'cosine distance measures similarity', content_type: :concept }
11
+ ]
12
+ end
13
+ let(:entities) do
14
+ [{ name: 'pgvector', type: 'technology' }]
15
+ end
16
+
17
+ context 'when Apollo Knowledge is unavailable' do
18
+ before do
19
+ hide_const('Legion::Extensions::Apollo::Runners::Knowledge') if defined?(Legion::Extensions::Apollo::Runners::Knowledge)
20
+ end
21
+
22
+ it 'returns empty array' do
23
+ result = described_class.phase_relate(facts, entities)
24
+ expect(result).to eq([])
25
+ end
26
+ end
27
+
28
+ context 'when Apollo has similar entries' do
29
+ let(:knowledge_runner) { double('Knowledge') }
30
+ let(:similar_entries) do
31
+ {
32
+ success: true,
33
+ entries: [
34
+ { id: 'e1', content: 'HNSW is an approximate nearest neighbor algorithm', content_type: 'concept', confidence: 0.85 }
35
+ ],
36
+ count: 1
37
+ }
38
+ end
39
+
40
+ before do
41
+ stub_const('Legion::Extensions::Apollo::Runners::Knowledge', knowledge_runner)
42
+ allow(knowledge_runner).to receive(:retrieve_relevant).and_return(similar_entries)
43
+ end
44
+
45
+ context 'when GaiaCaller is unavailable' do
46
+ it 'falls back to similar_to relations' do
47
+ result = described_class.phase_relate(facts, entities)
48
+ expect(result).to all(include(relation_type: 'similar_to'))
49
+ end
50
+
51
+ it 'returns relations for each fact-entry pair' do
52
+ result = described_class.phase_relate(facts, entities)
53
+ expect(result).not_to be_empty
54
+ end
55
+ end
56
+
57
+ context 'when GaiaCaller is available' do
58
+ let(:gaia_caller) { double('GaiaCaller') }
59
+ let(:mock_response) do
60
+ double(
61
+ 'Response',
62
+ message: {
63
+ content: '{"relations":[{"relation_type":"depends_on","confidence":0.85}]}'
64
+ }
65
+ )
66
+ end
67
+
68
+ before do
69
+ stub_const('Legion::LLM::Pipeline::GaiaCaller', gaia_caller)
70
+ stub_const('Legion::JSON', double(load: { 'relations' => [{ 'relation_type' => 'depends_on', 'confidence' => 0.85 }] }))
71
+ allow(gaia_caller).to receive(:structured).and_return(mock_response)
72
+ end
73
+
74
+ it 'classifies relations via LLM' do
75
+ result = described_class.phase_relate(facts, entities)
76
+ expect(result).not_to be_empty
77
+ expect(result.first[:relation_type]).to eq('depends_on')
78
+ end
79
+
80
+ it 'gates on confidence > 0.7' do
81
+ low_conf_response = double(
82
+ 'Response',
83
+ message: {
84
+ content: '{"relations":[{"relation_type":"contradicts","confidence":0.3}]}'
85
+ }
86
+ )
87
+ allow(gaia_caller).to receive(:structured).and_return(low_conf_response)
88
+ allow(Legion::JSON).to receive(:load).and_return(
89
+ { 'relations' => [{ 'relation_type' => 'contradicts', 'confidence' => 0.3 }] }
90
+ )
91
+
92
+ result = described_class.phase_relate(facts, entities)
93
+ # Low confidence relations should fall back to similar_to
94
+ classified = result.select { |r| r[:relation_type] == 'contradicts' }
95
+ expect(classified).to be_empty
96
+ end
97
+ end
98
+ end
99
+
100
+ context 'when Apollo returns no similar entries' do
101
+ let(:knowledge_runner) { double('Knowledge') }
102
+
103
+ before do
104
+ stub_const('Legion::Extensions::Apollo::Runners::Knowledge', knowledge_runner)
105
+ allow(knowledge_runner).to receive(:retrieve_relevant).and_return(
106
+ { success: true, entries: [], count: 0 }
107
+ )
108
+ end
109
+
110
+ it 'returns empty array' do
111
+ result = described_class.phase_relate(facts, entities)
112
+ expect(result).to eq([])
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/apollo/runners/gas'
5
+
6
+ RSpec.describe Legion::Extensions::Apollo::Runners::Gas do
7
+ describe '.process' do
8
+ let(:audit_event) do
9
+ {
10
+ request_id: 'req_abc',
11
+ messages: [{ role: :user, content: 'How does pgvector work?' }],
12
+ response_content: 'pgvector uses HNSW indexes for approximate nearest neighbor search.',
13
+ routing: { provider: :anthropic, model: 'claude-opus-4-6' },
14
+ tokens: { input: 50, output: 30, total: 80 },
15
+ caller: { requested_by: { identity: 'user:matt', type: :user } },
16
+ timestamp: Time.now
17
+ }
18
+ end
19
+
20
+ it 'runs all 6 phases in order' do
21
+ allow(described_class).to receive(:phase_comprehend).and_return([{ content: 'fact', content_type: :fact }])
22
+ allow(described_class).to receive(:phase_extract).and_return([{ name: 'pgvector', type: 'technology' }])
23
+ allow(described_class).to receive(:phase_relate).and_return([])
24
+ allow(described_class).to receive(:phase_synthesize).and_return([])
25
+ allow(described_class).to receive(:phase_deposit).and_return({ deposited: 1 })
26
+ allow(described_class).to receive(:phase_anticipate).and_return([])
27
+
28
+ result = described_class.process(audit_event)
29
+ expect(result).to be_a(Hash)
30
+ expect(result[:phases_completed]).to eq(6)
31
+ end
32
+
33
+ it 'skips when audit event has no content' do
34
+ result = described_class.process({ request_id: 'req_abc' })
35
+ expect(result[:phases_completed]).to eq(0)
36
+ end
37
+
38
+ it 'skips when response_content is nil' do
39
+ result = described_class.process({ request_id: 'req_abc', messages: [{ role: :user, content: 'hi' }] })
40
+ expect(result[:phases_completed]).to eq(0)
41
+ end
42
+
43
+ it 'returns error details on failure' do
44
+ allow(described_class).to receive(:phase_comprehend).and_raise(StandardError, 'boom')
45
+
46
+ result = described_class.process(audit_event)
47
+ expect(result[:phases_completed]).to eq(0)
48
+ expect(result[:error]).to eq('boom')
49
+ end
50
+ end
51
+
52
+ describe '.processable?' do
53
+ it 'returns true with messages and response_content' do
54
+ event = { messages: [{ role: :user, content: 'hi' }], response_content: 'hello' }
55
+ expect(described_class.processable?(event)).to be true
56
+ end
57
+
58
+ it 'returns false without messages' do
59
+ expect(described_class.processable?({ response_content: 'hello' })).to be false
60
+ end
61
+
62
+ it 'returns false without response_content' do
63
+ expect(described_class.processable?({ messages: [{ role: :user, content: 'hi' }] })).to be false
64
+ end
65
+ end
66
+
67
+ describe '.mechanical_comprehend' do
68
+ it 'wraps response as a single observation' do
69
+ messages = [{ role: :user, content: 'hi' }]
70
+ result = described_class.mechanical_comprehend(messages, 'pgvector is fast')
71
+ expect(result).to eq([{ content: 'pgvector is fast', content_type: :observation }])
72
+ end
73
+ end
74
+
75
+ describe '.phase_extract' do
76
+ it 'returns empty array when EntityExtractor not defined' do
77
+ hide_const('Legion::Extensions::Apollo::Runners::EntityExtractor') if defined?(Legion::Extensions::Apollo::Runners::EntityExtractor)
78
+ result = described_class.phase_extract({ response_content: 'test' }, [])
79
+ expect(result).to eq([])
80
+ end
81
+ end
82
+
83
+ describe '.phase_relate' do
84
+ it 'returns empty array (stub)' do
85
+ expect(described_class.phase_relate([], [])).to eq([])
86
+ end
87
+ end
88
+
89
+ describe '.phase_synthesize' do
90
+ it 'returns empty array (stub)' do
91
+ expect(described_class.phase_synthesize([], [])).to eq([])
92
+ end
93
+ end
94
+
95
+ describe '.phase_anticipate' do
96
+ it 'returns empty array (stub)' do
97
+ expect(described_class.phase_anticipate([], [])).to eq([])
98
+ end
99
+ end
100
+
101
+ describe '.phase_deposit' do
102
+ it 'returns deposited 0 when Knowledge not defined' do
103
+ hide_const('Legion::Extensions::Apollo::Runners::Knowledge') if defined?(Legion::Extensions::Apollo::Runners::Knowledge)
104
+ result = described_class.phase_deposit([], [], [], [], { request_id: 'req_1' })
105
+ expect(result).to eq({ deposited: 0 })
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/apollo/runners/gas'
5
+
6
+ RSpec.describe Legion::Extensions::Apollo::Runners::Gas, '.phase_synthesize' do
7
+ let(:facts) do
8
+ [
9
+ { content: 'pgvector uses HNSW indexes', content_type: :fact, confidence: 0.9 },
10
+ { content: 'HNSW provides logarithmic search time', content_type: :fact, confidence: 0.85 }
11
+ ]
12
+ end
13
+
14
+ let(:relations) do
15
+ [
16
+ { from_content: 'pgvector uses HNSW indexes', to_id: 'e1', relation_type: 'depends_on', confidence: 0.8 }
17
+ ]
18
+ end
19
+
20
+ context 'when GaiaCaller is unavailable' do
21
+ it 'returns empty array' do
22
+ result = described_class.phase_synthesize(facts, relations)
23
+ expect(result).to eq([])
24
+ end
25
+ end
26
+
27
+ context 'when GaiaCaller is available' do
28
+ let(:gaia_caller) { double('GaiaCaller') }
29
+ let(:mock_response) do
30
+ double(
31
+ 'Response',
32
+ message: {
33
+ content: '{"synthesis":[{"content":"pgvector achieves fast search via HNSW",' \
34
+ '"content_type":"inference","source_indices":[0,1]}]}'
35
+ }
36
+ )
37
+ end
38
+
39
+ before do
40
+ stub_const('Legion::LLM::Pipeline::GaiaCaller', gaia_caller)
41
+ allow(gaia_caller).to receive(:structured).and_return(mock_response)
42
+ allow(Legion::JSON).to receive(:load).and_return(
43
+ {
44
+ 'synthesis' => [
45
+ {
46
+ 'content' => 'pgvector achieves fast similarity search through HNSW logarithmic indexing',
47
+ 'content_type' => 'inference',
48
+ 'source_indices' => [0, 1]
49
+ }
50
+ ]
51
+ }
52
+ )
53
+ end
54
+
55
+ it 'generates derivative knowledge entries' do
56
+ result = described_class.phase_synthesize(facts, relations)
57
+ expect(result).not_to be_empty
58
+ expect(result.first[:content]).to include('pgvector')
59
+ end
60
+
61
+ it 'marks entries as candidate status' do
62
+ result = described_class.phase_synthesize(facts, relations)
63
+ expect(result.first[:status]).to eq(:candidate)
64
+ end
65
+
66
+ it 'caps confidence at 0.7' do
67
+ result = described_class.phase_synthesize(facts, relations)
68
+ result.each do |entry|
69
+ expect(entry[:confidence]).to be <= 0.7
70
+ end
71
+ end
72
+
73
+ it 'includes depends_on source indices' do
74
+ result = described_class.phase_synthesize(facts, relations)
75
+ expect(result.first[:source_indices]).to eq([0, 1])
76
+ end
77
+ end
78
+
79
+ context 'when LLM call fails' do
80
+ let(:gaia_caller) { double('GaiaCaller') }
81
+
82
+ before do
83
+ stub_const('Legion::LLM::Pipeline::GaiaCaller', gaia_caller)
84
+ allow(gaia_caller).to receive(:structured).and_raise(StandardError, 'LLM timeout')
85
+ end
86
+
87
+ it 'returns empty array on error' do
88
+ result = described_class.phase_synthesize(facts, relations)
89
+ expect(result).to eq([])
90
+ end
91
+ end
92
+
93
+ context 'with empty inputs' do
94
+ it 'returns empty array when no facts' do
95
+ result = described_class.phase_synthesize([], [])
96
+ expect(result).to eq([])
97
+ end
98
+
99
+ it 'returns empty array when single fact (nothing to synthesize)' do
100
+ result = described_class.phase_synthesize(
101
+ [{ content: 'one fact', content_type: :fact, confidence: 0.9 }],
102
+ []
103
+ )
104
+ expect(result).to eq([])
105
+ end
106
+ end
107
+ end
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.9
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -150,6 +150,7 @@ files:
150
150
  - lib/legion/extensions/apollo/actors/decay.rb
151
151
  - lib/legion/extensions/apollo/actors/entity_watchdog.rb
152
152
  - lib/legion/extensions/apollo/actors/expertise_aggregator.rb
153
+ - lib/legion/extensions/apollo/actors/gas_subscriber.rb
153
154
  - lib/legion/extensions/apollo/actors/ingest.rb
154
155
  - lib/legion/extensions/apollo/actors/query_responder.rb
155
156
  - lib/legion/extensions/apollo/client.rb
@@ -161,18 +162,21 @@ files:
161
162
  - lib/legion/extensions/apollo/helpers/similarity.rb
162
163
  - lib/legion/extensions/apollo/runners/entity_extractor.rb
163
164
  - lib/legion/extensions/apollo/runners/expertise.rb
165
+ - lib/legion/extensions/apollo/runners/gas.rb
164
166
  - lib/legion/extensions/apollo/runners/knowledge.rb
165
167
  - lib/legion/extensions/apollo/runners/maintenance.rb
166
168
  - lib/legion/extensions/apollo/transport.rb
167
169
  - lib/legion/extensions/apollo/transport/exchanges/apollo.rb
168
170
  - lib/legion/extensions/apollo/transport/messages/ingest.rb
169
171
  - lib/legion/extensions/apollo/transport/messages/query.rb
172
+ - lib/legion/extensions/apollo/transport/queues/gas.rb
170
173
  - lib/legion/extensions/apollo/transport/queues/ingest.rb
171
174
  - lib/legion/extensions/apollo/transport/queues/query.rb
172
175
  - lib/legion/extensions/apollo/version.rb
173
176
  - spec/legion/extensions/apollo/actors/decay_spec.rb
174
177
  - spec/legion/extensions/apollo/actors/entity_watchdog_spec.rb
175
178
  - spec/legion/extensions/apollo/actors/expertise_aggregator_spec.rb
179
+ - spec/legion/extensions/apollo/actors/gas_subscriber_spec.rb
176
180
  - spec/legion/extensions/apollo/actors/ingest_spec.rb
177
181
  - spec/legion/extensions/apollo/client_spec.rb
178
182
  - spec/legion/extensions/apollo/contradiction_spec.rb
@@ -185,6 +189,10 @@ files:
185
189
  - spec/legion/extensions/apollo/runners/decay_cycle_spec.rb
186
190
  - spec/legion/extensions/apollo/runners/entity_extractor_spec.rb
187
191
  - spec/legion/extensions/apollo/runners/expertise_spec.rb
192
+ - spec/legion/extensions/apollo/runners/gas_anticipate_spec.rb
193
+ - spec/legion/extensions/apollo/runners/gas_relate_spec.rb
194
+ - spec/legion/extensions/apollo/runners/gas_spec.rb
195
+ - spec/legion/extensions/apollo/runners/gas_synthesize_spec.rb
188
196
  - spec/legion/extensions/apollo/runners/knowledge_spec.rb
189
197
  - spec/legion/extensions/apollo/runners/maintenance_spec.rb
190
198
  - spec/legion/extensions/apollo/transport/messages/ingest_spec.rb