lex-apollo 0.4.30 → 0.4.44

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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/README.md +42 -5
  4. data/lib/legion/extensions/apollo/actors/contradiction_scanner.rb +14 -11
  5. data/lib/legion/extensions/apollo/actors/gas_subscriber.rb +4 -6
  6. data/lib/legion/extensions/apollo/api.rb +19 -9
  7. data/lib/legion/extensions/apollo/helpers/capability.rb +8 -20
  8. data/lib/legion/extensions/apollo/helpers/confidence.rb +1 -1
  9. data/lib/legion/extensions/apollo/helpers/graph_query.rb +30 -46
  10. data/lib/legion/extensions/apollo/helpers/writeback.rb +9 -13
  11. data/lib/legion/extensions/apollo/runners/gas.rb +90 -20
  12. data/lib/legion/extensions/apollo/runners/knowledge.rb +152 -104
  13. data/lib/legion/extensions/apollo/runners/maintenance.rb +133 -34
  14. data/lib/legion/extensions/apollo/runners/request.rb +4 -5
  15. data/lib/legion/extensions/apollo/transport.rb +20 -0
  16. data/lib/legion/extensions/apollo/version.rb +1 -1
  17. data/spec/legion/extensions/apollo/actors/contradiction_scanner_spec.rb +38 -0
  18. data/spec/legion/extensions/apollo/actors/gas_subscriber_spec.rb +4 -14
  19. data/spec/legion/extensions/apollo/api_spec.rb +11 -21
  20. data/spec/legion/extensions/apollo/contradiction_spec.rb +0 -9
  21. data/spec/legion/extensions/apollo/helpers/capability_spec.rb +2 -2
  22. data/spec/legion/extensions/apollo/helpers/writeback_spec.rb +29 -1
  23. data/spec/legion/extensions/apollo/runners/corroboration_integration_spec.rb +191 -0
  24. data/spec/legion/extensions/apollo/runners/gas_deposit_integration_spec.rb +92 -0
  25. data/spec/legion/extensions/apollo/runners/gas_spec.rb +27 -33
  26. data/spec/legion/extensions/apollo/runners/ingest_transaction_integration_spec.rb +114 -0
  27. data/spec/legion/extensions/apollo/runners/knowledge_spec.rb +47 -57
  28. data/spec/legion/extensions/apollo/runners/lifecycle_integration_spec.rb +165 -0
  29. data/spec/legion/extensions/apollo/runners/maintenance_spec.rb +22 -108
  30. data/spec/legion/extensions/apollo/runners/request_spec.rb +43 -26
  31. metadata +34 -2
  32. data/spec/legion/extensions/apollo/helpers/graph_query_spec.rb +0 -100
@@ -122,11 +122,10 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
122
122
  end
123
123
 
124
124
  describe '#deprecate_entry' do
125
- it 'returns a deprecate payload' do
125
+ it 'retains the client action envelope without claiming a completed service mutation' do
126
126
  result = runner.deprecate_entry(entry_id: 'uuid-123', reason: 'outdated')
127
127
  expect(result[:action]).to eq(:deprecate)
128
- expect(result[:entry_id]).to eq('uuid-123')
129
- expect(result[:reason]).to eq('outdated')
128
+ expect(result).not_to have_key(:success)
130
129
  end
131
130
  end
132
131
 
@@ -169,6 +168,7 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
169
168
  allow(dedup_chain).to receive(:exclude).with(status: 'archived').and_return(double(first: nil))
170
169
  allow(mock_entry_class).to receive(:db).and_return(mock_db)
171
170
  allow(mock_db).to receive(:fetch).and_return(double(all: []))
171
+ allow(mock_db).to receive(:transaction) { |**_options, &block| block.call }
172
172
  allow(mock_entry_class).to receive(:create).and_return(mock_entry)
173
173
  allow(mock_expertise_class).to receive(:where).and_return(double(first: nil))
174
174
  allow(mock_expertise_class).to receive(:create)
@@ -345,12 +345,28 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
345
345
  expect(result[:success]).to be true
346
346
  expect(result[:entry_id]).to eq('uuid-race-winner')
347
347
  end
348
+
349
+ it 'returns a failure when a rolled-back uniqueness conflict has no winner' do
350
+ allow(mock_entry_class).to receive(:create)
351
+ .and_raise(Sequel::UniqueConstraintViolation, 'duplicate key value violates unique constraint')
352
+ collision_dataset = double('collision_dataset')
353
+ allow(mock_entry_class).to receive(:where).with(content_hash: anything).and_return(collision_dataset)
354
+ allow(collision_dataset).to receive(:exclude).with(status: 'archived').and_return(collision_dataset)
355
+ allow(collision_dataset).to receive(:first).and_return(nil, nil)
356
+
357
+ result = host.handle_ingest(content: 'lost race content', content_type: 'fact',
358
+ source_agent: 'agent-1', content_hash: 'no-winner')
359
+
360
+ expect(result).to include(success: false, error: 'duplicate key value violates unique constraint')
361
+ end
348
362
  end
