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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -0
- data/README.md +42 -5
- data/lib/legion/extensions/apollo/actors/contradiction_scanner.rb +14 -11
- data/lib/legion/extensions/apollo/actors/gas_subscriber.rb +4 -6
- data/lib/legion/extensions/apollo/api.rb +19 -9
- data/lib/legion/extensions/apollo/helpers/capability.rb +8 -20
- data/lib/legion/extensions/apollo/helpers/confidence.rb +1 -1
- data/lib/legion/extensions/apollo/helpers/graph_query.rb +30 -46
- data/lib/legion/extensions/apollo/helpers/writeback.rb +9 -13
- data/lib/legion/extensions/apollo/runners/gas.rb +90 -20
- data/lib/legion/extensions/apollo/runners/knowledge.rb +152 -104
- data/lib/legion/extensions/apollo/runners/maintenance.rb +133 -34
- data/lib/legion/extensions/apollo/runners/request.rb +4 -5
- data/lib/legion/extensions/apollo/transport.rb +20 -0
- data/lib/legion/extensions/apollo/version.rb +1 -1
- data/spec/legion/extensions/apollo/actors/contradiction_scanner_spec.rb +38 -0
- data/spec/legion/extensions/apollo/actors/gas_subscriber_spec.rb +4 -14
- data/spec/legion/extensions/apollo/api_spec.rb +11 -21
- data/spec/legion/extensions/apollo/contradiction_spec.rb +0 -9
- data/spec/legion/extensions/apollo/helpers/capability_spec.rb +2 -2
- data/spec/legion/extensions/apollo/helpers/writeback_spec.rb +29 -1
- data/spec/legion/extensions/apollo/runners/corroboration_integration_spec.rb +191 -0
- data/spec/legion/extensions/apollo/runners/gas_deposit_integration_spec.rb +92 -0
- data/spec/legion/extensions/apollo/runners/gas_spec.rb +27 -33
- data/spec/legion/extensions/apollo/runners/ingest_transaction_integration_spec.rb +114 -0
- data/spec/legion/extensions/apollo/runners/knowledge_spec.rb +47 -57
- data/spec/legion/extensions/apollo/runners/lifecycle_integration_spec.rb +165 -0
- data/spec/legion/extensions/apollo/runners/maintenance_spec.rb +22 -108
- data/spec/legion/extensions/apollo/runners/request_spec.rb +43 -26
- metadata +34 -2
- 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 '
|
|
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
|
|
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:,
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
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 '
|
|
146
|
-
expect(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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 '
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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(
|
|
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)
|