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,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/llm/inventory/callable_handle'
5
+
6
+ RSpec.describe Legion::Extensions::Llm::Inventory::CallableHandle do
7
+ let(:errors) { Legion::Extensions::Llm::Inventory::Errors }
8
+
9
+ # Minimal callable recording exactly-once disconnect; no registry/provider hooks.
10
+ let(:callable_class) do
11
+ Class.new do
12
+ attr_reader :disconnect_count
13
+
14
+ def initialize
15
+ @disconnect_count = 0
16
+ end
17
+
18
+ def disconnect
19
+ @disconnect_count += 1
20
+ end
21
+ end
22
+ end
23
+
24
+ let(:callable) { callable_class.new }
25
+ let(:handle) { described_class.new(handle_id: 'call:v1:test', callable: callable) }
26
+
27
+ describe 'construction' do
28
+ it 'starts ACTIVE with zero references' do
29
+ expect(handle.state).to eq(:active)
30
+ expect(handle.reference_count).to eq(0)
31
+ expect(handle.handle_id).to eq('call:v1:test')
32
+ end
33
+
34
+ it 'rejects an empty handle_id and a nil callable' do
35
+ expect { described_class.new(handle_id: '', callable: callable) }.to raise_error(errors::ValidationError)
36
+ expect { described_class.new(handle_id: 'call:v1:x', callable: nil) }.to raise_error(errors::ValidationError)
37
+ end
38
+ end
39
+
40
+ describe '#acquire' do
41
+ it 'increments the reference count and returns a lease exposing the exact callable' do
42
+ lease = handle.acquire
43
+ expect(handle.reference_count).to eq(1)
44
+ expect(lease.callable).to equal(callable)
45
+ expect(lease).not_to be_released
46
+ end
47
+
48
+ it 'returns independent leases with distinct ids' do
49
+ a = handle.acquire
50
+ b = handle.acquire
51
+ expect(handle.reference_count).to eq(2)
52
+ expect(a.lease_id).not_to eq(b.lease_id)
53
+ end
54
+
55
+ it 'does not disconnect while leases are released back (error/cancellation/stream-close simulation)' do
56
+ lease = handle.acquire
57
+ lease.release
58
+ expect(lease).to be_released
59
+ expect(handle.reference_count).to eq(0)
60
+ expect(callable.disconnect_count).to eq(0)
61
+ expect(handle.state).to eq(:active)
62
+ end
63
+ end
64
+
65
+ describe '#retire' do
66
+ it 'disposes immediately and disconnects once when idle' do
67
+ expect(handle.retire).to eq(:disposed)
68
+ expect(handle.state).to eq(:disposed)
69
+ expect(callable.disconnect_count).to eq(1)
70
+ end
71
+
72
+ it 'moves to RETIRING while a lease is outstanding and blocks new acquisition' do
73
+ lease = handle.acquire
74
+ expect(handle.retire).to eq(:retiring)
75
+ expect(handle.state).to eq(:retiring)
76
+ expect { handle.acquire }.to raise_error(errors::StaleCallableError)
77
+ expect(callable.disconnect_count).to eq(0)
78
+
79
+ lease.release
80
+ expect(handle.state).to eq(:disposed)
81
+ expect(callable.disconnect_count).to eq(1)
82
+ end
83
+
84
+ it 'raises CallableDisposedError on acquire after disposal' do
85
+ handle.retire
86
+ expect { handle.acquire }.to raise_error(errors::CallableDisposedError)
87
+ end
88
+
89
+ it 'disconnects exactly once even with multiple leases' do
90
+ a = handle.acquire
91
+ b = handle.acquire
92
+ handle.retire
93
+ a.release
94
+ expect(callable.disconnect_count).to eq(0)
95
+ b.release
96
+ expect(callable.disconnect_count).to eq(1)
97
+ expect(handle.state).to eq(:disposed)
98
+ end
99
+ end
100
+
101
+ describe 'release safety' do
102
+ it 'raises on duplicate release and does not decrement twice' do
103
+ a = handle.acquire
104
+ b = handle.acquire
105
+ a.release
106
+ expect { a.release }.to raise_error(errors::InvalidTransitionError)
107
+ expect(handle.reference_count).to eq(1)
108
+ b.release
109
+ expect(handle.reference_count).to eq(0)
110
+ end
111
+ end
112
+
113
+ describe 'disconnect failure' do
114
+ let(:callable_class) do
115
+ Class.new do
116
+ def disconnect
117
+ raise StandardError, 'boom'
118
+ end
119
+ end
120
+ end
121
+
122
+ it 'still becomes DISPOSED and returns :disposed_with_error without raising' do
123
+ expect(handle.retire).to eq(:disposed_with_error)
124
+ expect(handle.state).to eq(:disposed)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,138 @@
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
+
96
+ it 'never lets config/override sources carry :supported (enable_* is consumed router-side, not published)' do
97
+ %i[model_override instance_override provider_override].each do |source|
98
+ expect { capability_evidence.new(capability: :tools, status: :supported, source: source) }
99
+ .to raise_error(errors::ValidationError)
100
+ expect { capability_evidence.new(capability: :thinking, status: :supported, source: source) }
101
+ .to raise_error(errors::ValidationError)
102
+ expect { operation_evidence.new(operation: :chat, status: :supported, source: source) }
103
+ .to raise_error(errors::ValidationError)
104
+ expect { capability_evidence.new(capability: :tools, status: :unknown, source: source) }
105
+ .not_to raise_error
106
+ end
107
+ end
108
+ end
109
+
110
+ describe 'ValueEvidence' do
111
+ it 'requires a non-nil value when known and copies/freezes it' do
112
+ ev = value_evidence.new(status: :known, value: 128_000, source: :model_metadata)
113
+ expect(ev).to be_known
114
+ expect(ev.value).to eq(128_000)
115
+ end
116
+
117
+ it 'freezes a known collection value' do
118
+ ev = value_evidence.new(status: :known, value: [512, 1024], source: :provider_catalog)
119
+ expect(ev.value).to be_frozen
120
+ end
121
+
122
+ it 'rejects a known status with a nil value' do
123
+ expect { value_evidence.new(status: :known, value: nil, source: :model_metadata) }
124
+ .to raise_error(errors::ValidationError)
125
+ end
126
+
127
+ it 'requires nil value when unknown' do
128
+ expect { value_evidence.new(status: :unknown, value: 5, source: :absent) }
129
+ .to raise_error(errors::ValidationError)
130
+ expect(value_evidence.new(status: :unknown, source: :absent)).to be_unknown
131
+ end
132
+
133
+ it 'enforces the unknown-only source rule' do
134
+ expect { value_evidence.new(status: :known, value: 10, source: :guessed) }
135
+ .to raise_error(errors::ValidationError)
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,280 @@
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 three fields (physical_id nil by default)' 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', physical_id: nil)
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
+
217
+ it 'keeps the two-argument constructor working (physical_id defaults to nil)' do
218
+ key = instance_key_class.new(provider_family: :vllm, instance_id: 'apollo')
219
+ expect(key.physical_id).to be_nil
220
+ end
221
+
222
+ describe 'physical_id (secondary, not identity)' do
223
+ it 'normalizes like identity text: trims, NFC, frozen String' do
224
+ key = instance_key_class.new(provider_family: :vllm, instance_id: 'apollo', physical_id: ' 10.0.0.1:8000/ak:abc123 ')
225
+ expect(key.physical_id).to eq('10.0.0.1:8000/ak:abc123')
226
+ expect(key.physical_id).to be_frozen
227
+ end
228
+
229
+ it 'preserves the physical id for diagnostics while identity stays the config name' do
230
+ key = instance_key_class.new(
231
+ provider_family: :vllm, instance_id: 'apollo', physical_id: '10.0.0.1:8000/ak:abc123'
232
+ )
233
+ expect(key.instance_id).to eq('apollo')
234
+ expect(key.physical_id).to eq('10.0.0.1:8000/ak:abc123')
235
+ end
236
+
237
+ it 'excludes physical_id from equality and hashing' do
238
+ bare = instance_key_class.new(provider_family: :vllm, instance_id: 'apollo')
239
+ with_physical = instance_key_class.new(
240
+ provider_family: :vllm, instance_id: 'apollo', physical_id: '10.0.0.1:8000/ak:abc123'
241
+ )
242
+ other_physical = instance_key_class.new(
243
+ provider_family: :vllm, instance_id: 'apollo', physical_id: '10.0.0.9:9000'
244
+ )
245
+ expect(bare).to eq(with_physical)
246
+ expect(bare.hash).to eq(with_physical.hash)
247
+ expect(with_physical).to eq(other_physical)
248
+ expect({ bare => :found }[with_physical]).to eq(:found)
249
+ end
250
+
251
+ it 'does not reserve "default" for physical_id (only instance_id is reserved)' do
252
+ key = instance_key_class.new(provider_family: :vllm, instance_id: 'apollo', physical_id: 'default')
253
+ expect(key.physical_id).to eq('default')
254
+ end
255
+
256
+ it 'rejects a blank or non-text physical_id' do
257
+ expect { instance_key_class.new(provider_family: 'vllm', instance_id: 'x', physical_id: ' ') }
258
+ .to raise_error(errors::ValidationError)
259
+ expect { instance_key_class.new(provider_family: 'vllm', instance_id: 'x', physical_id: 5) }
260
+ .to raise_error(errors::ValidationError)
261
+ end
262
+
263
+ it 'still distinguishes distinct config names on the same physical endpoint' do
264
+ apollo = instance_key_class.new(provider_family: :ollama, instance_id: 'apollo', physical_id: 'localhost:11434')
265
+ apollo_embed = instance_key_class.new(provider_family: :ollama, instance_id: 'apollo-embed', physical_id: 'localhost:11434')
266
+ expect(apollo).not_to eq(apollo_embed)
267
+ end
268
+ end
269
+ end
270
+
271
+ describe 'public surface' do
272
+ it 'exposes exactly the seven documented module methods' do
273
+ expected = %i[
274
+ normalize_text normalize_enum length_frame offering_id lane_id
275
+ validate_offering_id! validate_lane_id!
276
+ ].sort
277
+ expect(described_class.singleton_methods(false).sort).to eq(expected)
278
+ end
279
+ end
280
+ 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