objectstate 3.0.1__tar.gz → 3.0.2__tar.gz

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 (112) hide show
  1. {objectstate-3.0.1 → objectstate-3.0.2}/.github/badges/coverage.svg +1 -1
  2. {objectstate-3.0.1 → objectstate-3.0.2}/PKG-INFO +1 -1
  3. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/__init__.py +1 -1
  4. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/docs/configuration_framework.rst +1 -1
  5. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/docs/context_system.rst +31 -4
  6. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/dual_axis_resolver.py +9 -4
  7. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/object_state.py +68 -7
  8. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/object_state_registry.py +54 -39
  9. objectstate-3.0.2/tests/test_resolution_precedence_order.py +315 -0
  10. objectstate-3.0.2/tests/test_resolved_values_follow_registered_ancestors.py +341 -0
  11. {objectstate-3.0.1 → objectstate-3.0.2}/.github/workflows/ci.yml +0 -0
  12. {objectstate-3.0.1 → objectstate-3.0.2}/.github/workflows/coverage-pages.yml +0 -0
  13. {objectstate-3.0.1 → objectstate-3.0.2}/.github/workflows/publish.yml +0 -0
  14. {objectstate-3.0.1 → objectstate-3.0.2}/.gitignore +0 -0
  15. {objectstate-3.0.1 → objectstate-3.0.2}/.readthedocs.yaml +0 -0
  16. {objectstate-3.0.1 → objectstate-3.0.2}/BADGE_SETUP.md +0 -0
  17. {objectstate-3.0.1 → objectstate-3.0.2}/LICENSE +0 -0
  18. {objectstate-3.0.1 → objectstate-3.0.2}/README.md +0 -0
  19. {objectstate-3.0.1 → objectstate-3.0.2}/docs/.gitignore +0 -0
  20. {objectstate-3.0.1 → objectstate-3.0.2}/docs/Makefile +0 -0
  21. {objectstate-3.0.1 → objectstate-3.0.2}/docs/api/modules.rst +0 -0
  22. {objectstate-3.0.1 → objectstate-3.0.2}/docs/architectural_fixes.rst +0 -0
  23. {objectstate-3.0.1 → objectstate-3.0.2}/docs/architecture.rst +0 -0
  24. {objectstate-3.0.1 → objectstate-3.0.2}/docs/archive/DOCUMENTATION_AUDIT.md +0 -0
  25. {objectstate-3.0.1 → objectstate-3.0.2}/docs/archive/DOCUMENTATION_FIXES.md +0 -0
  26. {objectstate-3.0.1 → objectstate-3.0.2}/docs/archive/README.md +0 -0
  27. {objectstate-3.0.1 → objectstate-3.0.2}/docs/conf.py +0 -0
  28. {objectstate-3.0.1 → objectstate-3.0.2}/docs/examples/atomic_operations.rst +0 -0
  29. {objectstate-3.0.1 → objectstate-3.0.2}/docs/examples/basic.rst +0 -0
  30. {objectstate-3.0.1 → objectstate-3.0.2}/docs/examples/dual_axis.rst +0 -0
  31. {objectstate-3.0.1 → objectstate-3.0.2}/docs/examples/index.rst +0 -0
  32. {objectstate-3.0.1 → objectstate-3.0.2}/docs/examples/ui.rst +0 -0
  33. {objectstate-3.0.1 → objectstate-3.0.2}/docs/index.rst +0 -0
  34. {objectstate-3.0.1 → objectstate-3.0.2}/docs/make.bat +0 -0
  35. {objectstate-3.0.1 → objectstate-3.0.2}/docs/performance/canonical-context-type-resolution-20261005.json +0 -0
  36. {objectstate-3.0.1 → objectstate-3.0.2}/docs/provenance.rst +0 -0
  37. {objectstate-3.0.1 → objectstate-3.0.2}/docs/quickstart.rst +0 -0
  38. {objectstate-3.0.1 → objectstate-3.0.2}/docs/state_management.rst +0 -0
  39. {objectstate-3.0.1 → objectstate-3.0.2}/docs/state_propagation.rst +0 -0
  40. {objectstate-3.0.1 → objectstate-3.0.2}/docs/undo_redo.rst +0 -0
  41. {objectstate-3.0.1 → objectstate-3.0.2}/paper.bib +0 -0
  42. {objectstate-3.0.1 → objectstate-3.0.2}/paper.md +0 -0
  43. {objectstate-3.0.1 → objectstate-3.0.2}/plans/n_dimensional_inheritance.md +0 -0
  44. {objectstate-3.0.1 → objectstate-3.0.2}/pyproject.toml +0 -0
  45. {objectstate-3.0.1 → objectstate-3.0.2}/scripts/check_ruff_added_lines.py +0 -0
  46. {objectstate-3.0.1 → objectstate-3.0.2}/scripts/release.py +0 -0
  47. {objectstate-3.0.1 → objectstate-3.0.2}/scripts/update_and_release.py +0 -0
  48. {objectstate-3.0.1 → objectstate-3.0.2}/scripts/update_version.py +0 -0
  49. {objectstate-3.0.1 → objectstate-3.0.2}/scripts/verify_release_ready.py +0 -0
  50. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/codegen.py +0 -0
  51. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/collection_containers.py +0 -0
  52. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/config.py +0 -0
  53. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/construction_binding.py +0 -0
  54. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/context_manager.py +0 -0
  55. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/docs/object_state_lifecycle.rst +0 -0
  56. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/edit_session.py +0 -0
  57. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/field_access.py +0 -0
  58. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/global_config.py +0 -0
  59. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/inspection.py +0 -0
  60. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/lazy_factory.py +0 -0
  61. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/live_context_resolver.py +0 -0
  62. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/object_state_metadata.py +0 -0
  63. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/parameter_owner.py +0 -0
  64. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/parameter_structure.py +0 -0
  65. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/parametric_axes.py +0 -0
  66. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/placeholder.py +0 -0
  67. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/reified_generics.py +0 -0
  68. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/scope_key.py +0 -0
  69. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/snapshot_model.py +0 -0
  70. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/subfield_semantics.py +0 -0
  71. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/time_travel_profile.py +0 -0
  72. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/token_cache.py +0 -0
  73. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/transaction_checkpoint.py +0 -0
  74. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/ui_visibility.py +0 -0
  75. {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/value_semantics.py +0 -0
  76. {objectstate-3.0.1 → objectstate-3.0.2}/tests/__init__.py +0 -0
  77. {objectstate-3.0.1 → objectstate-3.0.2}/tests/conftest.py +0 -0
  78. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_atomic_block_context.py +0 -0
  79. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_authored_paths.py +0 -0
  80. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_callable_baseline.py +0 -0
  81. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_codegen.py +0 -0
  82. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_config.py +0 -0
  83. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_construction_history.py +0 -0
  84. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_context_failures_surface.py +0 -0
  85. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_context_manager.py +0 -0
  86. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_field_access.py +0 -0
  87. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_global_config.py +0 -0
  88. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_history_persistence.py +0 -0
  89. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_inspection.py +0 -0
  90. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_integration.py +0 -0
  91. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_lazy_counterpart_registry.py +0 -0
  92. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_lazy_factory.py +0 -0
  93. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_live_context_resolver_answers.py +0 -0
  94. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_object_state_metadata_contracts.py +0 -0
  95. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_object_state_restore.py +0 -0
  96. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_object_state_update_instance.py +0 -0
  97. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_one_field_resolution.py +0 -0
  98. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_parameter_owner.py +0 -0
  99. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_parametric_axes.py +0 -0
  100. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_placeholder.py +0 -0
  101. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_registry_subscription.py +0 -0
  102. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_reified_generics.py +0 -0
  103. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_release_readiness.py +0 -0
  104. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_resolved_change_notifications.py +0 -0
  105. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_scope_key.py +0 -0
  106. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_scopeless_state_resolution.py +0 -0
  107. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_subfield_semantics.py +0 -0
  108. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_timeline_projection.py +0 -0
  109. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_transaction_checkpoint.py +0 -0
  110. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_ui_visible_field_projection.py +0 -0
  111. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_undo_redo_cascade.py +0 -0
  112. {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_value_semantics.py +0 -0
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage: 75.78%"><title>coverage: 75.78%</title><filter id="blur"><feGaussianBlur stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="114" height="20" rx="3"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="53" height="20" fill="#67ac09"/><rect width="114" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="315" y="150" fill-opacity=".8" filter="url(#blur)" textLength="510">coverage</text><text x="315" y="150" fill-opacity=".3" textLength="510">coverage</text></g><text x="315" y="140" textLength="510">coverage</text></g><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="865" y="150" fill-opacity=".8" filter="url(#blur)" textLength="430">75.78%</text><text x="865" y="150" fill-opacity=".3" textLength="430">75.78%</text></g><text x="865" y="140" textLength="430">75.78%</text></g></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage: 81.07%"><title>coverage: 81.07%</title><filter id="blur"><feGaussianBlur stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="114" height="20" rx="3"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="53" height="20" fill="#67ac09"/><rect width="114" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="315" y="150" fill-opacity=".8" filter="url(#blur)" textLength="510">coverage</text><text x="315" y="150" fill-opacity=".3" textLength="510">coverage</text></g><text x="315" y="140" textLength="510">coverage</text></g><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="865" y="150" fill-opacity=".8" filter="url(#blur)" textLength="430">81.07%</text><text x="865" y="150" fill-opacity=".3" textLength="430">81.07%</text></g><text x="865" y="140" textLength="430">81.07%</text></g></g></svg>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: objectstate
3
- Version: 3.0.1
3
+ Version: 3.0.2
4
4
  Summary: Generic lazy dataclass configuration framework with dual-axis inheritance and contextvars-based resolution
5
5
  Project-URL: Homepage, https://github.com/OpenHCSDev/objectstate
6
6
  Project-URL: Documentation, https://objectstate.readthedocs.io
@@ -210,7 +210,7 @@ __all__ = [
210
210
  'ObjectStateEditSession',
211
211
  ]
212
212
 
213
- __version__ = "3.0.1"
213
+ __version__ = "3.0.2"
214
214
  __author__ = 'OpenHCS Team'
215
215
  __description__ = 'Generic configuration framework for lazy dataclass resolution'
216
216
 
@@ -149,7 +149,7 @@ Usage Example
149
149
  )
150
150
  )