349
363
  end
350
364
 
351
365
  context 'when Sequel raises an error' do
352
366
  before do
353
367
  stub_const('Legion::Data::Model::ApolloEntry', Class.new)
368
+ stub_const('Legion::Data::Model::ApolloExpertise', Class.new)
369
+ stub_const('Legion::Data::Model::ApolloAccessLog', Class.new)
354
370
  allow(Legion::LLM::Call::Embeddings).to receive(:generate)
355
371
  .and_return({ vector: Array.new(1024, 0.1), model: 'test', provider: :ollama, dimensions: 1024, tokens: 0 })
356
372
  allow(Legion::Data::Model::ApolloEntry).to receive(:where)
@@ -376,6 +392,7 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
376
392
  let(:mock_expertise_class2) { double('ApolloExpertise2') }
377
393
  let(:mock_access_log_class2) { double('ApolloAccessLog2') }
378
394
  let(:mock_entry2) { double('entry2', id: 99, embedding: nil) }
395
+ let(:mock_db2) { double('db2') }
379
396
 
380
397
  before do
381
398
  stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class2)
@@ -384,6 +401,8 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
384
401
  scoped_ds = double('scoped_ds2', first: nil, where: double('scoped_ds2b', first: nil))
385
402
  allow(mock_entry_class2).to receive(:where).and_return(scoped_ds)
386
403
  allow(scoped_ds).to receive(:exclude).and_return(scoped_ds)
404
+ allow(mock_entry_class2).to receive(:db).and_return(mock_db2)
405
+ allow(mock_db2).to receive(:transaction) { |**_options, &block| block.call }
387
406
  allow(mock_expertise_class2).to receive(:where).and_return(double(first: nil))
388
407
  allow(mock_expertise_class2).to receive(:create)
389
408
  allow(mock_access_log_class2).to receive(:create)
@@ -433,6 +452,7 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
433
452
  let(:mock_access_log_class3) { double('ApolloAccessLog3') }
434
453
  let(:mock_entry_a) { double('entry_a', id: 7, embedding: nil) }
435
454
  let(:mock_entry_b) { double('entry_b', id: 8, embedding: nil) }
455
+ let(:mock_db3) { double('db3') }
436
456
 
437
457
  before do
438
458
  stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class3)
@@ -449,6 +469,8 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
449
469
  allow(scoped_ds3).to receive(:where).and_return(double('scoped_ds3b', first: nil))
450
470
  allow(scoped_ds3).to receive(:exclude).and_return(scoped_ds3)
451
471
  allow(mock_entry_class3).to receive(:where).and_return(scoped_ds3)
472
+ allow(mock_entry_class3).to receive(:db).and_return(mock_db3)
473
+ allow(mock_db3).to receive(:transaction) { |**_options, &block| block.call }
452
474
  # two different principals each get their own entry
453
475
  allow(mock_entry_class3).to receive(:create).and_return(mock_entry_a, mock_entry_b)
454
476
  end
@@ -515,6 +537,7 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
515
537
  allow(dedup_chain).to receive(:exclude).with(status: 'archived').and_return(double(first: nil))
516
538
  allow(mock_entry_class).to receive(:db).and_return(mock_db)
517
539
  allow(mock_db).to receive(:fetch).and_return(double(all: []))
540
+ allow(mock_db).to receive(:transaction) { |**_options, &block| block.call }
518
541
  allow(mock_entry_class).to receive(:create).and_return(mock_entry)
519
542
  allow(mock_expertise_class).to receive(:where).and_return(double(first: nil))
520
543
  allow(mock_expertise_class).to receive(:create)
@@ -589,6 +612,25 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
589
612
  end
590
613
  end
591
614
 
615
+ context 'when semantic query access logging is unavailable' do
616
+ let(:mock_entry_class) { double('ApolloEntry') }
617
+
618
+ before do
619
+ stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
620
+ hide_const('Legion::Data::Model::ApolloAccessLog') if defined?(Legion::Data::Model::ApolloAccessLog)
621
+ hide_const('Legion::Data::Model::Apollo::AccessLog') if defined?(Legion::Data::Model::Apollo::AccessLog)
622
+ end
623
+
624
+ it 'fails before embedding, lookup, or confidence mutation' do
625
+ expect(mock_entry_class).not_to receive(:db)
626
+ expect(Legion::LLM::Call::Embeddings).not_to receive(:generate)
627
+
628
+ expect(host.handle_query(query: 'semantic query')).to include(
629
+ success: false, error: 'apollo_data_not_available'
630
+ )
631
+ end
632
+ end
633
+
592
634
  context 'when Apollo data is available' do
