objectstate 3.0.2__tar.gz → 3.0.3__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 (114) hide show
  1. {objectstate-3.0.2 → objectstate-3.0.3}/.github/badges/coverage.svg +1 -1
  2. {objectstate-3.0.2 → objectstate-3.0.3}/PKG-INFO +1 -1
  3. {objectstate-3.0.2 → objectstate-3.0.3}/docs/state_propagation.rst +39 -0
  4. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/__init__.py +1 -1
  5. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/object_state.py +147 -94
  6. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/object_state_registry.py +13 -35
  7. objectstate-3.0.3/src/objectstate/resolution_sources.py +176 -0
  8. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/scope_key.py +12 -4
  9. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/transaction_checkpoint.py +3 -0
  10. objectstate-3.0.3/tests/test_resolved_values_follow_registered_ancestors.py +630 -0
  11. objectstate-3.0.2/tests/test_resolved_values_follow_registered_ancestors.py +0 -341
  12. {objectstate-3.0.2 → objectstate-3.0.3}/.github/workflows/ci.yml +0 -0
  13. {objectstate-3.0.2 → objectstate-3.0.3}/.github/workflows/coverage-pages.yml +0 -0
  14. {objectstate-3.0.2 → objectstate-3.0.3}/.github/workflows/publish.yml +0 -0
  15. {objectstate-3.0.2 → objectstate-3.0.3}/.gitignore +0 -0
  16. {objectstate-3.0.2 → objectstate-3.0.3}/.readthedocs.yaml +0 -0
  17. {objectstate-3.0.2 → objectstate-3.0.3}/BADGE_SETUP.md +0 -0
  18. {objectstate-3.0.2 → objectstate-3.0.3}/LICENSE +0 -0
  19. {objectstate-3.0.2 → objectstate-3.0.3}/README.md +0 -0
  20. {objectstate-3.0.2 → objectstate-3.0.3}/docs/.gitignore +0 -0
  21. {objectstate-3.0.2 → objectstate-3.0.3}/docs/Makefile +0 -0
  22. {objectstate-3.0.2 → objectstate-3.0.3}/docs/api/modules.rst +0 -0
  23. {objectstate-3.0.2 → objectstate-3.0.3}/docs/architectural_fixes.rst +0 -0
  24. {objectstate-3.0.2 → objectstate-3.0.3}/docs/architecture.rst +0 -0
  25. {objectstate-3.0.2 → objectstate-3.0.3}/docs/archive/DOCUMENTATION_AUDIT.md +0 -0
  26. {objectstate-3.0.2 → objectstate-3.0.3}/docs/archive/DOCUMENTATION_FIXES.md +0 -0
  27. {objectstate-3.0.2 → objectstate-3.0.3}/docs/archive/README.md +0 -0
  28. {objectstate-3.0.2 → objectstate-3.0.3}/docs/conf.py +0 -0
  29. {objectstate-3.0.2 → objectstate-3.0.3}/docs/examples/atomic_operations.rst +0 -0
  30. {objectstate-3.0.2 → objectstate-3.0.3}/docs/examples/basic.rst +0 -0
  31. {objectstate-3.0.2 → objectstate-3.0.3}/docs/examples/dual_axis.rst +0 -0
  32. {objectstate-3.0.2 → objectstate-3.0.3}/docs/examples/index.rst +0 -0
  33. {objectstate-3.0.2 → objectstate-3.0.3}/docs/examples/ui.rst +0 -0
  34. {objectstate-3.0.2 → objectstate-3.0.3}/docs/index.rst +0 -0
  35. {objectstate-3.0.2 → objectstate-3.0.3}/docs/make.bat +0 -0
  36. {objectstate-3.0.2 → objectstate-3.0.3}/docs/performance/canonical-context-type-resolution-20261005.json +0 -0
  37. {objectstate-3.0.2 → objectstate-3.0.3}/docs/provenance.rst +0 -0
  38. {objectstate-3.0.2 → objectstate-3.0.3}/docs/quickstart.rst +0 -0
  39. {objectstate-3.0.2 → objectstate-3.0.3}/docs/state_management.rst +0 -0
  40. {objectstate-3.0.2 → objectstate-3.0.3}/docs/undo_redo.rst +0 -0
  41. {objectstate-3.0.2 → objectstate-3.0.3}/paper.bib +0 -0
  42. {objectstate-3.0.2 → objectstate-3.0.3}/paper.md +0 -0
  43. {objectstate-3.0.2 → objectstate-3.0.3}/plans/n_dimensional_inheritance.md +0 -0
  44. {objectstate-3.0.2 → objectstate-3.0.3}/pyproject.toml +0 -0
  45. {objectstate-3.0.2 → objectstate-3.0.3}/scripts/check_ruff_added_lines.py +0 -0
  46. {objectstate-3.0.2 → objectstate-3.0.3}/scripts/release.py +0 -0
  47. {objectstate-3.0.2 → objectstate-3.0.3}/scripts/update_and_release.py +0 -0
  48. {objectstate-3.0.2 → objectstate-3.0.3}/scripts/update_version.py +0 -0
  49. {objectstate-3.0.2 → objectstate-3.0.3}/scripts/verify_release_ready.py +0 -0
  50. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/codegen.py +0 -0
  51. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/collection_containers.py +0 -0
  52. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/config.py +0 -0
  53. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/construction_binding.py +0 -0
  54. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/context_manager.py +0 -0
  55. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/docs/configuration_framework.rst +0 -0
  56. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/docs/context_system.rst +0 -0
  57. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/docs/object_state_lifecycle.rst +0 -0
  58. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/dual_axis_resolver.py +0 -0
  59. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/edit_session.py +0 -0
  60. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/field_access.py +0 -0
  61. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/global_config.py +0 -0
  62. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/inspection.py +0 -0
  63. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/lazy_factory.py +0 -0
  64. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/live_context_resolver.py +0 -0
  65. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/object_state_metadata.py +0 -0
  66. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/parameter_owner.py +0 -0
  67. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/parameter_structure.py +0 -0
  68. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/parametric_axes.py +0 -0
  69. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/placeholder.py +0 -0
  70. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/reified_generics.py +0 -0
  71. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/snapshot_model.py +0 -0
  72. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/subfield_semantics.py +0 -0
  73. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/time_travel_profile.py +0 -0
  74. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/token_cache.py +0 -0
  75. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/ui_visibility.py +0 -0
  76. {objectstate-3.0.2 → objectstate-3.0.3}/src/objectstate/value_semantics.py +0 -0
  77. {objectstate-3.0.2 → objectstate-3.0.3}/tests/__init__.py +0 -0
  78. {objectstate-3.0.2 → objectstate-3.0.3}/tests/conftest.py +0 -0
  79. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_atomic_block_context.py +0 -0
  80. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_authored_paths.py +0 -0
  81. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_callable_baseline.py +0 -0
  82. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_codegen.py +0 -0
  83. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_config.py +0 -0
  84. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_construction_history.py +0 -0
  85. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_context_failures_surface.py +0 -0
  86. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_context_manager.py +0 -0
  87. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_field_access.py +0 -0
  88. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_global_config.py +0 -0
  89. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_history_persistence.py +0 -0
  90. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_inspection.py +0 -0
  91. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_integration.py +0 -0
  92. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_lazy_counterpart_registry.py +0 -0
  93. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_lazy_factory.py +0 -0
  94. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_live_context_resolver_answers.py +0 -0
  95. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_object_state_metadata_contracts.py +0 -0
  96. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_object_state_restore.py +0 -0
  97. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_object_state_update_instance.py +0 -0
  98. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_one_field_resolution.py +0 -0
  99. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_parameter_owner.py +0 -0
  100. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_parametric_axes.py +0 -0
  101. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_placeholder.py +0 -0
  102. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_registry_subscription.py +0 -0
  103. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_reified_generics.py +0 -0
  104. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_release_readiness.py +0 -0
  105. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_resolution_precedence_order.py +0 -0
  106. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_resolved_change_notifications.py +0 -0
  107. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_scope_key.py +0 -0
  108. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_scopeless_state_resolution.py +0 -0
  109. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_subfield_semantics.py +0 -0
  110. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_timeline_projection.py +0 -0
  111. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_transaction_checkpoint.py +0 -0
  112. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_ui_visible_field_projection.py +0 -0
  113. {objectstate-3.0.2 → objectstate-3.0.3}/tests/test_undo_redo_cascade.py +0 -0
  114. {objectstate-3.0.2 → objectstate-3.0.3}/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: 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
