lex-llm 0.7.3 → 0.8.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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +52 -67
  8. data/lib/legion/extensions/llm/canonical/params.rb +59 -33
  9. data/lib/legion/extensions/llm/canonical/request.rb +67 -54
  10. data/lib/legion/extensions/llm/canonical/response.rb +61 -76
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
  13. data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
  14. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  15. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  16. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  17. data/lib/legion/extensions/llm/canonical.rb +6 -5
  18. data/lib/legion/extensions/llm/configuration.rb +40 -10
  19. data/lib/legion/extensions/llm/connection.rb +8 -30
  20. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  21. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  22. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  23. data/lib/legion/extensions/llm/error.rb +0 -14
  24. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  25. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  26. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  27. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  28. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
  29. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  30. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
  31. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  32. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  33. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  34. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  35. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  36. data/lib/legion/extensions/llm/inventory/records.rb +82 -99
  37. data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
  38. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  39. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
  40. data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
  41. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
  42. data/lib/legion/extensions/llm/provider.rb +192 -315
  43. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  44. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  45. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  46. data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
  47. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  48. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  49. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  50. data/lib/legion/extensions/llm/streaming.rb +50 -35
  51. data/lib/legion/extensions/llm/taxonomies.rb +29 -17
  52. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  53. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  54. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  55. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  56. data/lib/legion/extensions/llm/utils.rb +23 -5
  57. data/lib/legion/extensions/llm/version.rb +1 -1
  58. data/lib/legion/extensions/llm.rb +10 -98
  59. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  60. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  61. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  62. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  63. data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
  64. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  65. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  66. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
  67. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  68. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  69. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  70. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  71. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  72. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  73. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  74. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  75. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
  76. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
  77. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  78. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  79. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  80. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  81. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
  82. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  83. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
  84. data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
  85. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  86. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  87. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  88. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  89. data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
  90. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
  91. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  92. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  93. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
  94. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
  95. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
  96. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  97. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  98. data/spec/legion/extensions/llm/provider_spec.rb +98 -78
  99. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  100. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  101. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  102. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
  103. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  104. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  105. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  106. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  107. data/spec/support/fake_llm_provider.rb +45 -39
  108. data/spec/support/fake_ssot_harness.rb +7 -2
  109. data/spec/support/ssot_registry_helpers.rb +3 -2
  110. metadata +15 -54
  111. data/lib/legion/extensions/llm/agent.rb +0 -366
  112. data/lib/legion/extensions/llm/aliases.json +0 -436
  113. data/lib/legion/extensions/llm/aliases.rb +0 -67
  114. data/lib/legion/extensions/llm/attachment.rb +0 -229
  115. data/lib/legion/extensions/llm/chat.rb +0 -354
  116. data/lib/legion/extensions/llm/chunk.rb +0 -10
  117. data/lib/legion/extensions/llm/content.rb +0 -81
  118. data/lib/legion/extensions/llm/context.rb +0 -33
  119. data/lib/legion/extensions/llm/embedding.rb +0 -33
  120. data/lib/legion/extensions/llm/image.rb +0 -109
  121. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  122. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
  123. data/lib/legion/extensions/llm/message.rb +0 -118
  124. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  125. data/lib/legion/extensions/llm/model/info.rb +0 -286
  126. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  127. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  128. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  129. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  130. data/lib/legion/extensions/llm/model.rb +0 -11
  131. data/lib/legion/extensions/llm/models.json +0 -57313
  132. data/lib/legion/extensions/llm/models.rb +0 -530
  133. data/lib/legion/extensions/llm/models_schema.json +0 -168
  134. data/lib/legion/extensions/llm/moderation.rb +0 -60
  135. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  136. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  137. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  138. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  139. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  140. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  141. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  142. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  143. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  144. data/lib/legion/extensions/llm/thinking.rb +0 -53
  145. data/lib/legion/extensions/llm/tokens.rb +0 -51
  146. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  147. data/lib/legion/extensions/llm/transcription.rb +0 -39
  148. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  149. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  150. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  151. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  152. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  153. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  154. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
  155. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  156. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  157. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  158. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  159. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  160. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  161. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  162. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  163. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -0,0 +1,507 @@