593
635
  let(:mock_entry_class) { double('ApolloEntry') }
594
636
  let(:mock_access_log_class) { double('ApolloAccessLog') }
@@ -637,6 +679,7 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
637
679
 
638
680
  before do
639
681
  stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
682
+ stub_const('Legion::Data::Model::ApolloAccessLog', Class.new)
640
683
  allow(Legion::LLM::Call::Embeddings).to receive(:generate)
641
684
  .and_return({ vector: Array.new(1024, 0.1), model: 'test', provider: :ollama, dimensions: 1024, tokens: 0 })
642
685
  allow(mock_entry_class).to receive(:db).and_return(mock_db)
@@ -694,37 +737,6 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
694
737
  expect(dataset).to have_received(:where).with(knowledge_domain: 'general')
695
738
  end
696
739
  end
697
-
698
- context 'access scope forwarding' do
699
- let(:mock_entry_class) { double('ApolloEntry') }
700
- let(:mock_db) { double('db') }
701
-
702
- before do
703
- stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
704
- allow(Legion::LLM::Call::Embeddings).to receive(:generate)
705
- .and_return({ vector: Array.new(1024, 0.1), model: 'test', provider: :ollama, dimensions: 1024, tokens: 0 })
706
- allow(mock_entry_class).to receive(:db).and_return(mock_db)
707
- allow(mock_db).to receive(:fetch).and_return(double(all: []))
708
- allow(mock_entry_class).to receive(:where).and_return(double(update: true))
709
- end
710
-
711
- it 'forwards requesting_principal_id to build_semantic_search_sql' do
712
- expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_semantic_search_sql).with(
713
- hash_including(requesting_principal_id: 42)
714
- ).and_return('SELECT 1')
715
- allow(mock_db).to receive(:fetch).and_return(double(all: []))
716
-
717
- host.handle_query(query: 'test', requesting_principal_id: 42)
718
- end
719
-
720
- it 'passes nil requesting_principal_id when not provided (no filter)' do
721
- expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_semantic_search_sql).with(
722
- hash_including(requesting_principal_id: nil)
723
- ).and_return('SELECT 1')
724
-
725
- host.handle_query(query: 'test')
726
- end
727
- end
728
740
  end
729
741
 
730
742
  describe '#normalize_text_input' do
@@ -794,21 +806,6 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
794
806
  expect(result[:success]).to be true
795
807
  expect(result[:count]).to eq(1)
796
808
  end
797
-
798
- it 'passes domain to graph query builder' do
799
- expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_semantic_search_sql).with(
800
- hash_including(domain: 'clinical')
801
- ).and_call_original
802
- host.retrieve_relevant(query: 'treatment', domain: 'clinical')
803
- end
804
-
805
- it 'forwards requesting_principal_id to build_semantic_search_sql' do
806
- expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_semantic_search_sql).with(
807
- hash_including(requesting_principal_id: 7)
808
- ).and_return('SELECT 1')
809
-
810
- host.retrieve_relevant(query: 'test', requesting_principal_id: 7)
811
- end
812
809
  end
813
810
  end
814
811
 
@@ -865,13 +862,6 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
865
862
  expect(mock_access_log_class).not_to receive(:create)
866
863
  host.handle_traverse(entry_id: 'uuid-1')
867
864
  end
868
-
869
- it 'filters invalid relation_types' do
870
- expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_traversal_sql).with(
871
- hash_including(relation_types: ['causes'])
872
- ).and_call_original
873
- host.handle_traverse(entry_id: 'uuid-1', relation_types: %w[causes invalid_type])
874
- end
875
865
  end
876
866
 
877
867
  context 'when Sequel raises an error' do
@@ -943,7 +933,7 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
943
933
  it 'stores into trace shared_store when Memory::Trace is available' do
944
934
  mock_store = double('store')
945
935
  mock_trace_helpers = Module.new do
946
- def self.new_trace(type:, content_payload: nil, **kwargs) # rubocop:disable Lint/UnusedMethodArgument
936
+ def self.new_trace(type:, **kwargs)
947
937
  { trace_id: 'trace-abc', trace_type: type, strength: kwargs[:strength] || 0.5 }
948
938
  end
949
939
  end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'open3'
