lex-llm-azure-foundry 0.3.4 → 0.3.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4bd55b677c93605664cd0865b329ecb256da01604c0d4e518f0fc4f60aa881b1
4
- data.tar.gz: 59b84b52669fcb74740f563acc88e05b12e24e93765354f0b0f306d21ce9c4d9
3
+ metadata.gz: 49e5d8dc9a03ec7e353324aaf22050bf23a28ff085da22a8f779c7b01b28ffe0
4
+ data.tar.gz: 3c8c342a9bc24b2133a2060f9cc9dc9a9743d4d69bda1205e26db02f6d5a4f59
5
5
  SHA512:
6
- metadata.gz: c5171449f55eb43c93f88ece8a3e2db532a51be1176d29c0769d926b10add693296ca78002360f5525ee5412b7ec4b43b40e96a24a5255aca256a9cca511567e
7
- data.tar.gz: e804a59536425ff887d8b0d6787c801ed78280e239634c7be0749a513d366b17db5a9be12f58a548b3760e6f67148ba577da345ee99864089f0902da498d03fe
6
+ metadata.gz: ba398b8cdb7dcd17fa6138c9edccc482b66abb201c8ce198d4373d83ced0de5f8c1c756be4b55ec3b3a64921278db4d3b6673d146a77ed2121d6e7317622be79
7
+ data.tar.gz: d1c2877ae02270b74dba8be1daa23c9c676070c4d705194fa35933d31206d3c9bd5eccb0d2837c80ffe2fe278153766f0865d1e53d077cd4b9e84ce67c5fdef0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.5] - 2026-08-19
4
+
5
+ ### Changed
6
+ - **Write-time SSOT lane weights** — Build Azure deployment drafts with the shared
7
+ four-component weight pair using the deployment name as the provider-native
8
+ offering identity, and reconcile weight-only changes atomically on the existing
9
+ discovery cadence. Initial/recovery activation now rebuilds from current settings,
10
+ removal wins readiness races, and dormant configured weights log once per absence
11
+ period without any Settings lifecycle callback.
12
+ - **Callable-path system conformance** — Capture the rendered Azure/OpenAI-compatible
13
+ HTTP payload and verify a folded leading system message remains in the dialect-native
14
+ `role: system` message position.
15
+ - **Dependency floor** — Raise `lex-llm` to `>= 0.7.6` for `WeightSchema`,
16
+ `WeightReconciler`, and the immutable record weight pair. The `legion-settings`
17
+ dependency and provider/operator workflow are unchanged.
18
+
19
+ ### Fixed
20
+ - **Malformed-weight startup cleanup** — Validate and build weighted offering
21
+ drafts before constructing or claiming any callable scope, so invalid weight
22
+ values cannot leave an orphaned initializing Registry publication. A later
23
+ corrected discovery pass claims and activates the instance normally without
24
+ requiring a restart or operator cleanup.
25
+ - **Complete offering replacement comparison** — Ordinary discovery now compares
26
+ every authoritative `OfferingDraft` field while ignoring only evidence
27
+ observation timestamps. Deployment order does not churn, duplicate counts stay
28
+ significant, and native-key, evidence, quota, metadata, publication-source,
29
+ tier, or weight drift advances the Registry snapshot exactly once.
30
+
3
31
  ## [0.3.4] - 2026-08-18
4
32
 
5
33
  ### Fixed
@@ -27,5 +27,5 @@ Gem::Specification.new do |spec|
27
27
  spec.add_dependency 'legion-logging', '>= 1.3.2'
28
28
  spec.add_dependency 'legion-settings', '>= 1.4.2'
29
29
  spec.add_dependency 'legion-transport', '>= 1.4.14'
30
- spec.add_dependency 'lex-llm', '>= 0.7.1'
30
+ spec.add_dependency 'lex-llm', '>= 0.7.6'
31
31
  end
@@ -11,6 +11,7 @@ require 'legion/extensions/llm/inventory/records'
11
11
  require 'legion/extensions/llm/inventory/evidence'
12
12
  require 'legion/extensions/llm/inventory/probe_coordinator'