1
+ # frozen_string_literal: true
2
+
3
+ # 09 §2–§4 — boundary (B), fleet (F), and registry (R) conformance shared
4
+ # examples. The kit is the single oracle: provider gems load these and run
5
+ # them against their REAL callable boundary and the shared Registry/Responder.
6
+ #
7
+ # The host spec provides:
8
+ # let(:callable) — a 0.8.0 callable: chat(messages, model:, ...),
9
+ # stream_chat(messages, model:, ...) yielding
10
+ # Canonical::Chunk, count_tokens(messages:, model:)
11
+ # let(:registry) — Legion::Extensions::Llm::Inventory::Registry
12
+ # let(:key) — an InstanceKey for the activated scope
13
+ # let(:lane_id) — a 5-tuple lane id on the activated instance
14
+ # (tier:provider_family:instance_id:type:model)
15
+ # let(:callable_handle) — the activated instance's CallableHandle (B6)
16
+ # let(:activated) — true once the scope is activated (F/R groups)
17
+
18
+ # The kit's single operation-evidence builder: one supported operation, the
19
+ # rest unknown. Kept here so every host resolves the 5-tuple type the same way.
20
+ module KitEvidenceSupport
21
+ def kit_operation_evidence(operation)
22
+ LLM::Taxonomies::OPERATIONS.to_h do |op|
23
+ status, source = op == operation ? %i[supported provider_implementation] : %i[unknown absent]
24
+ [op, INV::OperationEvidence.new(operation: op, status: status, source: source)]
25
+ end
26
+ end
27
+ end
28
+
29
+ LLM = Legion::Extensions::Llm
30
+ CAN = LLM::Canonical
31
+ INV = LLM::Inventory
32
+
33
+ RSpec.shared_examples 'B1 — central canonical enforcement (08 F2)' do
34
+ let(:canonical_messages) { [CAN::Message.build(role: :user, content: 'hi')] }
35
+
36
+ [
37
+ ['a raw Hash message', [{ role: 'user', content: 'x' }]],
38
+ ['a String element', ['not canonical']],
39
+ ['a nil element', [nil]]
40
+ ].each do |label, bad_messages|
41
+ it "chat rejects #{label} with a typed ArgumentError" do
42
+ expect { callable.chat(bad_messages, model: 'm') }
43
+ .to raise_error(ArgumentError, /Canonical::Message/)
44
+ end
45
+
46
+ it "stream_chat rejects #{label} with a typed ArgumentError" do
47
+ # rubocop:disable Lint/EmptyBlock -- the block never runs: enforcement raises first
48
+ expect { callable.stream_chat(bad_messages, model: 'm') { |_c| } }
49
+ .to raise_error(ArgumentError, /Canonical::Message/)
50
+ # rubocop:enable Lint/EmptyBlock
51
+ end
52
+
53
+ it "count_tokens rejects #{label} with a typed ArgumentError" do
54
+ expect { callable.count_tokens(messages: bad_messages, model: 'm') }
55
+ .to raise_error(ArgumentError, /Canonical::Message/)
56
+ end
57
+ end
58
+
59
+ it 'accepts a canonical Array<Canonical::Message>' do
60
+ expect { callable.chat(canonical_messages, model: 'm') }.not_to raise_error
61
+ end
62
+ end
63
+
64
+ # H3: the tools half of the same boundary — one contract, one oracle.
65
+ # A non-empty tools value must be Hash<name, Canonical::ToolDefinition>;
66
+ # Hash/legacy tool values raise before any provider rendering.
67
+ RSpec.shared_examples 'B1b — central canonical tool enforcement (H3)' do
68
+ let(:tool) { CAN::ToolDefinition.build(name: 'get_weather') }
69
+
70
+ it 'chat accepts Hash<name, Canonical::ToolDefinition>' do
71
+ expect do
72
+ callable.chat([CAN::Message.build(role: :user, content: 'hi')], model: 'm',
73
+ tools: { get_weather: tool })
74
+ end.not_to raise_error
75
+ end
76
+
77
+ it 'chat rejects Hash tool values with a typed ArgumentError' do
78
+ expect do
79
+ callable.chat([CAN::Message.build(role: :user, content: 'hi')], model: 'm',
80
+ tools: { get_weather: { name: 'get_weather' } })
81
+ end
82
+ .to raise_error(ArgumentError, /Canonical::ToolDefinition/)
83
+ end
84
+
85
+ it 'chat rejects a non-Hash tools container with a typed ArgumentError' do
86
+ expect do
87
+ callable.chat([CAN::Message.build(role: :user, content: 'hi')], model: 'm',
88
+ tools: [tool])
89
+ end
90
+ .to raise_error(ArgumentError, /Hash<name, Canonical::ToolDefinition>/)
91
+ end
92
+ end
93
+
94
+ RSpec.shared_examples 'B2 — canonical outputs (05 O5, 08 R2)' do
95
+ it 'chat returns a Canonical::Response (asserted by type)' do
96
+ expect(callable.chat([CAN::Message.build(role: :user, content: 'hi')], model: 'm'))
97
+ .to be_a(CAN::Response)
98
+ end
99
+
100
+ it 'stream_chat yields Canonical::Chunk objects ending in exactly one done chunk' do
101
+ chunks = []
102
+ callable.stream_chat([CAN::Message.build(role: :user, content: 'hi')], model: 'm') { |c| chunks << c }
103
+
104
+ expect(chunks.size).to be > 0
105
+ expect(chunks).to all(be_a(CAN::Chunk))
106
+ expect(chunks.count(&:done?)).to eq(1)
107
+ expect(chunks.last).to be_done
108
+ end
109
+ end
110
+
111
+ RSpec.shared_examples 'B3 — operation preservation (PR #189 defect class)' do
112
+ it 'a stream_chat dispatch invokes the stream_chat callable, never chat' do
113
+ chat_invocations = 0
114
+ stream_invocations = 0
115
+ allow(callable).to receive(:chat).and_wrap_original do |original, *args, **kwargs|
116
+ chat_invocations += 1
117
+ original.call(*args, **kwargs)
118
+ end
119
+ allow(callable).to receive(:stream_chat).and_wrap_original do |_original, *_args, &block|
120
+ stream_invocations += 1
121
+ block&.call(CAN::Chunk.done(request_id: nil))
122
+ end
123
+
124
+ # rubocop:disable Lint/EmptyBlock -- the block is the assertion target
125
+ callable.stream_chat([CAN::Message.build(role: :user, content: 'hi')], model: 'm') { |_c| }
126
+ # rubocop:enable Lint/EmptyBlock
127
+
128
+ expect(stream_invocations).to eq(1)
129
+ expect(chat_invocations).to eq(0)
130
+ end
131
+ end
132
+
133
+ RSpec.shared_examples 'B4 — no model re-derivation (PR #45 law)' do
134
+ it 'the Selection-derived model reaches the callable unchanged' do
135
+ response = callable.chat([CAN::Message.build(role: :user, content: 'hi')], model: 'selected-model-x')
136
+ expect(response.model).to eq('selected-model-x')
137
+ end
138
+ end
139
+
140
+ RSpec.shared_examples 'B5 — no weight recomputation (PR #47 defect class)' do
141
+ include KitEvidenceSupport
142
+
143
+ it 'the lane keeps the write-time pair even when settings would compute a different one' do
144
+ lane = registry.snapshot.lanes_for(instance_key: key).first
145
+ expect(lane.base_weight).to eq(100 * 50 * 2 * 1)
146
+
147
+ # A later settings read (e.g. after an operator change) would compute a
148
+ # different pair — the stored lane pair is consumed as-is.
149
+ family = key.provider_family.to_sym
150
+ other_inputs = LLM::Inventory.const_get(:WeightSchema).weight_inputs(
151
+ settings: { extensions: { llm: { family => { weight: 77, instances: { key.instance_id.to_sym => { weight: 77 } } } } },
152
+ llm: { routing: { tier_weights: { local: 100 } } } },
153
+ instance_key: key, model: lane.model, tier: :local,
154
+ operation_evidence: kit_operation_evidence(lane.operation)
155
+ )
156
+ expect(other_inputs).to eq(tier: 100, provider: 77, instance: 77, model_or_offering: 100)
157
+ expect(registry.snapshot.lanes_for(instance_key: key).first.base_weight).to eq(10_000)
158
+ end
159
+ end
160
+
161
+ RSpec.shared_examples 'B6 — zero-weight disable (U4)' do
162
+ it 'Selection validation accepts a zero component (operator disable)' do
163
+ selection = LLM::Routing::Selection.new(
164
+ inventory_generation: 7, lane_id: lane_id, instance_key: instance_key,
165
+ provider_family: instance_key.provider_family, instance_id: instance_key.instance_id,
166
+ model: 'gemma4', operation: :chat, callable_handle: callable_handle,
167
+ publisher_token_id: 'ptok:v1:abc', capability_evidence: {},
168
+ context_evidence: INV::ValueEvidence.new(status: :unknown, source: :absent),
169
+ weight_inputs: { tier: 100, provider: 0, instance: 100, model_or_offering: 100 },
170
+ base_weight: 0, preference_ppm: 1_000_000, effective_weight: 0, rendezvous_score: 1
171
+ )
172
+ expect(selection.base_weight).to eq(0)
173
+ expect(selection.weight_inputs[:provider]).to eq(0)
174
+ end
175
+
176
+ it 'Selection validation still rejects a negative component' do
177
+ expect do
178
+ LLM::Routing::Selection.new(
179
+ inventory_generation: 7, lane_id: lane_id, instance_key: instance_key,
180
+ provider_family: instance_key.provider_family, instance_id: instance_key.instance_id,
181
+ model: 'gemma4', operation: :chat, callable_handle: callable_handle,
182
+ publisher_token_id: 'ptok:v1:abc', capability_evidence: {},
183
+ context_evidence: INV::ValueEvidence.new(status: :unknown, source: :absent),
184
+ weight_inputs: { tier: 100, provider: -1, instance: 100, model_or_offering: 100 },
185
+ base_weight: 0, preference_ppm: 1_000_000, effective_weight: 0, rendezvous_score: 1
186
+ )
187
+ end.to raise_error(INV::Errors::ValidationError, /nonnegative/)
188
+ end
189
+ end
190
+
191
+ RSpec.shared_examples 'B7 — health authority (RULES 9)' do
192
+ it 'transient provider outcomes do not change instance availability' do
193
+ outcome = callable.normalize_dispatch_error(error: LLM::RateLimitError.new(nil, 'slow down'))
194
+ expect(outcome.kind).to eq(:rate_limited)
195
+ expect(outcome.kind).not_to eq(:instance_unavailable)
196
+ expect(registry.snapshot.instance(instance_key: key).availability.state).to eq(:available)
197
+ end
198
+
199
+ it 'only the authoritative dispatch path marks the instance unavailable' do
200
+ health_key = INV::Identity::InstanceKey.new(provider_family: key.provider_family, instance_id: 'health-b')
201
+ health_coordinator = LLM::Inventory::ProbeCoordinator.new(instance_key: health_key, enqueue: ->(**) { true })
202
+ token = registry.claim_instance(instance_key: health_key, callable: Object.new, probe_request_handle: health_coordinator)
203
+ probe = registry.readiness_probe_started(instance_key: health_key, publisher_token: token)
204
+ registry.activate_instance_snapshot(publisher_token: token, instance_key: health_key,
205
+ offerings: drafts, sequence: 0, probe_token: probe)
206
+ record = registry.snapshot.instance(instance_key: health_key)
207
+ registry.dispatch_instance_unavailable(
208
+ instance_key: health_key, publisher_token_id: record.publisher_token_id, reason: 'node down'
209
+ )
210
+ expect(registry.snapshot.instance(instance_key: health_key).availability.state).to eq(:unavailable)
211
+ end
212
+ end
213
+
214
+ RSpec.shared_examples 'B8 — exact execution never downgrades (06 P2/W1)' do
215
+ it 'a fleet envelope without the exact marker is rejected' do
216
+ legacy_envelope = LLM::Fleet::FleetEnvelope.new(
217
+ data: exact_envelope_data.except(:execution_contract, :offering_id)
218
+ )
219
+ expect { LLM::Fleet::ProviderResponder.check_envelope!(legacy_envelope, provider_family: key.provider_family.to_s) }
220
+ .to raise_error(ArgumentError, /execution_contract is required/)
221
+ end
222
+
223
+ it 'the provider-object dispatch path does not exist' do
224
+ expect(LLM::Fleet::ProviderResponder).not_to respond_to(:build_provider)
225
+ expect(LLM::Fleet::WorkerExecution).not_to respond_to(:dispatch_local_provider!)
226
+ end
227
+ end
228
+
229
+ RSpec.shared_examples 'B9 — no silent defaults (06 W5)' do
230
+ it 'a missing per-operation param raises, never a default' do
231
+ expect { LLM::Fleet::WorkerExecution.call(envelope: exact_envelope_data.merge(params: {}), registry: registry) }
232
+ .to raise_error(LLM::Fleet::ContractError, /requires the messages param/)
233
+ end
234
+
235
+ it 'a params-supplied model raises — the Selection model is the only model' do
236
+ bad = exact_envelope_data.merge(params: { messages: [], model: 'other' })
237
+ expect { LLM::Fleet::WorkerExecution.call(envelope: bad, registry: registry) }
238
+ .to raise_error(LLM::Fleet::ContractError, /must not contain model/)
239
+ end
240
+ end
241
+
242
+ RSpec.shared_examples 'F1 — envelope round-trip (06 E2)' do
243
+ it 'FleetRequest encode → JSON → parse carries every required field' do
244
+ request = LLM::Transport::Messages::FleetRequest.new(**valid_request_options)
245
+ wire = Legion::JSON.load(Legion::JSON.dump(request.message))
246
+
247
+ LLM::Fleet::Protocol::REQUIRED_FIELDS.each do |field|
248
+ expect(wire).to have_key(field)
249
+ # JSON normalizes the wire: compare in wire form (symbols become strings).
250
+ expect(wire[field].is_a?(Hash) ? wire[field].transform_keys(&:to_s) : wire[field].to_s)
251
+ .to eq(request.message[field].is_a?(Hash) ? request.message[field].transform_keys(&:to_s) : request.message[field].to_s)
252
+ end
253
+ end
254
+ end
255
+
256
+ RSpec.shared_examples 'F2 — rehydration identity (06 W4, E01)' do
257
+ it 'JSON-round-tripped wire messages rehydrate to Data-equal Canonical::Message' do
258
+ original = CAN::Message.build(
259
+ role: :user, content: 'hi', cache_control: { type: :ephemeral },
260
+ tool_calls: [CAN::ToolCall.build(name: 'f', id: 'c1', arguments: { a: 1 })]
261
+ )
262
+ wire = Legion::JSON.load(Legion::JSON.dump([original.to_h]))
263
+ rehydrated = LLM::Fleet::WorkerExecution.rehydrate_wire_messages(wire)
264
+
265
+ expect(rehydrated.size).to eq(1)
266
+ rehydrated_message = rehydrated.first
267
+ expect(rehydrated_message).to be_a(CAN::Message)
268
+ expect(rehydrated_message.id).to eq(original.id)
269
+ expect(rehydrated_message.role).to eq(:user)
270
+ expect(rehydrated_message.content).to eq('hi')
271
+ expect(rehydrated_message.tool_calls.first.name).to eq('f')
272
+ expect(rehydrated_message.tool_calls.first.arguments).to eq(a: 1)
273
+ # E01: cache_control survives the full JSON wire round-trip.
274
+ expect(rehydrated_message.cache_control).to eq(type: 'ephemeral')
275
+ end
276
+
277
+ it 'a raw Hash message can NOT reach a callable — rehydration raises' do
278
+ expect { LLM::Fleet::WorkerExecution.rehydrate_wire_messages(['not a message']) }
279
+ .to raise_error(LLM::Fleet::ContractError, /serialized Canonical::Message/)
280
+ end
281
+ end
282
+
283
+ RSpec.shared_examples 'F3 — signing law (06 S3)' do
284
+ it 'verifies exact claims unconditionally — a missing marker claim is a TokenError' do
285
+ expect do
286
+ LLM::Fleet::TokenValidator.validate_exact_execution_claims!({}, { operation: 'chat' })
287
+ end.to raise_error(LLM::Fleet::TokenError, /missing signed execution_contract/)
288
+ end
289
+
290
+ it 'a tampered offering_id claim is a TokenError' do
291
+ claims = {
292
+ execution_contract: LLM::Fleet::Protocol::EXACT_EXECUTION_CONTRACT,
293
+ offering_id: 'local:other:other:inference:other'
294
+ }
295
+ expect do
296
+ LLM::Fleet::TokenValidator.validate_exact_execution_claims!(claims, exact_envelope_data)
297
+ end.to raise_error(LLM::Fleet::TokenError, /offering_id claim mismatch/)
298
+ end
299
+ end
300
+
301
+ RSpec.shared_examples 'F4 — fencing (07 §3)' do
302
+ let(:fence_key) { INV::Identity::InstanceKey.new(provider_family: 'kit', instance_id: 'fence') }
303
+ let(:fence_coordinator) do
304
+ LLM::Inventory::ProbeCoordinator.new(instance_key: fence_key, enqueue: ->(**) { true })
305
+ end
306
+
307
+ it 'a superseded publisher token is fenced' do
308
+ registry.reset!
309
+ token1 = registry.claim_instance(instance_key: fence_key, callable: Object.new, probe_request_handle: fence_coordinator)
310
+ registry.claim_instance(instance_key: fence_key, callable: Object.new, probe_request_handle: fence_coordinator)
311
+ expect { registry.readiness_probe_started(instance_key: fence_key, publisher_token: token1) }
312
+ .to raise_error(INV::Errors::FencedPublisherError)
313
+ end
314
+
315
+ it 'a sequence at or below the claim baseline is rejected (StaleSequenceError)' do
316
+ first = registry.claim_instance(instance_key: fence_key, callable: Object.new, probe_request_handle: fence_coordinator)
317
+ probe = registry.readiness_probe_started(instance_key: fence_key, publisher_token: first)
318
+ expect do
319
+ registry.activate_instance_snapshot(publisher_token: first, instance_key: fence_key,
320
+ offerings: drafts, sequence: -1, probe_token: probe)
321
+ end.to raise_error(INV::Errors::StaleSequenceError)
322
+ end
323
+ end
324
+
325
+ RSpec.shared_examples 'F5 — response envelope (06 E3/E4)' do
326
+ it 'carries the serialized Canonical::Response and never the thinking member' do
327
+ thinking_callable = Class.new do
328
+ def chat(_messages, model:, **_rest)
329
+ CAN::Response.build(
330
+ text: 'done', model: model, stop_reason: :end_turn,
331
+ thinking: CAN::Thinking.build(content: 'secret-reasoning')
332
+ )
333
+ end
334
+ end.new
335
+ thinking_key = INV::Identity::InstanceKey.new(provider_family: 'fake_llm', instance_id: 'thinking')
336
+ token = registry.claim_instance(instance_key: thinking_key, callable: thinking_callable,
337
+ probe_request_handle: LLM::Inventory::ProbeCoordinator.new(
338
+ instance_key: thinking_key, enqueue: ->(**) { true }
339
+ ))
340
+ probe = registry.readiness_probe_started(instance_key: thinking_key, publisher_token: token)
341
+ thinking_lane = INV::Identity.compose_lane_id(
342
+ tier: :local, provider_family: thinking_key.provider_family, instance_id: thinking_key.instance_id,
343
+ type: LLM::Taxonomies.lane_type_for(operation: :chat), model: 'gemma4'
344
+ )
345
+ registry.activate_instance_snapshot(publisher_token: token, instance_key: thinking_key,
346
+ offerings: [
347
+ INV::OfferingDraft.new(
348
+ provider_native_key: 'gemma4', model: 'gemma4', tier: :local,
349
+ operation_evidence: operation_evidence_map,
350
+ context_evidence: unknown_value, max_output_evidence: unknown_value,
351
+ embedding_dimensions_evidence: unknown_value,
352
+ model_revision_evidence: unknown_value, tokenizer_evidence: unknown_value,
353
+ publication_source: :provider_catalog
354
+ )
355
+ ], sequence: 0, probe_token: probe)
356
+
357
+ published_args = nil
358
+ response_double = instance_double(LLM::Transport::Messages::FleetResponse, publish: true)
359
+ allow(LLM::Transport::Messages::FleetResponse).to receive(:new) do |*args|
360
+ published_args = args
361
+ response_double
362
+ end
363
+
364
+ LLM::Fleet::ProviderResponder.call(
365
+ payload: exact_envelope_data.merge(provider_instance: 'thinking', offering_id: thinking_lane),
366
+ provider_family: exact_envelope_data[:provider],
367
+ registry: registry
368
+ )
369
+
370
+ expect(published_args.first[:response]).to include(text: 'done')
371
+ expect(published_args.first[:response]).not_to have_key(:thinking)
372
+ expect(response_double).to have_received(:publish)
373
+ end
374
+ end
375
+
376
+ RSpec.shared_examples 'F6 — contract errors (06 §5)' do
377
+ {
378
+ 'a non-Hash wire message' => ->(data) { data.merge(params: { messages: ['nope'] }) },
379
+ 'duplicate param spellings' => ->(data) { data.merge(params: { 'messages' => [], :messages => [] }) },
380
+ 'an unknown operation' => ->(data) { data.merge(operation: 'teleport') }
381
+ }.each do |label, mutator|
382
+ it "#{label} is a Fleet::ContractError, never a silent substitute" do
383
+ expect { LLM::Fleet::WorkerExecution.call(envelope: mutator.call(exact_envelope_data), registry: registry) }
384
+ .to raise_error(LLM::Fleet::ContractError)
385
+ end
386
+ end
387
+ end
388
+
389
+ RSpec.shared_examples 'F7 — retryability (06 F6)' do
390
+ it 'contract, policy, and auth errors are never retryable' do
391
+ responder = LLM::Fleet::ProviderResponder
392
+ expect(responder.retryable_error?(LLM::Fleet::ContractError.new('x'))).to be(false)
393
+ expect(responder.retryable_error?(LLM::Fleet::WorkerExecution::PolicyError.new('x'))).to be(false)
394
+ expect(responder.retryable_error?(LLM::Fleet::TokenError.new('x'))).to be(false)
395
+ expect(responder.retryable_error?(INV::Errors::ExactOfferingMismatchError.new('x'))).to be(false)
396
+ end
397
+
398
+ it 'transient kinds are retryable' do
399
+ responder = LLM::Fleet::ProviderResponder
400
+ expect(responder.retryable_error?(LLM::RateLimitError.new(nil, 'x'))).to be(true)
401
+ expect(responder.retryable_error?(LLM::OverloadedError.new(nil, 'x'))).to be(true)
402
+ expect(responder.retryable_error?(Timeout::Error.new('x'))).to be(true)
403
+ end
404
+ end
405
+
406
+ RSpec.shared_examples 'R1 — state machine (07 §3)' do
407
+ let(:r1_key) { INV::Identity::InstanceKey.new(provider_family: 'kit', instance_id: 'r1') }
408
+ let(:r1_coordinator) { LLM::Inventory::ProbeCoordinator.new(instance_key: r1_key, enqueue: ->(**) { true }) }
409
+
410
+ it 'activate without a claim is invalid' do
411
+ registry.reset!
412
+ expect do
413
+ registry.activate_instance_snapshot(publisher_token: 'forged', instance_key: r1_key,
414
+ offerings: drafts, sequence: 0)
415
+ end.to raise_error(ArgumentError)
416
+ end
417
+
418
+ it 'a claim without activation stays initializing (no active instance)' do
419
+ registry.claim_instance(instance_key: r1_key, callable: Object.new, probe_request_handle: r1_coordinator)
420
+ expect(registry.snapshot.instance(instance_key: r1_key)).to be_nil
421
+ expect(registry.snapshot.publication_status(instance_key: r1_key).state).to eq(:initializing)
422
+ end
423
+ end
424
+
425
+ RSpec.shared_examples 'R2 — identity (07 §1, 5-tuple law)' do
426
+ it 'composes the same 5 tuple deterministically from the same fields' do
427
+ lane = INV::Identity.compose_lane_id(
428
+ tier: :local, provider_family: key.provider_family, instance_id: key.instance_id,
429
+ type: LLM::Taxonomies.lane_type_for(operation: :chat), model: 'gemma4'
430
+ )
431
+ expect(lane).to eq(
432
+ INV::Identity.compose_lane_id(
433
+ tier: :local, provider_family: key.provider_family, instance_id: key.instance_id,
434
+ type: LLM::Taxonomies.lane_type_for(operation: :chat), model: 'gemma4'
435
+ )
436
+ )
437
+ expect(INV::Identity.validate_lane_id!(value: lane)).to eq(lane)
438
+ end
439
+
440
+ it 'round-trips a colon-containing model through the bounded parse' do
441
+ lane = INV::Identity.compose_lane_id(
442
+ tier: :local, provider_family: key.provider_family, instance_id: key.instance_id,
443
+ type: :inference, model: 'model:tag:0'
444
+ )
445
+ expect(INV::Identity.parse_lane_id(lane).last).to eq('model:tag:0')
446
+ end
447
+
448
+ it 'rejects a value that is not a 5-tuple' do
449
+ expect do
450
+ INV::Identity.validate_lane_id!(value: 'not-a-five-tuple')
451
+ end.to raise_error(INV::Errors::ValidationError, /5 parts/)
452
+ end
453
+ end
454
+
455
+ RSpec.shared_examples 'R3 — snapshot law (07 §5)' do
456
+ it 'lookups return nil for absent scopes (no synthesized defaults)' do
457
+ absent = INV::Identity::InstanceKey.new(provider_family: 'vllm', instance_id: 'nowhere')
458
+ expect(registry.snapshot.instance(instance_key: absent)).to be_nil
459
+ end
460
+
461
+ it 'is generation-stable across later mutations' do
462
+ snapshot_before = registry.snapshot
463
+ before_record = snapshot_before.instance(instance_key: key)
464
+ expect(before_record).not_to be_nil
465
+
466
+ r3_key = INV::Identity::InstanceKey.new(provider_family: 'kit', instance_id: 'r3')
467
+ r3_coordinator = LLM::Inventory::ProbeCoordinator.new(instance_key: r3_key, enqueue: ->(**) { true })
468
+ registry.claim_instance(instance_key: r3_key, callable: Object.new, probe_request_handle: r3_coordinator)
469
+ expect(snapshot_before.generation).not_to eq(registry.snapshot.generation)
470
+ # The captured snapshot still serves its own generation's records.
471
+ expect(snapshot_before.instance(instance_key: key)).to eq(before_record)
472
+ end
473
+ end
474
+
475
+ RSpec.shared_examples 'R4 — weight law (07 §4)' do
476
+ it 'accepts zero (operator disable), rejects non-Integer, and requires base == product' do
477
+ schema = LLM::Inventory.const_get(:WeightSchema)
478
+ zero = { tier: 100, provider: 0, instance: 100, model_or_offering: 100 }
479
+ expect(schema.base_weight(zero)).to eq(0)
480
+
481
+ expect do
482
+ INV::RecordSupport.validated_weight_pair(
483
+ weight_inputs: { tier: '100', provider: 0, instance: 100, model_or_offering: 100 }, base_weight: 0
484
+ )
485
+ end.to raise_error(INV::Errors::ValidationError)
486
+
487
+ expect do
488
+ INV::RecordSupport.validated_weight_pair(
489
+ weight_inputs: zero, base_weight: 1
490
+ )
491
+ end.to raise_error(INV::Errors::ValidationError, /product/)
492
+ end
493
+ end
494
+
495
+ RSpec.shared_examples 'R5 — callable lifecycle (07 §6)' do
496
+ it 'acquires a lease exposing the exact captured callable and releases it' do
497
+ handle = registry.snapshot.instance(instance_key: key).callable_handle
498
+ lease = registry.acquire(callable_handle: handle)
499
+ expect(lease.callable).to be(callable)
500
+ lease.release
501
+ end
502
+
503
+ it 'an unknown handle is rejected' do
504
+ expect { registry.acquire(callable_handle: Object.new) }
505
+ .to raise_error(INV::Errors::UnknownCallableError)
506
+ end
507
+ end
@@ -78,7 +78,7 @@ RSpec.shared_examples 'an SSOT v3 provider adapter' do
78
78
  lane_a = snapshot.lanes_for(instance_key: a[:key]).first