5
+ require 'rbconfig'
6
+ require 'json'
7
+
8
+ RSpec.describe 'Apollo service lifecycle mutations' do
9
+ let(:lifecycle_script) do
10
+ <<~'RUBY'
11
+ require 'sequel'
12
+ require 'sinatra/base'
13
+ require 'rack/mock'
14
+ require 'rspec'
15
+ require 'spec_helper'
16
+ require 'json'
17
+ require 'legion/extensions/apollo/api'
18
+ db = Sequel.sqlite
19
+ db.create_table(:apollo_entries) do
20
+ primary_key :id
21
+ String :content
22
+ String :content_type
23
+ String :status
24
+ Float :confidence
25
+ String :source_context
26
+ DateTime :created_at
27
+ DateTime :updated_at
28
+ DateTime :confirmed_at
29
+ check(Sequel.lit("content != 'protected' OR status != 'archived'"))
30
+ end
31
+ Legion::Data.const_set(:Model, Module.new) unless defined?(Legion::Data::Model)
32
+ Legion::Data::Model.const_set(:Apollo, Module.new)
33
+ entry_model = Class.new(Sequel::Model(db[:apollo_entries]))
34
+ Legion::Data::Model::Apollo.const_set(:Entry, entry_model)
35
+ now = Time.now
36
+ old = now - (100 * 86_400)
37
+ attributes = { content_type: 'fact', status: 'candidate', confidence: 0.8,
38
+ source_context: ::JSON.generate(origin: 'preserve'), created_at: now, updated_at: nil }
39
+ db[:apollo_entries].insert(attributes.merge(id: 1, content: 'target'))
40
+ db[:apollo_entries].insert(attributes.merge(id: 2, content: 'protected'))
41
+ db[:apollo_entries].insert(attributes.merge(id: 3, content: 'stale', confidence: 0.04, created_at: old))
42
+ db[:apollo_entries].insert(attributes.merge(id: 4, content: 'touched', status: 'confirmed', confidence: 0.6,
43
+ created_at: old, updated_at: now))
44
+ db[:apollo_entries].insert(attributes.merge(id: 5, content: 'already archived', status: 'archived', confidence: 0.7,
45
+ created_at: old))
46
+ input = ::JSON.parse($stdin.read, symbolize_names: true)
47
+ maintenance = Object.new.extend(Legion::Extensions::Apollo::Runners::Maintenance)
48
+ case input[:operation]
49
+ when 'deprecate'
50
+ api = Rack::MockRequest.new(Legion::Extensions::Apollo::Api)
51
+ payload = ::JSON.generate(reason: 'Verified replacement exists.')
52
+ first = api.post('/api/apollo/entries/1/deprecate', 'CONTENT_TYPE' => 'application/json', input: payload)
53
+ first_updated_at = entry_model[1].updated_at
54
+ repeated = api.post('/api/apollo/entries/1/deprecate', 'CONTENT_TYPE' => 'application/json', input: payload)
55
+ repeat_preserved_time = entry_model[1].updated_at == first_updated_at
56
+ missing = api.post('/api/apollo/entries/999/deprecate')
57
+ failed = api.post('/api/apollo/entries/2/deprecate', 'CONTENT_TYPE' => 'application/json', input: payload)
58
+ malformed = api.post('/api/apollo/entries/1/deprecate', 'CONTENT_TYPE' => 'application/json', input: '{')
59
+ Legion::Data::Model::Apollo.send(:remove_const, :Entry)
60
+ unavailable = api.post('/api/apollo/entries/1/deprecate')
61
+ Legion::Data::Model::Apollo.const_set(:Entry, entry_model)
62
+ responses = { first: first, repeated: repeated, missing: missing, failed: failed,
63
+ malformed: malformed, unavailable: unavailable }.transform_values do |response|
64
+ { status: response.status, body: ::JSON.parse(response.body) }
65
+ end
66
+ result = { responses: responses, repeat_preserved_time: repeat_preserved_time }
67
+ when 'maintenance'
68
+ decay = maintenance.force_decay(factor: 0.5)
69
+ after_decay = db[:apollo_entries].order(:id).all
70
+ invalid_factor = maintenance.force_decay(factor: -0.1)
71
+ invalid_factor_unchanged = after_decay == db[:apollo_entries].order(:id).all
72
+ archive = maintenance.archive_stale(days: 90)
73
+ repeated = maintenance.archive_stale(days: 90)
74
+ before_invalid_days = db[:apollo_entries].order(:id).all
75
+ invalid_days = maintenance.archive_stale(days: -1)
76
+ result = { decay: decay, after_decay: after_decay, invalid_factor: invalid_factor,
77
+ invalid_factor_unchanged: invalid_factor_unchanged, archive: archive, repeated: repeated,
78
+ invalid_days: invalid_days, invalid_days_unchanged: before_invalid_days == db[:apollo_entries].order(:id).all }
79
+ when 'dispute'
80
+ db[:apollo_entries].where(id: [1, 2, 3]).update(status: 'disputed')
81
+ db[:apollo_entries].insert(attributes.merge(id: 6, content: 'kept dispute', status: 'disputed'))
82
+ before_unknown = entry_model[6].values
83
+ unknown = maintenance.resolve_dispute(entry_id: 6, resolution: :unexpected)
84
+ unknown_unchanged = before_unknown == entry_model[6].values
85
+ accepted = maintenance.resolve_dispute(entry_id: 1, resolution: :accept)
86
+ rejected = maintenance.resolve_dispute(entry_id: 3, resolution: :reject)
87
+ kept = maintenance.resolve_dispute(entry_id: 6, resolution: :keep)
88
+ failed = maintenance.resolve_dispute(entry_id: 2, resolution: :reject)
89
+ missing = maintenance.resolve_dispute(entry_id: 999, resolution: :accept)
90
+ not_disputed = maintenance.resolve_dispute(entry_id: 4, resolution: :reject)
91
+ repeated = maintenance.resolve_dispute(entry_id: 1, resolution: :accept)
92
+ result = { accepted: accepted, rejected: rejected, kept: kept, failed: failed, missing: missing,
93
+ not_disputed: not_disputed, repeated: repeated, unknown: unknown, unknown_unchanged: unknown_unchanged }
94
+ end
95
+ puts "APOLLO_RESULT=#{::JSON.generate(result.merge(entries: db[:apollo_entries].order(:id).all))}"
96
+ RUBY
97
+ end
98
+
99
+ def lifecycle_state(operation:)
100
+ stdout, stderr, status = Open3.capture3(
101
+ RbConfig.ruby, '-Ilib', '-Ispec', '-e', lifecycle_script,
102
+ chdir: File.expand_path('../../../../..', __dir__),
103
+ stdin_data: JSON.generate(operation: operation)
104
+ )
105
+ line = stdout.lines.find { |output| output.start_with?('APOLLO_RESULT=') }
106
+ raise "Lifecycle process failed: #{stderr}\n#{stdout}" unless status.success? && line
107
+
108
+ JSON.parse(line.delete_prefix('APOLLO_RESULT='), symbolize_names: true)
109
+ end
110
+
111
+ it 'archives through HTTP with retained metadata and truthful repeat, missing, unavailable, and rollback outcomes' do
112
+ state = lifecycle_state(operation: 'deprecate')
113
+ responses = state[:responses]
114
+
115
+ expect(responses[:first]).to include(status: 200, body: hash_including(success: true, updated: true, status: 'archived'))
116
+ expect(responses[:repeated]).to include(status: 200, body: hash_including(success: true, updated: false))
117
+ expect(state[:repeat_preserved_time]).to be true
118
+ expect(responses[:missing]).to include(status: 404, body: hash_including(error: 'entry_not_found'))
119
+ expect(responses[:unavailable]).to include(status: 503, body: hash_including(error: 'apollo_data_not_available'))
120
+ expect(responses[:failed]).to include(status: 500, body: hash_including(success: false))
121
+ expect(responses[:malformed][:status]).to eq(400)
122
+ expect(state[:entries].first[:status]).to eq('archived')
123
+ expect(JSON.parse(state[:entries].first[:source_context], symbolize_names: true))
124
+ .to include(origin: 'preserve', deprecation_reason: 'Verified replacement exists.')
125
+ expect(state[:entries][1]).to include(status: 'candidate', source_context: JSON.generate(origin: 'preserve'))
126
+ end
127
+
128
+ it 'multiplies active confidence without implicit archival and archives only stale, untouched entries' do
129
+ state = lifecycle_state(operation: 'maintenance')
130
+
131
+ expect(state[:decay]).to include(success: true, decayed: 4)
132
+ expect(state[:after_decay].map { |entry| entry[:confidence] }).to eq([0.4, 0.4, 0.02, 0.3, 0.7])
133
+ expect(state[:after_decay][2][:status]).to eq('candidate')
134
+ expect(state[:invalid_factor]).to include(success: false, error: 'invalid_factor')
135
+ expect(state[:invalid_factor_unchanged]).to be true
136
+ expect(state[:archive]).to include(success: true, archived: 1)
137
+ expect(state[:repeated]).to include(success: true, archived: 0)
138
+ expect(state[:entries][2][:status]).to eq('archived')
139
+ expect(state[:entries][3][:status]).to eq('confirmed')
140
+ expect(state[:invalid_days]).to include(success: false, error: 'invalid_days')
141
+ expect(state[:invalid_days_unchanged]).to be true
142
+ end
143
+
144
+ it 'applies the approved dispute transitions and preserves state for invalid, ineligible, or failed decisions' do
145
+ state = lifecycle_state(operation: 'dispute')
146
+
147
+ expect(state[:accepted]).to include(success: true, status: 'confirmed')
148
+ expect(state[:kept]).to include(success: true, status: 'confirmed')
149
+ expect(state[:rejected]).to include(success: true, status: 'archived')
150
+ expect(state[:failed]).to include(success: false)
151
+ expect(state[:missing]).to include(success: false, error: 'entry_not_found')
152
+ expect(state[:not_disputed]).to include(success: false, error: 'entry_not_disputed')
153
+ expect(state[:repeated]).to include(success: false, error: 'entry_not_disputed')
154
+ expect(state[:unknown]).to include(success: false, error: 'invalid_resolution')
155
+ expect(state[:unknown_unchanged]).to be true
156
+ expect(state[:entries].first).to include(status: 'confirmed', confidence: 0.8)
157
+ expect(state[:entries].first[:confirmed_at]).not_to be_nil
158
+ expect(JSON.parse(state[:entries].first[:source_context], symbolize_names: true))
159
+ .to include(origin: 'preserve', dispute_resolution: 'accept')
160
+ expect(state[:entries][1]).to include(status: 'disputed', source_context: JSON.generate(origin: 'preserve'))
161
+ expect(state[:entries][2][:status]).to eq('archived')
162
+ expect(state[:entries][3][:status]).to eq('confirmed')
163
+ expect(state[:entries].last[:status]).to eq('confirmed')
164
+ end
165
+ end
@@ -12,28 +12,12 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Maintenance do
12
12
  obj