+ <svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage: 82.11%"><title>coverage: 82.11%</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">82.11%</text><text x="865" y="150" fill-opacity=".3" textLength="430">82.11%</text></g><text x="865" y="140" textLength="430">82.11%</text></g></g></svg>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: objectstate
3
- Version: 3.0.2
3
+ Version: 3.0.3
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
@@ -94,3 +94,42 @@ Use parent forwarding when all of the following are true:
94
94
 
95
95
  For a nested dataclass field in the same state, call ``update_parameter()`` on
96
96
  the dotted path instead.
97
+
98
+ What a resolved value is computed from
99
+ --------------------------------------
100
+
101
+ A resolved value of a state is computed from three things: which states are
102
+ registered at the scopes above it, what each of them stores for the field's config
103
+ type and field name, and what the global config stores. Each resolved snapshot (live
104
+ and saved) keeps the objects it was computed from (``ResolutionSources``): the global
105
+ config instance of the thread that resolved it and the object of every state
106
+ registered above. Every resolved read (``get_resolved_value``,
107
+ ``get_saved_resolved_value``, ``get_provenance``, ``to_resolved_object``,
108
+ ``state.fields``) first compares those objects with the ones the state would be given
109
+ now. When they are the same objects the read answers from the snapshot. When one is
110
+ another object, the two are compared field by field, the way field resolution reads a
111
+ context layer, and only the fields of the state that are unset and can inherit a
112
+ differing ``(config type, field)`` are resolved again. When other scopes have a
113
+ registered state than before, every field is.
114
+
115
+ So no caller announces a change. Registering, unregistering or replacing an ancestor,
116
+ editing, saving, restoring, undoing or redoing an ancestor's value, and replacing the
117
+ global config with ``set_live_global_config`` / ``set_saved_global_config`` all reach
118
+ a state on its next read, whether the state is registered or not. A registered state
119
+ is also told at once, so its subscribers hear of the change without a read; a state
120
+ that is not registered is referenced by nothing and tells its subscribers on its next
121
+ read.
122
+
123
+ Limits:
124
+
125
+ - Objects are compared by identity. A source that is changed in place is not seen.
126
+ ``ObjectState.to_object`` returns the same function object for a state that wraps a
127
+ plain function, so states registered below a function-backed state follow its edits
128
+ only through the registry (that is, when they are registered themselves).
129
+ - The global config is stored per thread. A snapshot follows the global config of the
130
+ thread that resolved it; a read from another thread answers from the snapshot and
131
+ does not resolve it again for that thread's global config.
132
+ - A state built or resolved inside a ``config_context`` the caller entered takes that
133
+ context's layers into its snapshot and keeps them after the block
134
+ (`issue #53 <https://github.com/OpenHCSDev/ObjectState/issues/53>`_).
135
+
@@ -210,7 +210,7 @@ __all__ = [
210
210
  'ObjectStateEditSession',
211
211
  ]
