lex-llm 0.6.16 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/lex-llm.gemspec +1 -1
  4. data/lib/legion/extensions/llm/auto_registration.rb +3 -0
  5. data/lib/legion/extensions/llm/canonical/message.rb +5 -1
  6. data/lib/legion/extensions/llm/fleet/protocol.rb +12 -0
  7. data/lib/legion/extensions/llm/fleet/provider_responder.rb +40 -5
  8. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -0
  9. data/lib/legion/extensions/llm/fleet/worker_execution.rb +166 -2
  10. data/lib/legion/extensions/llm/inventory/callable_handle.rb +144 -0
  11. data/lib/legion/extensions/llm/inventory/errors.rb +34 -0
  12. data/lib/legion/extensions/llm/inventory/evidence.rb +134 -0
  13. data/lib/legion/extensions/llm/inventory/identity.rb +141 -0
  14. data/lib/legion/extensions/llm/inventory/immutable_value.rb +96 -0
  15. data/lib/legion/extensions/llm/inventory/probe_coordinator.rb +117 -0
  16. data/lib/legion/extensions/llm/inventory/probe_token.rb +146 -0
  17. data/lib/legion/extensions/llm/inventory/publisher.rb +116 -0
  18. data/lib/legion/extensions/llm/inventory/records.rb +599 -0
  19. data/lib/legion/extensions/llm/inventory/registry.rb +636 -0
  20. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +206 -0
  21. data/lib/legion/extensions/llm/inventory/snapshot.rb +91 -0
  22. data/lib/legion/extensions/llm/provider.rb +55 -29
  23. data/lib/legion/extensions/llm/routing/lane_key.rb +4 -0
  24. data/lib/legion/extensions/llm/routing/model_offering.rb +3 -0
  25. data/lib/legion/extensions/llm/routing/offering_registry.rb +2 -0
  26. data/lib/legion/extensions/llm/routing/provider_outcome.rb +54 -0
  27. data/lib/legion/extensions/llm/routing/records.rb +288 -0
  28. data/lib/legion/extensions/llm/settings_cascade.rb +120 -0
  29. data/lib/legion/extensions/llm/taxonomies.rb +90 -0
  30. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -1
  31. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +3 -1
  32. data/lib/legion/extensions/llm/version.rb +1 -1
  33. data/lib/legion/extensions/llm.rb +18 -0
  34. data/spec/legion/extensions/llm/canonical/message_spec.rb +24 -0
  35. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +1 -0
  36. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +1 -0
  37. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +1 -0
  38. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +1 -0
  39. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +1 -0
  40. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +1 -0
  41. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +84 -0
  42. data/spec/legion/extensions/llm/conformance/ssot_provider_conformance_spec.rb +13 -0
  43. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +196 -0
  44. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +179 -0
  45. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +26 -0
  46. data/spec/legion/extensions/llm/fleet_messages_spec.rb +14 -0
  47. data/spec/legion/extensions/llm/inventory/boot_spec.rb +80 -0
  48. data/spec/legion/extensions/llm/inventory/callable_handle_spec.rb +127 -0
  49. data/spec/legion/extensions/llm/inventory/evidence_spec.rb +138 -0
  50. data/spec/legion/extensions/llm/inventory/identity_spec.rb +280 -0
  51. data/spec/legion/extensions/llm/inventory/immutable_value_spec.rb +132 -0
  52. data/spec/legion/extensions/llm/inventory/probe_coordinator_spec.rb +107 -0
  53. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +107 -0
  54. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +144 -0
  55. data/spec/legion/extensions/llm/inventory/records_spec.rb +300 -0
  56. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +140 -0
  57. data/spec/legion/extensions/llm/inventory/registry_availability_spec.rb +152 -0
  58. data/spec/legion/extensions/llm/inventory/registry_callable_spec.rb +63 -0
  59. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +69 -0
  60. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +128 -0
  61. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +109 -0
  62. data/spec/legion/extensions/llm/provider_contract_spec.rb +20 -0
  63. data/spec/legion/extensions/llm/provider_spec.rb +83 -0
  64. data/spec/legion/extensions/llm/routing/provider_outcome_spec.rb +73 -0
  65. data/spec/legion/extensions/llm/routing/records_spec.rb +173 -0
  66. data/spec/legion/extensions/llm/settings_cascade_spec.rb +208 -0
  67. data/spec/legion/extensions/llm/taxonomies_spec.rb +51 -0
  68. data/spec/support/fake_llm_provider.rb +11 -0
  69. data/spec/support/fake_ssot_harness.rb +137 -0
  70. data/spec/support/ssot_registry_helpers.rb +75 -0
  71. metadata +40 -3
