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
@@ -11,20 +11,20 @@ module Legion
11
11
  # A structurally immutable, generation-tagged read model of the registry.
12
12
  # Lookups return the frozen record or nil and never synthesize a default.
13
13
  # `instances_by_key` contains only activated instances;
14
- # `publication_status_by_key` also contains initializing claims. A caller
15
- # cannot observe a later inventory-record or generation mutation through
16
- # an older snapshot; the captured CallableHandle lifecycle is the single
17
- # intentional exception. See phase-1-lex-llm-additive.md section 11.3.
14
+ # `publication_status_by_key` also contains initializing claims. Lanes
15
+ # are keyed by the 5-tuple lane id and iterate in lexicographic id
16
+ # order (readable iteration). A caller cannot observe a later
17
+ # inventory-record or generation mutation through an older snapshot;
18
+ # the captured CallableHandle lifecycle is the single intentional
19
+ # exception. See phase-1-lex-llm-additive.md section 11.3.
18
20
  class Snapshot
19
21
  attr_reader :generation
20
22
 
21
- def initialize(generation:, instances_by_key:, offerings_by_id:, lanes_by_id:, publication_status_by_key:)
23
+ def initialize(generation:, instances_by_key:, lanes_by_id:, publication_status_by_key:)
22
24
  @generation = generation
23
25
  @instances_by_key = order_instances(instances_by_key).freeze
24
- @offerings_by_id = offerings_by_id.dup.freeze
25
26
  @lanes_by_id = lanes_by_id.dup.freeze
26
27
  @publication_status_by_key = order_instances(publication_status_by_key).freeze
27
- @ordered_offerings = @offerings_by_id.keys.sort.map { |id| @offerings_by_id[id] }.freeze
28
28
  @ordered_lanes = @lanes_by_id.keys.sort.map { |id| @lanes_by_id[id] }.freeze
29
29
  freeze
30
30
  end
@@ -33,10 +33,6 @@ module Legion
33
33
  @instances_by_key[instance_key]
34
34
  end
35
35
 
36
- def offering(offering_id:)
37
- @offerings_by_id[offering_id]
38
- end
39
-
40
36
  def lane(lane_id:)
41
37
  @lanes_by_id[lane_id]
42
38
  end
@@ -45,10 +41,6 @@ module Legion
45
41
  @ordered_lanes.select { |lane| lane.instance_key == instance_key }.freeze
46
42
  end
47
43
 
48
- def offerings_for(instance_key:)
49
- @ordered_offerings.select { |offering| offering.instance_key == instance_key }.freeze
50
- end
51
-
52
44
  def publication_status(instance_key:)
53
45
  @publication_status_by_key[instance_key]
54
46
  end
@@ -59,12 +51,6 @@ module Legion
59
51
  @ordered_lanes.each(&block)
60
52
  end
61
53
 
62
- def each_offering(&block)
63
- return to_enum(:each_offering) unless block
64
-
65
- @ordered_offerings.each(&block)
66
- end
67
-
68
54
  def each_instance(&block)
69
55
  return to_enum(:each_instance) unless block