79
79
  lane_b = snapshot.lanes_for(instance_key: b[:key]).first
80
80
  expect(lane_a.lane_id).not_to eq(lane_b.lane_id)
81
- expect(lane_a.offering_id).not_to eq(lane_b.offering_id)
81
+ expect(lane_a.lane_id).to match(/\A[a-z_]+:[a-z_]+:[^:]+:[a-z_]+:.+\z/)
82
82
  end
83
83
 
84
84
  it 'exposes no selector-visible model or callable before startup readiness' do
@@ -107,22 +107,23 @@ RSpec.shared_examples 'an SSOT v3 provider adapter' do
107
107
  it 'supports complete refresh, complete empty, and failed-refresh retention' do
108
108
  context = bring_up(configs[0])
109
109
  context[:publisher].replace_instance_snapshot(instance_id: context[:key].instance_id, publisher_token: context[:token], offerings: context[:drafts], sequence: 1)
110
- expect(registry.snapshot.offerings_for(instance_key: context[:key]).size).to eq(1)
110
+ expect(registry.snapshot.lanes_for(instance_key: context[:key]).size).to eq(1)
111
111
  context[:publisher].replace_instance_snapshot(instance_id: context[:key].instance_id, publisher_token: context[:token], offerings: [], sequence: 2)
