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
@@ -21,7 +21,6 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
21
21
  snapshot = described_class.snapshot
22
22
  expect(snapshot.generation).to eq(0)
23
23
  expect(snapshot.each_lane.to_a).to be_empty
24
- expect(snapshot.each_offering.to_a).to be_empty
25
24
  expect(snapshot.instance(instance_key: key)).to be_nil
26
25
  end
27
26
  end
@@ -63,17 +62,69 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
63
62
  .to raise_error(errors::InvalidTransitionError)
64
63
  end
65
64
 
66
- it 'atomically publishes callable, offerings, lanes, and available on activation' do
65
+ it 'atomically publishes callable, lanes, and available on activation' do
67
66
  claim_and_activate(key: key, callable: callable, coordinator: coordinator)
68
67
  snapshot = described_class.snapshot
69
68
  record = snapshot.instance(instance_key: key)
70
69
  expect(record).not_to be_nil
71
70
  expect(record.availability.state).to eq(:available)
72
71
  expect(snapshot.lanes_for(instance_key: key).size).to eq(1)
73
- expect(snapshot.offerings_for(instance_key: key).size).to eq(1)
74
72
  expect(snapshot.publication_status(instance_key: key).state).to eq(:complete)
75
73
  end
76
74
 
75
+ it 'stores the lane keyed by the 5 tuple and exposes it through the record' do
76
+ claim_and_activate(key: key, callable: callable, coordinator: coordinator)
77
+ snapshot = described_class.snapshot
78
+ lane = snapshot.lanes_for(instance_key: key).first
79
+ expect(lane.lane_id).to eq('local:vllm:h200:inference:gemma4')
80
+ expect(snapshot.lane(lane_id: lane.lane_id)).to be(lane)
81
+ expect(snapshot.instance(instance_key: key).lanes_by_id.keys).to eq([lane.lane_id])
82
+ end
83
+
84
+ it 'derives one lane per supported operation of each draft' do
85
+ claim_and_activate(
86
+ key: key, callable: callable, coordinator: coordinator, supported: %i[chat embed]
87
+ )
88
+ snapshot = described_class.snapshot
89
+ expect(snapshot.lanes_for(instance_key: key).map(&:lane_id).sort).to eq(
90
+ %w[local:vllm:h200:embedding:gemma4 local:vllm:h200:inference:gemma4]
91
+ )
92
+ end
93
+
94
+ it 'raises on a duplicate (operation, model) published under a conflicting tier (D1)' do
95
+ key2 = instance_key(instance: 'tier-collision')
96
+ coordinator2 = probe_coordinator(key2)
97
+ token = described_class.claim_instance(instance_key: key2, callable: callable, probe_request_handle: coordinator2)
98
+ probe = described_class.readiness_probe_started(instance_key: key2, publisher_token: token)
99
+ expect do
100
+ described_class.activate_instance_snapshot(
101
+ publisher_token: token, instance_key: key2,
102
+ offerings: [
103
+ *drafts(model: 'm1', native: 'm1-a', supported: %i[chat], tier: :local),
104
+ *drafts(model: 'm1', native: 'm1-b', supported: %i[chat], tier: :frontier)
105
+ ],
106
+ sequence: 0, probe_token: probe
107
+ )
108
+ end.to raise_error(inventory::Errors::ValidationError, /conflicting tier/)
109
+ end
110
+
111
+ it 'raises on a duplicate 5 tuple from two native keys of the same model' do
112
+ key3 = instance_key(instance: 'dup-tuple')
113
+ coordinator3 = probe_coordinator(key3)
114
+ token = described_class.claim_instance(instance_key: key3, callable: callable, probe_request_handle: coordinator3)
115
+ probe = described_class.readiness_probe_started(instance_key: key3, publisher_token: token)
116
+ expect do
117
+ described_class.activate_instance_snapshot(
118
+ publisher_token: token, instance_key: key3,
119
+ offerings: [
120
+ *drafts(model: 'm1', native: 'm1-a', supported: %i[chat], tier: :local),
121
+ *drafts(model: 'm1', native: 'm1-b', supported: %i[chat], tier: :local)
122
+ ],
123
+ sequence: 0, probe_token: probe
124
+ )
125
+ end.to raise_error(inventory::Errors::ValidationError, /duplicate lane_id/)
126
+ end
127
+
77
128
  it 'accepts an explicit complete empty activation as complete, not initializing' do