13
13
  end
14
14
 
15
- describe '#force_decay' do
16
- it 'returns a force_decay payload' do
17
- result = runner.force_decay(factor: 0.5)
18
- expect(result[:action]).to eq(:force_decay)
19
- expect(result[:factor]).to eq(0.5)
20
- end
21
- end
22
-
23
- describe '#archive_stale' do
24
- it 'returns an archive payload with default days' do
25
- result = runner.archive_stale
26
- expect(result[:action]).to eq(:archive_stale)
27
- expect(result[:days]).to eq(90)
28
- end
29
- end
15
+ it 'retains client action envelopes without claiming completed service mutations when data is unavailable' do
16
+ results = [runner.force_decay(factor: 0.5), runner.archive_stale(days: 90),
17
+ runner.resolve_dispute(entry_id: 'uuid-123', resolution: :keep)]
30
18
 
31
- describe '#resolve_dispute' do
32
- it 'returns a resolve payload' do
33
- result = runner.resolve_dispute(entry_id: 'uuid-123', resolution: :keep)
34
- expect(result[:action]).to eq(:resolve_dispute)
35
- expect(result[:resolution]).to eq(:keep)
36
- end
19
+ expect(results.map { |result| result[:action] }).to eq(%i[force_decay archive_stale resolve_dispute])
20
+ expect(results).to all(satisfy { |result| !result.key?(:success) })
37
21
  end