151
151
 
152
- The lazy configs resolve through dual-axis algorithm: check ``AppConfig`` context first, then ``GlobalAppConfig`` context, walking MRO at each level.
152
+ The lazy configs resolve class first: ``DatabaseConfig`` is looked up in the ``AppConfig`` layer, then in the ``GlobalAppConfig`` layer, and only if neither holds a value is a base class of ``DatabaseConfig`` tried in those layers. A value on the more specific class in the global config therefore beats a value on a base class in the app config. :ref:`precedence-order` states the order with an example.
153
153
 
154
154
  Framework Modules
155
155
  ----------------
@@ -195,10 +195,37 @@ The dual-axis resolver reads the layer stack of the active context:
195
195
 
196
196
  value, source_scope, source_type = resolve_with_provenance(LazyDatabaseConfig, "port")
197
197
 
198
- For each type in the container type's MRO, most specific first, it asks every layer from
199
- innermost to outermost for a config of that type and returns the first value that is not
200
- ``None``, together with the layer's scope id and the type it was read from. Attribute access
201
- on a lazy config returns the ``value`` of this same result.
198
+ Attribute access on a lazy config returns the ``value`` of this same result.
199
+
200
+ .. _precedence-order:
201
+
202
+ Precedence Order
203
+ ~~~~~~~~~~~~~~~~
204
+
205
+ Resolution is class first. For each class in the MRO of the type being read, most specific
206
+ first, the resolver asks every layer from the innermost scope to the outermost for a config
207
+ of that class and returns the first value that is not ``None``, together with the layer's
208
+ scope id and the class it was read from. Only when no layer holds a value for a class is
209
+ the next, less specific class tried.
210
+
211
+ So the class decides before the scope does. Take ``Leaf(Base)``, a field ``value`` read on
212
+ ``Leaf`` in a step, and two layers that hold a value:
213
+
214
+ ========================== ====================== =====================
215
+ Layer (scope) ``Base.value`` ``Leaf.value``
216
+ ========================== ====================== =====================
217
+ plate (inner) ``"base at plate"`` ``None``
218
+ global (outer) ``None`` ``"leaf at global"``
219
+ ========================== ====================== =====================
220
+
221
+ The read returns ``"leaf at global"`` with source ``("", Leaf)``: ``Leaf`` is tried in
222
+ every scope before ``Base`` is tried in any, although the plate is the nearer scope. A
223
+ value on the same class in a nearer scope does win: with ``Leaf.value`` also set on the
224
+ plate, the plate's value is returned.
225
+
226
+ This is the only statement of the order. ``objectstate.dual_axis_resolver`` implements it
227
+ in ``_resolve_in_layers``; ``tests/test_resolution_precedence_order.py`` checks it for
228
+ every set of (class, scope) sites.
202
229
 