112
- expect(registry.snapshot.offerings_for(instance_key: context[:key])).to be_empty
112
+ expect(registry.snapshot.lanes_for(instance_key: context[:key])).to be_empty
113
113
  expect do
114
114
  context[:publisher].replace_instance_snapshot(instance_id: context[:key].instance_id, publisher_token: context[:token], offerings: [], sequence: 2)
115
115
  end.to raise_error(Legion::Extensions::Llm::Inventory::Errors::StaleSequenceError)
116
116
  end
117
117
 
118
- it 'preserves offering and lane identity across a tier-only republication' do
118
+ it 'reflects a tier change in the lane id (tier is the 5 tuple first part)' do
119
119
  context = bring_up(configs[0], tier: :local)
120
- before_offering = registry.snapshot.offerings_for(instance_key: context[:key]).first.offering_id
121
120
  before_lane = registry.snapshot.lanes_for(instance_key: context[:key]).first.lane_id
121
+ expect(before_lane).to start_with('local:')
122
122
  frontier_drafts = ssot_harness.build_offering_drafts(instance_config: configs[0], callable: context[:callable], tier: :frontier)
123
123
  context[:publisher].replace_instance_snapshot(instance_id: context[:key].instance_id, publisher_token: context[:token], offerings: frontier_drafts, sequence: 1)
124
- expect(registry.snapshot.offerings_for(instance_key: context[:key]).first.offering_id).to eq(before_offering)
125
- expect(registry.snapshot.lanes_for(instance_key: context[:key]).first.lane_id).to eq(before_lane)
124
+ after_lane = registry.snapshot.lanes_for(instance_key: context[:key]).first.lane_id
125
+ expect(after_lane).to start_with('frontier:')
126
+ expect(after_lane).not_to eq(before_lane)
126
127
  end
127
128
 
128
129
  it 'refuses recovery from a stale probe started before the failure' do
@@ -154,11 +155,11 @@ RSpec.shared_examples 'an SSOT v3 provider adapter' do
154
155
  it 'executes an exact fleet request against only the captured callable' do
155
156
  allow(Legion::Extensions::Llm::Fleet::WorkerExecution).to receive_messages(validate_identity!: true, validate_idempotency!: nil)
156
157
  context = bring_up(configs[0])
157
- offering = registry.snapshot.offerings_for(instance_key: context[:key]).first
158
+ lane = registry.snapshot.lanes_for(instance_key: context[:key]).first
158
159
  envelope = {
159
160
  execution_contract: Legion::Extensions::Llm::Fleet::Protocol::EXACT_EXECUTION_CONTRACT,
160
- offering_id: offering.offering_id, provider: ssot_harness.provider_family.to_s,
161
- provider_instance: context[:key].instance_id, model: offering.model, operation: 'chat', params: { messages: [] }
161
+ offering_id: lane.lane_id, provider: ssot_harness.provider_family.to_s,
162
+ provider_instance: context[:key].instance_id, model: lane.model, operation: 'chat', params: { messages: [] }
162
163
  }
163
164
  Legion::Extensions::Llm::Fleet::WorkerExecution.call(envelope: envelope, registry: registry)
164
165
  expect(ssot_harness.inference_call_count(callable: context[:callable])).to eq(1)
@@ -410,26 +410,25 @@ RSpec.describe Legion::Extensions::Llm::CredentialSources do
410
410
  end
411
411
  end
412
412
 
413
- it 'returns empty hash for missing file' do
414
- result = mod.send(:read_json, '/nonexistent/file.json')
415
- expect(result).to eq({})
413
+ it 'raises a configuration error for a missing file (O11 fail-closed)' do
414
+ expect { mod.send(:read_json, '/nonexistent/file.json') }
415
+ .to raise_error(Legion::Extensions::Llm::ConfigurationError, /credential file is missing/)
416
416
  end
417
417
 
418
- it 'returns empty hash for invalid JSON' do
418
+ it 'raises for invalid JSON (O11 fail-closed)' do
419
419
  Dir.mktmpdir do |dir|
420
420
  path = File.join(dir, 'bad.json')
421
421
  File.write(path, 'not json{{{')
422
- result = mod.send(:read_json, path)
423
- expect(result).to eq({})
422
+ expect { mod.send(:read_json, path) }.to raise_error(Legion::JSON::ParseError)
424
423
  end