78
129
  token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
79
130
  probe = described_class.readiness_probe_started(instance_key: key, publisher_token: token)
@@ -83,7 +134,7 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
83
134
  expect(result.applied).to be(true)
84
135
  snapshot = described_class.snapshot
85
136
  expect(snapshot.publication_status(instance_key: key).state).to eq(:complete)
86
- expect(snapshot.instance(instance_key: key).offerings_by_id).to eq({})
137
+ expect(snapshot.instance(instance_key: key).lanes_by_id).to eq({})
87
138
  end
88
139
 
89
140
  it 'consumes the probe token so it cannot be reused' do
@@ -23,8 +23,9 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
23
23
  )
24
24
  expect(result.applied).to be(true)
25
25
  expect(result.reason).to eq(:snapshot_replaced)
26
- offerings = described_class.snapshot.offerings_for(instance_key: key)
27
- expect(offerings.map(&:provider_native_key)).to eq(['gemma5'])
26
+ lanes = described_class.snapshot.lanes_for(instance_key: key)
27
+ expect(lanes.map(&:model)).to eq(['gemma5'])
28
+ expect(lanes.map(&:lane_id)).to eq(%w[local:vllm:h200:inference:gemma5])
28
29
  end
29
30
 
30
31
  it 'preserves the exact availability fact across replacement' do
@@ -45,7 +46,7 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
45
46
  it 'treats an empty array as an authoritative complete empty snapshot' do
46
47
  token
47
48
  described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: [], sequence: 1)
48
- expect(described_class.snapshot.offerings_for(instance_key: key)).to be_empty
49
+ expect(described_class.snapshot.lanes_for(instance_key: key)).to be_empty
49
50
  expect(described_class.snapshot.instance(instance_key: key).availability.state).to eq(:available)
50
51
  end
51
52
 
@@ -56,7 +57,7 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
56
57
  expect { described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: duplicate, sequence: 1) }
57
58
  .to raise_error(errors::ValidationError)
58
59
  expect(described_class.snapshot.generation).to eq(generation)
59
- expect(described_class.snapshot.offerings_for(instance_key: key).size).to eq(1)
60
+ expect(described_class.snapshot.lanes_for(instance_key: key).size).to eq(1)
60
61
  end
61
62
 
62
63
  it 'returns a stale publisher result for a superseded token' do
@@ -26,8 +26,7 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Snapshot do
26
26
  record = snapshot.instance(instance_key: key_a)
27
27
  expect(record).to be_frozen
28
28
  expect(snapshot.instance(instance_key: instance_key(instance: 'absent'))).to be_nil
29
- expect(snapshot.lane(lane_id: 'lane:v1:missing')).to be_nil
30
- expect(snapshot.offering(offering_id: 'off:v1:missing')).to be_nil
29
+ expect(snapshot.lane(lane_id: 'local:absent:absent:inference:missing')).to be_nil
31
30
  end
32
31
 
33
32
  it 'only lists activated instances but keeps initializing claims in publication status' do
@@ -41,17 +40,24 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Snapshot do
41
40
  end
42
41
 
43
42
  describe 'ordering and iteration' do
44
- it 'returns frozen lanes_for/offerings_for arrays ordered by id' do
43
+ it 'returns a frozen lanes_for array ordered by 5-tuple id' do
45
44
  snapshot = activate_both
46
45
  lanes = snapshot.lanes_for(instance_key: key_a)
47
46
  expect(lanes).to be_frozen
48
47
  expect(lanes.map(&:lane_id)).to eq(lanes.map(&:lane_id).sort)
48
+ expect(lanes.map(&:lane_id)).to all(match(/\A[a-z_]+:[a-z_0-9]+:[^:]+:[a-z_]+:.+\z/))
49
49
  end
50
50
 
51
- it 'each_lane/each_offering yield in id order and return an Enumerator without a block' do
51
+ it 'exposes lanes only — no offering surface' do
52
+ snapshot = activate_both
53
+ expect(snapshot).not_to respond_to(:offering)
54
+ expect(snapshot).not_to respond_to(:offerings_for)
55
+ expect(snapshot).not_to respond_to(:each_offering)
56
+ end
57
+
58
+ it 'each_lane yields in id order and returns an Enumerator without a block' do
52
59
  snapshot = activate_both
53
60
  expect(snapshot.each_lane).to be_a(Enumerator)
