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
@@ -6,23 +6,40 @@ module Legion
6
6
  # Documents the canonical public provider method signatures shared by provider gems.
7
7
  module ProviderContract
8
8
  REQUIRED_SIGNATURES = {
9
- chat: [%i[keyreq messages], %i[keyreq model]],
10
- stream_chat: [%i[keyreq messages], %i[keyreq model]],
9
+ chat: [%i[req messages], %i[keyreq model]],
10
+ stream_chat: [%i[req messages], %i[keyreq model]],
11
11
  embed: [%i[keyreq text], %i[keyreq model]],
12
12
  image: [%i[keyreq prompt], %i[keyreq model]],
13
- list_models: [%i[key live], %i[keyrest filters]],
14
13
  discover_offerings: [%i[key live], %i[key raise_on_unreachable], %i[keyrest filters]],
15
14
  health: [%i[key live]],
16
15
  count_tokens: [%i[keyreq messages], %i[keyreq model], %i[key params]]
17
16
  }.freeze
18
17
 
19
- # Tools passed to chat/stream_chat must support Canonical::ToolDefinition objects.
20
- # Providers must not crash on Data.define instances (not Hashes).
18
+ # H3: the tools half of the dispatch boundary contract. A non-empty
19
+ # `tools:` value must be Hash<name, Canonical::ToolDefinition>; the
20
+ # base funnel enforces it once (08 F2) and non-canonical tool values
21
+ # raise ArgumentError. Renderers consume Canonical::ToolDefinition
22
+ # only (schema access via Canonical::ToolSchema); Hash and legacy
23
+ # Lex::Llm::Tool tolerance is deleted from the contract.
21
24
  TOOL_SUPPORT_CONTRACT = <<~DOC
22
- - chat and stream_chat accept keyword `tools:` (Hash<name, tool_object>)
23
- - tools may be Canonical::ToolDefinition, Hash, or legacy Lex::Llm::Tool
25
+ - chat and stream_chat accept keyword `tools:` (Hash<name, Canonical::ToolDefinition> or nil)
26
+ - the base funnel enforces the tool contract once (08 F2): a non-Hash or
27
+ non-ToolDefinition tool value raises ArgumentError before any rendering
24
28
  - Renderers must use Canonical::ToolSchema.extract(tool) for schema access
25
- - discover_offerings(live: true, raise_on_unreachable: true) raises on transport failure
29
+ DOC
30
+
31
+ # H5: the read-path contract, stated as implemented. The two base
32
+ # read paths perform different amounts of transport and say so.
33
+ READ_PATH_CONTRACT = <<~DOC
34
+ - discover_offerings: the base read path serves the SSOT registry
35
+ snapshot for this provider instance — NO transport occurs.
36
+ `live:` and `raise_on_unreachable:` are accepted for signature
37
+ compatibility and have no effect in the base (there is nothing
38
+ that can become unreachable). `filters` select from the snapshot.
39
+ - Provider gems that override either path with a live fetch MUST
40
+ implement `live:` and (for discover_offerings)
41
+ `raise_on_unreachable:` for real — the base guarantees are not
42
+ inherited semantics.
26
43
  DOC
27
44
  end
28
45
  end
@@ -11,15 +11,15 @@ module Legion
11
11
  **legacy_settings)
12
12
  validate_provider_defaults!(fleet:, gateways:, legacy_settings:)
13
13
 