38
22
 
39
23
  describe '#run_decay_cycle' do
@@ -101,111 +85,41 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Maintenance do
101
85
  end
102
86
  end
103
87
 
104
- context 'when no candidates exist' do
88
+ context 'when relation persistence is unavailable' do
105
89
  let(:mock_entry_class) { double('ApolloEntry') }
106
90
 
107
91
  before do
108
92
  stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
109
- allow(mock_entry_class).to receive(:where).with(status: 'candidate')
110
- .and_return(double(exclude: double(limit: double(all: []))))
111
- allow(mock_entry_class).to receive(:where).with(status: 'confirmed')
112
- .and_return(double(exclude: double(limit: double(all: []))))
113
- end
114
-
115
- it 'returns zero promoted and scanned' do
116
- result = host.check_corroboration
117
- expect(result[:success]).to be true
118
- expect(result[:promoted]).to eq(0)
119
- expect(result[:scanned]).to eq(0)
120
- end
121
- end
122
-
123
- context 'when a candidate matches a confirmed entry' do
124
- let(:mock_entry_class) { double('ApolloEntry') }
125
- let(:mock_relation_class) { double('ApolloRelation') }
126
- let(:embedding) { Array.new(1536, 0.5) }
127
- let(:candidate) do
128
- double('candidate', id: 'c-1', content_type: 'fact', embedding: embedding,
129
- confidence: 0.5, update: true)
130
- end
131
- let(:confirmed_entry) do
132
- double('confirmed', id: 'f-1', content_type: 'fact', embedding: embedding)
133
- end
134
-
135
- before do
136
- stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
137
- stub_const('Legion::Data::Model::ApolloRelation', mock_relation_class)
138
- allow(mock_entry_class).to receive(:where).with(status: 'candidate')
139
- .and_return(double(exclude: double(limit: double(all: [candidate]))))
140
- allow(mock_entry_class).to receive(:where).with(status: 'confirmed')
141
- .and_return(double(exclude: double(limit: double(all: [confirmed_entry]))))
142
- allow(mock_relation_class).to receive(:create)
93
+ hide_const('Legion::Data::Model::ApolloRelation') if defined?(Legion::Data::Model::ApolloRelation)
94
+ hide_const('Legion::Data::Model::Apollo::Relation') if defined?(Legion::Data::Model::Apollo::Relation)
143
95
  end