13
13
  require 'legion/extensions/llm/inventory/scoped_refresher'
14
+ require 'legion/extensions/llm/inventory/weight_reconciler'
14
15
  require 'legion/extensions/llm/routing/provider_outcome'
15
16
  require 'legion/extensions/llm/taxonomies'
16
17
  require 'legion/extensions/llm/capabilities'
@@ -257,18 +258,34 @@ module Legion
257
258
 
258
259
  Legion::Extensions::Llm::Inventory::OfferingDraft.new(
259
260
  provider_native_key: deployment_name, model: model_id, tier: tier,
261
+ **offering_evidence_fields(deployment: deployment, instance_cfg: instance_cfg, embed: embed),
262
+ quota_domains: build_quota_domains(deployment_name: deployment_name),
263
+ metadata: build_offering_metadata(deployment: deployment, deployment_name: deployment_name,
264
+ instance_key: instance_key).freeze,
265
+ publication_source: :provider_static_catalog,
266
+ **weight_fields(instance_key: instance_key, deployment_name: deployment_name,
267
+ model_id: model_id, tier: tier)
268
+ )
269
+ end
270
+
271
+ def weight_fields(instance_key:, deployment_name:, model_id:, tier:)
272
+ inputs = Legion::Extensions::Llm::Inventory::WeightSchema.weight_inputs(
273
+ settings: Legion::Settings, instance_key: instance_key,
274
+ provider_native_key: deployment_name, model: model_id, tier: tier
275
+ )
276
+ { weight_inputs: inputs, base_weight: Legion::Extensions::Llm::Inventory::WeightSchema.base_weight(inputs) }
277
+ end
278
+
279
+ def offering_evidence_fields(deployment:, instance_cfg:, embed:)
280
+ {
260
281
  operation_evidence: build_operation_evidence(embed_supported: embed),
261
282
  capability_evidence: build_capability_evidence(deployment: deployment, embed_supported: embed),
262
283
  context_evidence: build_context_evidence(deployment: deployment, instance_cfg: instance_cfg),
263
284
  max_output_evidence: build_max_output_evidence(deployment: deployment, instance_cfg: instance_cfg),
264
285
  embedding_dimensions_evidence: absent_value_evidence,
265
286
  model_revision_evidence: absent_value_evidence,
266
- tokenizer_evidence: absent_value_evidence,
267
- quota_domains: build_quota_domains(deployment_name: deployment_name),
268
- metadata: build_offering_metadata(deployment: deployment, deployment_name: deployment_name,
269
- instance_key: instance_key).freeze,
270
- publication_source: :provider_static_catalog
271
- )
287
+ tokenizer_evidence: absent_value_evidence
288
+ }
272
289
  end
273
290
 
274
291
  def build_context_evidence(deployment:, instance_cfg:)
@@ -311,59 +328,47 @@ module Legion
311
328
  end
312
329
  end
313
330
 
314
- # Offerings change detection. Time.now observed_at stamps in the
315
- # evidence poison Data#==, so a rebuilt-but-unchanged catalog would
316
- # otherwise replace the snapshot (and bump the sequence) every tick.
317
- # Compare identity/status fields only.
331
+ # Complete OfferingDraft comparison. Evidence observation timestamps
332
+ # are telemetry-only; every other draft and evidence field remains
333
+ # authoritative. Catalog order is set-like and duplicate counts remain
334
+ # significant.
318
335
  module OfferingComparisonHelpers
336
+ SCALAR_EVIDENCE_FIELDS = %i[
337
+ context_evidence max_output_evidence embedding_dimensions_evidence
338
+ model_revision_evidence tokenizer_evidence
339
+ ].freeze
340
+
319
341
  private
320
342
 
321
343
  def offerings_changed?(previous:, current:)
322
- return true unless previous.size == current.size
323
-
324
- current.any? do |draft|
325
- previous.none? { |candidate| drafts_stable?(candidate, draft) }
326
- end
344
+ offerings_contract(previous) != offerings_contract(current)
327
345
  end
328
346
 