14
- deep_merge(
14
+ Utils.deep_merge(
15
15
  Legion::Extensions::Llm.default_settings,
16
16
  {
17
17
  enabled: enabled,
18
18
  provider_family: family,
19
- discovery: deep_merge({ enabled: true, interval_seconds: 300 }, discovery || {}),
20
- instances: deep_merge(
19
+ discovery: Utils.deep_merge({ enabled: true, interval_seconds: 300 }, discovery || {}),
20
+ instances: Utils.deep_merge(
21
21
  {
22
- default: deep_merge(
22
+ default: Utils.deep_merge(
23
23
  { enabled: true, credentials: nil, fleet: { enabled: false, consumer_priority: 0, prefetch: 1 } },
24
24
  instance || {}
25
25
  )
@@ -44,31 +44,10 @@ module Legion
44
44
  raise ArgumentError, "Unsupported top-level provider settings: #{invalid_keys.join(', ')}"
45
45
  end
46
46
 
47
- def deep_dup(value)
48
- case value
49
- when Hash
50
- value.to_h { |key, inner_value| [key, deep_dup(inner_value)] }
51
- when Array
52
- value.map { |inner_value| deep_dup(inner_value) }
53
- else
54
- value
55
- end
56
- end
57
-
58
- def deep_merge(left, right)
59
- deep_dup(left || {}).merge(deep_dup(right || {})) do |_key, left_value, right_value|
60
- left_value.is_a?(Hash) && right_value.is_a?(Hash) ? deep_merge(left_value, right_value) : right_value
61
- end
62
- end
63
-
64
47
  def infer_tier_from_endpoint(url)
65
48
  return :direct if url.nil? || url.to_s.empty?
66
49
 
67
- require 'uri'
68
- host = URI.parse(url.to_s).host.to_s.downcase
69
- %w[localhost 127.0.0.1 ::1 [::1]].include?(host) ? :local : :direct
70
- rescue URI::InvalidURIError
71
- :direct
50
+ Legion::Extensions::Llm::Utils.localhost_url?(url) ? :local : :direct
72
51
  end
73
52
  end
74
53
  end
@@ -156,6 +156,9 @@ module Legion
156
156
  end
157
157
  private_class_method :consume_open_think_segment
158
158
 
159
+ # The shared tag-segment matcher (10 U1): batch extraction and the
160
+ # streaming state machine both match tags through this one
161
+ # implementation.
159
162
  def next_tag_match(text, type)
160
163
  matches = THINK_TAG_PAIRS.filter_map do |open_tag, close_tag|
161
164
  tag = type == :open ? open_tag : close_tag
@@ -164,7 +167,11 @@ module Legion
164
167
  end
165
168
  matches.min_by { |match| match[:index] }
166
169
  end
167
- private_class_method :next_tag_match
170
+
171
+ # All tag tokens (opens + closes) for cross-chunk boundary buffering.
172
+ def tag_tokens
173
+ THINK_TAG_PAIRS.flat_map { |open_tag, close_tag| [open_tag, close_tag] }
174
+ end
168
175
 
169
176
  def untagged_reasoning_preamble?(preamble)
170
177
  return false if preamble.length > UNTAGGED_PREAMBLE_MAX_LENGTH
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Responses
7
+ # The ONE shared tool-arguments parser (10 U2). Streaming fragments are
8
+ # assembled into a complete JSON string before parsing; sync providers pass
9
+ # the provider wire string. One strict policy: invalid or non-object JSON
10
+ # raises — corrupted tool arguments never cross the boundary as a
11
+ # fabricated {} (04 L1/L7; the two old rescue-to-{} policies are deleted).
12
+ module ToolArguments
13
+ module_function
14
+
15
+ # Parse assembled tool-call arguments JSON into a Hash.
16
+ # nil / empty string means "no arguments" and returns {} (documented
17
+ # default, not tolerance).
18
+ def parse!(raw)
19
+ return {} if raw.nil?
20
+ return {} if raw.is_a?(::String) && raw.strip.empty?
21
+ raise ArgumentError, "tool call arguments expected JSON String, got #{raw.class}" unless raw.is_a?(::String)
22
+
23
+ parsed = ::Legion::JSON.parse(raw, symbolize_names: false)
24
+ raise ArgumentError, "tool call arguments must be a JSON object, got #{parsed.class}" unless parsed.is_a?(::Hash)
25
+
26
+ parsed
27
+ rescue ::Legion::JSON::ParseError => e
28
+ raise ArgumentError, "tool call arguments are not valid JSON: #{e.message}"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -47,7 +47,32 @@ module Legion
47
47
  raise Legion::Extensions::Llm::Inventory::Errors::ValidationError,
48
48
  'retry_after must be nil or a finite nonnegative Numeric'
49
49
  end
50
+
51
+ # The ONE error→kind base table (05 O6 / 08 E1). Providers override
52
+ # #normalize_dispatch_error only when their wire semantics supply
53
+ # stronger evidence; the table itself is frozen here and is also the
54
+ # source of fleet retryability (06 F6).
55
+ def self.kind_for(error)
56
+ case error
57
+ when OverloadedError then :overloaded
58
+ when RateLimitError then :rate_limited
59
+ when UnauthorizedError then :authentication
60
+ when PaymentRequiredError then :billing
61
+ when ForbiddenError then :authorization
62
+ when ContextLengthExceededError then :context_rejected
63
+ when BadRequestError then :invalid_request
64
+ when ModelNotFoundError then :model_missing
65
+ when ModelNotAllowedError then :policy
66
+ when Faraday::TimeoutError, Timeout::Error then :timeout
67
+ when Faraday::ConnectionFailed, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError then :connection_failure
68
+ else :provider_error
69
+ end
70
+ end
50
71
  end
72
+
73
+ # 06 F6: the transient kinds that may be retried at the fleet edge.
74
+ # Classification/contract/auth/policy kinds never retry.
75
+ ProviderOutcome::RETRYABLE_KINDS = %i[overloaded rate_limited timeout connection_failure provider_error].freeze
51
76
  end
52
77
  end
53
78
  end
@@ -83,9 +83,13 @@ module Legion
83
83
 
84
84
  # An immutable selected target produced by legion-llm's selector and
85
85
  # consumed by dispatch. lex-llm validates cross-field identity only; it
86
- # does not compute routing or choose a lane. See section 15.4.
86
+ # does not compute routing or choose a lane. lane_id is the 5 tuple
87
+ # tier:provider_family:instance_id:type:model; the tier part is not a
88
+ # Selection member, so cross-field validation covers the four parts the
89
+ # record does carry (family, instance, type-from-operation, model).
90
+ # See section 15.4.
87
91
  Selection = ::Data.define(
88
- :inventory_generation, :lane_id, :instance_key, :offering_id, :provider_family, :instance_id,
92
+ :inventory_generation, :lane_id, :instance_key, :provider_family, :instance_id,
89
93
  :model, :operation, :callable_handle, :publisher_token_id, :capability_evidence, :context_evidence,
90
94
  :weight_inputs, :base_weight, :preference_ppm, :effective_weight, :rendezvous_score
91
95
  ) do
@@ -103,29 +107,44 @@ module Legion
103
107
  raise errors::ValidationError, 'callable_handle must be a CallableHandle' unless callable_handle.is_a?(Legion::Extensions::Llm::Inventory::CallableHandle)
104
108
 
105
109
  canonical_model = identity.normalize_text(value: kwargs[:model], field: :model)
106
- canonical_operation = Legion::Extensions::Llm::Taxonomies.normalize_operation(value: kwargs[:operation], allow_aliases: false)
110
+ canonical_operation = Legion::Extensions::Llm::Taxonomies.normalize_operation(value: kwargs[:operation])
107
111
  family = Legion::Extensions::Llm::Inventory::RecordSupport.normalize_provider_family(value: kwargs[:provider_family])
108
112
  normalized_instance = identity.normalize_text(value: kwargs[:instance_id], field: :instance_id)
109
113
  unless family == instance_key.provider_family && normalized_instance == instance_key.instance_id
110
114
  raise errors::ValidationError, 'provider_family and instance_id must equal instance_key'
111
115
  end
112
116
 
113
- offering_id = kwargs[:offering_id]
114
- validate_offering_id_shape!(offering_id)
115
- identity.validate_lane_id!(
116
- value: kwargs[:lane_id], instance_key: instance_key, operation: canonical_operation,
117
- model: canonical_model, offering_id: offering_id
118
- )
117
+ identity.validate_lane_id!(value: kwargs[:lane_id])
118
+ validate_lane_id_fields!(kwargs[:lane_id], family, normalized_instance, canonical_operation, canonical_model)
119
119
  validate_publisher_token_id!(kwargs[:publisher_token_id])
120
120
 
121
121
  {
122
- lane_id: kwargs[:lane_id].dup.freeze, instance_key: instance_key, offering_id: offering_id.dup.freeze,
122
+ lane_id: kwargs[:lane_id].to_s.dup.freeze, instance_key: instance_key,
123
123
  provider_family: family, instance_id: normalized_instance, model: canonical_model,
124
124
  operation: canonical_operation, callable_handle: callable_handle,
125
125
  publisher_token_id: kwargs[:publisher_token_id].dup.freeze
126
126
  }
127
127
  end
128
128
 
129
+ # Cross-field check of the 5 tuple against the parts the Selection
130
+ # carries: provider_family (part 2), instance_id (part 3), type from
131
+ # the operation (part 4), and model (part 5, colons preserved by the
132
+ # bounded parse). Part 1 (tier) is checked against Taxonomies::TIERS
133
+ # by validate_lane_id!.
134
+ def validate_lane_id_fields!(lane_id, family, instance_id, operation, model)
135
+ errors = Legion::Extensions::Llm::Inventory::Errors
136
+ identity = Legion::Extensions::Llm::Inventory::Identity
137
+ parts = identity.parse_lane_id(lane_id)
138
+ raise errors::ValidationError, 'lane_id provider_family does not match the selection' \
139
+ unless parts[1] == family.to_s
140
+ raise errors::ValidationError, 'lane_id instance_id does not match the selection' \
141
+ unless parts[2] == instance_id
142
+ raise errors::ValidationError, 'lane_id type does not match the selection operation' \
143
+ unless parts[3] == Legion::Extensions::Llm::Taxonomies.lane_type_for(operation: operation).to_s
144
+ raise errors::ValidationError, 'lane_id model does not match the selection' \
145
+ unless parts[4] == model
146
+ end
147
+
129
148
  def scoring_attributes(kwargs)
130
149
  support = Legion::Extensions::Llm::Inventory::RecordSupport
131
150
  base_weight = kwargs[:base_weight]
@@ -145,12 +164,6 @@ module Legion
145
164
  AttemptTargetKey.new(provider_family: provider_family, instance_id: instance_id, model: model)
146
165
  end
147
166
 
148
- def validate_offering_id_shape!(offering_id)
149
- return if offering_id.is_a?(::String) && offering_id.match?(/\Aoff:v1:[0-9a-f]{64}\z/)
150
-
151
- raise Legion::Extensions::Llm::Inventory::Errors::ValidationError, 'offering_id must have the off:v1: shape'
152
- end
153
-
154
167
  def validate_publisher_token_id!(publisher_token_id)
155
168
  return if publisher_token_id.is_a?(::String) && publisher_token_id.start_with?('ptok:v1:') &&
156
169
  publisher_token_id.length > 'ptok:v1:'.length
@@ -158,12 +171,16 @@ module Legion
158
171
  raise Legion::Extensions::Llm::Inventory::Errors::ValidationError, 'publisher_token_id must be a ptok:v1: String'
159
172
  end
160
173
 
174
+ # U4: structural verification only — same semantics as
175
+ # RecordSupport.validated_weight_pair, never a second weight policy.
176
+ # 0 is a legal value (operator disable, 07 W3); base_weight must
177
+ # equal the product of the inputs.
161
178
  def validate_weight_inputs!(weight_inputs, base_weight)
162
179
  errors = Legion::Extensions::Llm::Inventory::Errors
163
180
  unless weight_inputs.is_a?(::Hash) && weight_inputs.keys.sort == %i[instance model_or_offering provider tier]
164
181
  raise errors::ValidationError, 'weight_inputs must have keys tier/provider/instance/model_or_offering'
165
182
  end
166
- raise errors::ValidationError, 'weight_inputs values must be positive Integers' unless weight_inputs.values.all? { |value| value.is_a?(::Integer) && value.positive? }
183
+ raise errors::ValidationError, 'weight_inputs values must be nonnegative Integers' unless weight_inputs.values.all? { |value| value.is_a?(::Integer) && !value.negative? }
167
184
 
168
185
  product = weight_inputs.values.reduce(1, :*)
169
186
  raise errors::ValidationError, 'base_weight must equal the product of weight_inputs' unless base_weight == product