lex-apollo 0.4.28 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -0
  3. data/README.md +42 -5
  4. data/lib/legion/extensions/apollo/actors/contradiction_scanner.rb +63 -0
  5. data/lib/legion/extensions/apollo/actors/entity_watchdog.rb +1 -1
  6. data/lib/legion/extensions/apollo/actors/gas_subscriber.rb +4 -6
  7. data/lib/legion/extensions/apollo/api.rb +19 -9
  8. data/lib/legion/extensions/apollo/helpers/capability.rb +8 -20
  9. data/lib/legion/extensions/apollo/helpers/confidence.rb +11 -18
  10. data/lib/legion/extensions/apollo/helpers/entity_watchdog.rb +1 -1
  11. data/lib/legion/extensions/apollo/helpers/graph_query.rb +37 -51
  12. data/lib/legion/extensions/apollo/helpers/writeback.rb +9 -13
  13. data/lib/legion/extensions/apollo/runners/entity_extractor.rb +1 -1
  14. data/lib/legion/extensions/apollo/runners/expertise.rb +3 -3
  15. data/lib/legion/extensions/apollo/runners/gas.rb +95 -25
  16. data/lib/legion/extensions/apollo/runners/knowledge.rb +177 -113
  17. data/lib/legion/extensions/apollo/runners/maintenance.rb +135 -36
  18. data/lib/legion/extensions/apollo/runners/request.rb +4 -5
  19. data/lib/legion/extensions/apollo/transport.rb +20 -0
  20. data/lib/legion/extensions/apollo/version.rb +1 -1
  21. data/lib/legion/extensions/apollo.rb +2 -0
  22. data/spec/legion/extensions/apollo/actors/contradiction_scanner_spec.rb +38 -0
  23. data/spec/legion/extensions/apollo/actors/entity_watchdog_spec.rb +1 -1
  24. data/spec/legion/extensions/apollo/actors/gas_subscriber_spec.rb +4 -14
  25. data/spec/legion/extensions/apollo/api_spec.rb +11 -21
  26. data/spec/legion/extensions/apollo/contradiction_spec.rb +0 -9
  27. data/spec/legion/extensions/apollo/helpers/capability_spec.rb +2 -2
  28. data/spec/legion/extensions/apollo/helpers/writeback_spec.rb +31 -3
  29. data/spec/legion/extensions/apollo/runners/corroboration_integration_spec.rb +191 -0
  30. data/spec/legion/extensions/apollo/runners/entity_extractor_spec.rb +2 -2
  31. data/spec/legion/extensions/apollo/runners/gas_deposit_integration_spec.rb +92 -0
  32. data/spec/legion/extensions/apollo/runners/gas_spec.rb +27 -33
  33. data/spec/legion/extensions/apollo/runners/ingest_transaction_integration_spec.rb +114 -0
  34. data/spec/legion/extensions/apollo/runners/knowledge_spec.rb +129 -57
  35. data/spec/legion/extensions/apollo/runners/lifecycle_integration_spec.rb +165 -0
  36. data/spec/legion/extensions/apollo/runners/maintenance_spec.rb +22 -108
  37. data/spec/legion/extensions/apollo/runners/request_spec.rb +43 -26
  38. metadata +35 -2
  39. 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
@@ -492,6 +514,89 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
492
514
  end
493
515
  end
494
516
  end