203
230
  Usage Pattern
204
231
  ------------
@@ -151,11 +151,16 @@ def resolve_in_stack(stack, container_type: type, field_name: str) -> ResolvedFi
151
151
 
152
152
 
153
153
  def _resolve_in_layers(layers: tuple, container_type: type, field_name: str) -> ResolvedField:
154
- """Walk the container type's MRO and, for each type, the layers from inner to outer.
154
+ """Resolve class first: the MRO is the outer loop, the layers the inner one.
155
155
 
156
- MRO order dominates layer order: ``None`` in an inner layer means "inherit", so
157
- every outer layer is asked for the same type before a less specific type is
158
- tried. A layer that masks the layers outside it ends the walk outward.
156
+ For each class in the container type's MRO, most specific first, every layer is
157
+ asked from the innermost scope to the outermost; the first value that is not
158
+ ``None`` wins. ``None`` in an inner layer means "inherit", so a less specific
159
+ class is tried only after every layer was asked for the more specific one.
160
+ A layer that masks the layers outside it ends the walk outward.
161
+
162
+ The order is stated with an example in ``docs/context_system.rst`` under
163
+ "Precedence Order"; this function is its implementation.
159
164
  """
160
165
  container_base = _normalize_to_base(container_type)
161
166
 
@@ -226,6 +226,10 @@ class ObjectState:
226
226
  # Maps dotted_path → (source_scope_id, source_type) for inherited fields
227
227
  # source_type may differ from local container_type due to MRO inheritance
228
228
  self._live_provenance: dict[str, tuple[str | None, type | None]] = {}
229
+ # The registered ancestors each resolved snapshot was computed from, keyed by
230
+ # ``use_saved``. A snapshot is current only while ``_registered_ancestors()``
231
+ # still returns the same states.
232
+ self._ancestors_resolved_from: dict[bool, tuple[ObjectState, ...]] = {}
229
233
 
230
234
  # === Saved baseline (2 attributes) ===
231
235
  self._saved_resolved: dict[str, Any] = {}
@@ -476,8 +480,7 @@ class ObjectState:
476
480
  def to_saved_resolved_object(self) -> Any:
477
481
  """Reconstruct this state as an object with saved resolved values."""
478
482
  self._check_and_sync_delegate()
479
- if not self._saved_resolved:
480
- self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
483
+ self._ensure_saved_resolved()
481
484
  return self._reconstruct_from_resolved(
482
485
  "", self._saved_resolved, self._extraction_target, self._parameter_structure
483
486
  )
@@ -742,6 +745,12 @@ class ObjectState:
742
745
  self._invalid_fields.clear()
743
746
  return set() # First populate - no "changes" to flash
744
747
 
748
+ if not self._resolved_from_registered_ancestors():
749
+ # Registration changed above this state: every inherited field may have
750
+ # another source, so both snapshots are resolved again in full. Pending
751
+ # field invalidations are covered by the full pass.
752
+ return self._resolve_again_from_registered_ancestors(notify=notify_flash)
753
+
745
754
  # Partial recompute for invalid fields only
746
755
  if self._invalid_fields:
747
756
  logger.debug(f"🔄 _ensure_live_resolved: scope={self.scope_id}, recomputing {len(self._invalid_fields)} invalid fields: {list(self._invalid_fields)}")
@@ -759,6 +768,60 @@ class ObjectState:
759
768
 
760
769
  return changed_paths
761
770
 
771
+ def _registered_ancestors(self) -> tuple[ObjectState, ...]:
772
+ """The other states this state's resolved values are computed from.
773
+
774
+ This state is left out: registering it adds no value it does not hold.
775
+ """
776
+ return tuple(
777
+ state
778
+ for _, state in ObjectStateRegistry.registered_ancestors(self.scope_id)
779
+ if state is not self
780
+ )
781
+
782
+ def _resolved_from_registered_ancestors(self) -> bool:
783
+ """Whether both resolved snapshots were computed from today's ancestors."""
784
+ ancestors = self._registered_ancestors()
785
+ return all(
786
+ self._ancestors_resolved_from.get(use_saved) == ancestors
787
+ for use_saved in (False, True)
788
+ )
789
+
790
+ def _resolve_again_from_registered_ancestors(self, *, notify: bool) -> set[str]:
791
+ """Resolve the live and saved snapshots in full; return the changed live paths."""
792
+ previous = self._live_resolved or {}
793
+ self._live_resolved = self._compute_resolved_snapshot()
794
+ self._invalid_fields.clear()
795
+ self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
796
+ changed_values = {
797
+ path: (previous.get(path), value)
798
+ for path, value in self._live_resolved.items()
799
+ if previous.get(path) != value
800
+ }
801
+ if changed_values:
802
+ self._set_last_changed_values(changed_values)
803
+ changed_paths = self.notification_fields_for_value_changes(changed_values)
804
+ self._sync_materialized_state(
805
+ changed_value_fields=changed_paths, emit_notifications=notify
806
+ )
807
+ return changed_paths
808
+
809
+ def resolve_again_if_ancestors_changed(self) -> None:
810
+ """Resolve again when other states are registered above this one than before.
811
+
812
+ Every resolved read makes this check itself. ``ObjectStateRegistry`` calls it
813
+ when registration changes so subscribers are told at once.
814
+ """
815
+ if self._live_resolved is not None:
816
+ self._ensure_live_resolved()
817
+
818
+ def _ensure_saved_resolved(self) -> None:
819
+ """Make ``_saved_resolved`` hold every field, resolved from today's ancestors."""
820
+ if self._live_resolved is not None and not self._resolved_from_registered_ancestors():
821
+ self._resolve_again_from_registered_ancestors(notify=True)
822
+ elif not self._saved_resolved:
823
+ self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
824
+
762
825
  # DELETED: _create_nested_states() - No longer needed with flat storage