212
212
 
213
- __version__ = "3.0.2"
213
+ __version__ = "3.0.3"
214
214
  __author__ = 'OpenHCS Team'
215
215
  __description__ = 'Generic configuration framework for lazy dataclass resolution'
216
216
 
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import copy
14
14
  import logging
15
+ import threading
15
16
  from collections.abc import Callable, Sequence
16
17
  from dataclasses import fields as dataclass_fields
17
18
  from dataclasses import is_dataclass
@@ -33,6 +34,7 @@ from objectstate.object_state_registry import ObjectStateRegistry
33
34
  from objectstate.scope_key import ScopeKey
34
35
  from objectstate.parameter_owner import ParameterOwner
35
36
  from objectstate.parameter_structure import ParameterStructure
37
+ from objectstate.resolution_sources import ResolutionSources
36
38
  from objectstate.global_config import GlobalContextValues
37
39
  from objectstate.lazy_factory import is_global_config_type
38
40
  from objectstate.subfield_semantics import (
@@ -226,10 +228,10 @@ class ObjectState:
226
228
  # Maps dotted_path → (source_scope_id, source_type) for inherited fields
227
229
  # source_type may differ from local container_type due to MRO inheritance
228
230
  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, ...]] = {}
231
+ # What each resolved snapshot was computed from, keyed by ``use_saved``. A
232
+ # snapshot is current only while ``_resolution_sources()`` still returns the
233
+ # same objects; a snapshot without an entry is resolved again in full.
234
+ self._resolved_from: dict[bool, ResolutionSources] = {}
233
235
 