329
- def drafts_stable?(candidate, draft)
330
- basic_fields_stable?(candidate, draft) &&
331
- evidence_maps_stable?(candidate.operation_evidence, draft.operation_evidence) &&
332
- evidence_maps_stable?(candidate.capability_evidence, draft.capability_evidence) &&
333
- value_fields_stable?(candidate, draft) &&
334
- candidate.quota_domains == draft.quota_domains &&
335
- candidate.metadata == draft.metadata
336
- end
337
-
338
- def basic_fields_stable?(candidate, draft)
339
- candidate.model == draft.model &&
340
- candidate.tier == draft.tier &&
341
- evidence_key_set_stable?(candidate.operation_evidence, draft.operation_evidence) &&
342
- evidence_key_set_stable?(candidate.capability_evidence, draft.capability_evidence)
343
- end
344
-
345
- def evidence_key_set_stable?(previous_map, current_map)
346
- previous_map.keys.sort == current_map.keys.sort
347
+ def offerings_contract(offerings)
348
+ contracts = offerings.map { |draft| stable_offering_contract(draft) }
349
+ grouped = contracts.group_by do |contract|
350
+ [contract.fetch(:provider_native_key), contract.fetch(:model)]
351
+ end
352
+ grouped.sort_by { |identity, _| identity.map { |value| value.to_s.b } }
353
+ .to_h.transform_values(&:tally)
347
354
  end
348
355
 
349
- def evidence_maps_stable?(previous_map, current_map)
350
- previous_map.all? do |key, evidence|
351
- other = current_map[key]
352
- other&.status == evidence.status && other&.source == evidence.source
356
+ def stable_offering_contract(draft)
357
+ contract = draft.to_h
358
+ contract[:operation_evidence] = stable_evidence_map(contract.fetch(:operation_evidence))
359
+ contract[:capability_evidence] = stable_evidence_map(contract.fetch(:capability_evidence))
360
+ SCALAR_EVIDENCE_FIELDS.each do |field|
361
+ contract[field] = stable_evidence(contract.fetch(field))
353
362
  end
363
+ contract
354
364
  end
355
365
 
356
- def value_fields_stable?(candidate, draft)
357
- values_stable?(candidate.context_evidence, draft.context_evidence) &&
358
- values_stable?(candidate.max_output_evidence, draft.max_output_evidence) &&
359
- values_stable?(candidate.embedding_dimensions_evidence, draft.embedding_dimensions_evidence) &&
360
- values_stable?(candidate.model_revision_evidence, draft.model_revision_evidence)
366
+ def stable_evidence_map(evidence_by_key)
367
+ evidence_by_key.transform_values { |evidence| stable_evidence(evidence) }
361
368
  end
362
369
 
363
- def values_stable?(previous_value, current_value)
364
- previous_value.status == current_value.status &&
365
- previous_value.value == current_value.value &&
366
- previous_value.source == current_value.source
370
+ def stable_evidence(evidence)
371
+ evidence.to_h.except(:observed_at)
367
372
  end
368
373
  end
369
374
 
@@ -460,7 +465,7 @@ module Legion
460
465
  end
461
466
 
462
467
  def handle_reactive_probe(instance_id:, request:)
463
- state = @instance_states && @instance_states[instance_id]
468
+ state = tracked_instance_state(instance_id)
464
469
  return unless state
465
470
 
466
471
  coordinator = state[:probe_coordinator]
@@ -481,6 +486,12 @@ module Legion
481
486
  instance_id: instance_id)
482
487
  end
483
488
 
489
+ def tracked_instance_state(instance_id)
490
+ return unless @instance_states
491
+
492
+ state_mutex.synchronize { @instance_states[instance_id] }
493
+ end
494
+
484
495
  def finish_probe_safely(coordinator:, request: nil)
485
496
  args = request ? { request: request } : {}
486
497
  coordinator&.finish_probe(**args)
@@ -528,17 +539,29 @@ module Legion
528
539
  def remove_all_instances
529
540
  return unless @instance_states
530
541
 