763
826
  # Nested ObjectStates are no longer created - flat storage handles all parameters
764
827
 
@@ -1157,9 +1220,7 @@ class ObjectState:
1157
1220
  # Auto-detect delegate changes before resolving values
1158
1221
  self._check_and_sync_delegate()
1159
1222
 
1160
- # Ensure saved resolved cache is populated
1161
- if not self._saved_resolved:
1162
- self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
1223
+ self._ensure_saved_resolved()
1163
1224
 
1164
1225
  # Check if this is a container/dataclass field (has subfields in _saved_resolved)
1165
1226
  prefix = f"{param_name}."
@@ -1190,8 +1251,7 @@ class ObjectState:
1190
1251
  self._check_and_sync_delegate()
1191
1252
  self._ensure_live_resolved()
1192
1253
  assert self._live_resolved is not None
1193
- if not self._saved_resolved:
1194
- self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
1254
+ self._ensure_saved_resolved()
1195
1255
 
1196
1256
  path = owner_field_path
1197
1257
  raw_value = self.parameters.get(path.value, MISSING)
@@ -2092,6 +2152,7 @@ class ObjectState:
2092
2152
  raise TypeError("ObjectState.parameters must remain a canonical flat dictionary.")
2093
2153
  if not isinstance(self._saved_parameters, dict):