234
236
  # === Saved baseline (2 attributes) ===
235
237
  self._saved_resolved: dict[str, Any] = {}
@@ -268,9 +270,8 @@ class ObjectState:
268
270
  # === Time-Travel ===
269
271
  # Instance-level time-travel removed - use ObjectStateRegistry class-level DAG instead
270
272
 
271
- # Initialize baselines (suppress flash during init)
272
- self._ensure_live_resolved(notify_flash=False)
273
- assert self._live_resolved is not None # Guaranteed by _ensure_live_resolved
273
+ # Initialize baselines (nothing to tell anyone during init)
274
+ self._live_resolved = self._compute_resolved_snapshot()
274
275
 
275
276
  # CRITICAL: Initialize _saved_parameters BEFORE _compute_resolved_snapshot(use_saved=True)
276
277
  # because that method reads from _saved_parameters to get raw values.
@@ -723,91 +724,153 @@ class ObjectState:
723
724
  self._forwarding_to_parent = False
724
725
 
725
726
  def _ensure_live_resolved(self, notify_flash: bool = True) -> set[str]:
726
- """Ensure _live_resolved cache is populated.
727
-
728
- PERFORMANCE: Field-level invalidation only.
729
- - First access: full compute to populate cache
730
- - After update_parameter(): only recompute invalid fields
731
- - Cross-window access: return cached values directly (no work)
727
+ """Bring both resolved snapshots up to date; return the changed live paths.
728
+
729
+ Two things make a field out of date: it was invalidated on this state
730
+ (``_invalid_fields``), or the snapshot was computed from other sources than
731
+ the state has now (``_fields_with_other_sources``): an ancestor registered,
732
+ unregistered or replaced, an ancestor's value edited, saved or restored, the
733
+ global config replaced. Both are found here, on every resolved read, so no
734
+ caller has to announce a change and a state that is not registered follows
735
+ its ancestors like a registered one. Only the fields that can be affected are
736
+ resolved again; when nothing changed the cost is the comparison of the
737
+ sources.
732
738
 
733
739
  Args:
734
- notify_flash: If True, fire on_resolved_changed callbacks for flash animations.
735
- Set to False during initialization to suppress flash.
736
-
737
- Returns:
738
- Set of paths that changed (for flash). Empty if no changes.
739
-
740
- NOTE: This method handles CACHE + FLASH only. Caller handles _sync_materialized_state().
740
+ notify_flash: True (reads): when anything was resolved again, dirty and
741
+ signature fields are updated and subscribers are told here. False:
742
+ the caller does that with the returned paths
743
+ (``_sync_materialized_state``).
741
744
  """
742
- # First access - populate cache
743
745
  if self._live_resolved is None:
746
+ # First access - populate cache; there is no earlier value to compare with.
744
747
  self._live_resolved = self._compute_resolved_snapshot()
745
748
  self._invalid_fields.clear()
746
- return set() # First populate - no "changes" to flash
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
-
754
- # Partial recompute for invalid fields only
755
- if self._invalid_fields:
756
- logger.debug(f"🔄 _ensure_live_resolved: scope={self.scope_id}, recomputing {len(self._invalid_fields)} invalid fields: {list(self._invalid_fields)}")
757
- changed_paths = self._recompute_invalid_fields()
749
+ return set()
750
+
751
+ sources, saved_sources = self._resolution_sources()
752
+ saved_resolved_again = self._resolve_saved_again_if_sources_changed(saved_sources)
753
+ if saved_resolved_again:
754
+ sources, _ = self._resolution_sources()
755
+
756
+ stale = self._fields_with_other_sources(sources, use_saved=False)
757
+ if stale is None:
758
+ # Other states are registered above this one: every inherited field may
759
+ # have another source. Pending field invalidations are covered.
760
+ previous = self._live_resolved
761
+ self._live_resolved = self._compute_resolved_snapshot()
758
762
  self._invalid_fields.clear()
763
+ changed_values = {
764
+ path: (previous.get(path), value)
765
+ for path, value in self._live_resolved.items()
766
+ if previous.get(path) != value
767
+ }
768
+ if changed_values:
769
+ self._set_last_changed_values(changed_values)
770
+ changed_paths = self.notification_fields_for_value_changes(changed_values)
771
+ live_resolved_again = True
759
772
  else:
760
- logger.debug(f"🔄 _ensure_live_resolved: scope={self.scope_id}, no invalid fields to recompute")
761
- changed_paths = set()
773
+ self._invalid_fields.update(stale)
774
+ live_resolved_again = bool(self._invalid_fields)
775
+ if live_resolved_again:
776
+ changed_paths = self._recompute_invalid_fields(sources)
777
+ self._invalid_fields.clear()
778
+ else:
779
+ changed_paths = set()
780
+ self._resolved_from[False] = sources.recorded_after(self._resolved_from.get(False))
762
781
 
763
- if notify_flash and changed_paths:
764
- self._notify_resolved_changed(
765
- changed_paths,
766
- context="_ensure_live_resolved",
767
- )
782
+ if notify_flash and (live_resolved_again or saved_resolved_again):
783
+ self._sync_materialized_state(changed_value_fields=changed_paths)
768
784
 
769
785
  return changed_paths
770
786
 
771
- def _registered_ancestors(self) -> tuple[ObjectState, ...]:
772
- """The other states this state's resolved values are computed from.
787
+ def _resolution_sources(
788
+ self, *, sync_delegates: bool = False
789
+ ) -> tuple[ResolutionSources, ResolutionSources]:
790
+ """What this state's fields are resolved from today, live then saved (so the
791
+ pair is indexed by ``use_saved``): the global config stored for the running
792
+ thread and the objects of the other states registered above.
773
793
 
774
- This state is left out: registering it adds no value it does not hold.
794
+ This state's own object is left out (its own parameters are not an outside
795
+ source), and so are global config objects of ancestors, for which
796
+ ``build_context_stack`` enters the global config itself.
775
797
  """
776
- return tuple(
777
- state
778
- for _, state in ObjectStateRegistry.registered_ancestors(self.scope_id)
779
- if state is not self
780
- )
798
+ from objectstate.config import get_base_config_type
799
+ from objectstate.global_config import get_live_global_config, get_saved_global_config
781
800
 
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)
801
+ live: list[tuple[str, Any]] = []
802
+ saved: list[tuple[str, Any]] = []
803
+ for scope, state in ObjectStateRegistry.registered_ancestors(self.scope_id):
804
+ if state is self:
805
+ continue
806
+ saved_object = state.object_for_resolution(use_saved=True)
807
+ if is_global_config_type(type(saved_object)):
808
+ continue
809
+ saved.append((scope, saved_object))
810
+ live.append(
811
+ (scope, state.object_for_resolution(sync_delegate=sync_delegates))
812
+ )
813
+ thread = threading.current_thread()
814
+ global_config_type = get_base_config_type()
815
+ return (
816
+ ResolutionSources(thread, get_live_global_config(global_config_type), tuple(live)),
817
+ ResolutionSources(thread, get_saved_global_config(global_config_type), tuple(saved)),
788
818
  )