70
56
 
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Direct dependency; the explicit receiver keeps Ruby 4's redundant-require cop from deleting it.
4
+ Kernel.require 'set'
5
+ require 'legion/extensions/llm/inventory/weight_schema'
6
+ require 'legion/extensions/llm/settings_cascade'
7
+ require 'legion/extensions/llm/inventory/identity'
8
+ require 'legion/extensions/llm/taxonomies'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module Llm
13
+ module Inventory
14
+ # Tracks configured weight keys that currently have no published lane.
15
+ class DormantWeightTracker
16
+ def initialize
17
+ @dormant = Set.new
18
+ end
19
+
20
+ def observe(configured_keys:, published_keys:)
21
+ current = Set.new(configured_keys).difference(published_keys)
22
+ newly_dormant = current.difference(@dormant).sort_by(&:inspect)
23
+ @dormant.replace(current)
24
+ newly_dormant
25
+ end
26
+
27
+ def clear!
28
+ @dormant.clear
29
+ end
30
+ end
31
+
32
+ # Shared atomic write-time weight publication for existing writer cadences.
33
+ module WeightReconciler
34
+ module_function
35
+
36
+ def rebuild_offerings(settings:, instance_key:, offerings:)
37
+ offerings.map do |draft|
38
+ inputs = WeightSchema.weight_inputs(
39
+ settings: settings,
40
+ instance_key: instance_key,
41
+ model: draft.model,
42
+ tier: draft.tier,
43
+ operation_evidence: draft.operation_evidence
44
+ )
45
+ draft.with(weight_inputs: inputs, base_weight: WeightSchema.base_weight(inputs))
46
+ end.freeze
47
+ end
48
+
49
+ # Periodic discovery calls this AFTER its existing network/discovery work. Read
50
+ # current Settings and perform weight rebuilding, comparison, sequence
51
+ # allocation, publish, and cache update under the same writer mutex. No Settings
52
+ # callback or separate reweight path exists.
53
+ def commit_if_changed!(settings:, instance_id:, state:, discovered_offerings:, **publication)
54
+ unknown = publication.keys - %i[mutex equivalent replace stable_signature]
55
+ raise ArgumentError, "unknown publication keyword(s): #{unknown.join(', ')}" unless unknown.empty?
56
+
57
+ mutex = publication.fetch(:mutex)
58
+ equivalent = publication.fetch(:equivalent)
59
+ replace = publication.fetch(:replace)
60
+ stable_signature = publication[:stable_signature]
61
+ mutex.synchronize do
62
+ offerings = rebuild_offerings(
63
+ settings: settings, instance_key: state.fetch(:instance_key),
64
+ offerings: discovered_offerings
65
+ )
66
+ return false if equivalent.call(state.fetch(:offerings), offerings)
67
+
68
+ if state.fetch(:published)
69
+ commit_locked!(
70
+ instance_id: instance_id, state: state, offerings: offerings,
71
+ replace: replace, stable_signature: stable_signature
72
+ )
73
+ else
74
+ cache_locked!(
75
+ state: state, offerings: offerings, stable_signature: stable_signature
76
+ )
77
+ end
78
+ true
79
+ end
80
+ end
81
+
82
+ # Track a claimed but not-yet-activated instance before its readiness I/O. An
83
+ # ordinary writer pass may refresh its cached weights, but must never send a
84
+ # replacement for a publication that is still :initializing.
85
+ def track_initializing!(states:, state_key:, state:, mutex:)
86
+ mutex.synchronize do
87
+ state[:published] = false
88
+ states[state_key] = state
89
+ end
90
+ state
91
+ end
92
+
93
+ # Initial and recovery activation both use this helper after readiness succeeds.
94
+ # Rebuild from current settings and publish under the writer's publication
95
+ # mutex, then mark the state published only after the publisher call succeeds.
96
+ def activate_tracked!(settings:, instance_id:, state_key:, state:, **activation)
97
+ unknown = activation.keys - %i[states mutex probe_token activate activation_sequence stable_signature]
98
+ raise ArgumentError, "unknown activation keyword(s): #{unknown.join(', ')}" unless unknown.empty?
99
+
100
+ states = activation.fetch(:states)
101
+ mutex = activation.fetch(:mutex)
102
+ probe_token = activation.fetch(:probe_token)
103
+ activate = activation.fetch(:activate)
104
+ activation_sequence = activation.fetch(:activation_sequence)
105
+ stable_signature = activation[:stable_signature]
106
+ mutex.synchronize do
107
+ return false unless states[state_key].equal?(state)
108
+
109
+ offerings = rebuild_offerings(
110
+ settings: settings, instance_key: state.fetch(:instance_key),
111
+ offerings: state.fetch(:offerings)
112
+ )
113
+ sequence = activation_sequence.call(state)
114
+ activate.call(
115
+ instance_id: instance_id, state: state, offerings: offerings,
116
+ sequence: sequence, probe_token: probe_token
117
+ )
118
+ state[:sequence] = sequence
119
+ cache_locked!(
120
+ state: state, offerings: offerings, stable_signature: stable_signature
121
+ )
122
+ state[:published] = true
123
+ true
124
+ end
125
+ end
126
+
127
+ # Ordinary ticks call this after reconcile. Dormant observation has the same
128
+ # cadence as the existing writer; there is no reload callback path.
129
+ def observe_dormant!(settings:, provider_family:, states:, mutex:, **observation)
130
+ unknown = observation.keys - %i[tracker dormant_logger]
131
+ raise ArgumentError, "unknown observation keyword(s): #{unknown.join(', ')}" unless unknown.empty?
132
+
133
+ tracker = observation.fetch(:tracker)
134
+ dormant_logger = observation.fetch(:dormant_logger)
135
+ mutex.synchronize do
136
+ observe_dormant_locked!(
137
+ settings: settings, provider_family: provider_family, states: states,
138
+ tracker: tracker, dormant_logger: dormant_logger
139
+ )
140
+ end
141
+ end
142
+
143
+ def commit_locked!(instance_id:, state:, offerings:, replace:, stable_signature:)
144
+ sequence = state.fetch(:sequence) + 1
145
+ replace.call(instance_id: instance_id, state: state, offerings: offerings, sequence: sequence)
146
+ state[:sequence] = sequence
147
+ cache_locked!(
148
+ state: state, offerings: offerings, stable_signature: stable_signature
149
+ )
150
+ end
151
+ private_class_method :commit_locked!
152
+
153
+ def cache_locked!(state:, offerings:, stable_signature:)
154
+ state[:offerings] = offerings
155
+ state[:signature] = stable_signature.call(offerings) if stable_signature
156
+ end
157
+ private_class_method :cache_locked!
158
+
159
+ def observe_dormant_locked!(settings:, provider_family:, states:, tracker:,
160
+ dormant_logger:)
161
+ configured = configured_weight_keys(settings: settings, provider_family: provider_family)
162
+ published = published_weight_keys(provider_family: provider_family, states: states)
163
+ tracker.observe(configured_keys: configured, published_keys: published).each do |key|
164
+ dormant_logger.call(key)
165
+ end
166
+ end
167
+ private_class_method :observe_dormant_locked!
168
+
169
+ def configured_weight_keys(settings:, provider_family:)
170
+ llm = config_hash(settings.dig(:extensions, :llm))
171
+ provider = config_hash(SettingsCascade.lookup(llm, provider_family))
172
+ keys = Set.new
173
+ keys << canonical_key(provider_family, :provider) if weight_present?(provider)
174
+
175
+ config_hash(SettingsCascade.lookup(provider, :models)).each do |model, config|
176
+ keys << canonical_key(provider_family, :model, model.to_s) if weight_present?(config_hash(config))
177
+ end
178
+ config_hash(SettingsCascade.lookup(provider, :instances)).each do |instance, config|
179
+ instance_config = config_hash(config)
180
+ keys << canonical_key(provider_family, :instance, instance.to_s) if weight_present?(instance_config)
181
+ config_hash(SettingsCascade.lookup(instance_config, :models)).each do |model, model_config|
182
+ next unless weight_present?(config_hash(model_config))
183
+
184
+ keys << canonical_key(
185
+ provider_family, :instance, instance.to_s, :model, model.to_s
186
+ )
187
+ end
188
+ end
189
+ config_hash(SettingsCascade.lookup(provider, :offerings)).each do |offering_id, config|
190
+ keys << canonical_key(provider_family, :offering, offering_id.to_s) \
191
+ if weight_present?(config_hash(config))
192
+ end
193
+ keys
194
+ end
195
+
196
+ def published_weight_keys(provider_family:, states:)
197
+ keys = Set.new
198
+ states.each_value do |state|
199
+ next unless state.fetch(:published)
200
+
201
+ offerings = Array(state[:offerings])
202
+ next if offerings.empty?
203
+
204
+ instance_key = state.fetch(:instance_key)
205
+ keys << canonical_key(provider_family, :provider)
206
+ keys << canonical_key(provider_family, :instance, instance_key.instance_id.to_s)
207
+ offerings.each do |draft|
208
+ keys << canonical_key(provider_family, :model, draft.model.to_s)
209
+ keys << canonical_key(
210
+ provider_family, :instance, instance_key.instance_id.to_s,
211
+ :model, draft.model.to_s
212
+ )
213
+ draft.operation_evidence.each_value do |evidence|
214
+ next unless evidence.supported?
215
+
216
+ lane_id = Identity.compose_lane_id(
217
+ tier: draft.tier, provider_family: instance_key.provider_family,
218
+ instance_id: instance_key.instance_id,
219
+ type: Taxonomies.lane_type_for(operation: evidence.operation), model: draft.model
220
+ )
221
+ keys << canonical_key(provider_family, :offering, lane_id)
222
+ end
223
+ end
224
+ end
225
+ keys
226
+ end
227
+
228
+ def canonical_key(provider_family, *parts)
229
+ [provider_family.to_sym, *parts].freeze
230
+ end
231
+ private_class_method :canonical_key
232
+
233
+ def config_hash(value)
234
+ return {} if value.nil?
235
+ return value if value.is_a?(::Hash)
236
+
237
+ raise ArgumentError, "weight configuration scope must be a Hash, got #{value.inspect}"
238
+ end
239
+ private_class_method :config_hash
240
+
241
+ def weight_present?(scope)
242
+ !SettingsCascade.lookup(scope, :weight).nil?
243
+ end
244
+ private_class_method :weight_present?
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/llm/settings_cascade'
4
+ require 'legion/extensions/llm/inventory/identity'
5
+ require 'legion/extensions/llm/taxonomies'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Llm
10
+ module Inventory
11
+ # Write-time lane-weight computation (RANKING v2 law): the stateless router
12
+ # reads the stored scalar; it is computed ONLY here, at write events, from a
13
+ # live settings read. Each multiplicative axis reads ITS OWN scope — never a
14
+ # fall-through cascade (a fall-through would double-count the other axes).
15
+ # The offering-scope settings key is the lane's 5 tuple — operator-readable,
16
+ # composed via Identity.compose_lane_id.
17
+ module WeightSchema
18
+ module_function
19
+
20
+ IDENTITY = 100
21
+
22
+ # Exact 4-key hash. The offering-scope settings key is the 5 tuple of
23
+ # the lanes the draft's operation evidence supports (one per distinct
24
+ # lane type) — finally operator-usable, no digest.
25
+ def weight_inputs(settings:, instance_key:, model:, tier:, operation_evidence:)
26
+ llm_conf = scope_hash(settings.dig(:extensions, :llm), path: 'extensions.llm')
27
+ provider_conf = scope_hash(
28
+ SettingsCascade.lookup(llm_conf, instance_key.provider_family),
29
+ path: "extensions.llm.#{instance_key.provider_family}"
30
+ )
31
+ instances = scope_hash(
32
+ SettingsCascade.lookup(provider_conf, :instances), path: 'provider.instances'
33
+ )
34
+ instance_cfg = scope_hash(
35
+ SettingsCascade.lookup(instances, instance_key.instance_id),
36
+ path: "provider.instances.#{instance_key.instance_id}"
37
+ )
38
+ tier_weights = scope_hash(
39
+ settings.dig(:llm, :routing, :tier_weights), path: 'llm.routing.tier_weights'
40
+ )
41
+ offerings = scope_hash(SettingsCascade.lookup(provider_conf, :offerings), path: 'provider.offerings')
42
+ offering_w = offering_scope_weight(
43
+ offerings: offerings, instance_key: instance_key, model: model, tier: tier,
44
+ operation_evidence: operation_evidence
45
+ )
46
+ provider_models = scope_hash(
47
+ SettingsCascade.lookup(provider_conf, :models), path: 'provider.models'
48
+ )
49
+ instance_models = scope_hash(
50
+ SettingsCascade.lookup(instance_cfg, :models), path: 'instance.models'
51
+ )
52
+ scope_hash(
53
+ SettingsCascade.lookup(provider_models, model), path: "provider.models.#{model}"
54
+ )
55
+ scope_hash(
56
+ SettingsCascade.lookup(instance_models, model), path: "instance.models.#{model}"
57
+ )
58
+ model_scopes = scope_hash(
59
+ SettingsCascade.merge_model_scopes(
60
+ provider_conf: provider_conf, instance_cfg: instance_cfg, model: model
61
+ ),
62
+ path: "merged model scope #{model}"
63
+ )
64
+
65
+ model_w = SettingsCascade.lookup(model_scopes, :weight)
66
+ {
67
+ tier: component(SettingsCascade.lookup(tier_weights, tier), IDENTITY),
68
+ provider: component(SettingsCascade.lookup(provider_conf, :weight), IDENTITY),
69
+ instance: component(SettingsCascade.lookup(instance_cfg, :weight), IDENTITY),
70
+ # Explicit nil? precedence (NEVER `||`): offering overrides the model
71
+ # component only; nil offering → model; both nil → identity. A
72
+ # non-nil non-Integer (false, strings, negatives) RAISES in `component`.
73
+ model_or_offering: component(offering_w.nil? ? model_w : offering_w, IDENTITY)
74
+ }
75
+ end
76
+
77
+ # The configured offering-scope weight for the draft: the operator keys
78
+ # `extensions.llm.<provider>.offerings` by the lane's 5 tuple. The lanes
79
+ # a draft supports are one 5 tuple per distinct lane type; the draft
80
+ # carries a SINGLE weight pair, so configured weights across its lanes
81
+ # must agree — a disagreement is an ambiguous operator input and RAISES
82
+ # instead of silently picking one.
83
+ def offering_scope_weight(offerings:, instance_key:, model:, tier:, operation_evidence:)
84
+ lane_ids = supported_lane_ids(
85
+ instance_key: instance_key, model: model, tier: tier, operation_evidence: operation_evidence
86
+ )
87
+ weights = lane_ids.filter_map do |lane_id|
88
+ scope = scope_hash(SettingsCascade.lookup(offerings, lane_id), path: "provider.offerings.#{lane_id}")
89
+ SettingsCascade.lookup(scope, :weight)
90
+ end
91
+ return nil if weights.empty?
92
+
93
+ unless weights.uniq.length == 1
94
+ raise ArgumentError, "offering weight scope is ambiguous: the draft's lanes " \
95
+ "#{lane_ids.join(', ')} carry differing configured weights"
96
+ end
97
+
98
+ weights.first
99
+ end
100
+
101
+ def supported_lane_ids(instance_key:, model:, tier:, operation_evidence:)
102
+ operation_evidence.each_value.with_object([]) do |evidence, lane_ids|
103
+ next unless evidence.supported?
104
+
105
+ lane_id = Identity.compose_lane_id(
106
+ tier: tier, provider_family: instance_key.provider_family,
107
+ instance_id: instance_key.instance_id,
108
+ type: Taxonomies.lane_type_for(operation: evidence.operation), model: model
109
+ )
110
+ lane_ids << lane_id unless lane_ids.include?(lane_id)
111
+ end
112
+ end
113
+
114
+ # nil → default; an explicit 0 passes through (0 = operator disable —
115
+ # 0 is TRUTHY in Ruby and must not be defaulted); any other non-Integer
116
+ # RAISES instead of silently applying a different weight.
117
+ def component(value, default)
118
+ return default if value.nil?
119
+
120
+ raise ArgumentError, "weight component must be an Integer >= 0, got #{value.inspect}" \
121
+ unless value.is_a?(::Integer) && value >= 0
122
+
123
+ value
124
+ end
125
+
126
+ # Missing scope is the identity default. A present malformed scope is
127
+ # never treated as missing (`false || {}` would silently erase operator
128
+ # input and recreate a flat identity result).
129
+ def scope_hash(value, path:)
130
+ return {} if value.nil?
131
+ return value if value.is_a?(::Hash)
132
+
133
+ raise ArgumentError, "#{path} must be a Hash, got #{value.inspect}"
134
+ end
135
+
136
+ # Pinned return contract: TWO methods. The writer calls both:
137
+ # wi = WeightSchema.weight_inputs(...)
138
+ # base = WeightSchema.base_weight(wi)
139
+ def base_weight(weight_inputs)
140
+ weight_inputs.values.reduce(1, :*)
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end