2094
2154
  raise TypeError("ObjectState._saved_parameters must remain a canonical flat dictionary.")
2155
+ self._ancestors_resolved_from[use_saved] = self._registered_ancestors()
2095
2156
  ancestors = ObjectStateRegistry.get_ancestor_objects_with_scopes(
2096
2157
  self.scope_id, use_saved=use_saved
2097
2158
  )
@@ -17,6 +17,7 @@ from typing import (
17
17
  Tuple,
18
18
  TYPE_CHECKING,
19
19
  Generator,
20
+ Iterable,
20
21
  TypeAlias,
21
22
  )
22
23
  import copy
@@ -409,6 +410,7 @@ class ObjectStateRegistry:
409
410
  cls._states[key] = state
410
411
  cls.mark_snapshot_dirty_scope(key)
411
412
  logger.debug(f"Registered ObjectState: scope={key}, type={type(state.object_instance).__name__}")
413
+ cls._resolve_again_below((key,))
412
414
 
413
415
  # Fire callbacks for UI binding
414
416
  cls._fire_register_callbacks(key, state)
@@ -433,6 +435,7 @@ class ObjectStateRegistry:
433
435
  # that reference this ObjectState. Graveyard persists for session lifetime.
434
436
  cls._graveyard[key] = removed_state