54
- expect(snapshot.each_offering).to be_a(Enumerator)
55
61
  expect(snapshot.each_lane.map(&:lane_id)).to eq(snapshot.each_lane.map(&:lane_id).sort)
56
62
  end
57
63
  end
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'open3'
5
+ require 'rbconfig'
6
+ require 'legion/extensions/llm/inventory/records'
7
+ require_relative '../../../../support/ssot_registry_helpers'
8
+
9
+ RSpec.describe 'Inventory::WeightReconciler' do
10
+ include SsotRegistryHelpers
11
+
12
+ inventory = Legion::Extensions::Llm::Inventory
13
+
14
+ let(:reconciler) { inventory.const_get(:WeightReconciler) }
15
+ let(:tracker_class) { inventory.const_get(:DormantWeightTracker) }
16
+ let(:key) { instance_key(family: 'vllm', instance: 'helios') }
17
+ let(:mutex) { Mutex.new }
18
+ let(:identity_draft) { drafts.first }
19
+
20
+ it 'loads its Set dependency when directly required without the ambient constant' do
21
+ script = <<~RUBY
22
+ require 'tmpdir'
23
+
24
+ Object.send(:remove_const, :Set)
25
+ $LOADED_FEATURES.delete_if { |path| File.basename(path) == 'set.rb' }
26
+ Dir.mktmpdir do |directory|
27
+ File.write(File.join(directory, 'set.rb'), <<~'SET')
28
+ class Set
29
+ def initialize(values = []) = @values = values.to_a.uniq
30
+ def difference(other) = self.class.new(@values.reject { |value| other.include?(value) })
31
+ def sort_by(&block) = @values.sort_by(&block)
32
+ def replace(other) = (@values = other.to_a; self)
33
+ def include?(value) = @values.include?(value)
34
+ def to_a = @values.dup
35
+ end
36
+ SET
37
+ $LOAD_PATH.unshift(directory)
38
+ require 'legion/extensions/llm/inventory/weight_reconciler'
39
+ tracker = Legion::Extensions::Llm::Inventory::DormantWeightTracker.new
40
+ abort 'wrong result' unless tracker.observe(configured_keys: [[:vllm]], published_keys: []).size == 1
41
+ end
42
+ RUBY
43
+
44
+ _stdout, stderr, status = Open3.capture3(
45
+ RbConfig.ruby, '-Ilib', '-e', script,
46
+ chdir: File.expand_path('../../../../..', __dir__)
47
+ )
48
+
49
+ expect(status.success?).to be(true), stderr
50
+ end
51
+
52
+ def settings_for(provider: {}, tier: 100)
53
+ {
54
+ extensions: { llm: { vllm: provider } },
55
+ llm: { routing: { tier_weights: { local: tier } } }
56
+ }
57
+ end
58
+
59
+ def state_for(draft: identity_draft, published: true, sequence: 0)
60
+ {
61
+ instance_key: key, offerings: [draft].freeze, published: published,
62
+ sequence: sequence, publisher_token: 'ptok:v1:test'
63
+ }
64
+ end
65
+
66
+ it 'rebuilds every offering with the current weight pair in a frozen array' do
67
+ rebuilt = reconciler.rebuild_offerings(
68
+ settings: settings_for(provider: { weight: 110 }), instance_key: key,
69
+ offerings: [identity_draft]
70
+ )
71
+
72
+ expect(rebuilt).to be_frozen
73
+ expect(rebuilt.first.weight_inputs)
74
+ .to eq(tier: 100, provider: 110, instance: 100, model_or_offering: 100)
75
+ expect(rebuilt.first.base_weight).to eq(110_000_000)
76
+ expect(rebuilt.first).to eq(
77
+ identity_draft.with(
78
+ weight_inputs: { tier: 100, provider: 110, instance: 100, model_or_offering: 100 },
79
+ base_weight: 110_000_000
80
+ )
81
+ )
82
+ end
83
+
84
+ it 'returns false and publishes nothing when the rebuilt offerings are equivalent' do
85
+ state = state_for
86
+ replace = instance_spy(Proc)
87
+
88
+ result = reconciler.commit_if_changed!(
89
+ settings: settings_for, instance_id: 'helios', state: state,
90
+ discovered_offerings: [identity_draft], mutex: mutex,
91
+ equivalent: ->(previous, current) { previous == current }, replace: replace
92
+ )
93
+
94
+ expect(result).to be(false)
95
+ expect(replace).not_to have_received(:call)
96
+ expect(state).to include(sequence: 0, offerings: [identity_draft])
97
+ end
98
+
99
+ it 'publishes a changed frozen array then atomically updates sequence, cache, and signature' do
100
+ state = state_for
101
+ published = []
102
+ signature = ->(offerings) { offerings.map { |draft| [draft.base_weight, draft.model] } }
103
+ replace = lambda do |instance_id:, state:, offerings:, sequence:|
104
+ published << { instance_id: instance_id, state: state, offerings: offerings, sequence: sequence }
105
+ end
106
+
107
+ result = reconciler.commit_if_changed!(
108
+ settings: settings_for(provider: { weight: 110 }), instance_id: 'helios', state: state,
109
+ discovered_offerings: [identity_draft], mutex: mutex,
110
+ equivalent: ->(previous, current) { previous == current }, replace: replace,
111
+ stable_signature: signature
112
+ )
113
+
114
+ expect(result).to be(true)
115
+ expect(published.one?).to be(true)
116
+ expect(published.first).to include(instance_id: 'helios', state: state, sequence: 1)
117
+ expect(published.first[:offerings]).to be_frozen
118
+ expect(published.first[:offerings]).to be_a(Array)
119
+ expect(state[:sequence]).to eq(1)
120
+ expect(state[:offerings]).to equal(published.first[:offerings])
121
+ expect(state[:signature]).to eq([[110_000_000, 'gemma4']])
122
+ end
123
+
124
+ it 'updates an unpublished cache without replacing or advancing its sequence' do
125
+ state = state_for(published: false)
126
+ replace = instance_spy(Proc)
127
+
128
+ result = reconciler.commit_if_changed!(
129
+ settings: settings_for(provider: { weight: 110 }), instance_id: 'helios', state: state,
130
+ discovered_offerings: [identity_draft], mutex: mutex,
131
+ equivalent: ->(previous, current) { previous == current }, replace: replace
132
+ )
133
+
134
+ expect(result).to be(true)
135
+ expect(replace).not_to have_received(:call)
136
+ expect(state[:sequence]).to eq(0)
137
+ expect(state[:offerings].first.base_weight).to eq(110_000_000)
138
+ end
139
+
140
+ it 'leaves published state unchanged when replacement raises so the next pass can retry' do
141
+ state = state_for
142
+ before = state.dup
143
+ replace = ->(**) { raise 'publisher down' }
144
+
145
+ expect do
146
+ reconciler.commit_if_changed!(
147
+ settings: settings_for(provider: { weight: 110 }), instance_id: 'helios', state: state,
148
+ discovered_offerings: [identity_draft], mutex: mutex,
149
+ equivalent: ->(previous, current) { previous == current }, replace: replace
150
+ )
151
+ end.to raise_error(RuntimeError, 'publisher down')
152
+ expect(state).to eq(before)
153
+ end
154
+
155
+ it 'tracks initializing state and activates it only while the same object remains tracked' do
156
+ states = {}
157
+ state = state_for(published: true)
158
+ reconciler.track_initializing!(states: states, state_key: 'helios', state: state, mutex: mutex)
159
+ expect(state[:published]).to be(false)
160
+ expect(states['helios']).to equal(state)
161
+
162
+ activations = []
163
+ activated = reconciler.activate_tracked!(
164
+ settings: settings_for(provider: { weight: 120 }), instance_id: 'helios',
165
+ state_key: 'helios', state: state, states: states, mutex: mutex,
166
+ probe_token: :probe, activation_sequence: ->(tracked) { tracked.fetch(:sequence) },
167
+ activate: ->(**kwargs) { activations << kwargs }
168
+ )
169
+ expect(activated).to be(true)
170
+ expect(activations.first[:offerings].first.base_weight).to eq(120_000_000)
171
+ expect(state[:offerings]).to equal(activations.first[:offerings])
172
+ expect(state[:published]).to be(true)
173
+
174
+ states.delete('helios')
175
+ expect(
176
+ reconciler.activate_tracked!(
177
+ settings: settings_for, instance_id: 'helios', state_key: 'helios', state: state,
178
+ states: states, mutex: mutex, probe_token: :probe,
179
+ activation_sequence: ->(tracked) { tracked.fetch(:sequence) }, activate: ->(**) { raise 'not called' }
180
+ )
181
+ ).to be(false)
182
+ end
183
+
184
+ it 'leaves initializing state unchanged when activation publication raises' do
185
+ state = state_for(published: false)
186
+ states = { 'helios' => state }
187
+ before = state.dup
188
+
189
+ expect do
190
+ reconciler.activate_tracked!(
191
+ settings: settings_for(provider: { weight: 120 }), instance_id: 'helios',
192
+ state_key: 'helios', state: state, states: states, mutex: mutex,
193
+ probe_token: :probe, activation_sequence: ->(tracked) { tracked.fetch(:sequence) + 1 },
194
+ activate: ->(**) { raise 'activation down' }
195
+ )
196
+ end.to raise_error(RuntimeError, 'activation down')
197
+ expect(state).to eq(before)
198
+ end
199
+
200
+ it 'logs each dormant key once, clears it on appearance, and logs re-disappearance' do
201
+ tracker = tracker_class.new
202
+ model_settings = settings_for(provider: { models: { ghost: { weight: 125 } } })
203
+ states = { 'helios' => state_for }
204
+ logged = []
205
+ observe = lambda do
206
+ reconciler.observe_dormant!(
207
+ settings: model_settings, provider_family: :vllm, states: states,
208
+ mutex: mutex, tracker: tracker, dormant_logger: ->(key_value) { logged << key_value }
209
+ )
210
+ end
211
+
212
+ observe.call
213
+ observe.call
214
+ expect(logged).to eq([[:vllm, :model, 'ghost']])
215
+
216
+ states['helios'] = state_for(draft: drafts(model: 'ghost', native: 'ghost').first)
217
+ observe.call
218
+ states['helios'] = state_for
219
+ observe.call
220
+ expect(logged).to eq([[:vllm, :model, 'ghost'], [:vllm, :model, 'ghost']])
221
+
222
+ tracker.clear!
223
+ observe.call
224
+ expect(logged.size).to eq(3)
225
+ end
226
+
227
+ it 'does not let an unpublished state satisfy a configured key and rejects malformed scopes' do
228
+ tracker = tracker_class.new
229
+ logged = []
230
+ states = { 'helios' => state_for(published: false) }
231
+ settings = settings_for(provider: { models: { gemma4: { weight: 125 } } })
232
+ reconciler.observe_dormant!(
233
+ settings: settings, provider_family: :vllm, states: states, mutex: mutex,
234
+ tracker: tracker, dormant_logger: ->(value) { logged << value }
235
+ )
236
+ expect(logged).to include([:vllm, :model, 'gemma4'])
237
+
238
+ expect do
239
+ reconciler.observe_dormant!(
240
+ settings: settings_for(provider: { models: false }), provider_family: :vllm,
241
+ states: {}, mutex: mutex, tracker: tracker, dormant_logger: ->(_value) {}
242
+ )
243
+ end.to raise_error(ArgumentError, /weight configuration scope must be a Hash/)
244
+ end
245
+
246
+ it 'uses the exact provider, instance, model, instance-model, and offering dormant keys' do
247
+ # The offering-scope key is the lane's 5 tuple — operator-readable.
248
+ offering_lane_id = inventory::Identity.compose_lane_id(
249
+ tier: :local, provider_family: 'vllm', instance_id: 'helios', type: :inference, model: 'gemma4'
250
+ )
251
+ expect(offering_lane_id).to eq('local:vllm:helios:inference:gemma4')
252
+ settings = settings_for(
253
+ provider: {
254
+ weight: false,
255
+ models: { gemma4: { weight: 101 } },
256
+ instances: { helios: { weight: 0, models: { gemma4: { weight: 102 } } } },
257
+ offerings: { offering_lane_id => { weight: 103 } }
258
+ },
259
+ tier: 777
260
+ )
261
+ tracker = tracker_class.new
262
+ logged = []
263
+
264
+ reconciler.observe_dormant!(
265
+ settings: settings, provider_family: :vllm, states: {}, mutex: mutex,
266
+ tracker: tracker, dormant_logger: ->(value) { logged << value }
267
+ )
268
+ expect(logged).to contain_exactly(
269
+ %i[vllm provider],
270
+ [:vllm, :instance, 'helios'],
271
+ [:vllm, :model, 'gemma4'],
272
+ [:vllm, :instance, 'helios', :model, 'gemma4'],
273
+ [:vllm, :offering, offering_lane_id]
274
+ )
275
+ expect(logged.flatten).not_to include(:tier)
276
+
277
+ reconciler.observe_dormant!(
278
+ settings: settings, provider_family: :vllm,
279
+ states: { 'helios' => state_for }, mutex: mutex,
280
+ tracker: tracker, dormant_logger: ->(value) { logged << value }
281
+ )
282
+ expect(logged.size).to eq(5)
283
+ end
284
+
285
+ it 'serializes concurrent ordinary commits and leaves cache equal to the final publication' do
286
+ state = state_for
287
+ publications = []
288
+ ready = Queue.new
289
+ start = Queue.new
290
+ replace = lambda do |offerings:, sequence:, **|
291
+ publications << [sequence, offerings]
292
+ end
293
+ run = lambda do |weight|
294
+ ready << true
295
+ start.pop
296
+ reconciler.commit_if_changed!(
297
+ settings: settings_for(provider: { weight: weight }), instance_id: 'helios', state: state,
298
+ discovered_offerings: [identity_draft], mutex: mutex,
299
+ equivalent: ->(previous, current) { previous == current }, replace: replace
300
+ )
301
+ end
302
+ threads = [Thread.new { run.call(110) }, Thread.new { run.call(120) }]
303
+ 2.times { ready.pop }
304
+ 2.times { start << true }
305
+ threads.each(&:join)
306
+
307
+ expect(publications.map(&:first)).to eq([1, 2])
308
+ expect(state[:sequence]).to eq(2)
309
+ expect(state[:offerings]).to equal(publications.last.last)
310
+ expect(state[:offerings].first.weight_inputs).to eq(publications.last.last.first.weight_inputs)
311
+ end
312
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/llm/inventory/identity'
5
+ require 'legion/extensions/llm/inventory/weight_schema'
6
+
7
+ RSpec.describe 'Inventory::WeightSchema' do
8
+ inventory = Legion::Extensions::Llm::Inventory
9
+
10
+ let(:schema) { inventory.const_get(:WeightSchema) }
11
+ let(:instance_key) do
12
+ inventory::Identity::InstanceKey.new(provider_family: :vllm, instance_id: 'helios')
13
+ end
14
+
15
+ def operation_evidence(supported: %i[chat], **)
16
+ Legion::Extensions::Llm::Taxonomies::OPERATIONS.to_h do |op|
17
+ status, source = supported.include?(op) ? %i[supported provider_implementation] : %i[unknown absent]
18
+ [op, Legion::Extensions::Llm::Inventory::OperationEvidence.new(operation: op, status: status, source: source)]
19
+ end
20
+ end
21
+
22
+ def settings_for(provider: {})
23
+ { extensions: { llm: { vllm: provider } }, llm: { routing: { tier_weights: { direct: 100 } } } }
24
+ end
25
+
26
+ # The offering-scope settings key is the lane's 5 tuple (operator-readable).
27
+ def lane_id(tier: :direct, model: 'model-y', type: :inference)
28
+ Legion::Extensions::Llm::Inventory::Identity.compose_lane_id(
29
+ tier: tier, provider_family: 'vllm', instance_id: 'helios', type: type, model: model
30
+ )
31
+ end
32
+
33
+ def inputs(settings, operation: :chat, model: 'model-y')
34
+ schema.weight_inputs(
35
+ settings: settings, instance_key: instance_key, model: model, tier: :direct,
36
+ operation_evidence: operation_evidence(supported: [operation])
37
+ )
38
+ end
39
+
40
+ it 'multiplies independent scopes without double-counting and lets offering override model' do
41
+ settings = settings_for(
42
+ provider: {
43
+ weight: 100,
44
+ models: { 'model-y' => { weight: 200 } },
45
+ instances: { helios: { weight: 115 } },
46
+ offerings: { lane_id => { weight: 300 } }
47
+ }
48
+ )
49
+
50
+ result = inputs(settings)
51
+ expect(result).to eq(tier: 100, provider: 100, instance: 115, model_or_offering: 300)
52
+ expect(schema.base_weight(result)).to eq(345_000_000)
53
+ end
54
+
55
+ it 'uses provider model weight and lets instance model weight override it' do
56
+ provider = { models: { 'model-y' => { weight: 200 } }, instances: { helios: {} } }
57
+ expect(inputs(settings_for(provider: provider))[:model_or_offering]).to eq(200)
58
+
59
+ provider[:instances][:helios][:models] = { 'model-y' => { weight: 250 } }
60
+ expect(inputs(settings_for(provider: provider))[:model_or_offering]).to eq(250)
61
+ end
62
+
63
+ it 'preserves zero, rejects false, and defaults all missing axes to identity' do
64
+ zero = inputs(settings_for(provider: { weight: 0, instances: { helios: {} } }))
65
+ expect(zero[:provider]).to eq(0)
66
+ expect(schema.base_weight(zero)).to eq(0)
67
+
68
+ expect { inputs(settings_for(provider: { weight: false })) }
69
+ .to raise_error(ArgumentError, /weight component/)
70
+
71
+ identity = inputs(settings_for)
72
+ expect(identity).to eq(tier: 100, provider: 100, instance: 100, model_or_offering: 100)
73
+ expect(schema.base_weight(identity)).to eq(100_000_000)
74
+ end
75
+
76
+ it 'treats the offering scope as the lane 5 tuple: a different model key does not apply' do
77
+ other_model_key = lane_id(model: 'other-model')
78
+ settings = settings_for(
79
+ provider: { offerings: { other_model_key => { weight: 999 }, lane_id => { weight: 321 } } }
80
+ )
81
+
82
+ expect(inputs(settings)[:model_or_offering]).to eq(321)
83
+ end
84
+
85
+ it 'derives the offering scope type from the operation: an embed key does not apply to a chat lane' do
86
+ embed_key = lane_id(type: :embedding)
87
+ settings = settings_for(provider: { offerings: { embed_key => { weight: 999 } } })
88
+
89
+ expect(inputs(settings, operation: :embed)[:model_or_offering]).to eq(999)
90
+ expect(inputs(settings)[:model_or_offering]).to eq(100) # chat draft: no inference key configured
91
+ end
92
+
93
+ it 'falls through to the model scope when no offering-scope key is configured' do
94
+ settings = settings_for(provider: { models: { 'model-y' => { weight: 200 } } })
95
+ expect(inputs(settings)[:model_or_offering]).to eq(200)
96
+ end
97
+
98
+ it 'raises when a multi-type draft has differing configured weights across its lanes' do
99
+ inference_key = lane_id(type: :inference)
100
+ embedding_key = lane_id(type: :embedding)
101
+ settings = settings_for(
102
+ provider: { offerings: { inference_key => { weight: 100 }, embedding_key => { weight: 200 } } }
103
+ )
104
+ expect do
105
+ schema.weight_inputs(
106
+ settings: settings, instance_key: instance_key, model: 'model-y', tier: :direct,
107
+ operation_evidence: operation_evidence(supported: %i[chat embed])
108
+ )
109
+ end.to raise_error(ArgumentError, /ambiguous/)
110
+
111
+ # Equal weights across the draft's lanes resolve to that weight.
112
+ settings = settings_for(
113
+ provider: { offerings: { inference_key => { weight: 100 }, embedding_key => { weight: 100 } } }
114
+ )
115
+ expect(
116
+ schema.weight_inputs(
117
+ settings: settings, instance_key: instance_key, model: 'model-y', tier: :direct,
118
+ operation_evidence: operation_evidence(supported: %i[chat embed])
119
+ )[:model_or_offering]
120
+ ).to eq(100)
121
+ end
122
+
123
+ it 'rejects every present malformed configuration scope instead of defaulting it' do
124
+ cases = {
125
+ 'extensions.llm' => { extensions: { llm: false } },
126
+ 'extensions.llm.vllm' => { extensions: { llm: { vllm: false } } },
127
+ 'provider.instances' => settings_for(provider: { instances: false }),
128
+ 'provider.instances.helios' => settings_for(provider: { instances: { helios: false } }),
129
+ 'llm.routing.tier_weights' => { extensions: { llm: {} }, llm: { routing: { tier_weights: false } } },
130
+ 'provider.offerings' => settings_for(provider: { offerings: false }),
131
+ 'provider.models.model-y' => settings_for(provider: { models: { 'model-y' => false } }),
132
+ 'instance.models.model-y' => settings_for(
133
+ provider: { instances: { helios: { models: { 'model-y' => false } } } }
134
+ )
135
+ }
136
+
137
+ cases.each do |path, settings|
138
+ expect { inputs(settings) }.to raise_error(ArgumentError, /#{Regexp.escape(path)}/)
139
+ end
140
+ end
141
+ end