lex-apollo 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +135 -0
  4. data/lib/legion/extensions/apollo/actors/corroboration_checker.rb +22 -0
  5. data/lib/legion/extensions/apollo/actors/decay.rb +22 -0
  6. data/lib/legion/extensions/apollo/actors/expertise_aggregator.rb +22 -0
  7. data/lib/legion/extensions/apollo/actors/ingest.rb +25 -0
  8. data/lib/legion/extensions/apollo/actors/query_responder.rb +25 -0
  9. data/lib/legion/extensions/apollo/client.rb +30 -0
  10. data/lib/legion/extensions/apollo/helpers/confidence.rb +46 -0
  11. data/lib/legion/extensions/apollo/helpers/embedding.rb +22 -0
  12. data/lib/legion/extensions/apollo/helpers/graph_query.rb +77 -0
  13. data/lib/legion/extensions/apollo/helpers/similarity.rb +36 -0
  14. data/lib/legion/extensions/apollo/runners/expertise.rb +71 -0
  15. data/lib/legion/extensions/apollo/runners/knowledge.rb +213 -0
  16. data/lib/legion/extensions/apollo/runners/maintenance.rb +72 -0
  17. data/lib/legion/extensions/apollo/transport/exchanges/apollo.rb +19 -0
  18. data/lib/legion/extensions/apollo/transport/messages/ingest.rb +43 -0
  19. data/lib/legion/extensions/apollo/transport/messages/query.rb +43 -0
  20. data/lib/legion/extensions/apollo/transport/queues/ingest.rb +23 -0
  21. data/lib/legion/extensions/apollo/transport/queues/query.rb +23 -0
  22. data/lib/legion/extensions/apollo/version.rb +9 -0
  23. data/lib/legion/extensions/apollo.rb +25 -0
  24. data/spec/legion/extensions/apollo/actors/decay_spec.rb +45 -0
  25. data/spec/legion/extensions/apollo/actors/expertise_aggregator_spec.rb +41 -0
  26. data/spec/legion/extensions/apollo/actors/ingest_spec.rb +33 -0
  27. data/spec/legion/extensions/apollo/client_spec.rb +75 -0
  28. data/spec/legion/extensions/apollo/helpers/confidence_spec.rb +109 -0
  29. data/spec/legion/extensions/apollo/helpers/embedding_spec.rb +68 -0
  30. data/spec/legion/extensions/apollo/helpers/graph_query_spec.rb +69 -0
  31. data/spec/legion/extensions/apollo/helpers/similarity_spec.rb +58 -0
  32. data/spec/legion/extensions/apollo/runners/expertise_spec.rb +111 -0
  33. data/spec/legion/extensions/apollo/runners/knowledge_spec.rb +308 -0
  34. data/spec/legion/extensions/apollo/runners/maintenance_spec.rb +133 -0
  35. data/spec/legion/extensions/apollo/transport/messages/ingest_spec.rb +65 -0
  36. data/spec/legion/extensions/apollo/transport/messages/query_spec.rb +75 -0
  37. data/spec/spec_helper.rb +30 -0
  38. metadata +108 -0
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/apollo/helpers/confidence'
5
+
6
+ RSpec.describe Legion::Extensions::Apollo::Helpers::Confidence do
7
+ describe 'constants' do
8
+ it 'defines INITIAL_CONFIDENCE' do
9
+ expect(described_class::INITIAL_CONFIDENCE).to eq(0.5)
10
+ end
11
+
12
+ it 'defines CORROBORATION_BOOST' do
13
+ expect(described_class::CORROBORATION_BOOST).to eq(0.3)
14
+ end
15
+
16
+ it 'defines RETRIEVAL_BOOST' do
17
+ expect(described_class::RETRIEVAL_BOOST).to eq(0.02)
18
+ end
19
+
20
+ it 'defines HOURLY_DECAY_FACTOR' do
21
+ expect(described_class::HOURLY_DECAY_FACTOR).to eq(0.998)
22
+ end
23
+
24
+ it 'defines DECAY_THRESHOLD' do
25
+ expect(described_class::DECAY_THRESHOLD).to eq(0.1)
26
+ end
27
+
28
+ it 'defines CORROBORATION_SIMILARITY_THRESHOLD' do
29
+ expect(described_class::CORROBORATION_SIMILARITY_THRESHOLD).to eq(0.9)
30
+ end
31
+
32
+ it 'defines WRITE_CONFIDENCE_GATE' do
33
+ expect(described_class::WRITE_CONFIDENCE_GATE).to eq(0.6)
34
+ end
35
+
36
+ it 'defines WRITE_NOVELTY_GATE' do
37
+ expect(described_class::WRITE_NOVELTY_GATE).to eq(0.3)
38
+ end
39
+
40
+ it 'defines STALE_DAYS' do
41
+ expect(described_class::STALE_DAYS).to eq(90)
42
+ end
43
+ end
44
+
45
+ describe '.apply_decay' do
46
+ it 'multiplies confidence by HOURLY_DECAY_FACTOR' do
47
+ result = described_class.apply_decay(confidence: 1.0)
48
+ expect(result).to eq(0.998)
49
+ end
50
+
51
+ it 'accepts a custom factor' do
52
+ result = described_class.apply_decay(confidence: 1.0, factor: 0.5)
53
+ expect(result).to eq(0.5)
54
+ end
55
+
56
+ it 'clamps to 0.0 minimum' do
57
+ result = described_class.apply_decay(confidence: 0.001, factor: 0.001)
58
+ expect(result).to be >= 0.0
59
+ end
60
+ end
61
+
62
+ describe '.apply_retrieval_boost' do
63
+ it 'adds RETRIEVAL_BOOST to confidence' do
64
+ result = described_class.apply_retrieval_boost(confidence: 0.5)
65
+ expect(result).to eq(0.52)
66
+ end
67
+
68
+ it 'clamps to 1.0 maximum' do
69
+ result = described_class.apply_retrieval_boost(confidence: 0.99)
70
+ expect(result).to eq(1.0)
71
+ end
72
+ end
73
+
74
+ describe '.apply_corroboration_boost' do
75
+ it 'adds CORROBORATION_BOOST to confidence' do
76
+ result = described_class.apply_corroboration_boost(confidence: 0.5)
77
+ expect(result).to eq(0.8)
78
+ end
79
+
80
+ it 'clamps to 1.0 maximum' do
81
+ result = described_class.apply_corroboration_boost(confidence: 0.9)
82
+ expect(result).to eq(1.0)
83
+ end
84
+ end
85
+
86
+ describe '.decayed?' do
87
+ it 'returns true when confidence below threshold' do
88
+ expect(described_class.decayed?(confidence: 0.05)).to be true
89
+ end
90
+
91
+ it 'returns false when confidence at or above threshold' do
92
+ expect(described_class.decayed?(confidence: 0.1)).to be false
93
+ end
94
+ end
95
+
96
+ describe '.meets_write_gate?' do
97
+ it 'returns true when both gates met' do
98
+ expect(described_class.meets_write_gate?(confidence: 0.7, novelty: 0.4)).to be true
99
+ end
100
+
101
+ it 'returns false when confidence below gate' do
102
+ expect(described_class.meets_write_gate?(confidence: 0.5, novelty: 0.4)).to be false
103
+ end
104
+
105
+ it 'returns false when novelty below gate' do
106
+ expect(described_class.meets_write_gate?(confidence: 0.7, novelty: 0.2)).to be false
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/apollo/helpers/embedding'
5
+
6
+ RSpec.describe Legion::Extensions::Apollo::Helpers::Embedding do
7
+ describe '.generate' do
8
+ context 'when Legion::LLM is not defined' do
9
+ before do
10
+ hide_const('Legion::LLM') if defined?(Legion::LLM)
11
+ end
12
+
13
+ it 'returns a zero vector of the correct dimension' do
14
+ result = described_class.generate(text: 'hello world')
15
+ expect(result).to eq(Array.new(1536, 0.0))
16
+ expect(result.size).to eq(1536)
17
+ end
18
+ end
19
+
20
+ context 'when Legion::LLM is defined but not started' do
21
+ before do
22
+ stub_const('Legion::LLM', Module.new { def self.started? = false })
23
+ end
24
+
25
+ it 'returns a zero vector' do
26
+ result = described_class.generate(text: 'hello world')
27
+ expect(result).to eq(Array.new(1536, 0.0))
28
+ end
29
+ end
30
+
31
+ context 'when Legion::LLM is available and started' do
32
+ let(:mock_embedding) { Array.new(1536) { rand(-1.0..1.0) } }
33
+
34
+ before do
35
+ llm = Module.new do
36
+ define_method(:started?) { true }
37
+ define_method(:embed) { |_text:| nil }
38
+ extend self
39
+ end
40
+ stub_const('Legion::LLM', llm)
41
+ allow(Legion::LLM).to receive(:embed).and_return(mock_embedding)
42
+ end
43
+
44
+ it 'returns the embedding from LLM' do
45
+ result = described_class.generate(text: 'hello world')
46
+ expect(result).to eq(mock_embedding)
47
+ expect(Legion::LLM).to have_received(:embed).with(text: 'hello world')
48
+ end
49
+ end
50
+
51
+ context 'when Legion::LLM returns invalid embedding' do
52
+ before do
53
+ llm = Module.new do
54
+ define_method(:started?) { true }
55
+ define_method(:embed) { |_text:| nil }
56
+ extend self
57
+ end
58
+ stub_const('Legion::LLM', llm)
59
+ allow(Legion::LLM).to receive(:embed).and_return([0.1, 0.2])
60
+ end
61
+
62
+ it 'returns a zero vector as fallback' do
63
+ result = described_class.generate(text: 'hello world')
64
+ expect(result).to eq(Array.new(1536, 0.0))
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/apollo/helpers/graph_query'
5
+
6
+ RSpec.describe Legion::Extensions::Apollo::Helpers::GraphQuery do
7
+ describe 'constants' do
8
+ it 'defines SPREAD_FACTOR' do
9
+ expect(described_class::SPREAD_FACTOR).to eq(0.6)
10
+ end
11
+
12
+ it 'defines DEFAULT_DEPTH' do
13
+ expect(described_class::DEFAULT_DEPTH).to eq(2)
14
+ end
15
+
16
+ it 'defines MIN_ACTIVATION' do
17
+ expect(described_class::MIN_ACTIVATION).to eq(0.1)
18
+ end
19
+ end
20
+
21
+ describe '.build_traversal_sql' do
22
+ it 'returns SQL string with entry_id placeholder' do
23
+ sql = described_class.build_traversal_sql(depth: 2)
24
+ expect(sql).to include('apollo_entries')
25
+ expect(sql).to include('apollo_relations')
26
+ expect(sql).to include('WITH RECURSIVE')
27
+ expect(sql).to include('$entry_id')
28
+ end
29
+
30
+ it 'includes relation type filter when specified' do
31
+ sql = described_class.build_traversal_sql(depth: 2, relation_types: %w[causes depends_on])
32
+ expect(sql).to include("'causes'")
33
+ expect(sql).to include("'depends_on'")
34
+ end
35
+
36
+ it 'respects custom depth' do
37
+ sql = described_class.build_traversal_sql(depth: 3)
38
+ expect(sql).to include('g.depth < 3')
39
+ end
40
+
41
+ it 'applies spread factor and min activation' do
42
+ sql = described_class.build_traversal_sql(depth: 2)
43
+ expect(sql).to include('0.6')
44
+ expect(sql).to include('0.1')
45
+ end
46
+ end
47
+
48
+ describe '.build_semantic_search_sql' do
49
+ it 'returns SQL with vector placeholder' do
50
+ sql = described_class.build_semantic_search_sql(limit: 5, min_confidence: 0.3)
51
+ expect(sql).to include('apollo_entries')
52
+ expect(sql).to include('$embedding')
53
+ expect(sql).to include('<=>')
54
+ expect(sql).to include('LIMIT 5')
55
+ end
56
+
57
+ it 'includes status filter' do
58
+ sql = described_class.build_semantic_search_sql(limit: 10, statuses: %w[confirmed])
59
+ expect(sql).to include("'confirmed'")
60
+ end
61
+
62
+ it 'includes tag filter when specified' do
63
+ sql = described_class.build_semantic_search_sql(limit: 10, tags: %w[vault dns])
64
+ expect(sql).to include("'vault'")
65
+ expect(sql).to include("'dns'")
66
+ expect(sql).to include('&&')
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/apollo/helpers/similarity'
5
+
6
+ RSpec.describe Legion::Extensions::Apollo::Helpers::Similarity do
7
+ describe '.cosine_similarity' do
8
+ it 'returns 1.0 for identical vectors' do
9
+ vec = [1.0, 0.0, 0.0]
10
+ expect(described_class.cosine_similarity(vec_a: vec, vec_b: vec)).to be_within(0.001).of(1.0)
11
+ end
12
+
13
+ it 'returns 0.0 for orthogonal vectors' do
14
+ vec_a = [1.0, 0.0]
15
+ vec_b = [0.0, 1.0]
16
+ expect(described_class.cosine_similarity(vec_a: vec_a, vec_b: vec_b)).to be_within(0.001).of(0.0)
17
+ end
18
+
19
+ it 'returns correct similarity for known vectors' do
20
+ vec_a = [1.0, 2.0, 3.0]
21
+ vec_b = [4.0, 5.0, 6.0]
22
+ expect(described_class.cosine_similarity(vec_a: vec_a, vec_b: vec_b)).to be_within(0.001).of(0.9746)
23
+ end
24
+
25
+ it 'returns 0.0 for zero vectors' do
26
+ vec_a = [0.0, 0.0]
27
+ vec_b = [1.0, 1.0]
28
+ expect(described_class.cosine_similarity(vec_a: vec_a, vec_b: vec_b)).to eq(0.0)
29
+ end
30
+ end
31
+
32
+ describe '.above_corroboration_threshold?' do
33
+ it 'returns true when similarity exceeds threshold' do
34
+ expect(described_class.above_corroboration_threshold?(similarity: 0.95)).to be true
35
+ end
36
+
37
+ it 'returns false when similarity below threshold' do
38
+ expect(described_class.above_corroboration_threshold?(similarity: 0.85)).to be false
39
+ end
40
+ end
41
+
42
+ describe '.classify_match' do
43
+ it 'returns :corroboration for high similarity same type' do
44
+ result = described_class.classify_match(similarity: 0.95, same_content_type: true)
45
+ expect(result).to eq(:corroboration)
46
+ end
47
+
48
+ it 'returns :contradiction for high similarity with contradicts relation' do
49
+ result = described_class.classify_match(similarity: 0.95, same_content_type: true, contradicts: true)
50
+ expect(result).to eq(:contradiction)
51
+ end
52
+
53
+ it 'returns :novel for low similarity' do
54
+ result = described_class.classify_match(similarity: 0.5, same_content_type: true)
55
+ expect(result).to eq(:novel)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/apollo/runners/expertise'
5
+
6
+ RSpec.describe Legion::Extensions::Apollo::Runners::Expertise do
7
+ let(:runner) do
8
+ obj = Object.new
9
+ obj.extend(described_class)
10
+ obj
11
+ end
12
+
13
+ describe '#get_expertise' do
14
+ it 'returns an expertise query payload' do
15
+ result = runner.get_expertise(domain: 'vault')
16
+ expect(result[:action]).to eq(:expertise_query)
17
+ expect(result[:domain]).to eq('vault')
18
+ expect(result[:min_proficiency]).to eq(0.0)
19
+ end
20
+ end
21
+
22
+ describe '#domains_at_risk' do
23
+ it 'returns an at-risk query payload' do
24
+ result = runner.domains_at_risk(min_agents: 2)
25
+ expect(result[:action]).to eq(:domains_at_risk)
26
+ expect(result[:min_agents]).to eq(2)
27
+ end
28
+ end
29
+
30
+ describe '#agent_profile' do
31
+ it 'returns a profile query payload' do
32
+ result = runner.agent_profile(agent_id: 'worker-1')
33
+ expect(result[:action]).to eq(:agent_profile)
34
+ expect(result[:agent_id]).to eq('worker-1')
35
+ end
36
+ end
37
+
38
+ describe '#aggregate' do
39
+ let(:host) { Object.new.extend(described_class) }
40
+
41
+ context 'when Apollo data is not available' do
42
+ before { hide_const('Legion::Data::Model::ApolloEntry') if defined?(Legion::Data::Model::ApolloEntry) }
43
+
44
+ it 'returns a structured error' do
45
+ result = host.aggregate
46
+ expect(result[:success]).to be false
47
+ expect(result[:error]).to eq('apollo_data_not_available')
48
+ end
49
+ end
50
+
51
+ context 'when entries exist' do
52
+ let(:mock_entry_class) { double('ApolloEntry') }
53
+ let(:mock_expertise_class) { double('ApolloExpertise') }
54
+ let(:entries) do
55
+ [
56
+ double('e1', source_agent: 'agent-1', tags: ['ruby'], confidence: 0.8),
57
+ double('e2', source_agent: 'agent-1', tags: ['ruby'], confidence: 0.6),
58
+ double('e3', source_agent: 'agent-2', tags: ['python'], confidence: 0.9)
59
+ ]
60
+ end
61
+
62
+ before do
63
+ stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
64
+ stub_const('Legion::Data::Model::ApolloExpertise', mock_expertise_class)
65
+ allow(mock_entry_class).to receive(:select).and_return(double(exclude: double(all: entries)))
66
+ allow(mock_expertise_class).to receive(:where).and_return(double(first: nil))
67
+ allow(mock_expertise_class).to receive(:create)
68
+ end
69
+
70
+ it 'returns agent and domain counts' do
71
+ result = host.aggregate
72
+ expect(result[:success]).to be true
73
+ expect(result[:agents]).to eq(2)
74
+ expect(result[:domains]).to eq(2)
75
+ end
76
+
77
+ it 'creates expertise records' do
78
+ expect(mock_expertise_class).to receive(:create).twice
79
+ host.aggregate
80
+ end
81
+
82
+ it 'computes proficiency using log2 formula' do
83
+ # agent-1 ruby: avg=0.7, count=2, proficiency = 0.7 * log2(3) = 0.7 * 1.585 = 1.109 -> capped at 1.0
84
+ expect(mock_expertise_class).to receive(:create).with(
85
+ hash_including(agent_id: 'agent-1', domain: 'ruby', proficiency: 1.0)
86
+ )
87
+ # agent-2 python: avg=0.9, count=1, proficiency = 0.9 * log2(2) = 0.9 * 1.0 = 0.9
88
+ expect(mock_expertise_class).to receive(:create).with(
89
+ hash_including(agent_id: 'agent-2', domain: 'python', proficiency: 0.9)
90
+ )
91
+ host.aggregate
92
+ end
93
+ end
94
+
95
+ context 'when no entries exist' do
96
+ let(:mock_entry_class) { double('ApolloEntry') }
97
+
98
+ before do
99
+ stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
100
+ allow(mock_entry_class).to receive(:select).and_return(double(exclude: double(all: [])))
101
+ end
102
+
103
+ it 'returns zero counts' do
104
+ result = host.aggregate
105
+ expect(result[:success]).to be true
106
+ expect(result[:agents]).to eq(0)
107
+ expect(result[:domains]).to eq(0)
108
+ end
109
+ end
110
+ end
111
+ end