435
437
  logger.debug(f"Unregistered ObjectState: scope={key} (moved to graveyard)")
438
+ cls._resolve_again_below((key,))
436
439
 
437
440
  # Fire callbacks for UI binding
438
441
  cls._fire_unregister_callbacks(key, state)
@@ -440,6 +443,36 @@ class ObjectStateRegistry:
440
443
  # Structural unregistration is not a user-edit boundary. The
441
444
  # graveyard preserves state for explicit operation snapshots.
442
445
 
446
+ @classmethod
447
+ def _resolve_again_below(cls, changed_scopes: Iterable[ScopeKey]) -> None:
448
+ """Tell registered states at or below ``changed_scopes`` that registration changed.
449
+
450
+ Each state compares the ancestors registered now with the ones its resolved
451
+ values were computed from (``ObjectState.resolve_again_if_ancestors_changed``),
452
+ so a state whose ancestors are the same does no work. This call only makes
453
+ the comparison happen now, so subscribers hear of changed values without
454
+ waiting for the next read.
455
+ """
456
+ changed_scopes = tuple(changed_scopes)
457
+ for state in tuple(cls._states.values()):
458
+ if any(scope.contains(state.scope_id) for scope in changed_scopes):
459
+ state.resolve_again_if_ancestors_changed()
460
+
461
+ @classmethod
462
+ def registered_ancestors(
463
+ cls, scope_id: str | None
464
+ ) -> tuple[tuple[ScopeKey, 'ObjectState'], ...]:
465
+ """The states registered at the global scope, each prefix of ``scope_id`` and
466
+ ``scope_id`` itself, least specific first.
467
+
468
+ Resolved values of a state at ``scope_id`` are computed from these states.
469
+ """
470
+ return tuple(
471
+ (ancestor_key, cls._states[ancestor_key])
472
+ for ancestor_key in cls._normalize_scope_id(scope_id).ancestors()
473
+ if ancestor_key in cls._states
474
+ )
475
+
443
476
  @classmethod