789
819
 
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
820
+ def object_for_resolution(self, use_saved: bool = False, sync_delegate: bool = False) -> Any:
821
+ """The object a state below this one enters as a context layer.
822
+
823
+ Saved: the object as last saved, without delegate sync or reconstruction.
824
+ Live: rebuilt from the current parameters when they changed since the last
825
+ call; ``sync_delegate`` decides whether a replaced delegate is taken over
826
+ first, which must not happen while a snapshot is being recomputed.
827
+ """
828
+ if use_saved:
829
+ return self._extraction_target
830
+ if self._cached_object is not None and not sync_delegate:
831
+ return self._cached_object
832
+ return self.to_object(update_delegate=False, sync_delegate=sync_delegate)
833
+
834
+ def _fields_with_other_sources(
835
+ self, sources: ResolutionSources, *, use_saved: bool
836
+ ) -> set[str] | None:
837
+ """Fields of one snapshot that ``sources`` may resolve differently.
838
+
839
+ These are the fields this state leaves unset whose config type inherits a
840
+ ``(config type, field)`` that an ancestor or the global config stores
841
+ differently than when the snapshot was computed. A field this state sets
842
+ itself has no outside source. ``None`` means every field.
843
+ """
844
+ resolved_from = self._resolved_from.get(use_saved)
845
+ if sources.same_objects_as(resolved_from):
846
+ return set()
847
+ differing = sources.fields_that_differ(resolved_from)
848
+ if differing is None:
849
+ return None
850
+ own_values = self._saved_parameters if use_saved else self.parameters
851
+ return {
852
+ path
853
+ for config_type, field_name in differing
854
+ for path in self.inheritance_field_paths(config_type, field_name)
855
+ if own_values.get(path) is None
800
856
  }
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
857
 
809
- def resolve_again_if_ancestors_changed(self) -> None:
810
- """Resolve again when other states are registered above this one than before.
858
+ def _resolve_saved_again_if_sources_changed(self, sources: ResolutionSources) -> bool:
859
+ """Resolve the saved snapshot again when its sources changed; say whether it was.
860
+
861
+ The saved sources change when an ancestor is saved, restored or replaced and
862
+ when the saved global config is replaced, not while values are edited, so the
863
+ snapshot is resolved in full.
864
+ """
865
+ stale = self._fields_with_other_sources(sources, use_saved=True)
866
+ if stale is not None and not stale:
867
+ self._resolved_from[True] = sources.recorded_after(self._resolved_from.get(True))
868
+ return False
869
+ self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
870
+ return True
871
+
872
+ def resolve_again_if_sources_changed(self) -> None:
873
+ """Resolve again now, and tell subscribers, if this state's sources changed.
811
874
 
812
875
  Every resolved read makes this check itself. ``ObjectStateRegistry`` calls it
813
876
  when registration changes so subscribers are told at once.
@@ -816,9 +879,9 @@ class ObjectState:
816
879
  self._ensure_live_resolved()
817
880
 
818
881
  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)
882
+ """Make ``_saved_resolved`` hold every field, resolved from today's sources."""
883
+ if self._live_resolved is not None:
884
+ self._ensure_live_resolved()
822
885
  elif not self._saved_resolved:
823
886
  self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
824
887
 
@@ -1046,7 +1109,7 @@ class ObjectState:
1046
1109
  )
1047
1110
  # Establish the pre-mutation resolved authority even for a state that