531
- @instance_states.each do |instance_id, state|
532
- publisher.remove_instance(
533
- instance_id: instance_id, publisher_token: state[:publisher_token],
534
- physical_id: state[:instance_key].physical_id
535
- )
536
- clear_instance_health(config_name: state[:name])
542
+ drain_instance_states.each do |instance_id, state|
543
+ remove_published_instance(instance_id: instance_id, state: state)
537
544
  rescue StandardError => e
538
545
  handle_exception(e, level: :warn, operation: 'azure_foundry.actor.remove_instance',
539
546
  instance_id: instance_id)
540
547
  end
541
- @instance_states.clear
548
+ end
549
+
550
+ def drain_instance_states
551
+ state_mutex.synchronize do
552
+ tracked = @instance_states.each_pair.map { |instance_id, state| [instance_id, state] }
553
+ @instance_states.clear
554
+ dormant_weight_tracker.clear!
555
+ tracked
556
+ end
557
+ end
558
+
559
+ def remove_published_instance(instance_id:, state:)
560
+ publisher.remove_instance(
561
+ instance_id: instance_id, publisher_token: state[:publisher_token],
562
+ physical_id: state[:instance_key].physical_id
563
+ )
564
+ clear_instance_health(config_name: state[:name])
542
565
  end
543
566
  end
544
567
 
@@ -562,12 +585,15 @@ module Legion
562
585
  # enable_*) by. The derived endpoint id is the secondary
563
586
  # physical field only.
564
587
  instance_id = name.to_s
565
- next if @instance_states.key?(instance_id)
588
+ next if state_mutex.synchronize { @instance_states.key?(instance_id) }
566
589
 
567
590
  physical_id = derive_physical_id(instance_cfg: instance_cfg)
568
- @instance_states[instance_id] = build_instance_context(
591
+ state = build_instance_context(
569
592
  name: name, instance_id: instance_id, physical_id: physical_id, instance_cfg: instance_cfg
570
593
  )
594
+ Legion::Extensions::Llm::Inventory::WeightReconciler.track_initializing!(
595
+ states: @instance_states, state_key: instance_id, state: state, mutex: state_mutex
596
+ )
571
597
  rescue StandardError => e
572
598
  handle_exception(e, level: :warn, operation: 'azure_foundry.actor.claim_instance',
573
599
  instance_name: name.to_s)
@@ -576,11 +602,13 @@ module Legion
576
602
 
577
603
  def release_removed_instances(discovered)
578
604
  discovered_names = discovered.keys.map(&:to_s)
579
- (@instance_states.keys - discovered_names).each { |instance_id| remove_instance_state(instance_id) }
605
+ existing_names = state_mutex.synchronize { @instance_states.keys }
606
+ (existing_names - discovered_names).each { |instance_id| remove_instance_state(instance_id) }
580
607
  end
581
608
 
582
609
  def build_instance_context(name:, instance_id:, physical_id:, instance_cfg:)
583
610
  instance_key = build_instance_key(instance_id: instance_id, physical_id: physical_id)
611
+ offerings = discover_offerings_for_instance(instance_cfg: instance_cfg, instance_key: instance_key)
584
612
  callable = Legion::Extensions::Llm::AzureFoundry::Actor::AzureFoundryCallable.new(
585
613
  instance_cfg: instance_cfg, logger: log
586
614
  )
@@ -594,7 +622,7 @@ module Legion
594
622
  name: name, instance_key: instance_key, instance_cfg: instance_cfg,
595
623
  callable: callable, probe_coordinator: probe_coordinator,
596
624
  publisher_token: publisher_token, sequence: 0, last_probe_outcome: nil,
597
- offerings: discover_offerings_for_instance(instance_cfg: instance_cfg, instance_key: instance_key)
625
+ offerings: offerings
598
626
  }
599
627
  end
600
628
 
@@ -605,7 +633,7 @@ module Legion
605
633
  end
606
634
 
607
635
  def remove_instance_state(instance_id)
608
- state = @instance_states.delete(instance_id)
636
+ state = state_mutex.synchronize { @instance_states.delete(instance_id) }
609
637
  return unless state