444
477
  def unregister_scope_and_descendants(cls, scope_id: Optional[str], _skip_snapshot: bool = False) -> int:
445
478
  """Unregister an ObjectState and all its descendants from the registry.
@@ -474,8 +507,9 @@ class ObjectStateRegistry:
474
507
  # NOTE: Unlike plain unregister(), this is often used for hierarchical deletes.
475
508
  # When history is enabled we must preserve removed states for time-travel (undo/redo),
476
509
  # otherwise snapshots may reference scopes that can no longer be resurrected.
477
- for key in scopes_to_delete:
478
- state = cls._states.pop(key)
510
+ removed = [(key, cls._states.pop(key)) for key in scopes_to_delete]
511
+ cls._resolve_again_below(scopes_to_delete)
512
+ for key, state in removed:
479
513
  if cls._history_enabled:
480
514
  cls._graveyard[key] = state
481
515
  logger.debug(f"Unregistered ObjectState (cascade): scope={key} (moved to graveyard)")
@@ -669,21 +703,10 @@ class ObjectStateRegistry:
669
703
  List of objects from ancestor scopes, ordered least→most specific.
670
704
  Each object is from state.object_instance (saved) or state.to_object() (live).
671
705
  """
672
- scope_key = cls._normalize_scope_id(scope_id)
673
-
674
- # Get objects from ancestor scopes, least-specific to most-specific
675
- objects = []
676
- for ancestor_key in scope_key.ancestors():
677
- state = cls._states.get(ancestor_key)
678
- if state:
679
- if use_saved:
680
- # Return saved baseline (saved_object handles delegation correctly)
681
- objects.append(state.saved_object)
682
- else:
683
- # Return live state with current edits
684
- objects.append(state.to_object(update_delegate=False))
685
-
686
- return objects
706
+ return [
707
+ state.saved_object if use_saved else state.to_object(update_delegate=False)
708
+ for _, state in cls.registered_ancestors(scope_id)
709
+ ]
687
710
 
688
711
  @classmethod
689
712
  def get_ancestor_objects_with_scopes(
@@ -714,28 +737,20 @@ class ObjectStateRegistry:
714
737
  Returns:
715
738
  List of (scope_id, object) tuples from ancestor scopes, ordered least→most specific.
716
739
  """
717
- scope_key = cls._normalize_scope_id(scope_id)
718
-
719
- # Get (scope_id, object) tuples from ancestor scopes
720
- results: List[Tuple[str, Any]] = []
721
- for ancestor_key in scope_key.ancestors():
722
- state = cls._states.get(ancestor_key)
723
- if state:
724
- # Get object - either with or without delegate sync
725
- logger.debug(f"🐛 get_ancestor_objects_with_scopes: Getting object for ancestor_key={ancestor_key!r}, skip_delegate_sync={skip_delegate_sync}, use_saved={use_saved}")
726
-
727
- if use_saved:
728
- # For saved baseline, we want object_instance (the original saved state)
729
- # Not reconstructed, no delegate sync needed
730
- obj = state._extraction_target
731
- else:
732
- # For live state, reconstruct from current parameters
733
- # Control whether delegate sync happens
734
- obj = state.to_object(update_delegate=False, sync_delegate=not skip_delegate_sync)
735
-
736
- results.append((ancestor_key, obj))
737
-
738
- return results
740
+ # Saved: the object as last saved, without delegate sync or reconstruction.
741
+ # Live: rebuilt from the current parameters; ``skip_delegate_sync`` decides
742
+ # whether the delegate is synchronised first.
743
+ return [
744
+ (
745
+ ancestor_key,
746
+ state._extraction_target
747
+ if use_saved
748
+ else state.to_object(
749
+ update_delegate=False, sync_delegate=not skip_delegate_sync
750
+ ),
751
+ )
752
+ for ancestor_key, state in cls.registered_ancestors(scope_id)
753
+ ]
739
754
 
740
755
  # ========== SCOPE + TYPE + FIELD AWARE INVALIDATION ==========
741
756