1048
1111
  # has not yet been displayed. Raw edits can preserve the resolved value.
1049
- self._ensure_live_resolved(notify_flash=False)
1112
+ changed_before_the_edit = self._ensure_live_resolved(notify_flash=False)
1050
1113
  ObjectStateRegistry.ensure_baseline_snapshot()
1051
1114
 
1052
1115
  # Update state directly (no type conversion - that's VIEW responsibility)
@@ -1114,7 +1177,7 @@ class ObjectState:
1114
1177
 
1115
1178
  # Materialize the origin once after its live context is authoritative,
1116
1179
  # then cascade only to descendants.
1117
- changed_paths = self._ensure_live_resolved(notify_flash=False)
1180
+ changed_paths = self._ensure_live_resolved(notify_flash=False) | changed_before_the_edit
1118
1181
  for changed_param in changed_semantic_params:
1119
1182
  # SCOPE + TYPE + FIELD AWARE INVALIDATION:
1120
1183
  # Get the CONTAINER type for this field (e.g., PartitionFilterConfig for
@@ -1790,8 +1853,8 @@ class ObjectState:
1790
1853
  f"Updated ObjectState(scope={self.scope_id!r}) to new instance of type {type(new_instance).__name__}"
1791
1854
  )
1792
1855
 
1793
- def _recompute_invalid_fields(self) -> set[str]:
1794
- """Recompute only the invalid fields, not the entire snapshot.
1856
+ def _recompute_invalid_fields(self, sources: ResolutionSources) -> set[str]:
1857
+ """Recompute only the invalid fields of the live snapshot, from ``sources``.
1795
1858
 
1796
1859
  PERFORMANCE: For explicitly set values, use parameters directly.
1797
1860
  Only build context stack for inherited (None) values.
@@ -1845,14 +1908,6 @@ class ObjectState:
1845
1908
  from objectstate.dual_axis_resolver import resolve_with_provenance
1846
1909
  from objectstate.lazy_factory import has_lazy_resolution
1847
1910
 
1848
- # Get ancestor objects for context stack building
1849
- # CRITICAL: Skip delegate sync to avoid re-entrant invalidate_cache() calls
1850
- # that would destroy _live_resolved while we're computing it.
1851
- ancestor_objects_with_scopes = ObjectStateRegistry.get_ancestor_objects_with_scopes(
1852
- self.scope_id,
1853
- skip_delegate_sync=True
1854
- )
1855
-
1856
1911
  # ARCHITECTURAL FIX: Reconstruct current object from parameters for real-time MRO.
1857
1912
  # Using object_instance (original saved state) gives stale values, breaking inheritance.
1858
1913
  # Calling to_object() with sync_delegate=False reconstructs from CURRENT parameters
@@ -1861,7 +1916,7 @@ class ObjectState:
1861
1916
 
1862
1917
  with build_context_stack(
1863
1918
  object_instance=current_obj,
1864
- ancestor_objects_with_scopes=ancestor_objects_with_scopes,
1919
+ ancestor_objects_with_scopes=sources.ancestors,
1865
1920
  current_scope_id=self.scope_id,
1866
1921
  ):
1867
1922
  # For each inherited field, resolve using dual-axis resolution with provenance
@@ -2152,10 +2207,8 @@ class ObjectState:
2152
2207
  raise TypeError("ObjectState.parameters must remain a canonical flat dictionary.")
2153
2208
  if not isinstance(self._saved_parameters, dict):
2154
2209
  raise TypeError("ObjectState._saved_parameters must remain a canonical flat dictionary.")
2155
- self._ancestors_resolved_from[use_saved] = self._registered_ancestors()
2156
- ancestors = ObjectStateRegistry.get_ancestor_objects_with_scopes(
2157
- self.scope_id, use_saved=use_saved
2158
- )
2210
+ sources = self._resolution_sources(sync_delegates=True)[use_saved]
2211
+ self._resolved_from[use_saved] = sources
2159
2212
  current_obj = (
2160
2213
  self.saved_object if use_saved else self.to_object(update_delegate=False)
2161
2214
  )