144
96
 
145
- it 'promotes the candidate' do
146
- expect(candidate).to receive(:update).with(hash_including(status: 'confirmed'))
147
- result = host.check_corroboration
148
- expect(result[:promoted]).to eq(1)
149
- expect(result[:scanned]).to eq(1)
150
- end
97
+ it 'fails before scanning or promoting candidates' do
98
+ expect(mock_entry_class).not_to receive(:where)
151
99
 
152
- it 'creates a similar_to relation' do
153
- expect(mock_relation_class).to receive(:create).with(
154
- hash_including(from_entry_id: 'c-1', to_entry_id: 'f-1', relation_type: 'similar_to')
155
- )
156
- host.check_corroboration
100
+ expect(host.check_corroboration).to include(success: false, error: 'apollo_data_not_available')
157
101
  end
158
102
  end
159
103
 
160
- context 'when candidate and confirmed share the same source_channel' do
161
- let(:mock_entry_class) { double('ApolloEntry') }
162
- let(:mock_relation_class) { double('ApolloRelation') }
163
- let(:embedding) { Array.new(1536, 0.5) }
164
- let(:candidate) do
165
- double('candidate', id: 'c-1', content_type: 'fact', embedding: embedding,
166
- confidence: 0.5, source_provider: 'openai', source_channel: 'slack-alerts')
167
- end
168
- let(:confirmed_entry) do
169
- double('confirmed', id: 'f-1', content_type: 'fact', embedding: embedding,
170
- source_provider: 'claude', source_channel: 'slack-alerts')
171
- end
172
-
173
- before do
174
- stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
175
- stub_const('Legion::Data::Model::ApolloRelation', mock_relation_class)
176
- allow(mock_entry_class).to receive(:where).with(status: 'candidate')
177
- .and_return(double(exclude: double(limit: double(all: [candidate]))))
178
- allow(mock_entry_class).to receive(:where).with(status: 'confirmed')
179
- .and_return(double(exclude: double(limit: double(all: [confirmed_entry]))))
180
- end
181
-
182
- it 'does not promote even with different providers' do
183
- result = host.check_corroboration
184
- expect(result[:promoted]).to eq(0)
185
- end
186
- end
187
-
188
- context 'when candidate has different content_type than confirmed' do
104
+ context 'when no candidates exist' do
189
105
  let(:mock_entry_class) { double('ApolloEntry') }
190
- let(:embedding) { Array.new(1536, 0.5) }
191
- let(:candidate) do
192
- double('candidate', id: 'c-1', content_type: 'fact', embedding: embedding, confidence: 0.5)
193
- end
194
- let(:confirmed_entry) do
195
- double('confirmed', id: 'f-1', content_type: 'concept', embedding: embedding)
196
- end
197
106
 
198
107
  before do
199
108
  stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
200
- allow(mock_entry_class).to receive(:where).with(status: 'candidate')
201
- .and_return(double(exclude: double(limit: double(all: [candidate]))))
202
- allow(mock_entry_class).to receive(:where).with(status: 'confirmed')
203
- .and_return(double(exclude: double(limit: double(all: [confirmed_entry]))))
109
+ stub_const('Legion::Data::Model::ApolloRelation', Class.new)
110
+ candidate_dataset = double(
111
+ exclude: double(order: double(limit: double(all: [])))
112
+ )
113
+ confirmed_dataset = double(select_map: [])
114
+ allow(mock_entry_class).to receive(:where).with(status: 'candidate').and_return(candidate_dataset)
115
+ allow(mock_entry_class).to receive(:where).with(status: 'confirmed').and_return(confirmed_dataset)
204
116
  end
205
117
 
206
- it 'does not promote' do
118
+ it 'returns zero promoted and scanned' do
207
119
  result = host.check_corroboration
120
+ expect(result[:success]).to be true
208
121
  expect(result[:promoted]).to eq(0)
122
+ expect(result[:scanned]).to eq(0)
209
123
  end
210
124
  end
211
125
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'open3'
5
+ require 'rbconfig'
4
6
  require 'legion/extensions/apollo/helpers/confidence'
5
7
  require 'legion/extensions/apollo/helpers/similarity'
6
8
  require 'legion/extensions/apollo/helpers/graph_query'