517
+
518
+ context 'contradiction detection flag' do
519
+ let(:mock_entry_class) { double('ApolloEntry') }
520
+ let(:mock_relation_class) { double('ApolloRelation') }
521
+ let(:mock_expertise_class) { double('ApolloExpertise') }
522
+ let(:mock_access_log_class) { double('ApolloAccessLog') }
523
+ let(:mock_entry) { double('entry', id: 'uuid-new', embedding: nil) }
524
+ let(:empty_dataset) { double('dataset', each: nil) }
525
+ let(:mock_db) { double('db') }
526
+
527
+ before do
528
+ stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
529
+ stub_const('Legion::Data::Model::ApolloRelation', mock_relation_class)
530
+ stub_const('Legion::Data::Model::ApolloExpertise', mock_expertise_class)
531
+ stub_const('Legion::Data::Model::ApolloAccessLog', mock_access_log_class)
532
+ allow(Legion::LLM::Call::Embeddings).to receive(:generate)
533
+ .and_return({ vector: Array.new(1024, 0.1), model: 'test', provider: :ollama, dimensions: 1024, tokens: 0 })
534
+ allow(mock_entry_class).to receive(:where).and_return(double(exclude: double(limit: empty_dataset)))
535
+ dedup_chain = double('dedup_chain')
536
+ allow(mock_entry_class).to receive(:where).with(content_hash: anything).and_return(dedup_chain)
537
+ allow(dedup_chain).to receive(:exclude).with(status: 'archived').and_return(double(first: nil))
538
+ allow(mock_entry_class).to receive(:db).and_return(mock_db)
539
+ allow(mock_db).to receive(:fetch).and_return(double(all: []))
540
+ allow(mock_db).to receive(:transaction) { |**_options, &block| block.call }
541
+ allow(mock_entry_class).to receive(:create).and_return(mock_entry)
542
+ allow(mock_expertise_class).to receive(:where).and_return(double(first: nil))
543
+ allow(mock_expertise_class).to receive(:create)
544
+ allow(mock_access_log_class).to receive(:create)
545
+ end
546
+
547
+ it 'defaults contradiction detection to disabled' do
548
+ expect(Legion::Extensions::Apollo.settings[:contradiction][:enabled]).to be false
549
+ end
550
+
551
+ it 'does not call detect_contradictions when flag is false' do
552
+ expect(host).not_to receive(:detect_contradictions)
553
+ result = host.handle_ingest(content: 'test fact', content_type: 'fact', source_agent: 'agent-1')
554
+ expect(result[:success]).to be true
555
+ expect(result[:contradictions]).to eq([])
556
+ end
557
+
558
+ it 'returns stable contradictions key as empty array when disabled' do
559
+ result = host.handle_ingest(content: 'test content', content_type: 'fact', source_agent: 'agent-1')
560
+ expect(result).to have_key(:contradictions)
561
+ expect(result[:contradictions]).to eq([])
562
+ end
563
+
564
+ context 'when enabled' do
565
+ before do
566
+ Legion::Settings[:extensions][:apollo][:contradiction][:enabled] = true
567
+ stub_const('Legion::Extensions::Apollo::Actor::ContradictionScanner',
568
+ Class.new do
569
+ @queue = Queue.new
570
+ class << self
571
+ attr_reader :queue
572
+
573
+ def enqueue(entry_id:, embedding:, content:)
574
+ queue.push({ entry_id: entry_id, embedding: embedding, content: content })
575
+ end
576
+
577
+ def pending_count = queue.size
578
+
579
+ def drain
580
+ items = []
581
+ items << queue.pop until queue.empty?
582
+ items
583
+ end
584
+ end
585
+ end)
586
+ end
587
+
588
+ after do
589
+ Legion::Settings[:extensions][:apollo][:contradiction][:enabled] = false
590
+ end
591
+
592
+ it 'enqueues contradiction work to the actor instead of running inline' do
593
+ result = host.handle_ingest(content: 'enabled test', content_type: 'fact', source_agent: 'agent-1')
594
+ expect(result[:success]).to be true
595
+ expect(result[:contradictions]).to eq([])
596
+ expect(Legion::Extensions::Apollo::Actor::ContradictionScanner.pending_count).to eq(1)
597
+ end
598
+ end
599
+ end
495
600
  end
496
601
 
497
602
  describe '#handle_query' do
@@ -507,6 +612,25 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
507
612
  end
508
613
  end
509
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
+
510
634
  context 'when Apollo data is available' do
511
635
  let(:mock_entry_class) { double('ApolloEntry') }
512
636
  let(:mock_access_log_class) { double('ApolloAccessLog') }
@@ -555,6 +679,7 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
555
679
 