@@ -2166,7 +2219,7 @@ class ObjectState:
2166
2219
  self._parameter_structure,
2167
2220
  current_obj,
2168
2221
  self.scope_id,
2169
- ancestors,
2222
+ sources.ancestors,
2170
2223
  use_saved=use_saved,
2171
2224
  )
2172
2225
  if not use_saved:
@@ -447,16 +447,16 @@ class ObjectStateRegistry:
447
447
  def _resolve_again_below(cls, changed_scopes: Iterable[ScopeKey]) -> None:
448
448
  """Tell registered states at or below ``changed_scopes`` that registration changed.
449
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.
450
+ Each state compares what it is resolved from now with what its resolved values
451
+ were computed from (``ObjectState.resolve_again_if_sources_changed``), so a
452
+ state whose ancestors are the same does no work. This call only makes the
453
+ comparison happen now, so subscribers hear of changed values without waiting
454
+ for the next read.
455
455
  """
456
456
  changed_scopes = tuple(changed_scopes)
457
457
  for state in tuple(cls._states.values()):
458
458
  if any(scope.contains(state.scope_id) for scope in changed_scopes):
459
- state.resolve_again_if_ancestors_changed()
459
+ state.resolve_again_if_sources_changed()
460
460
 
461
461
  @classmethod
462
462
  def registered_ancestors(
@@ -737,17 +737,10 @@ class ObjectStateRegistry:
737
737
  Returns:
738
738
  List of (scope_id, object) tuples from ancestor scopes, ordered least→most specific.
739
739
  """
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
740
  return [
744
741
  (
745
742
  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
- ),
743
+ state.object_for_resolution(use_saved, sync_delegate=not skip_delegate_sync),
751
744
  )
752
745
  for ancestor_key, state in cls.registered_ancestors(scope_id)
753
746
  ]
@@ -882,29 +875,14 @@ class ObjectStateRegistry:
882
875
  state.invalidate_field(dotted_path)
883
876
  invalidated_paths.add(dotted_path)
884
877
 
885
- # If invalidating saved baseline, remove from saved_resolved so it recomputes
886
- if invalidate_saved and dotted_path in state._saved_resolved:
887
- del state._saved_resolved[dotted_path]
888
- if _debug:
889
- logger.debug(f"Invalidated saved_resolved cache for {dotted_path}")
890
-
891
- # Trigger recompute immediately to detect if resolved values actually changed.
892
- # This ensures callbacks fire only when values change, not just when fields are invalidated.
893
- # Prevents false flashes when Reset is clicked on already-reset fields.
894
878
  if invalidated_paths:
895
- changed_paths = state._ensure_live_resolved(notify_flash=False)
896
-
897
- # If we invalidated saved baseline, recompute it now with fresh ancestor values
898
879
  if invalidate_saved:
899
- # Recompute entire saved_resolved snapshot to pick up new ancestor saved values
900
- old_saved_resolved = state._saved_resolved
901
- state._saved_resolved = state._compute_resolved_snapshot(use_saved=True)
902
- logger.debug(f"Recomputed saved_resolved baseline after invalidation")
903
-
904
- if old_saved_resolved != state._saved_resolved:
905
- state._sync_materialized_state(changed_value_fields=changed_paths)
906
- else:
907
- state._sync_materialized_state(changed_value_fields=changed_paths)
880
+ # Without recorded sources the saved snapshot is resolved again in full.
881
+ state._resolved_from.pop(True, None)
882
+ # Resolve now, so callbacks fire only for values that really changed (no
883
+ # flash when Reset is clicked on a field that is already reset).
884
+ changed_paths = state._ensure_live_resolved(notify_flash=False)
885
+ state._sync_materialized_state(changed_value_fields=changed_paths)
908
886
 
909
887
  # ========== REGISTRY-LEVEL TIME-TRAVEL (DAG Model) ==========
910
888