@@ -55,30 +57,43 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Request do
55
57
  end
56
58
 
57
59
  context 'when only transport is available' do
58
- let(:mock_transport) do
59
- Module.new do
60
- def self.connected?
61
- true
62
- end
63
-
64
- def self.respond_to?(method, *args)
65
- method == :connected? || super
66
- end
67
- end
68
- end
69
- let(:mock_message) { double('message', publish: true) }
70
-
71
- before do
72
- hide_const('Legion::Data::Model::ApolloEntry') if defined?(Legion::Data::Model::ApolloEntry)
73
- stub_const('Legion::Transport', mock_transport)
74
- stub_const('Legion::Extensions::Apollo::Transport::Messages::Query',
75
- double('QueryMsg', new: mock_message))
76
- end
77
-
78
- it 'publishes via transport' do
79
- result = described_class.query(text: 'test query')
80
- expect(result[:success]).to be true
81
- expect(result[:dispatched]).to eq(:transport)
60
+ it 'delivers all request operations through the real message classes and local transport' do
61
+ script = <<~'RUBY'
62
+ require 'rspec'
63
+ require 'spec_helper'
64
+ require 'json'
65
+ Legion::Transport::Connection.setup
66
+ channel = Legion::Transport::Connection.session.create_channel
67
+ Legion::Extensions::Apollo::Transport::Exchanges::Apollo.new('legion.apollo', channel: channel)
68
+ queries = []
69
+ ingests = []
70
+ Legion::Transport::Local.subscribe('legion.apollo.query') { |payload| queries << Legion::JSON.load(payload) }
71
+ Legion::Transport::Local.subscribe('legion.apollo.ingest') { |payload| ingests << Legion::JSON.load(payload) }
72
+ request = Legion::Extensions::Apollo::Runners::Request
73
+ results = [
74
+ request.query(text: 'remote query'),
75
+ request.retrieve(text: 'remote retrieval'),
76
+ request.traverse(entry_id: 'entry-1'),
77
+ request.ingest(content: 'remote fact', content_type: 'fact')
78
+ ]
79
+ Legion::Transport::Connection.shutdown
80
+ puts "APOLLO_RESULT=#{JSON.generate(results: results, queries: queries, ingests: ingests)}"
81
+ RUBY
82
+ stdout, stderr, status = Open3.capture3(
83
+ { 'LEGION_MODE' => 'lite' }, RbConfig.ruby, '-Ilib', '-Ispec', '-e', script,
84
+ chdir: File.expand_path('../../../../..', __dir__)
85
+ )
86
+ line = stdout.lines.find { |output| output.start_with?('APOLLO_RESULT=') }
87
+ raise "Request process failed: #{stderr}\n#{stdout}" unless status.success? && line
88
+
89
+ state = Legion::JSON.load(line.delete_prefix('APOLLO_RESULT='))
90
+ expect(state[:results]).to all(include(success: true, dispatched: 'transport'))
91
+ expect(state[:queries]).to contain_exactly(
92
+ include(action: 'query', query: 'remote query'),
93
+ include(action: 'query', query: 'remote retrieval'),
94
+ include(action: 'traverse', entry_id: 'entry-1')
95
+ )
96
+ expect(state[:ingests]).to contain_exactly(include(content: 'remote fact', content_type: 'fact'))
82
97
  end
83
98
  end
84
99
 
@@ -137,7 +152,7 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Request do
137
152
  let(:mock_expertise_class) { double('ApolloExpertise') }
138
153
  let(:mock_access_log_class) { double('ApolloAccessLog') }
139
154
  let(:mock_entry) { double('entry', id: 'uuid-123', embedding: nil) }
140
-
155
+ let(:mock_db) { double('db') }
141
156
  before do
142
157
  stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
143
158
  stub_const('Legion::Data::Model::ApolloExpertise', mock_expertise_class)
@@ -147,7 +162,9 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Request do
147
162
  allow(mock_entry_class).to receive(:where).and_return(double(exclude: double(limit: double(each: nil), first: nil)))
148
163
  allow(mock_entry_class).to receive(:exclude)
149
164
  .and_return(double(exclude: double(limit: double(all: []))))
150
- allow(mock_entry_class).to receive(:db).and_return(double(fetch: double(all: [])))
165
+ allow(mock_entry_class).to receive(:db).and_return(mock_db)
166
+ allow(mock_db).to receive(:fetch).and_return(double(all: []))
167
+ allow(mock_db).to receive(:transaction) { |**_options, &block| block.call }
151
168
  allow(mock_entry_class).to receive(:create).and_return(mock_entry)
152
169
  allow(mock_expertise_class).to receive(:where).and_return(double(first: nil))
153
170
  allow(mock_expertise_class).to receive(:create)