556
680
  before do
557
681
  stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
682
+ stub_const('Legion::Data::Model::ApolloAccessLog', Class.new)
558
683
  allow(Legion::LLM::Call::Embeddings).to receive(:generate)
559
684
  .and_return({ vector: Array.new(1024, 0.1), model: 'test', provider: :ollama, dimensions: 1024, tokens: 0 })
560
685
  allow(mock_entry_class).to receive(:db).and_return(mock_db)
@@ -612,37 +737,6 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
612
737
  expect(dataset).to have_received(:where).with(knowledge_domain: 'general')
613
738
  end
614
739
  end
615
-
616
- context 'access scope forwarding' do
617
- let(:mock_entry_class) { double('ApolloEntry') }
618
- let(:mock_db) { double('db') }
619
-
620
- before do
621
- stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
622
- allow(Legion::LLM::Call::Embeddings).to receive(:generate)
623
- .and_return({ vector: Array.new(1024, 0.1), model: 'test', provider: :ollama, dimensions: 1024, tokens: 0 })
624
- allow(mock_entry_class).to receive(:db).and_return(mock_db)
625
- allow(mock_db).to receive(:fetch).and_return(double(all: []))
626
- allow(mock_entry_class).to receive(:where).and_return(double(update: true))
627
- end
628
-
629
- it 'forwards requesting_principal_id to build_semantic_search_sql' do
630
- expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_semantic_search_sql).with(
631
- hash_including(requesting_principal_id: 42)
632
- ).and_return('SELECT 1')
633
- allow(mock_db).to receive(:fetch).and_return(double(all: []))
634
-
635
- host.handle_query(query: 'test', requesting_principal_id: 42)
636
- end
637
-
638
- it 'passes nil requesting_principal_id when not provided (no filter)' do
639
- expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_semantic_search_sql).with(
640
- hash_including(requesting_principal_id: nil)
641
- ).and_return('SELECT 1')
642
-
643
- host.handle_query(query: 'test')
644
- end
645
- end
646
740
  end
647
741
 
648
742
  describe '#normalize_text_input' do
@@ -712,21 +806,6 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
712
806
  expect(result[:success]).to be true
713
807
  expect(result[:count]).to eq(1)
714
808
  end
715
-
716
- it 'passes domain to graph query builder' do
717
- expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_semantic_search_sql).with(
718
- hash_including(domain: 'clinical')
719
- ).and_call_original
720
- host.retrieve_relevant(query: 'treatment', domain: 'clinical')
721
- end
722
-
723
- it 'forwards requesting_principal_id to build_semantic_search_sql' do
724
- expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_semantic_search_sql).with(
725
- hash_including(requesting_principal_id: 7)
726
- ).and_return('SELECT 1')
727
-
728
- host.retrieve_relevant(query: 'test', requesting_principal_id: 7)
729
- end
730
809
  end
731
810
  end
732
811
 
@@ -783,13 +862,6 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
783
862
  expect(mock_access_log_class).not_to receive(:create)
784
863
  host.handle_traverse(entry_id: 'uuid-1')
785
864
  end
786
-
787
- it 'filters invalid relation_types' do
788
- expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_traversal_sql).with(
789
- hash_including(relation_types: ['causes'])
790
- ).and_call_original
791
- host.handle_traverse(entry_id: 'uuid-1', relation_types: %w[causes invalid_type])
792
- end
793
865
  end
794
866
 
795
867
  context 'when Sequel raises an error' do
@@ -861,7 +933,7 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
861
933
  it 'stores into trace shared_store when Memory::Trace is available' do
862
934
  mock_store = double('store')
863
935
  mock_trace_helpers = Module.new do
864
- def self.new_trace(type:, content_payload: nil, **kwargs) # rubocop:disable Lint/UnusedMethodArgument
936
+ def self.new_trace(type:, **kwargs)
865
937
  { trace_id: 'trace-abc', trace_type: type, strength: kwargs[:strength] || 0.5 }
866
938
  end
867
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