lex-llm 0.6.16 → 0.7.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/lib/legion/extensions/llm/auto_registration.rb +3 -0
  4. data/lib/legion/extensions/llm/fleet/protocol.rb +12 -0
  5. data/lib/legion/extensions/llm/fleet/provider_responder.rb +40 -5
  6. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -0
  7. data/lib/legion/extensions/llm/fleet/worker_execution.rb +166 -2
  8. data/lib/legion/extensions/llm/inventory/callable_handle.rb +144 -0
  9. data/lib/legion/extensions/llm/inventory/errors.rb +34 -0
  10. data/lib/legion/extensions/llm/inventory/evidence.rb +134 -0
  11. data/lib/legion/extensions/llm/inventory/identity.rb +119 -0
  12. data/lib/legion/extensions/llm/inventory/immutable_value.rb +96 -0
  13. data/lib/legion/extensions/llm/inventory/probe_coordinator.rb +117 -0
  14. data/lib/legion/extensions/llm/inventory/probe_token.rb +146 -0
  15. data/lib/legion/extensions/llm/inventory/publisher.rb +110 -0
  16. data/lib/legion/extensions/llm/inventory/records.rb +592 -0
  17. data/lib/legion/extensions/llm/inventory/registry.rb +636 -0
  18. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +206 -0
  19. data/lib/legion/extensions/llm/inventory/snapshot.rb +91 -0
  20. data/lib/legion/extensions/llm/provider.rb +43 -0
  21. data/lib/legion/extensions/llm/routing/lane_key.rb +4 -0
  22. data/lib/legion/extensions/llm/routing/model_offering.rb +3 -0
  23. data/lib/legion/extensions/llm/routing/offering_registry.rb +2 -0
  24. data/lib/legion/extensions/llm/routing/provider_outcome.rb +54 -0
  25. data/lib/legion/extensions/llm/routing/records.rb +288 -0
  26. data/lib/legion/extensions/llm/taxonomies.rb +90 -0
  27. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -1
  28. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +3 -1
  29. data/lib/legion/extensions/llm/version.rb +1 -1
  30. data/lib/legion/extensions/llm.rb +15 -0
  31. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +84 -0
  32. data/spec/legion/extensions/llm/conformance/ssot_provider_conformance_spec.rb +13 -0
  33. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +196 -0
  34. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +179 -0
  35. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +26 -0
  36. data/spec/legion/extensions/llm/fleet_messages_spec.rb +14 -0
  37. data/spec/legion/extensions/llm/inventory/boot_spec.rb +80 -0
  38. data/spec/legion/extensions/llm/inventory/callable_handle_spec.rb +127 -0
  39. data/spec/legion/extensions/llm/inventory/evidence_spec.rb +125 -0
  40. data/spec/legion/extensions/llm/inventory/identity_spec.rb +227 -0
  41. data/spec/legion/extensions/llm/inventory/immutable_value_spec.rb +132 -0
  42. data/spec/legion/extensions/llm/inventory/probe_coordinator_spec.rb +107 -0
  43. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +107 -0
  44. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +104 -0
  45. data/spec/legion/extensions/llm/inventory/records_spec.rb +300 -0
  46. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +140 -0
  47. data/spec/legion/extensions/llm/inventory/registry_availability_spec.rb +152 -0
  48. data/spec/legion/extensions/llm/inventory/registry_callable_spec.rb +63 -0
  49. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +69 -0
  50. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +128 -0
  51. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +109 -0
  52. data/spec/legion/extensions/llm/provider_contract_spec.rb +20 -0
  53. data/spec/legion/extensions/llm/provider_spec.rb +83 -0
  54. data/spec/legion/extensions/llm/routing/provider_outcome_spec.rb +59 -0
  55. data/spec/legion/extensions/llm/routing/records_spec.rb +173 -0
  56. data/spec/legion/extensions/llm/taxonomies_spec.rb +51 -0
  57. data/spec/support/fake_llm_provider.rb +11 -0
  58. data/spec/support/fake_ssot_harness.rb +137 -0
  59. data/spec/support/ssot_registry_helpers.rb +75 -0
  60. metadata +36 -1
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/llm/inventory/evidence'
5
+
6
+ RSpec.describe Legion::Extensions::Llm::Inventory::Evidence do
7
+ let(:errors) { Legion::Extensions::Llm::Inventory::Errors }
8
+ let(:operation_evidence) { Legion::Extensions::Llm::Inventory::OperationEvidence }
9
+ let(:capability_evidence) { Legion::Extensions::Llm::Inventory::CapabilityEvidence }
10
+ let(:value_evidence) { Legion::Extensions::Llm::Inventory::ValueEvidence }
11
+
12
+ describe 'OperationEvidence' do
13
+ it 'stores a canonical operation and exposes exact predicates' do
14
+ ev = operation_evidence.new(operation: :chat, status: :supported, source: :provider_implementation)
15
+ expect(ev.operation).to eq(:chat)
16
+ expect(ev).to be_supported
17
+ expect(ev).not_to be_unknown
18
+ end
19
+
20
+ it 'keeps stream_chat as the operation and never canonicalizes to capability streaming' do
21
+ ev = operation_evidence.new(operation: :stream_chat, status: :supported, source: :provider_catalog)
22
+ expect(ev.operation).to eq(:stream_chat)
23
+ expect(ev.operation).not_to eq(:streaming)
24
+ end
25
+
26
+ it 'rejects an operation alias when allow_aliases is false (registry path)' do
27
+ expect { operation_evidence.new(operation: :stream, status: :supported, source: :provider_catalog) }
28
+ .to raise_error(errors::ValidationError)
29
+ end
30
+
31
+ it 'rejects an unknown operation' do
32
+ expect { operation_evidence.new(operation: :nonsense, status: :unknown, source: :absent) }
33
+ .to raise_error(errors::ValidationError)
34
+ end
35
+
36
+ it 'rejects a status outside supported/unsupported/unknown' do
37
+ expect { operation_evidence.new(operation: :chat, status: :maybe, source: :provider_implementation) }
38
+ .to raise_error(errors::ValidationError)
39
+ end
40
+
41
+ it 'rejects a source outside EVIDENCE_SOURCES' do
42
+ expect { operation_evidence.new(operation: :chat, status: :supported, source: :made_up) }
43
+ .to raise_error(errors::ValidationError)
44
+ end
45
+
46
+ it 'requires unknown status for an unknown-only source' do
47
+ expect { operation_evidence.new(operation: :chat, status: :supported, source: :default_false) }
48
+ .to raise_error(errors::ValidationError)
49
+ expect { operation_evidence.new(operation: :chat, status: :unknown, source: :default_false) }
50
+ .not_to raise_error
51
+ end
52
+
53
+ it 'freezes copied metadata' do
54
+ ev = operation_evidence.new(
55
+ operation: :chat, status: :supported, source: :provider_implementation, metadata: { note: +'x' }
56
+ )
57
+ expect(ev.metadata).to be_frozen
58
+ expect(ev.metadata[:note]).to be_frozen
59
+ end
60
+
61
+ it 'duplicates and freezes observed_at but rejects a non-Time' do
62
+ now = Time.now
63
+ ev = operation_evidence.new(operation: :chat, status: :supported, source: :probe, observed_at: now)
64
+ expect(ev.observed_at).to be_frozen
65
+ expect(ev.observed_at).not_to equal(now)
66
+ expect { operation_evidence.new(operation: :chat, status: :supported, source: :probe, observed_at: 5) }
67
+ .to raise_error(errors::ValidationError)
68
+ end
69
+ end
70
+
71
+ describe 'CapabilityEvidence' do
72
+ it 'canonicalizes a capability alias' do
73
+ ev = capability_evidence.new(capability: :tool_use, status: :supported, source: :provider_implementation)
74
+ expect(ev.capability).to eq(:tools)
75
+ end
76
+
77
+ it 'canonicalizes the stream_chat capability alias to streaming (distinct from the operation)' do
78
+ ev = capability_evidence.new(capability: :stream_chat, status: :supported, source: :provider_catalog)
79
+ expect(ev.capability).to eq(:streaming)
80
+ end
81
+
82
+ it 'rejects a non-canonical capability' do
83
+ expect { capability_evidence.new(capability: :telepathy, status: :unknown, source: :absent) }
84
+ .to raise_error(errors::ValidationError)
85
+ end
86
+
87
+ it 'requires unknown status for guessed/default_false/failed_probe sources' do
88
+ %i[guessed default_false failed_probe inconclusive_probe].each do |source|
89
+ expect { capability_evidence.new(capability: :vision, status: :unsupported, source: source) }
90
+ .to raise_error(errors::ValidationError)
91
+ expect { capability_evidence.new(capability: :vision, status: :unknown, source: source) }
92
+ .not_to raise_error
93
+ end
94
+ end
95
+ end
96
+
97
+ describe 'ValueEvidence' do
98
+ it 'requires a non-nil value when known and copies/freezes it' do
99
+ ev = value_evidence.new(status: :known, value: 128_000, source: :model_metadata)
100
+ expect(ev).to be_known
101
+ expect(ev.value).to eq(128_000)
102
+ end
103
+
104
+ it 'freezes a known collection value' do
105
+ ev = value_evidence.new(status: :known, value: [512, 1024], source: :provider_catalog)
106
+ expect(ev.value).to be_frozen
107
+ end
108
+
109
+ it 'rejects a known status with a nil value' do
110
+ expect { value_evidence.new(status: :known, value: nil, source: :model_metadata) }
111
+ .to raise_error(errors::ValidationError)
112
+ end
113
+
114
+ it 'requires nil value when unknown' do
115
+ expect { value_evidence.new(status: :unknown, value: 5, source: :absent) }
116
+ .to raise_error(errors::ValidationError)
117
+ expect(value_evidence.new(status: :unknown, source: :absent)).to be_unknown
118
+ end
119
+
120
+ it 'enforces the unknown-only source rule' do
121
+ expect { value_evidence.new(status: :known, value: 10, source: :guessed) }
122
+ .to raise_error(errors::ValidationError)
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/llm/inventory/identity'
5
+
6
+ RSpec.describe Legion::Extensions::Llm::Inventory::Identity do
7
+ let(:errors) { Legion::Extensions::Llm::Inventory::Errors }
8
+ let(:instance_key_class) { Legion::Extensions::Llm::Inventory::Identity::InstanceKey }
9
+
10
+ fixture_path = File.expand_path('../conformance/fixtures/ssot_identity_vectors.json', __dir__)
11
+ fixture = Legion::JSON.load(File.read(fixture_path))
12
+
13
+ def hex(binary)
14
+ binary.b.unpack1('H*')
15
+ end
16
+
17
+ describe 'binding identity vectors (recomputed field by field)' do
18
+ fixture[:vectors].each_with_index do |vector, index|
19
+ context "with vector #{index}: #{vector[:provider_family]}+#{vector[:instance_id]}" do
20
+ let(:instance_key) do
21
+ instance_key_class.new(
22
+ provider_family: vector[:provider_family],
23
+ instance_id: vector[:instance_id]
24
+ )
25
+ end
26
+
27
+ it 'reproduces every framed field as lowercase hex bytes' do
28
+ expect(hex(described_class.length_frame(value: instance_key.provider_family)))
29
+ .to eq(vector[:framed_hex][:provider_family])
30
+ expect(hex(described_class.length_frame(value: instance_key.instance_id)))
31
+ .to eq(vector[:framed_hex][:instance_id])
32
+ expect(hex(described_class.length_frame(value: vector[:provider_native_key])))
33
+ .to eq(vector[:framed_hex][:provider_native_key])
34
+ expect(hex(described_class.length_frame(value: vector[:operation])))
35
+ .to eq(vector[:framed_hex][:operation])
36
+ expect(hex(described_class.length_frame(value: vector[:model])))
37
+ .to eq(vector[:framed_hex][:model])
38
+ end
39
+
40
+ it 'reproduces the offering hash input and offering_id' do
41
+ input = described_class.length_frame(value: instance_key.provider_family) +
42
+ described_class.length_frame(value: instance_key.instance_id) +
43
+ described_class.length_frame(value: vector[:provider_native_key])
44
+ expect(hex(input)).to eq(vector[:offering_hash_input_hex])
45
+
46
+ offering_id = described_class.offering_id(
47
+ instance_key: instance_key, provider_native_key: vector[:provider_native_key]
48
+ )
49
+ expect(offering_id).to eq(vector[:expected_offering_id])
50
+ end
51
+
52
+ it 'reproduces the lane hash input and lane_id' do
53
+ offering_id = described_class.offering_id(
54
+ instance_key: instance_key, provider_native_key: vector[:provider_native_key]
55
+ )
56
+ input = "lane-v1\x00".b +
57
+ described_class.length_frame(value: instance_key.provider_family) +
58
+ described_class.length_frame(value: instance_key.instance_id) +
59
+ described_class.length_frame(value: vector[:operation]) +
60
+ described_class.length_frame(value: vector[:model]) +
61
+ described_class.length_frame(value: offering_id)
62
+ expect(hex(input)).to eq(vector[:lane_hash_input_hex])
63
+
64
+ lane_id = described_class.lane_id(
65
+ instance_key: instance_key,
66
+ operation: vector[:operation].to_sym,
67
+ model: vector[:model],
68
+ offering_id: offering_id
69
+ )
70
+ expect(lane_id).to eq(vector[:expected_lane_id])
71
+ end
72
+
73
+ it 'validates the reproduced offering_id and lane_id' do
74
+ offering_id = vector[:expected_offering_id]
75
+ expect(
76
+ described_class.validate_offering_id!(
77
+ value: offering_id, instance_key: instance_key, provider_native_key: vector[:provider_native_key]
78
+ )
79
+ ).to eq(offering_id)
80
+ expect(
81
+ described_class.validate_lane_id!(
82
+ value: vector[:expected_lane_id], instance_key: instance_key,
83
+ operation: vector[:operation].to_sym, model: vector[:model], offering_id: offering_id
84
+ )
85
+ ).to eq(vector[:expected_lane_id])
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ describe 'independence and delimiter safety' do
92
+ it 'produces distinct offering/lane IDs for the same model on two instance IDs' do
93
+ expect(fixture[:vectors][0][:expected_offering_id]).not_to eq(fixture[:vectors][1][:expected_offering_id])
94
+ expect(fixture[:vectors][0][:expected_lane_id]).not_to eq(fixture[:vectors][1][:expected_lane_id])
95
+ end
96
+
97
+ it 'is tier-independent: tier is never a framing input' do
98
+ vector = fixture[:vectors][0]
99
+ instance_key = instance_key_class.new(provider_family: vector[:provider_family], instance_id: vector[:instance_id])
100
+ %i[local frontier].each do |_tier|
101
+ offering_id = described_class.offering_id(
102
+ instance_key: instance_key, provider_native_key: vector[:provider_native_key]
103
+ )
104
+ expect(offering_id).to eq(vector[:expected_offering_id])
105
+ expect(
106
+ described_class.lane_id(
107
+ instance_key: instance_key, operation: vector[:operation].to_sym,
108
+ model: vector[:model], offering_id: offering_id
109
+ )
110
+ ).to eq(vector[:expected_lane_id])
111
+ end
112
+ end
113
+
114
+ it 'frames punctuation inside a component without delimiter ambiguity' do
115
+ vector = fixture[:vectors][2]
116
+ instance_key = instance_key_class.new(provider_family: vector[:provider_family], instance_id: vector[:instance_id])
117
+ expect(instance_key.instance_id).to eq('prod:eastus')
118
+ expect(
119
+ described_class.offering_id(instance_key: instance_key, provider_native_key: vector[:provider_native_key])
120
+ ).to eq(vector[:expected_offering_id])
121
+ end
122
+ end
123
+
124
+ describe '.length_frame' do
125
+ it 'is decimal byte length + ASCII colon + exact UTF-8 bytes, in binary' do
126
+ frame = described_class.length_frame(value: 'gemma4')
127
+ expect(frame.encoding).to eq(Encoding::BINARY)
128
+ expect(frame).to eq('6:gemma4'.b)
129
+ end
130
+
131
+ it 'counts UTF-8 bytes, not characters' do
132
+ composed = [0x00e9].pack('U*') # single codepoint => two UTF-8 bytes
133
+ expect(described_class.length_frame(value: composed)).to eq('2:'.b + composed.b)
134
+ end
135
+ end
136
+
137
+ describe '.normalize_text' do
138
+ it 'accepts String and Symbol and returns a frozen UTF-8 String' do
139
+ result = described_class.normalize_text(value: :vllm, field: :provider_family)
140
+ expect(result).to eq('vllm')
141
+ expect(result).to be_frozen
142
+ end
143
+
144
+ it 'trims surrounding whitespace' do
145
+ expect(described_class.normalize_text(value: ' h200 ', field: :instance_id)).to eq('h200')
146
+ end
147
+
148
+ it 'rejects empty and whitespace-only values' do
149
+ expect { described_class.normalize_text(value: ' ', field: :f) }.to raise_error(errors::ValidationError)
150
+ expect { described_class.normalize_text(value: '', field: :f) }.to raise_error(errors::ValidationError)
151
+ end
152
+
153
+ it 'rejects invalid UTF-8' do
154
+ expect { described_class.normalize_text(value: "\xFF".b, field: :f) }.to raise_error(errors::ValidationError)
155
+ end
156
+
157
+ it 'rejects a non-NFC (decomposed) string' do
158
+ decomposed = [0x0065, 0x0301].pack('U*') # e + combining acute; not NFC
159
+ expect { described_class.normalize_text(value: decomposed, field: :f) }
160
+ .to raise_error(errors::ValidationError, /NFC/)
161
+ end
162
+
163
+ it 'rejects a non-text type' do
164
+ expect { described_class.normalize_text(value: 5, field: :f) }.to raise_error(errors::ValidationError)
165
+ end
166
+ end
167
+
168
+ describe '.normalize_enum' do
169
+ it 'returns an allowed symbol' do
170
+ expect(described_class.normalize_enum(value: 'local', field: :tier, allowed: %i[local frontier])).to eq(:local)
171
+ end
172
+
173
+ it 'rejects a value not in the allowed set' do
174
+ expect { described_class.normalize_enum(value: :nope, field: :tier, allowed: %i[local]) }
175
+ .to raise_error(errors::ValidationError)
176
+ end
177
+ end
178
+
179
+ describe 'InstanceKey' do
180
+ it 'downcases provider_family to a Symbol and preserves instance_id case' do
181
+ key = instance_key_class.new(provider_family: 'Azure_Foundry', instance_id: 'Prod:EastUS')
182
+ expect(key.provider_family).to eq(:azure_foundry)
183
+ expect(key.instance_id).to eq('Prod:EastUS')
184
+ end
185
+
186
+ it 'exposes to_h with the two fields' do
187
+ key = instance_key_class.new(provider_family: :vllm, instance_id: 'h200')
188
+ expect(key.to_h).to eq(provider_family: :vllm, instance_id: 'h200')
189
+ end
190
+
191
+ it 'is value-equal for equal normalized fields' do
192
+ a = instance_key_class.new(provider_family: 'vllm', instance_id: 'h200')
193
+ b = instance_key_class.new(provider_family: :vllm, instance_id: ' h200 ')
194
+ expect(a).to eq(b)
195
+ expect(a.hash).to eq(b.hash)
196
+ end
197
+
198
+ it 'rejects a provider_family that is not snake-case ASCII' do
199
+ expect { instance_key_class.new(provider_family: '1bad', instance_id: 'x') }
200
+ .to raise_error(errors::ValidationError)
201
+ expect { instance_key_class.new(provider_family: 'has space', instance_id: 'x') }
202
+ .to raise_error(errors::ValidationError)
203
+ end
204
+
205
+ it 'rejects the reserved instance_id "default"' do
206
+ expect { instance_key_class.new(provider_family: 'vllm', instance_id: 'default') }
207
+ .to raise_error(errors::ValidationError)
208
+ end
209
+
210
+ it 'rejects nil fields' do
211
+ expect { instance_key_class.new(provider_family: nil, instance_id: 'x') }
212
+ .to raise_error(errors::ValidationError)
213
+ expect { instance_key_class.new(provider_family: 'vllm', instance_id: nil) }
214
+ .to raise_error(errors::ValidationError)
215
+ end
216
+ end
217
+
218
+ describe 'public surface' do
219
+ it 'exposes exactly the seven documented module methods' do
220
+ expected = %i[
221
+ normalize_text normalize_enum length_frame offering_id lane_id
222
+ validate_offering_id! validate_lane_id!
223
+ ].sort
224
+ expect(described_class.singleton_methods(false).sort).to eq(expected)
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/llm/inventory/immutable_value'
5
+
6
+ RSpec.describe Legion::Extensions::Llm::Inventory::ImmutableValue do
7
+ subject(:copy) { described_class.copy_and_freeze(value: value, field: :meta) }
8
+
9
+ describe 'scalar passthrough' do
10
+ it 'returns nil, booleans, symbols, and integers unchanged' do
11
+ [nil, true, false, :sym, 0, -5, 2**80].each do |scalar|
12
+ expect(described_class.copy_and_freeze(value: scalar, field: :f)).to eq(scalar)
13
+ end
14
+ end
15
+
16
+ it 'accepts finite floats' do
17
+ expect(described_class.copy_and_freeze(value: 1.5, field: :f)).to eq(1.5)
18
+ end
19
+
20
+ it 'rejects NaN' do
21
+ expect { described_class.copy_and_freeze(value: (0.0 / 0.0), field: :f) }
22
+ .to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError, /f is not a finite Float/)
23
+ end
24
+
25
+ it 'rejects positive and negative infinity' do
26
+ expect { described_class.copy_and_freeze(value: (1.0 / 0.0), field: :f) }
27
+ .to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError)
28
+ expect { described_class.copy_and_freeze(value: (-1.0 / 0.0), field: :f) }
29
+ .to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError)
30
+ end
31
+ end
32
+
33
+ describe 'strings' do
34
+ let(:value) { +'hello' }
35
+
36
+ it 'returns a frozen copy' do
37
+ expect(copy).to eq('hello')
38
+ expect(copy).to be_frozen
39
+ end
40
+
41
+ it 'does not retain the original mutable reference' do
42
+ original = +'mutable'
43
+ result = described_class.copy_and_freeze(value: original, field: :f)
44
+ expect(result).not_to equal(original)
45
+ end
46
+
47
+ it 'rejects invalid UTF-8' do
48
+ bad = "\xFF\xFE".b
49
+ expect { described_class.copy_and_freeze(value: bad, field: :f) }
50
+ .to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError, /not valid UTF-8/)
51
+ end
52
+ end
53
+
54
+ describe 'Time' do
55
+ let(:value) { Time.now }
56
+
57
+ it 'duplicates and freezes' do
58
+ original = Time.now
59
+ result = described_class.copy_and_freeze(value: original, field: :f)
60
+ expect(result).to eq(original)
61
+ expect(result).to be_frozen
62
+ expect(result).not_to equal(original)
63
+ end
64
+ end
65
+
66
+ describe 'arrays and hashes' do
67
+ it 'deeply freezes nested arrays preserving order' do
68
+ result = described_class.copy_and_freeze(value: [3, [2, [1]]], field: :f)
69
+ expect(result).to eq([3, [2, [1]]])
70
+ expect(result).to be_frozen
71
+ expect(result[1]).to be_frozen
72
+ expect(result[1][1]).to be_frozen
73
+ end
74
+
75
+ it 'deeply freezes nested hashes and preserves insertion order' do
76
+ result = described_class.copy_and_freeze(value: { b: 1, a: { c: [1] } }, field: :f)
77
+ expect(result.keys).to eq(%i[b a])
78
+ expect(result).to be_frozen
79
+ expect(result[:a]).to be_frozen
80
+ expect(result[:a][:c]).to be_frozen
81
+ end
82
+
83
+ it 'accepts String and Symbol hash keys and freezes String keys' do
84
+ result = described_class.copy_and_freeze(value: { 'x' => 1, y: 2 }, field: :f)
85
+ expect(result.keys.map(&:frozen?)).to all(be(true))
86
+ end
87
+
88
+ it 'rejects a non-String/Symbol hash key' do
89
+ expect { described_class.copy_and_freeze(value: { 1 => :v }, field: :f) }
90
+ .to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError, %r{non-String/Symbol key})
91
+ end
92
+
93
+ it 'does not retain nested mutable references from the original' do
94
+ inner = +'inner'
95
+ original = { list: [inner] }
96
+ result = described_class.copy_and_freeze(value: original, field: :f)
97
+ inner << '-mutated'
98
+ expect(result[:list].first).to eq('inner')
99
+ end
100
+ end
101
+
102
+ describe 'rejections' do
103
+ it 'rejects an unsupported object' do
104
+ expect { described_class.copy_and_freeze(value: Object.new, field: :f) }
105
+ .to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError, /unsupported value type/)
106
+ end
107
+
108
+ it 'rejects a callable' do
109
+ expect { described_class.copy_and_freeze(value: -> { 1 }, field: :f) }
110
+ .to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError, /unsupported value type/)
111
+ end
112
+
113
+ it 'rejects a cyclic array naming the field path' do
114
+ cyclic = []
115
+ cyclic << cyclic
116
+ expect { described_class.copy_and_freeze(value: cyclic, field: :meta) }
117
+ .to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError, /meta.*cyclic/)
118
+ end
119
+
120
+ it 'rejects a cyclic hash' do
121
+ cyclic = {}
122
+ cyclic[:self] = cyclic
123
+ expect { described_class.copy_and_freeze(value: cyclic, field: :meta) }
124
+ .to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError, /cyclic/)
125
+ end
126
+
127
+ it 'allows the same non-cyclic object referenced twice' do
128
+ shared = { k: 1 }
129
+ expect { described_class.copy_and_freeze(value: [shared, shared], field: :f) }.not_to raise_error
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/llm/inventory/probe_coordinator'
5
+
6
+ RSpec.describe Legion::Extensions::Llm::Inventory::ProbeCoordinator do
7
+ inventory = Legion::Extensions::Llm::Inventory
8
+ errors = inventory::Errors
9
+
10
+ let(:instance_key) { inventory::Identity::InstanceKey.new(provider_family: 'vllm', instance_id: 'h200') }
11
+ let(:enqueued) { [] }
12
+ let(:enqueue_result) { [true] }
13
+ let(:enqueue) do
14
+ lambda { |request:|
15
+ enqueued << request
16
+ enqueue_result.first
17
+ }
18
+ end
19
+ let(:coordinator) { described_class.new(instance_key: instance_key, enqueue: enqueue) }
20
+
21
+ def enqueue_request(revision)
22
+ coordinator.enqueue_probe_request(
23
+ instance_key: instance_key, publisher_token_id: 'ptok:v1:x', unavailable_revision: revision, reason: 'dispatch unavailable'
24
+ )
25
+ end
26
+
27
+ it 'does not invoke enqueue at construction (enqueue-only)' do
28
+ coordinator
29
+ expect(enqueued).to be_empty
30
+ end
31
+
32
+ it 'enqueues a ProbeRequest built from the reported parameters' do
33
+ enqueue_request(1)
34
+ expect(enqueued.size).to eq(1)
35
+ expect(enqueued.first).to be_a(inventory::ProbeRequest)
36
+ expect(enqueued.first.unavailable_revision).to eq(1)
37
+ end
38
+
39
+ it 'rejects a request for a different instance' do
40
+ other = inventory::Identity::InstanceKey.new(provider_family: 'vllm', instance_id: 'helios1')
41
+ expect do
42
+ coordinator.enqueue_probe_request(instance_key: other, publisher_token_id: 'ptok:v1:x', unavailable_revision: 1, reason: 'x')
43
+ end.to raise_error(errors::ValidationError)
44
+ end
45
+
46
+ it 'coalesces duplicate-revision requests to a single enqueue' do
47
+ enqueue_request(5)
48
+ enqueue_request(5)
49
+ expect(enqueued.size).to eq(1)
50
+ expect(coordinator.pending?(unavailable_revision: 5)).to be(true)
51
+ end
52
+
53
+ it 'retains only the greatest pending revision without re-enqueuing' do
54
+ enqueue_request(5)
55
+ enqueue_request(7)
56
+ expect(enqueued.size).to eq(1)
57
+ expect(coordinator.pending?(unavailable_revision: 7)).to be(true)
58
+ expect(coordinator.pending?(unavailable_revision: 5)).to be(false)
59
+ end
60
+
61
+ it 'retains pending and returns false when enqueue fails, without inline readiness' do
62
+ enqueue_result[0] = false
63
+ expect(enqueue_request(3)).to be(false)
64
+ expect(coordinator.pending?(unavailable_revision: 3)).to be(true)
65
+ end
66
+
67
+ describe 'single flight and post-finish re-enqueue' do
68
+ it 'admits one flight, holds newer work, and re-enqueues the greatest pending on finish' do
69
+ enqueue_request(5)
70
+ first = enqueued.last
71
+ expect(coordinator.begin_probe(request: first)).to be(true)
72
+ expect(coordinator.in_flight?).to be(true)
73
+
74
+ # A newer revision arrives during the flight: retained, not enqueued.
75
+ enqueue_request(7)
76
+ expect(enqueued.size).to eq(1)
77
+
78
+ coordinator.finish_probe(request: first)
79
+ expect(coordinator.in_flight?).to be(false)
80
+ expect(enqueued.size).to eq(2)
81
+ expect(enqueued.last.unavailable_revision).to eq(7)
82
+ end
83
+
84
+ it 'rejects a second begin while a flight is active' do
85
+ enqueue_request(5)
86
+ expect(coordinator.begin_probe(request: enqueued.last)).to be(true)
87
+ expect(coordinator.begin_probe(request: nil)).to be(false)
88
+ end
89
+
90
+ it 'supports the cadence (nil) begin/finish form and rejects a mismatched finish form' do
91
+ mismatched = inventory::ProbeRequest.new(
92
+ instance_key: instance_key, publisher_token_id: 'ptok:v1:x', unavailable_revision: 1, reason: 'x'
93
+ )
94
+ expect(coordinator.begin_probe(request: nil)).to be(true)
95
+ expect { coordinator.finish_probe(request: mismatched) }.to raise_error(errors::InvalidTransitionError)
96
+ expect { coordinator.finish_probe(request: nil) }.not_to raise_error
97
+ expect(coordinator.in_flight?).to be(false)
98
+ end
99
+
100
+ it 'only admits a begin for the exact greatest pending request' do
101
+ enqueue_request(5)
102
+ stale = enqueued.last
103
+ enqueue_request(9) # replaces pending; stale is no longer greatest
104
+ expect(coordinator.begin_probe(request: stale)).to be(false)
105
+ end
106
+ end
107
+ end