425
424
  end
426
425
 
427
- it 'returns empty hash for empty file' do
426
+ it 'raises a configuration error for an empty file (O11 fail-closed)' do
428
427
  Dir.mktmpdir do |dir|
429
428
  path = File.join(dir, 'empty.json')
430
429
  File.write(path, '')
431
- result = mod.send(:read_json, path)
432
- expect(result).to eq({})
430
+ expect { mod.send(:read_json, path) }
431
+ .to raise_error(Legion::Extensions::Llm::ConfigurationError, /credential file is empty/)
433
432
  end
434
433
  end
435
434
  end
@@ -457,12 +456,12 @@ RSpec.describe Legion::Extensions::Llm::CredentialSources do
457
456
  expect(mod.send(:token_valid?, token)).to be true
458
457
  end
459
458
 
460
- it 'returns true on parse error (malformed token)' do
461
- expect(mod.send(:token_valid?, 'not.a.jwt')).to be true
459
+ it 'returns false on parse error (O11 fail-closed — no benefit of the doubt)' do
460
+ expect(mod.send(:token_valid?, 'not.a.jwt')).to be false
462
461
  end
463
462
 
464
- it 'returns true for nil token' do
465
- expect(mod.send(:token_valid?, nil)).to be true
463
+ it 'returns false for nil token (O11 fail-closed)' do
464
+ expect(mod.send(:token_valid?, nil)).to be false
466
465
  end
467
466
  end
468
467
  end
@@ -71,17 +71,7 @@ RSpec.describe Legion::Extensions::Llm::Error do
71
71
  end
72
72
  end
73
73
 
74
- describe Legion::Extensions::Llm::UnsupportedCapabilityError do
75
- it 'keeps string message compatibility for existing callers' do
76
- error = described_class.new('not supported')
77
-
78
- expect(error.message).to eq('not supported')
79
- end
80
-
81
- it 'accepts the shared keyword initializer' do
82
- error = described_class.new(provider: :openai, capability: :embed)
83
-
84
- expect(error.message).to eq('Provider openai does not support embed')
85
- end
74
+ it 'removes the UnsupportedCapabilityError compat alias (0.8.0 rip)' do
75
+ expect(Legion::Extensions::Llm.const_defined?(:UnsupportedCapabilityError, false)).to be(false)
86
76
  end
87
77
  end