610
638
 
611
639
  publisher.remove_instance(
@@ -619,48 +647,54 @@ module Legion
619
647
  end
620
648
  end
621
649
 
622
- # Periodic refresh helpers — reconcile configured instances, recover
623
- # instances still :initializing, diff offerings, drive cadence probes.
624
- module RefreshHelpers
650
+ # Weight publication adapters bind Azure Foundry's existing publisher
651
+ # kwargs and stable comparison to the shared atomic reconciler.
652
+ module WeightPublicationHelpers
625
653
  private
626
654
 
627
- def tick_refresh
628
- reconcile_configured_instances
629
- @instance_states.each do |instance_id, state|
630
- refresh_instance(instance_id: instance_id, state: state)
631
- rescue StandardError => e
632
- handle_exception(e, level: :warn, operation: 'azure_foundry.actor.refresh_instance',
633
- instance_id: instance_id)
634
- end
655
+ def refresh_cached_offerings(instance_id:, state:)
656
+ offerings = discover_offerings_for_instance(
657
+ instance_cfg: state[:instance_cfg], instance_key: state[:instance_key]
658
+ )
659
+ commit_weight_snapshot(instance_id: instance_id, state: state, offerings: offerings)
660
+ end
661
+
662
+ def commit_weight_snapshot(instance_id:, state:, offerings:)
663
+ Legion::Extensions::Llm::Inventory::WeightReconciler.commit_if_changed!(
664
+ settings: Legion::Settings,
665
+ instance_id: instance_id,
666
+ state: state,
667
+ discovered_offerings: offerings,
668
+ mutex: state_mutex,
669
+ equivalent: lambda do |previous, current|
670
+ !offerings_changed?(previous: previous, current: current)
671
+ end,
672
+ replace: method(:replace_weight_snapshot)
673
+ )
635
674
  end
636
675
 
637
- # Re-scans configured instances every tick so instances configured
638
- # after boot appear without a restart, and removed instances are
639
- # released from the registry.
640
- def reconcile_configured_instances
641
- discovered = configured_instances
642
- claim_new_instances(discovered)
643
- release_removed_instances(discovered)
676
+ def replace_weight_snapshot(instance_id:, state:, offerings:, sequence:)
677
+ publisher.replace_instance_snapshot(
678
+ instance_id: instance_id, publisher_token: state[:publisher_token],
679
+ offerings: offerings, sequence: sequence,
680
+ physical_id: state[:instance_key].physical_id
681
+ )
644
682
  end
645
683
 
646
- def refresh_instance(instance_id:, state:)
647
- status = publisher.snapshot.publication_status(instance_key: state[:instance_key])
648
- return log.debug { "[azure_foundry] no publication status for #{instance_id}; skipping refresh" } if
649
- status.nil?
650
-
651
- if status.state == :initializing
652
- run_initialization_probe(instance_id: instance_id, state: state)
653
- else
654
- refresh_activated_instance(instance_id: instance_id, state: state)
655
- end
684
+ def activate_weight_snapshot(instance_id:, state:, offerings:, sequence:, probe_token:)
685
+ publisher.activate_instance_snapshot(
686
+ instance_id: instance_id, publisher_token: state[:publisher_token],
687
+ offerings: offerings, sequence: sequence, probe_token: probe_token,
688
+ physical_id: state[:instance_key].physical_id
689
+ )
656
690
  end
691
+ end
692
+
693
+ # Initial and recovery readiness share the tracked-state activation
694
+ # gate, so removal wins without resurrecting an instance.
695
+ module InitialReadinessHelpers
696
+ private
657
697
 
658
- # D4: an instance whose initial readiness failed stays :initializing.
659
- # activate_instance_snapshot is the only transition legal from
660
- # :initializing, so a later passing probe re-activates the claim
661
- # (fresh probe token, current offerings, next sequence) instead of
662
- # calling replace/readiness_succeeded, which would raise
663
- # InvalidTransitionError.
664
698
  def run_initialization_probe(instance_id:, state:)
665
699
  coordinator = state[:probe_coordinator]
666
700
  return unless coordinator.begin_probe
@@ -683,7 +717,7 @@ module Legion
683
717
 
684
718
  def apply_initial_readiness(instance_id:, state:, probe_token:, readiness:)
685
719
  if readiness.ready?
686
- activate_after_readiness(instance_id: instance_id, state: state, probe_token: probe_token)
720
+ activate_after_readiness?(instance_id: instance_id, state: state, probe_token: probe_token)
687
721
  else
688
722
  report_initial_failure(
689
723
  instance_id: instance_id, state: state, probe_token: probe_token, reason: readiness.reason
@@ -691,19 +725,27 @@ module Legion
691
725
  end
692
726
  end
693
727
 
694
- def activate_after_readiness(instance_id:, state:, probe_token:)
695
- state[:sequence] += 1
696
- state[:last_probe_outcome] = :success
697
- publisher.activate_instance_snapshot(
698
- instance_id: instance_id, publisher_token: state[:publisher_token],
699
- offerings: state[:offerings], sequence: state[:sequence], probe_token: probe_token,
700
- physical_id: state[:instance_key].physical_id
728
+ def activate_after_readiness?(instance_id:, state:, probe_token:)
729
+ activated = Legion::Extensions::Llm::Inventory::WeightReconciler.activate_tracked!(
730
+ settings: Legion::Settings,
731
+ instance_id: instance_id,
732
+ state_key: instance_id,
733
+ state: state,
734
+ states: @instance_states,
735
+ mutex: state_mutex,
736
+ probe_token: probe_token,
737
+ activate: method(:activate_weight_snapshot),
738
+ activation_sequence: ->(tracked) { tracked.fetch(:sequence) + 1 }
701
739
  )
740
+ return false unless activated
741
+
742
+ state[:last_probe_outcome] = :success
702
743
  write_instance_health(
703
744
  config_name: state[:name], available: true, reason: 'startup readiness succeeded',
704
745
  probe_outcome: :success, source: :startup_readiness,
705
746
  capabilities: instance_capabilities(state[:offerings])
706
747
  )
748
+ true
707
749
  end
708
750
 
709
751
  def report_initial_failure(instance_id:, state:, probe_token:, reason:)
@@ -717,33 +759,77 @@ module Legion
717
759
  probe_outcome: :failure, source: :startup_readiness
718
760
  )
719
761
  end
762
+ end
763
+
764
+ # Periodic refresh helpers — reconcile configured instances, recover
765
+ # instances still :initializing, diff offerings, drive cadence probes.
766
+ module RefreshHelpers
767
+ private
768
+
769
+ def tick_refresh
770
+ reconcile_configured_instances
771
+ states = state_mutex.synchronize do
772
+ @instance_states.each_pair.map { |instance_id, state| [instance_id, state] }
773
+ end
774
+ states.each do |instance_id, state|
775
+ refresh_instance(instance_id: instance_id, state: state)
776
+ rescue StandardError => e
777
+ handle_exception(e, level: :warn, operation: 'azure_foundry.actor.refresh_instance',
778
+ instance_id: instance_id)
779
+ end
780
+ Legion::Extensions::Llm::Inventory::WeightReconciler.observe_dormant!(
781
+ settings: Legion::Settings,
782
+ provider_family: :azure_foundry,
783
+ states: @instance_states,
784
+ mutex: state_mutex,
785
+ tracker: dormant_weight_tracker,
786
+ dormant_logger: lambda do |key|
787
+ log.info(
788
+ "[llm][azure_foundry] action=dormant_weight weight_key=#{key.inspect} no_lane_published=true"
789
+ )
790
+ end
791
+ )
792
+ end
793
+
794
+ # Re-scans configured instances every tick so instances configured
795
+ # after boot appear without a restart, and removed instances are
796
+ # released from the registry.
797
+ def reconcile_configured_instances
798
+ discovered = configured_instances
799
+ claim_new_instances(discovered)
800
+ release_removed_instances(discovered)
801
+ end
802
+
803
+ def refresh_instance(instance_id:, state:)
804
+ status = publisher.snapshot.publication_status(instance_key: state[:instance_key])
805
+ return log.debug { "[azure_foundry] no publication status for #{instance_id}; skipping refresh" } if
806
+ status.nil?
807
+
808
+ if status.state == :initializing
809
+ refresh_cached_offerings(instance_id: instance_id, state: state) unless state[:last_probe_outcome].nil?
810
+ run_initialization_probe(instance_id: instance_id, state: state)
811
+ else
812
+ refresh_activated_instance(instance_id: instance_id, state: state)
813
+ end
814
+ end
720
815
 
721
816
  def refresh_activated_instance(instance_id:, state:)
722
817
  new_offerings = discover_offerings_for_instance(
723
818
  instance_cfg: state[:instance_cfg], instance_key: state[:instance_key]
724
819
  )
725
-
726
- if offerings_changed?(previous: state[:offerings], current: new_offerings)
727
- replace_instance_offerings(instance_id: instance_id, state: state, offerings: new_offerings)
728
- state[:offerings] = new_offerings
820
+ changed = commit_weight_snapshot(
821
+ instance_id: instance_id, state: state, offerings: new_offerings
822
+ )
823
+ if changed
729
824
  write_instance_health(
730
825
  config_name: state[:name], available: true, reason: 'offerings refreshed',
731
826
  probe_outcome: state[:last_probe_outcome], source: :discovery,
732
- capabilities: instance_capabilities(new_offerings)
827
+ capabilities: instance_capabilities(state[:offerings])
733
828
  )
734
829
  end
735
830
 
736
831
  run_cadence_probe(instance_id: instance_id, state: state)
737
832
  end
738
-
739
- def replace_instance_offerings(instance_id:, state:, offerings:)
740
- state[:sequence] += 1
741
- publisher.replace_instance_snapshot(
742
- instance_id: instance_id, publisher_token: state[:publisher_token],
743
- offerings: offerings, sequence: state[:sequence],
744
- physical_id: state[:instance_key].physical_id
745
- )
746
- end
747
833
  end
748
834
 
749
835
  # Display-only health + capabilities projection into
@@ -846,6 +932,8 @@ module Legion
846
932
  include ProbeHelpers
847
933
  include ShutdownHelpers
848
934
  include ActivationHelpers
935
+ include WeightPublicationHelpers
936
+ include InitialReadinessHelpers
849
937
  include RefreshHelpers
850
938
  include DiscoveryHealthDisplay
851
939
 
@@ -862,6 +950,8 @@ module Legion
862
950
 
863
951
  def manual
864
952
  @instance_states ||= Concurrent::Map.new
953
+ state_mutex
954
+ dormant_weight_tracker
865
955
  tick_refresh
866
956
  rescue StandardError => e
867
957
  handle_exception(e, level: :warn, operation: 'azure_foundry.actor.discovery_refresh')
@@ -875,6 +965,14 @@ module Legion
875
965
 
876
966
  private
877
967
 
968
+ def state_mutex
969
+ @state_mutex ||= Mutex.new
970
+ end
971
+
972
+ def dormant_weight_tracker
973
+ @dormant_weight_tracker ||= Legion::Extensions::Llm::Inventory::DormantWeightTracker.new
974
+ end
975
+
878
976
  # D9: the tick interval is the registered discovery.interval_seconds.
879
977
  # A missing or non-positive value falls back to the registered
880
978
  # default — never nil (a nil interval stops the TimerTask after the
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module AzureFoundry
7
- VERSION = '0.3.4'
7
+ VERSION = '0.3.5'
8
8
  end
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm-azure-foundry
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.4
4
+ version: 0.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO
@@ -71,14 +71,14 @@ dependencies:
71
71
  requirements:
72
72
  - - ">="
73
73
  - !ruby/object:Gem::Version
74
- version: 0.7.1
74
+ version: 0.7.6
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
- version: 0.7.1
81
+ version: 0.7.6
82
82
  description: Azure AI Foundry and Azure OpenAI hosted provider integration for LegionIO
83
83
  LLM routing.
84
84
  email: