lex-llm 0.6.16 → 0.7.2

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/lex-llm.gemspec +1 -1
  4. data/lib/legion/extensions/llm/auto_registration.rb +3 -0
  5. data/lib/legion/extensions/llm/canonical/message.rb +5 -1
  6. data/lib/legion/extensions/llm/fleet/protocol.rb +12 -0
  7. data/lib/legion/extensions/llm/fleet/provider_responder.rb +40 -5
  8. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -0
  9. data/lib/legion/extensions/llm/fleet/worker_execution.rb +166 -2
  10. data/lib/legion/extensions/llm/inventory/callable_handle.rb +144 -0
  11. data/lib/legion/extensions/llm/inventory/errors.rb +34 -0
  12. data/lib/legion/extensions/llm/inventory/evidence.rb +134 -0
  13. data/lib/legion/extensions/llm/inventory/identity.rb +141 -0
  14. data/lib/legion/extensions/llm/inventory/immutable_value.rb +96 -0
  15. data/lib/legion/extensions/llm/inventory/probe_coordinator.rb +117 -0
  16. data/lib/legion/extensions/llm/inventory/probe_token.rb +146 -0
  17. data/lib/legion/extensions/llm/inventory/publisher.rb +116 -0
  18. data/lib/legion/extensions/llm/inventory/records.rb +599 -0
  19. data/lib/legion/extensions/llm/inventory/registry.rb +636 -0
  20. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +206 -0
  21. data/lib/legion/extensions/llm/inventory/snapshot.rb +91 -0
  22. data/lib/legion/extensions/llm/provider.rb +55 -29
  23. data/lib/legion/extensions/llm/routing/lane_key.rb +4 -0
  24. data/lib/legion/extensions/llm/routing/model_offering.rb +3 -0
  25. data/lib/legion/extensions/llm/routing/offering_registry.rb +2 -0
  26. data/lib/legion/extensions/llm/routing/provider_outcome.rb +54 -0
  27. data/lib/legion/extensions/llm/routing/records.rb +288 -0
  28. data/lib/legion/extensions/llm/settings_cascade.rb +120 -0
  29. data/lib/legion/extensions/llm/taxonomies.rb +90 -0
  30. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -1
  31. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +3 -1
  32. data/lib/legion/extensions/llm/version.rb +1 -1
  33. data/lib/legion/extensions/llm.rb +18 -0
  34. data/spec/legion/extensions/llm/canonical/message_spec.rb +24 -0
  35. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +1 -0
  36. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +1 -0
  37. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +1 -0
  38. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +1 -0
  39. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +1 -0
  40. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +1 -0
  41. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +84 -0
  42. data/spec/legion/extensions/llm/conformance/ssot_provider_conformance_spec.rb +13 -0
  43. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +196 -0
  44. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +179 -0
  45. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +26 -0
  46. data/spec/legion/extensions/llm/fleet_messages_spec.rb +14 -0
  47. data/spec/legion/extensions/llm/inventory/boot_spec.rb +80 -0
  48. data/spec/legion/extensions/llm/inventory/callable_handle_spec.rb +127 -0
  49. data/spec/legion/extensions/llm/inventory/evidence_spec.rb +138 -0
  50. data/spec/legion/extensions/llm/inventory/identity_spec.rb +280 -0
  51. data/spec/legion/extensions/llm/inventory/immutable_value_spec.rb +132 -0
  52. data/spec/legion/extensions/llm/inventory/probe_coordinator_spec.rb +107 -0
  53. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +107 -0
  54. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +144 -0
  55. data/spec/legion/extensions/llm/inventory/records_spec.rb +300 -0
  56. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +140 -0
  57. data/spec/legion/extensions/llm/inventory/registry_availability_spec.rb +152 -0
  58. data/spec/legion/extensions/llm/inventory/registry_callable_spec.rb +63 -0
  59. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +69 -0
  60. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +128 -0
  61. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +109 -0
  62. data/spec/legion/extensions/llm/provider_contract_spec.rb +20 -0
  63. data/spec/legion/extensions/llm/provider_spec.rb +83 -0
  64. data/spec/legion/extensions/llm/routing/provider_outcome_spec.rb +73 -0
  65. data/spec/legion/extensions/llm/routing/records_spec.rb +173 -0
  66. data/spec/legion/extensions/llm/settings_cascade_spec.rb +208 -0
  67. data/spec/legion/extensions/llm/taxonomies_spec.rb +51 -0
  68. data/spec/support/fake_llm_provider.rb +11 -0
  69. data/spec/support/fake_ssot_harness.rb +137 -0
  70. data/spec/support/ssot_registry_helpers.rb +75 -0
  71. metadata +40 -3
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/llm/inventory/registry'
5
+ require_relative '../../../../support/ssot_registry_helpers'
6
+
7
+ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
8
+ include SsotRegistryHelpers
9
+
10
+ inventory = Legion::Extensions::Llm::Inventory
11
+ errors = inventory::Errors
12
+
13
+ before { described_class.reset! }
14
+
15
+ let(:key) { instance_key }
16
+ let(:callable) { fake_callable }
17
+ let(:coordinator) { probe_coordinator(key) }
18
+
19
+ describe 'empty boot snapshot' do
20
+ it 'exposes generation zero and no records' do
21
+ snapshot = described_class.snapshot
22
+ expect(snapshot.generation).to eq(0)
23
+ expect(snapshot.each_lane.to_a).to be_empty
24
+ expect(snapshot.each_offering.to_a).to be_empty
25
+ expect(snapshot.instance(instance_key: key)).to be_nil
26
+ end
27
+ end
28
+
29
+ describe 'claim' do
30
+ it 'shows an initializing publication status with no active model or callable' do
31
+ described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
32
+ snapshot = described_class.snapshot
33
+ status = snapshot.publication_status(instance_key: key)
34
+ expect(status.state).to eq(:initializing)
35
+ expect(snapshot.instance(instance_key: key)).to be_nil
36
+ expect(snapshot.lanes_for(instance_key: key)).to be_empty
37
+ end
38
+
39
+ it 'returns an opaque PublisherToken' do
40
+ token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
41
+ expect(token).to be_a(inventory::PublisherToken)
42
+ end
43
+
44
+ it 'rejects reusing the exact same callable object on re-claim' do
45
+ described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
46
+ expect { described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator) }
47
+ .to raise_error(errors::ValidationError)
48
+ end
49
+
50
+ it 'fences the superseded token after a fresh re-claim with a distinct callable' do
51
+ token1 = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
52
+ described_class.claim_instance(instance_key: key, callable: fake_callable, probe_request_handle: coordinator)
53
+ expect { described_class.readiness_probe_started(instance_key: key, publisher_token: token1) }
54
+ .to raise_error(errors::FencedPublisherError)
55
+ end
56
+ end
57
+
58
+ describe 'startup activation sequence' do
59
+ it 'raises if readiness_succeeded is called before activation' do
60
+ token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
61
+ probe = described_class.readiness_probe_started(instance_key: key, publisher_token: token)
62
+ expect { described_class.readiness_succeeded(instance_key: key, probe_token: probe) }
63
+ .to raise_error(errors::InvalidTransitionError)
64
+ end
65
+
66
+ it 'atomically publishes callable, offerings, lanes, and available on activation' do
67
+ claim_and_activate(key: key, callable: callable, coordinator: coordinator)
68
+ snapshot = described_class.snapshot
69
+ record = snapshot.instance(instance_key: key)
70
+ expect(record).not_to be_nil
71
+ expect(record.availability.state).to eq(:available)
72
+ expect(snapshot.lanes_for(instance_key: key).size).to eq(1)
73
+ expect(snapshot.offerings_for(instance_key: key).size).to eq(1)
74
+ expect(snapshot.publication_status(instance_key: key).state).to eq(:complete)
75
+ end
76
+
77
+ it 'accepts an explicit complete empty activation as complete, not initializing' do
78
+ token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
79
+ probe = described_class.readiness_probe_started(instance_key: key, publisher_token: token)
80
+ result = described_class.activate_instance_snapshot(
81
+ publisher_token: token, instance_key: key, offerings: [], sequence: 0, probe_token: probe
82
+ )
83
+ expect(result.applied).to be(true)
84
+ snapshot = described_class.snapshot
85
+ expect(snapshot.publication_status(instance_key: key).state).to eq(:complete)
86
+ expect(snapshot.instance(instance_key: key).offerings_by_id).to eq({})
87
+ end
88
+
89
+ it 'consumes the probe token so it cannot be reused' do
90
+ token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
91
+ probe = described_class.readiness_probe_started(instance_key: key, publisher_token: token)
92
+ described_class.activate_instance_snapshot(publisher_token: token, instance_key: key, offerings: drafts, sequence: 0, probe_token: probe)
93
+ expect { described_class.readiness_succeeded(instance_key: key, probe_token: probe) }
94
+ .to raise_error(errors::InvalidProbeError)
95
+ end
96
+
97
+ it 'requires a sequence greater than the claim baseline' do
98
+ token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
99
+ probe = described_class.readiness_probe_started(instance_key: key, publisher_token: token)
100
+ expect do
101
+ described_class.activate_instance_snapshot(publisher_token: token, instance_key: key, offerings: drafts, sequence: -1, probe_token: probe)
102
+ end.to raise_error(errors::StaleSequenceError)
103
+ end
104
+
105
+ it 'increments generation once for a claim' do
106
+ generation = described_class.snapshot.generation
107
+ described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
108
+ expect(described_class.snapshot.generation).to eq(generation + 1)
109
+ end
110
+ end
111
+
112
+ describe 'initial readiness failure' do
113
+ it 'leaves the instance initializing, exposes no model/callable, and does not mark unavailable' do
114
+ token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
115
+ probe = described_class.readiness_probe_started(instance_key: key, publisher_token: token)
116
+ result = described_class.readiness_failed(instance_key: key, probe_token: probe, reason: 'probe failed')
117
+ expect(result.applied).to be(true)
118
+ expect(result.reason).to eq(:initial_readiness_failed)
119
+ snapshot = described_class.snapshot
120
+ expect(snapshot.instance(instance_key: key)).to be_nil
121
+ expect(snapshot.publication_status(instance_key: key).state).to eq(:initializing)
122
+ expect(snapshot.lanes_for(instance_key: key)).to be_empty
123
+ end
124
+ end
125
+
126
+ describe 'no external calls under the mutation mutex' do
127
+ it 'never invokes the callable disconnect or a probe enqueue while activating' do
128
+ enqueue_calls = []
129
+ probing = probe_coordinator(key, enqueue: lambda { |request:|
130
+ enqueue_calls << request
131
+ true
132
+ })
133
+ token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: probing)
134
+ probe = described_class.readiness_probe_started(instance_key: key, publisher_token: token)
135
+ described_class.activate_instance_snapshot(publisher_token: token, instance_key: key, offerings: drafts, sequence: 0, probe_token: probe)
136
+ expect(callable.disconnect_count).to eq(0)
137
+ expect(enqueue_calls).to be_empty
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/llm/inventory/registry'
5
+ require_relative '../../../../support/ssot_registry_helpers'
6
+
7
+ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
8
+ include SsotRegistryHelpers
9
+
10
+ errors = Legion::Extensions::Llm::Inventory::Errors
11
+
12
+ before { described_class.reset! }
13
+
14
+ let(:key_a) { instance_key(instance: 'h200') }
15
+ let(:key_b) { instance_key(instance: 'helios1') }
16
+
17
+ def revision_of(key)
18
+ described_class.snapshot.instance(instance_key: key)&.availability&.availability_revision
19
+ end
20
+
21
+ def state_of(key)
22
+ described_class.snapshot.instance(instance_key: key)&.availability&.state
23
+ end
24
+
25
+ def probe(token, key)
26
+ described_class.readiness_probe_started(instance_key: key, publisher_token: token)
27
+ end
28
+
29
+ describe 'ordinary readiness success while available' do
30
+ it 'records telemetry without changing availability revision or eligibility' do
31
+ token = claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
32
+ expect(revision_of(key_a)).to eq(1)
33
+ result = described_class.readiness_succeeded(instance_key: key_a, probe_token: probe(token, key_a))
34
+ expect(result.reason).to eq(:readiness_observed)
35
+ expect(revision_of(key_a)).to eq(1)
36
+ expect(state_of(key_a)).to eq(:available)
37
+ end
38
+ end
39
+
40
+ describe 'exact-instance isolation' do
41
+ it 'keeps two instances of the same provider and model independent through failure and recovery' do
42
+ token_a = claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
43
+ claim_and_activate(key: key_b, callable: fake_callable, coordinator: probe_coordinator(key_b))
44
+
45
+ described_class.readiness_failed(instance_key: key_a, probe_token: probe(token_a, key_a), reason: 'a failed')
46
+ expect(state_of(key_a)).to eq(:unavailable)
47
+ expect(state_of(key_b)).to eq(:available)
48
+
49
+ recover = described_class.readiness_succeeded(instance_key: key_a, probe_token: probe(token_a, key_a))
50
+ expect(recover.reason).to eq(:instance_recovered)
51
+ expect(state_of(key_a)).to eq(:available)
52
+ expect(state_of(key_b)).to eq(:available)
53
+ end
54
+ end
55
+
56
+ describe 'availability revisions' do
57
+ it 'increments the availability revision on each successive unavailable transition' do
58
+ token = claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
59
+ described_class.readiness_failed(instance_key: key_a, probe_token: probe(token, key_a), reason: 'first')
60
+ first = revision_of(key_a)
61
+ described_class.readiness_failed(instance_key: key_a, probe_token: probe(token, key_a), reason: 'second')
62
+ expect(revision_of(key_a)).to eq(first + 1)
63
+ end
64
+ end
65
+
66
+ describe 'stale and superseded probes cannot recover' do
67
+ it 'refuses recovery from a probe started before the failure' do
68
+ token = claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
69
+ stale = probe(token, key_a) # started at availability revision 1, before the failure
70
+ described_class.readiness_failed(instance_key: key_a, probe_token: probe(token, key_a), reason: 'failed')
71
+ result = described_class.readiness_succeeded(instance_key: key_a, probe_token: stale)
72
+ expect(result.applied).to be(false)
73
+ expect(result.reason).to eq(:stale_probe)
74
+ expect(state_of(key_a)).to eq(:unavailable)
75
+ end
76
+
77
+ it 'recovers with a probe started after the failure' do
78
+ token = claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
79
+ described_class.readiness_failed(instance_key: key_a, probe_token: probe(token, key_a), reason: 'failed')
80
+ fresh = probe(token, key_a) # started at the new (unavailable) revision
81
+ result = described_class.readiness_succeeded(instance_key: key_a, probe_token: fresh)
82
+ expect(result.reason).to eq(:instance_recovered)
83
+ expect(state_of(key_a)).to eq(:available)
84
+ end
85
+ end
86
+
87
+ describe 'dispatch-reported instance unavailable' do
88
+ it 'marks only the exact active instance unavailable using the non-secret token id and enqueues a probe' do
89
+ enqueued = []
90
+ coordinator_a = probe_coordinator(key_a, enqueue: lambda { |request:|
91
+ enqueued << request
92
+ true
93
+ })
94
+ token_a = claim_and_activate(key: key_a, callable: fake_callable, coordinator: coordinator_a)
95
+ claim_and_activate(key: key_b, callable: fake_callable, coordinator: probe_coordinator(key_b))
96
+
97
+ result = described_class.dispatch_instance_unavailable(
98
+ instance_key: key_a, publisher_token_id: token_a.publisher_token_id, reason: 'normalized instance_unavailable'
99
+ )
100
+ expect(result.applied).to be(true)
101
+ expect(result.reason).to eq(:instance_unavailable)
102
+ expect(state_of(key_a)).to eq(:unavailable)
103
+ expect(state_of(key_b)).to eq(:available)
104
+ expect(enqueued.size).to eq(1)
105
+ expect(enqueued.first.instance_key).to eq(key_a)
106
+ end
107
+
108
+ it 'rejects a stale (non-current) publisher token id without mutating' do
109
+ claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
110
+ result = described_class.dispatch_instance_unavailable(
111
+ instance_key: key_a, publisher_token_id: 'ptok:v1:not-current', reason: 'x'
112
+ )
113
+ expect(result.applied).to be(false)
114
+ expect(result.reason).to eq(:stale_publisher)
115
+ expect(state_of(key_a)).to eq(:available)
116
+ end
117
+
118
+ it 'is a process-local method that accepts the public token id, not the secret PublisherToken' do
119
+ token = claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
120
+ # Passing the secret token object where a String id is expected must not authorize the transition.
121
+ result = described_class.dispatch_instance_unavailable(
122
+ instance_key: key_a, publisher_token_id: token, reason: 'x'
123
+ )
124
+ expect(result.applied).to be(false)
125
+ expect(state_of(key_a)).to eq(:available)
126
+ end
127
+
128
+ it 'is invalid for an initializing instance' do
129
+ token = described_class.claim_instance(instance_key: key_a, callable: fake_callable, probe_request_handle: probe_coordinator(key_a))
130
+ expect do
131
+ described_class.dispatch_instance_unavailable(instance_key: key_a, publisher_token_id: token.publisher_token_id, reason: 'x')
132
+ end.to raise_error(errors::InvalidTransitionError)
133
+ end
134
+ end
135
+
136
+ describe 'global instance-unavailable filters all of that instance lanes' do
137
+ it 'marks every lane of one instance unavailable without touching another instance' do
138
+ two_models = drafts(native: 'gemma4', model: 'gemma4') +
139
+ drafts(native: 'gemma5', model: 'gemma5')
140
+ token_a = described_class.claim_instance(instance_key: key_a, callable: fake_callable, probe_request_handle: probe_coordinator(key_a))
141
+ probe_a = described_class.readiness_probe_started(instance_key: key_a, publisher_token: token_a)
142
+ described_class.activate_instance_snapshot(publisher_token: token_a, instance_key: key_a, offerings: two_models, sequence: 0, probe_token: probe_a)
143
+ claim_and_activate(key: key_b, callable: fake_callable, coordinator: probe_coordinator(key_b))
144
+
145
+ described_class.dispatch_instance_unavailable(instance_key: key_a, publisher_token_id: token_a.publisher_token_id, reason: 'down')
146
+ expect(state_of(key_a)).to eq(:unavailable)
147
+ # lanes remain visible for diagnosis on the instance record
148
+ expect(described_class.snapshot.lanes_for(instance_key: key_a).size).to eq(2)
149
+ expect(state_of(key_b)).to eq(:available)
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/llm/inventory/registry'
5
+ require_relative '../../../../support/ssot_registry_helpers'
6
+
7
+ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
8
+ include SsotRegistryHelpers
9
+
10
+ errors = Legion::Extensions::Llm::Inventory::Errors
11
+
12
+ before { described_class.reset! }
13
+
14
+ let(:key) { instance_key }
15
+ let(:callable) { fake_callable }
16
+ let(:coordinator) { probe_coordinator(key) }
17
+ let(:token) { claim_and_activate(key: key, callable: callable, coordinator: coordinator) }
18
+ let(:handle) { token && described_class.snapshot.instance(instance_key: key).callable_handle }
19
+
20
+ it 'acquires a lease that exposes the exact captured callable' do
21
+ lease = described_class.acquire(callable_handle: handle)
22
+ expect(lease.callable).to equal(callable)
23
+ lease.release
24
+ end
25
+
26
+ it 'rejects acquiring a non-CallableHandle' do
27
+ token
28
+ expect { described_class.acquire(callable_handle: Object.new) }.to raise_error(errors::UnknownCallableError)
29
+ end
30
+
31
+ it 'lets an already-acquired lease finish after the instance is removed, disconnecting exactly once' do
32
+ lease = described_class.acquire(callable_handle: handle)
33
+ described_class.remove_instance(instance_key: key, publisher_token: token)
34
+ expect(handle.state).to eq(:retiring)
35
+ expect(callable.disconnect_count).to eq(0)
36
+ # the in-flight lease still works
37
+ expect(lease.callable).to equal(callable)
38
+ lease.release
39
+ expect(handle.state).to eq(:disposed)
40
+ expect(callable.disconnect_count).to eq(1)
41
+ end
42
+
43
+ it 'blocks a new acquisition against a retired handle' do
44
+ described_class.acquire(callable_handle: handle) # keep one lease so removal leaves it retiring
45
+ described_class.remove_instance(instance_key: key, publisher_token: token)
46
+ expect { described_class.acquire(callable_handle: handle) }.to raise_error(errors::StaleCallableError)
47
+ end
48
+
49
+ it 'removes an idle instance and disposes its handle' do
50
+ handle
51
+ described_class.remove_instance(instance_key: key, publisher_token: token)
52
+ expect(handle.state).to eq(:disposed)
53
+ expect(described_class.snapshot.instance(instance_key: key)).to be_nil
54
+ end
55
+
56
+ it 'returns already_removed for a second removal' do
57
+ handle
58
+ described_class.remove_instance(instance_key: key, publisher_token: token)
59
+ result = described_class.remove_instance(instance_key: key, publisher_token: token)
60
+ expect(result.applied).to be(false)
61
+ expect(result.reason).to eq(:already_removed)
62
+ end
63
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/llm/inventory/registry'
5
+ require_relative '../../../../support/ssot_registry_helpers'
6
+
7
+ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
8
+ include SsotRegistryHelpers
9
+
10
+ errors = Legion::Extensions::Llm::Inventory::Errors
11
+
12
+ before { described_class.reset! }
13
+
14
+ let(:key) { instance_key }
15
+ let(:callable) { fake_callable }
16
+ let(:coordinator) { probe_coordinator(key) }
17
+ let(:token) { claim_and_activate(key: key, callable: callable, coordinator: coordinator, sequence: 0) }
18
+
19
+ it 'replaces the complete offering set with a strictly increasing sequence' do
20
+ token
21
+ result = described_class.replace_instance_snapshot(
22
+ publisher_token: token, instance_key: key, offerings: drafts(native: 'gemma5', model: 'gemma5'), sequence: 1
23
+ )
24
+ expect(result.applied).to be(true)
25
+ expect(result.reason).to eq(:snapshot_replaced)
26
+ offerings = described_class.snapshot.offerings_for(instance_key: key)
27
+ expect(offerings.map(&:provider_native_key)).to eq(['gemma5'])
28
+ end
29
+
30
+ it 'preserves the exact availability fact across replacement' do
31
+ token
32
+ before_fact = described_class.snapshot.instance(instance_key: key).availability
33
+ described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: drafts, sequence: 1)
34
+ after_fact = described_class.snapshot.instance(instance_key: key).availability
35
+ expect(after_fact.availability_revision).to eq(before_fact.availability_revision)
36
+ expect(after_fact.state).to eq(:available)
37
+ end
38
+
39
+ it 'rejects a non-increasing sequence' do
40
+ token
41
+ expect { described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: drafts, sequence: 0) }
42
+ .to raise_error(errors::StaleSequenceError)
43
+ end
44
+
45
+ it 'treats an empty array as an authoritative complete empty snapshot' do
46
+ token
47
+ described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: [], sequence: 1)
48
+ expect(described_class.snapshot.offerings_for(instance_key: key)).to be_empty
49
+ expect(described_class.snapshot.instance(instance_key: key).availability.state).to eq(:available)
50
+ end
51
+
52
+ it 'leaves the prior root untouched when draft validation fails' do
53
+ token
54
+ generation = described_class.snapshot.generation
55
+ duplicate = drafts + drafts # two drafts with the same provider_native_key
56
+ expect { described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: duplicate, sequence: 1) }
57
+ .to raise_error(errors::ValidationError)
58
+ expect(described_class.snapshot.generation).to eq(generation)
59
+ expect(described_class.snapshot.offerings_for(instance_key: key).size).to eq(1)
60
+ end
61
+
62
+ it 'returns a stale publisher result for a superseded token' do
63
+ token
64
+ described_class.claim_instance(instance_key: key, callable: fake_callable, probe_request_handle: coordinator)
65
+ result = described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: drafts, sequence: 5)
66
+ expect(result.applied).to be(false)
67
+ expect(result.reason).to eq(:stale_publisher)
68
+ end
69
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'spec_helper'
4
4
  require 'legion/extensions/llm/inventory/scoped_refresher'
5
+ require 'legion/extensions/llm/inventory/registry'
6
+ require_relative '../../../../support/ssot_registry_helpers'
5
7
 
6
8
  RSpec.describe Legion::Extensions::Llm::Inventory::ScopedRefresher do
7
9
  describe '.compose_id' do
@@ -206,4 +208,130 @@ RSpec.describe Legion::Extensions::Llm::Inventory::ScopedRefresher do
206
208
  expect(cache_store).not_to have_key(cooldown_key)
207
209
  end
208
210
  end
211
+
212
+ describe Legion::Extensions::Llm::Inventory::ScopedRefresher::LegacyCoordinatorAdapter do
213
+ include SsotRegistryHelpers
214
+
215
+ inventory = Legion::Extensions::Llm::Inventory
216
+ errors = inventory::Errors
217
+
218
+ let(:fake_inventory) do
219
+ Class.new do
220
+ attr_reader :lanes
221
+
222
+ def initialize
223
+ @lanes = {}
224
+ end
225
+
226
+ def write_lane(lane:, **)
227
+ @lanes[lane[:id]] = lane
228
+ lane
229
+ end
230
+
231
+ def delete_lane(id:, **)
232
+ @lanes.delete(id)
233
+ :deleted
234
+ end
235
+
236
+ def lanes_for(provider:, instance:, **)
237
+ @lanes.values.select { |l| l[:provider_family].to_s == provider.to_s && l[:instance_id].to_s == instance.to_s }
238
+ end
239
+ end.new
240
+ end
241
+
242
+ let(:key) { instance_key(family: 'vllm', instance: 'h200') }
243
+ let(:adapter) { described_class.new(provider_family: :vllm) }
244
+
245
+ before do
246
+ inventory::Registry.reset!
247
+ stub_const('Legion::LLM::Inventory', fake_inventory)
248
+ end
249
+
250
+ def activate_chat
251
+ claim_and_activate(key: key, callable: fake_callable, coordinator: probe_coordinator(key))
252
+ Legion::Extensions::Llm::Inventory::Registry.snapshot
253
+ end
254
+
255
+ it 'projects an available instance into an exact five-field legacy lane and round-trips' do
256
+ result = adapter.sync_snapshot(snapshot: activate_chat, instance_key: key, mutation_reason: :activated)
257
+ expect(result).to eq(:applied)
258
+
259
+ legacy_id = 'local:vllm:h200:inference:gemma4'
260
+ expect(fake_inventory.lanes).to have_key(legacy_id)
261
+ lane = fake_inventory.lanes[legacy_id]
262
+ expect(lane[:id]).to eq(legacy_id)
263
+ expect(lane[:provider_instance]).to eq('h200')
264
+ expect(lane[:enabled]).to be(true)
265
+ expect(lane[:capabilities]).to include(:completion)
266
+ expect(lane[:metadata]).to include(ssot_v3_compatibility_projection: true)
267
+ round_trip = fake_inventory.lanes_for(provider: :vllm, instance: 'h200', type: :inference, model: 'gemma4')
268
+ expect(round_trip.map { |l| l[:id] }).to eq([legacy_id])
269
+ end
270
+
271
+ it 'deletes the old projection when the instance is removed (desired set empty)' do
272
+ token = claim_and_activate(key: key, callable: fake_callable, coordinator: probe_coordinator(key))
273
+ adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :activated)
274
+ expect(fake_inventory.lanes).not_to be_empty
275
+
276
+ inventory::Registry.remove_instance(instance_key: key, publisher_token: token)
277
+ adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :removed)
278
+ expect(fake_inventory.lanes).to be_empty
279
+ end
280
+
281
+ it 'deletes the old projection when the instance becomes unavailable' do
282
+ token = claim_and_activate(key: key, callable: fake_callable, coordinator: probe_coordinator(key))
283
+ adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :activated)
284
+ inventory::Registry.dispatch_instance_unavailable(instance_key: key, publisher_token_id: token.publisher_token_id, reason: 'down')
285
+ adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :instance_unavailable)
286
+ expect(fake_inventory.lanes).to be_empty
287
+ end
288
+
289
+ it 'fails closed and omits an ambiguous group that collapses multiple offerings' do
290
+ token = inventory::Registry.claim_instance(instance_key: key, callable: fake_callable, probe_request_handle: probe_coordinator(key))
291
+ probe = inventory::Registry.readiness_probe_started(instance_key: key, publisher_token: token)
292
+ two = drafts(native: 'a', model: 'gemma4') + drafts(native: 'b', model: 'gemma4')
293
+ inventory::Registry.activate_instance_snapshot(publisher_token: token, instance_key: key, offerings: two, sequence: 0, probe_token: probe)
294
+ adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :activated)
295
+ expect(fake_inventory.lanes).to be_empty
296
+ end
297
+
298
+ it 'returns :not_loaded when Legion::LLM::Inventory is absent' do
299
+ hide_const('Legion::LLM::Inventory')
300
+ expect(adapter.sync_snapshot(snapshot: activate_chat, instance_key: key, mutation_reason: :activated)).to eq(:not_loaded)
301
+ end
302
+
303
+ it 'rejects a different provider family, an invalid snapshot, and an unknown reason' do
304
+ snapshot = activate_chat
305
+ other = instance_key(family: 'openai', instance: 'h200')
306
+ expect { adapter.sync_snapshot(snapshot: snapshot, instance_key: other, mutation_reason: :activated) }
307
+ .to raise_error(errors::ValidationError)
308
+ expect { adapter.sync_snapshot(snapshot: {}, instance_key: key, mutation_reason: :activated) }
309
+ .to raise_error(errors::ValidationError)
310
+ expect { adapter.sync_snapshot(snapshot: snapshot, instance_key: key, mutation_reason: :made_up) }
311
+ .to raise_error(errors::ValidationError)
312
+ end
313
+
314
+ it 'returns :failed and preserves the prior tracked set when a write raises' do
315
+ snapshot = activate_chat
316
+ allow(fake_inventory).to receive(:write_lane).and_raise(StandardError, 'coordinator down')
317
+ expect(adapter.sync_snapshot(snapshot: snapshot, instance_key: key, mutation_reason: :activated)).to eq(:failed)
318
+ end
319
+
320
+ it 'never lets an old five-part id be accepted as a canonical lane:v1: identity' do
321
+ offering_id = inventory::Identity.offering_id(instance_key: key, provider_native_key: 'gemma4')
322
+ expect do
323
+ inventory::Identity.validate_lane_id!(
324
+ value: 'local:vllm:h200:inference:gemma4', instance_key: key,
325
+ operation: :chat, model: 'gemma4', offering_id: offering_id
326
+ )
327
+ end.to raise_error(errors::ValidationError)
328
+ end
329
+
330
+ it 'leaves the new Registry unchanged when the legacy projection runs' do
331
+ snapshot = activate_chat
332
+ generation = inventory::Registry.snapshot.generation
333
+ adapter.sync_snapshot(snapshot: snapshot, instance_key: key, mutation_reason: :activated)
334
+ expect(inventory::Registry.snapshot.generation).to eq(generation)
335
+ end
336
+ end
209
337
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/llm/inventory/registry'
5
+ require_relative '../../../../support/ssot_registry_helpers'
6
+
7
+ RSpec.describe Legion::Extensions::Llm::Inventory::Snapshot do
8
+ include SsotRegistryHelpers
9
+
10
+ registry = Legion::Extensions::Llm::Inventory::Registry
11
+
12
+ before { registry.reset! }
13
+
14
+ let(:key_a) { instance_key(instance: 'h200') }
15
+ let(:key_b) { instance_key(instance: 'helios1') }
16
+
17
+ def activate_both
18
+ claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
19
+ claim_and_activate(key: key_b, callable: fake_callable, coordinator: probe_coordinator(key_b))
20
+ registry.snapshot
21
+ end
22
+
23
+ describe 'lookups' do
24
+ it 'returns the frozen record or nil and never synthesizes a default' do
25
+ snapshot = activate_both
26
+ record = snapshot.instance(instance_key: key_a)
27
+ expect(record).to be_frozen
28
+ expect(snapshot.instance(instance_key: instance_key(instance: 'absent'))).to be_nil
29
+ expect(snapshot.lane(lane_id: 'lane:v1:missing')).to be_nil
30
+ expect(snapshot.offering(offering_id: 'off:v1:missing')).to be_nil
31
+ end
32
+
33
+ it 'only lists activated instances but keeps initializing claims in publication status' do
34
+ claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
35
+ registry.claim_instance(instance_key: key_b, callable: fake_callable, probe_request_handle: probe_coordinator(key_b))
36
+ snapshot = registry.snapshot
37
+ expect(snapshot.instance(instance_key: key_a)).not_to be_nil
38
+ expect(snapshot.instance(instance_key: key_b)).to be_nil
39
+ expect(snapshot.publication_status(instance_key: key_b).state).to eq(:initializing)
40
+ end
41
+ end
42
+
43
+ describe 'ordering and iteration' do
44
+ it 'returns frozen lanes_for/offerings_for arrays ordered by id' do
45
+ snapshot = activate_both
46
+ lanes = snapshot.lanes_for(instance_key: key_a)
47
+ expect(lanes).to be_frozen
48
+ expect(lanes.map(&:lane_id)).to eq(lanes.map(&:lane_id).sort)
49
+ end
50
+
51
+ it 'each_lane/each_offering yield in id order and return an Enumerator without a block' do
52
+ snapshot = activate_both
53
+ expect(snapshot.each_lane).to be_a(Enumerator)
54
+ expect(snapshot.each_offering).to be_a(Enumerator)
55
+ expect(snapshot.each_lane.map(&:lane_id)).to eq(snapshot.each_lane.map(&:lane_id).sort)
56
+ end
57
+ end
58
+
59
+ describe 'each_instance and each_publication_status' do
60
+ it 'return an Enumerator without a block' do
61
+ snapshot = activate_both
62
+ expect(snapshot.each_instance).to be_a(Enumerator)
63
+ expect(snapshot.each_publication_status).to be_a(Enumerator)
64
+ end
65
+
66
+ it 'yield immutable instance records in canonical InstanceKey order' do
67
+ snapshot = activate_both
68
+ records = snapshot.each_instance.to_a
69
+ expect(records.map { |record| record.instance_key.instance_id }).to eq(%w[h200 helios1])
70
+ expect(records).to all(be_a(Legion::Extensions::Llm::Inventory::InstanceRecord))
71
+ expect(records).to all(be_frozen)
72
+ end
73
+
74
+ it 'include initializing claims in each_publication_status but not each_instance' do
75
+ claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
76
+ registry.claim_instance(instance_key: key_b, callable: fake_callable, probe_request_handle: probe_coordinator(key_b))
77
+ snapshot = registry.snapshot
78
+ status_states = snapshot.each_publication_status.map(&:state)
79
+ expect(status_states).to contain_exactly(:complete, :initializing)
80
+ expect(snapshot.each_instance.map(&:instance_key)).to eq([key_a])
81
+ end
82
+
83
+ it 'does not expose either backing Hash' do
84
+ snapshot = activate_both
85
+ expect(snapshot).not_to respond_to(:instances_by_key)
86
+ expect(snapshot).not_to respond_to(:publication_status_by_key)
87
+ end
88
+ end
89
+
90
+ describe 'structural immutability of an older snapshot' do
91
+ it 'does not observe a later record or generation mutation' do
92
+ claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
93
+ old = registry.snapshot
94
+ old_generation = old.generation
95
+ claim_and_activate(key: key_b, callable: fake_callable, coordinator: probe_coordinator(key_b))
96
+ expect(old.generation).to eq(old_generation)
97
+ expect(old.instance(instance_key: key_b)).to be_nil
98
+ expect(registry.snapshot.instance(instance_key: key_b)).not_to be_nil
99
+ end
100
+
101
+ it 'lets a captured callable handle move to retiring after fencing (lifecycle exception)' do
102
+ token = claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
103
+ captured = registry.snapshot.instance(instance_key: key_a).callable_handle
104
+ expect(captured.state).to eq(:active)
105
+ registry.remove_instance(instance_key: key_a, publisher_token: token)
106
+ expect(captured.state).to eq(:disposed)
107
+ end
108
+ end
109
+ end