@@ -0,0 +1,636 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+ require 'securerandom'
5
+ require 'legion/logging'
6
+ require 'legion/extensions/llm/inventory/errors'
7
+ require 'legion/extensions/llm/inventory/identity'
8
+ require 'legion/extensions/llm/inventory/callable_handle'
9
+ require 'legion/extensions/llm/inventory/probe_token'
10
+ require 'legion/extensions/llm/inventory/records'
11
+ require 'legion/extensions/llm/inventory/snapshot'
12
+
13
+ module Legion
14
+ module Extensions
15
+ module Llm
16
+ module Inventory
17
+ # The only mutable SSOT owner: a module-level facade over one internal
18
+ # synchronized Store. Production exposes no constructor. See
19
+ # phase-1-lex-llm-additive.md section 12.
20
+ module Registry
21
+ # Private per-instance state. Never leaves registry.rb.
22
+ ScopeState = ::Data.define(
23
+ :instance_key, :publisher_token, :callable, :callable_handle, :probe_request_handle,
24
+ :publication_status, :last_sequence, :availability, :offerings, :lanes,
25
+ :availability_revision, :unavailable_revision, :published_at
26
+ )
27
+
28
+ # Private issued-probe tracking. Never leaves registry.rb.
29
+ IssuedProbeState = ::Data.define(:probe_token, :consumed)
30
+
31
+ # The synchronized internal store. All validation-against-current plus
32
+ # the root swap happen under @mutation_mutex; snapshot reads are
33
+ # lock-free. It never calls a provider callable, disconnect, probe
34
+ # enqueue handle, logger callback, or transport while holding the
35
+ # mutex; those side effects run after the swap. See section 12.2.
36
+ class Store
37
+ include Legion::Logging::Helper
38
+
39
+ def initialize
40
+ @scopes = Concurrent::Map.new
41
+ @issued_probe_tokens = Concurrent::Map.new
42
+ @generation = 0
43
+ @mutation_mutex = Mutex.new
44
+ @snapshot_ref = Concurrent::AtomicReference.new(build_snapshot_locked)
45
+ end
46
+
47
+ def snapshot
48
+ @snapshot_ref.get
49
+ end
50
+
51
+ def claim_instance(instance_key:, callable:, probe_request_handle:)
52
+ validate_instance_key!(instance_key)
53
+ raise Errors::ValidationError, 'callable must not be nil' if callable.nil?
54
+ raise Errors::ValidationError, 'probe_request_handle must not be nil' if probe_request_handle.nil?
55
+
56
+ handle = CallableHandle.new(handle_id: "call:v1:#{SecureRandom.uuid}", callable: callable)
57
+ token = PublisherToken.issue(instance_key: instance_key)
58
+ old_handle = install_claim(instance_key, callable, handle, probe_request_handle, token)
59
+ old_handle&.retire
60
+ token
61
+ end
62
+
63
+ def readiness_probe_started(instance_key:, publisher_token:)
64
+ validate_instance_key!(instance_key)
65
+ @mutation_mutex.synchronize do
66
+ scope = @scopes[instance_key]
67
+ classification = classify_publisher(scope, publisher_token, instance_key)
68
+ raise Errors::FencedPublisherError, 'superseded or invalid publisher token' unless classification == :current
69
+
70
+ issue_probe_token(scope)
71
+ end
72
+ end
73
+
74
+ def activate_instance_snapshot(publisher_token:, instance_key:, offerings:, sequence:, probe_token:)
75
+ validate_instance_key!(instance_key)
76
+ drafts = ensure_drafts!(offerings)
77
+ prepared = prepare_records(instance_key, drafts)
78
+ @mutation_mutex.synchronize do
79
+ scope = @scopes[instance_key]
80
+ guard = guard_current(scope, publisher_token, instance_key)
81
+ return guard if guard
82
+ raise Errors::InvalidTransitionError, 'activate requires an initializing claim' unless scope.publication_status.state == :initializing
83
+ return stale_mutation(scope, instance_key) unless prepared_matches?(prepared, scope)
84
+
85
+ consume_probe_token!(probe_token, scope, instance_key)
86
+ validate_sequence!(sequence, scope.last_sequence)
87
+ apply_activation(scope, instance_key, prepared, sequence, probe_token)
88
+ end
89
+ end
90
+
91
+ def replace_instance_snapshot(publisher_token:, instance_key:, offerings:, sequence:)
92
+ validate_instance_key!(instance_key)
93
+ drafts = ensure_drafts!(offerings)
94
+ prepared = prepare_records(instance_key, drafts)
95
+ @mutation_mutex.synchronize do
96
+ scope = @scopes[instance_key]
97
+ guard = guard_current(scope, publisher_token, instance_key)
98
+ return guard if guard
99
+ raise Errors::InvalidTransitionError, 'replace requires an activated instance' unless activated?(scope)
100
+ return stale_mutation(scope, instance_key) unless prepared_matches?(prepared, scope)
101
+
102
+ validate_sequence!(sequence, scope.last_sequence)
103
+ apply_replacement(scope, instance_key, prepared, sequence)
104
+ end
105
+ end
106
+
107
+ def readiness_succeeded(instance_key:, probe_token:)
108
+ validate_instance_key!(instance_key)
109
+ @mutation_mutex.synchronize do
110
+ scope = @scopes[instance_key]
111
+ return absent_stale(instance_key) if scope.nil?
112
+
113
+ probe_guard = guard_probe_publisher(scope, probe_token, instance_key)
114
+ return probe_guard if probe_guard
115
+ raise Errors::InvalidTransitionError, 'readiness_succeeded before activation' if scope.publication_status.state == :initializing
116
+
117
+ consume_probe_token!(probe_token, scope, instance_key)
118
+ apply_readiness_success(scope, instance_key, probe_token)
119
+ end
120
+ end
121
+
122
+ def readiness_failed(instance_key:, probe_token:, reason:)
123
+ validate_instance_key!(instance_key)
124
+ @mutation_mutex.synchronize do
125
+ scope = @scopes[instance_key]
126
+ return absent_stale(instance_key) if scope.nil?
127
+
128
+ probe_guard = guard_probe_publisher(scope, probe_token, instance_key)
129
+ return probe_guard if probe_guard
130
+
131
+ consume_probe_token!(probe_token, scope, instance_key)
132
+ apply_readiness_failure(scope, instance_key, probe_token, reason)
133
+ end
134
+ end
135
+
136
+ def dispatch_instance_unavailable(instance_key:, publisher_token_id:, reason:)
137
+ validate_instance_key!(instance_key)
138
+ side_effect = nil
139
+ result =
140
+ @mutation_mutex.synchronize do
141
+ scope = @scopes[instance_key]
142
+ return absent_stale(instance_key) if scope.nil?
143
+ return stale_mutation(scope, instance_key) unless scope.publisher_token.publisher_token_id == publisher_token_id
144
+ raise Errors::InvalidTransitionError, 'dispatch_instance_unavailable requires an activated instance' unless activated?(scope)
145
+
146
+ mutation, side_effect = apply_dispatch_unavailable(scope, instance_key, reason)
147
+ mutation
148
+ end
149
+ enqueue_probe(*side_effect) if side_effect
150
+ result
151
+ end
152
+
153
+ def remove_instance(instance_key:, publisher_token:)
154
+ validate_instance_key!(instance_key)
155
+ old_handle = nil
156
+ result =
157
+ @mutation_mutex.synchronize do
158
+ scope = @scopes[instance_key]
159
+ return absent_removed(instance_key) if scope.nil?
160
+
161
+ guard = guard_current(scope, publisher_token, instance_key)
162
+ return guard if guard
163
+
164
+ old_handle = scope.callable_handle
165
+ @scopes.delete(instance_key)
166
+ bump_and_snapshot!
167
+ MutationResult.new(applied: true, reason: :removed, generation: @generation, instance_key: instance_key)
168
+ end
169
+ old_handle&.retire
170
+ result
171
+ end
172
+
173
+ def acquire(callable_handle:)
174
+ raise Errors::UnknownCallableError, 'acquire requires a CallableHandle' unless callable_handle.is_a?(CallableHandle)
175
+
176
+ callable_handle.acquire
177
+ end
178
+
179
+ def retire_all_handles
180
+ @scopes.each_value { |scope| scope.callable_handle.retire }
181
+ end
182
+
183
+ private
184
+
185
+ # --- Claim -------------------------------------------------------
186
+
187
+ def install_claim(instance_key, callable, handle, probe_request_handle, token)
188
+ @mutation_mutex.synchronize do
189
+ prior = @scopes[instance_key]
190
+ raise Errors::ValidationError, 'a new claim must provide a distinct callable object' if prior && prior.callable.equal?(callable)
191
+
192
+ status = PublicationStatus.new(
193
+ instance_key: instance_key, state: :initializing,
194
+ publisher_token_id: token.publisher_token_id, published_sequence: nil
195
+ )
196
+ @scopes[instance_key] = ScopeState.new(
197
+ instance_key: instance_key, publisher_token: token, callable: callable, callable_handle: handle,
198
+ probe_request_handle: probe_request_handle, publication_status: status,
199
+ last_sequence: -1, availability: nil, offerings: {}, lanes: {},
200
+ availability_revision: prior&.availability_revision || 0,
201
+ unavailable_revision: prior&.unavailable_revision, published_at: nil
202
+ )
203
+ bump_and_snapshot!
204
+ prior&.callable_handle
205
+ end
206
+ end
207
+
208
+ # --- Activation --------------------------------------------------
209
+
210
+ def apply_activation(scope, instance_key, prepared, sequence, probe_token)
211
+ now = Time.now
212
+ new_rev = scope.availability_revision + 1
213
+ availability = AvailabilityFact.new(
214
+ state: :available, availability_revision: new_rev, source: :startup_readiness,
215
+ reason: 'startup readiness succeeded', observed_at: now,
216
+ last_probe_started_at: probe_token.started_at, last_probe_completed_at: now, last_probe_outcome: :success
217
+ )
218
+ status = complete_status(scope, sequence, probe_token, now, :success)
219
+ new_scope = scope.with(
220
+ publication_status: status, last_sequence: sequence, availability: availability,
221
+ offerings: prepared[1], lanes: prepared[2], availability_revision: new_rev,
222
+ unavailable_revision: nil, published_at: now
223
+ )
224
+ @scopes[instance_key] = new_scope
225
+ bump_and_snapshot!
226
+ applied(:activated, new_scope)
227
+ end
228
+
229
+ def complete_status(scope, sequence, probe_token, now, outcome)
230
+ scope.publication_status.with(
231
+ state: :complete, published_sequence: sequence, last_probe_started_at: probe_token.started_at,
232
+ last_probe_completed_at: now, last_probe_outcome: outcome
233
+ )
234
+ end
235
+
236
+ # --- Replacement -------------------------------------------------
237
+
238
+ def apply_replacement(scope, instance_key, prepared, sequence)
239
+ status = scope.publication_status.with(published_sequence: sequence)
240
+ new_scope = scope.with(offerings: prepared[1], lanes: prepared[2], last_sequence: sequence, publication_status: status)
241
+ @scopes[instance_key] = new_scope
242
+ bump_and_snapshot!
243
+ applied(:snapshot_replaced, new_scope)
244
+ end
245
+
246
+ # --- Readiness success -------------------------------------------
247
+
248
+ def apply_readiness_success(scope, instance_key, probe_token)
249
+ if scope.availability.state == :available
250
+ observe_available_success(scope, instance_key, probe_token)
251
+ elsif probe_token.started_availability_revision >= scope.unavailable_revision
252
+ recover_instance(scope, instance_key, probe_token)
253
+ else
254
+ MutationResult.new(
255
+ applied: false, reason: :stale_probe, generation: @generation, instance_key: instance_key,
256
+ publication_status: scope.publication_status, instance_record: instance_record_for(scope)
257
+ )
258
+ end
259
+ end
260
+
261
+ def observe_available_success(scope, instance_key, probe_token)
262
+ now = Time.now
263
+ availability = scope.availability.with(
264
+ source: :readiness, last_probe_started_at: probe_token.started_at,
265
+ last_probe_completed_at: now, last_probe_outcome: :success
266
+ )
267
+ status = scope.publication_status.with(
268
+ last_probe_started_at: probe_token.started_at, last_probe_completed_at: now, last_probe_outcome: :success
269
+ )
270
+ new_scope = scope.with(availability: availability, publication_status: status)
271
+ @scopes[instance_key] = new_scope
272
+ bump_and_snapshot!
273
+ applied(:readiness_observed, new_scope)
274
+ end
275
+
276
+ def recover_instance(scope, instance_key, probe_token)
277
+ now = Time.now
278
+ new_rev = scope.availability_revision + 1
279
+ availability = AvailabilityFact.new(
280
+ state: :available, availability_revision: new_rev, source: :readiness,
281
+ reason: 'readiness recovered exact instance', observed_at: now,
282
+ last_probe_started_at: probe_token.started_at, last_probe_completed_at: now, last_probe_outcome: :success
283
+ )
284
+ status = scope.publication_status.with(
285
+ last_probe_started_at: probe_token.started_at, last_probe_completed_at: now, last_probe_outcome: :success
286
+ )
287
+ new_scope = scope.with(
288
+ availability: availability, availability_revision: new_rev, unavailable_revision: nil, publication_status: status
289
+ )
290
+ @scopes[instance_key] = new_scope
291
+ bump_and_snapshot!
292
+ applied(:instance_recovered, new_scope)
293
+ end
294
+
295
+ # --- Readiness failure -------------------------------------------
296
+
297
+ def apply_readiness_failure(scope, instance_key, probe_token, reason)
298
+ now = Time.now
299
+ if scope.publication_status.state == :initializing
300
+ status = scope.publication_status.with(
301
+ last_probe_started_at: probe_token.started_at, last_probe_completed_at: now,
302
+ last_probe_outcome: :failure, last_error: reason
303
+ )
304
+ new_scope = scope.with(publication_status: status)
305
+ @scopes[instance_key] = new_scope
306
+ bump_and_snapshot!
307
+ return MutationResult.new(
308
+ applied: true, reason: :initial_readiness_failed, generation: @generation,
309
+ instance_key: instance_key, publication_status: status
310
+ )
311
+ end
312
+ mark_unavailable_from_readiness(scope, instance_key, probe_token, reason, now)
313
+ end
314
+
315
+ def mark_unavailable_from_readiness(scope, instance_key, probe_token, reason, now)
316
+ new_rev = scope.availability_revision + 1
317
+ availability = AvailabilityFact.new(
318
+ state: :unavailable, availability_revision: new_rev, source: :readiness, reason: reason, observed_at: now,
319
+ last_probe_started_at: probe_token.started_at, last_probe_completed_at: now,
320
+ last_probe_outcome: :failure, unavailable_revision: new_rev
321
+ )
322
+ status = scope.publication_status.with(
323
+ last_probe_started_at: probe_token.started_at, last_probe_completed_at: now,
324
+ last_probe_outcome: :failure, last_error: reason
325
+ )
326
+ new_scope = scope.with(
327
+ availability: availability, availability_revision: new_rev, unavailable_revision: new_rev, publication_status: status
328
+ )
329
+ @scopes[instance_key] = new_scope
330
+ bump_and_snapshot!
331
+ applied(:instance_unavailable, new_scope)
332
+ end
333
+
334
+ # --- Dispatch-reported unavailable -------------------------------
335
+
336
+ def apply_dispatch_unavailable(scope, instance_key, reason)
337
+ now = Time.now
338
+ new_rev = scope.availability_revision + 1
339
+ availability = AvailabilityFact.new(
340
+ state: :unavailable, availability_revision: new_rev, source: :dispatch, reason: reason,
341
+ observed_at: now, unavailable_revision: new_rev
342
+ )
343
+ new_scope = scope.with(availability: availability, availability_revision: new_rev, unavailable_revision: new_rev)
344
+ @scopes[instance_key] = new_scope
345
+ bump_and_snapshot!
346
+ [applied(:instance_unavailable, new_scope),
347
+ [scope.probe_request_handle, instance_key, scope.publisher_token.publisher_token_id, new_rev, reason]]
348
+ end
349
+
350
+ def enqueue_probe(handle, instance_key, token_id, unavailable_revision, reason)
351
+ handle.enqueue_probe_request(
352
+ instance_key: instance_key, publisher_token_id: token_id,
353
+ unavailable_revision: unavailable_revision, reason: reason
354
+ )
355
+ rescue StandardError => e
356
+ handle_exception(
357
+ e, handled: true, level: :warn, operation: 'llm.inventory.registry.enqueue_probe',
358
+ provider_family: instance_key.provider_family, instance_id: instance_key.instance_id
359
+ )
360
+ end
361
+
362
+ # --- Probe token single-use --------------------------------------
363
+
364
+ def issue_probe_token(scope)
365
+ token = ProbeToken.issue(
366
+ instance_key: scope.instance_key, publisher_token_id: scope.publisher_token.publisher_token_id,
367
+ started_availability_revision: scope.availability_revision, started_at: Time.now
368
+ )
369
+ @issued_probe_tokens[token.token_id] = IssuedProbeState.new(probe_token: token, consumed: false)
370
+ status = scope.publication_status.with(last_probe_started_at: token.started_at)
371
+ @scopes[scope.instance_key] = scope.with(publication_status: status)
372
+ bump_and_snapshot!
373
+ token
374
+ end
375
+
376
+ def consume_probe_token!(probe_token, scope, instance_key)
377
+ validate_probe_token_shape!(probe_token, instance_key)
378
+ issued = @issued_probe_tokens[probe_token.token_id]
379
+ raise Errors::InvalidProbeError, 'unknown probe token' if issued.nil?
380
+ raise Errors::InvalidProbeError, 'probe token already consumed' if issued.consumed
381
+ raise Errors::InvalidProbeError, 'probe token does not belong to the current publisher' unless probe_token.publisher_token_id == scope.publisher_token.publisher_token_id
382
+
383
+ @issued_probe_tokens[probe_token.token_id] = issued.with(consumed: true)
384
+ end
385
+
386
+ def validate_probe_token_shape!(probe_token, instance_key)
387
+ return if probe_token.is_a?(ProbeToken) && probe_token.instance_key == instance_key
388
+
389
+ raise Errors::InvalidProbeError, 'probe token is malformed or for another instance'
390
+ end
391
+
392
+ # --- Fencing -----------------------------------------------------
393
+
394
+ def classify_publisher(scope, token, instance_key)
395
+ return :fenced unless token.is_a?(PublisherToken) && token.instance_key == instance_key
396
+ return :stale if scope.nil?
397
+
398
+ if scope.publisher_token.publisher_token_id == token.publisher_token_id
399
+ scope.publisher_token.authenticates?(token) ? :current : :fenced
400
+ else
401
+ :stale
402
+ end
403
+ end
404
+
405
+ # Returns a MutationResult when the publisher is not current, nil when
406
+ # it is current. Raises FencedPublisherError for a forged/malformed
407
+ # token.
408
+ def guard_current(scope, token, instance_key)
409
+ case classify_publisher(scope, token, instance_key)
410
+ when :fenced then raise Errors::FencedPublisherError, 'invalid or forged publisher token'
411
+ when :stale then stale_mutation(scope, instance_key)
412
+ end
413
+ end
414
+
415
+ def guard_probe_publisher(scope, probe_token, instance_key)
416
+ validate_probe_token_shape!(probe_token, instance_key)
417
+ return nil if probe_token.publisher_token_id == scope.publisher_token.publisher_token_id
418
+
419
+ stale_mutation(scope, instance_key)
420
+ end
421
+
422
+ # --- Snapshot construction ---------------------------------------
423
+
424
+ def bump_and_snapshot!
425
+ @generation += 1
426
+ @snapshot_ref.set(build_snapshot_locked)
427
+ end
428
+
429
+ def build_snapshot_locked
430
+ instances = {}
431
+ offerings = {}
432
+ lanes = {}
433
+ statuses = {}
434
+ @scopes.each_pair do |key, scope|
435
+ statuses[key] = scope.publication_status
436
+ next if scope.availability.nil?
437
+
438
+ instances[key] = instance_record_for(scope)
439
+ offerings.merge!(scope.offerings)
440
+ lanes.merge!(scope.lanes)
441
+ end
442
+ Snapshot.new(
443
+ generation: @generation, instances_by_key: instances, offerings_by_id: offerings,
444
+ lanes_by_id: lanes, publication_status_by_key: statuses
445
+ )
446
+ end
447
+
448
+ def instance_record_for(scope)
449
+ InstanceRecord.new(
450
+ instance_key: scope.instance_key, callable_handle: scope.callable_handle, availability: scope.availability,
451
+ offerings_by_id: scope.offerings, publisher_id: scope.publisher_token.publisher_id,
452
+ publisher_token_id: scope.publisher_token.publisher_token_id, published_sequence: scope.last_sequence,
453
+ published_at: scope.published_at
454
+ )
455
+ end
456
+
457
+ # --- Record derivation (pure; no I/O or application callbacks) ----
458
+
459
+ def prepare_records(instance_key, drafts)
460
+ scope = @scopes[instance_key]
461
+ return nil if scope.nil?
462
+
463
+ offerings_by_id, lanes_by_id = build_records(instance_key, scope.callable_handle, drafts)
464
+ [scope.callable_handle, offerings_by_id, lanes_by_id]
465
+ end
466
+
467
+ def prepared_matches?(prepared, scope)
468
+ !prepared.nil? && prepared[0].equal?(scope.callable_handle)
469
+ end
470
+
471
+ def build_records(instance_key, handle, drafts)
472
+ offerings_by_id = {}
473
+ native_keys = {}
474
+ drafts.each do |draft|
475
+ offering_id = Identity.offering_id(instance_key: instance_key, provider_native_key: draft.provider_native_key)
476
+ raise Errors::ValidationError, 'duplicate provider_native_key' if native_keys.key?(draft.provider_native_key)
477
+ raise Errors::ValidationError, 'duplicate offering_id' if offerings_by_id.key?(offering_id)
478
+
479
+ native_keys[draft.provider_native_key] = true
480
+ offerings_by_id[offering_id] = build_offering_record(instance_key, handle, offering_id, draft)
481
+ end
482
+ [offerings_by_id.freeze, build_lanes(instance_key, handle, offerings_by_id).freeze]
483
+ end
484
+
485
+ def build_offering_record(instance_key, handle, offering_id, draft)
486
+ OfferingRecord.new(
487
+ offering_id: offering_id, provider_native_key: draft.provider_native_key, instance_key: instance_key,
488
+ model: draft.model, tier: draft.tier, operation_evidence: draft.operation_evidence,
489
+ capability_evidence: draft.capability_evidence, context_evidence: draft.context_evidence,
490
+ max_output_evidence: draft.max_output_evidence,
491
+ embedding_dimensions_evidence: draft.embedding_dimensions_evidence,
492
+ model_revision_evidence: draft.model_revision_evidence, tokenizer_evidence: draft.tokenizer_evidence,
493
+ quota_domains: draft.quota_domains, metadata: draft.metadata, callable_handle: handle,
494
+ publication_source: draft.publication_source
495
+ )
496
+ end
497
+
498
+ def build_lanes(instance_key, handle, offerings_by_id)
499
+ lanes = {}
500
+ offerings_by_id.each_value do |offering|
501
+ offering.supported_operations.each do |operation|
502
+ lane_id = Identity.lane_id(
503
+ instance_key: instance_key, operation: operation, model: offering.model, offering_id: offering.offering_id
504
+ )
505
+ raise Errors::ValidationError, 'duplicate lane_id' if lanes.key?(lane_id)
506
+
507
+ lanes[lane_id] = build_lane_record(instance_key, handle, offering, operation, lane_id)
508
+ end
509
+ end
510
+ lanes
511
+ end
512
+
513
+ def build_lane_record(instance_key, handle, offering, operation, lane_id)
514
+ LaneRecord.new(
515
+ lane_id: lane_id, offering_id: offering.offering_id, instance_key: instance_key,
516
+ provider_family: instance_key.provider_family, instance_id: instance_key.instance_id,
517
+ model: offering.model, tier: offering.tier, operation: operation,
518
+ capability_evidence: offering.capability_evidence, context_evidence: offering.context_evidence,
519
+ max_output_evidence: offering.max_output_evidence,
520
+ embedding_dimensions_evidence: offering.embedding_dimensions_evidence,
521
+ model_revision_evidence: offering.model_revision_evidence, tokenizer_evidence: offering.tokenizer_evidence,
522
+ quota_domain: offering.quota_domain(operation: operation), metadata: offering.metadata,
523
+ callable_handle: handle, publication_source: offering.publication_source
524
+ )
525
+ end
526
+
527
+ # --- Small validators and result builders ------------------------
528
+
529
+ def ensure_drafts!(offerings)
530
+ raise Errors::ValidationError, 'offerings must be an Array of OfferingDraft' unless offerings.is_a?(::Array)
531
+ raise Errors::ValidationError, 'offerings must contain only OfferingDraft values' unless offerings.all?(OfferingDraft)
532
+
533
+ offerings
534
+ end
535
+
536
+ def validate_instance_key!(instance_key)
537
+ raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
538
+ end
539
+
540
+ def validate_sequence!(sequence, last_sequence)
541
+ return if sequence.is_a?(::Integer) && !sequence.negative? && sequence > last_sequence
542
+
543
+ raise Errors::StaleSequenceError, 'sequence must be a nonnegative Integer greater than the last accepted sequence'
544
+ end
545
+
546
+ def activated?(scope)
547
+ !scope.availability.nil? && scope.publication_status.state == :complete
548
+ end
549
+
550
+ def applied(reason, scope)
551
+ MutationResult.new(
552
+ applied: true, reason: reason, generation: @generation, instance_key: scope.instance_key,
553
+ publication_status: scope.publication_status, instance_record: instance_record_for(scope)
554
+ )
555
+ end
556
+
557
+ def stale_mutation(scope, instance_key)
558
+ MutationResult.new(
559
+ applied: false, reason: :stale_publisher, generation: @generation, instance_key: instance_key,
560
+ publication_status: scope&.publication_status,
561
+ instance_record: scope&.availability ? instance_record_for(scope) : nil
562
+ )
563
+ end
564
+
565
+ def absent_stale(instance_key)
566
+ MutationResult.new(applied: false, reason: :stale_publisher, generation: @generation, instance_key: instance_key)
567
+ end
568
+
569
+ def absent_removed(instance_key)
570
+ MutationResult.new(applied: false, reason: :already_removed, generation: @generation, instance_key: instance_key)
571
+ end
572
+ end
573
+
574
+ class << self
575
+ def claim_instance(...)
576
+ store.claim_instance(...)
577
+ end
578
+
579
+ def readiness_probe_started(...)
580
+ store.readiness_probe_started(...)
581
+ end
582
+
583
+ def activate_instance_snapshot(...)
584
+ store.activate_instance_snapshot(...)
585
+ end
586
+
587
+ def replace_instance_snapshot(...)
588
+ store.replace_instance_snapshot(...)
589
+ end
590
+
591
+ def readiness_succeeded(...)
592
+ store.readiness_succeeded(...)
593
+ end
594
+
595
+ def readiness_failed(...)
596
+ store.readiness_failed(...)
597
+ end
598
+
599
+ def dispatch_instance_unavailable(...)
600
+ store.dispatch_instance_unavailable(...)
601
+ end
602
+
603
+ def remove_instance(...)
604
+ store.remove_instance(...)
605
+ end
606
+
607
+ def snapshot
608
+ store.snapshot
609
+ end
610
+
611
+ def acquire(...)
612
+ store.acquire(...)
613
+ end
614
+
615
+ # Test-only: replace the store with a fresh generation-zero store and
616
+ # retire prior handles outside its mutation mutex.
617
+ def reset!
618
+ raise Errors::InvalidTransitionError, 'Registry.reset! is only available under RSpec' unless defined?(::RSpec)
619
+
620
+ previous = @store
621
+ @store = Store.new
622
+ previous&.retire_all_handles
623
+ @store
624
+ end
625
+
626
+ private
627
+
628
+ def store
629
+ @store ||= Store.new
630
+ end
631
+ end
632
+ end
633
+ end
634
+ end
635
+ end
636
+ end