objectstate 3.0.1__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 (113) hide show
  1. {objectstate-3.0.1 → objectstate-3.0.3}/.github/badges/coverage.svg +1 -1
  2. {objectstate-3.0.1 → objectstate-3.0.3}/PKG-INFO +1 -1
  3. {objectstate-3.0.1 → objectstate-3.0.3}/docs/state_propagation.rst +39 -0
  4. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/__init__.py +1 -1
  5. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/docs/configuration_framework.rst +1 -1
  6. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/docs/context_system.rst +31 -4
  7. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/dual_axis_resolver.py +9 -4
  8. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/object_state.py +167 -53
  9. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/object_state_registry.py +53 -60
  10. objectstate-3.0.3/src/objectstate/resolution_sources.py +176 -0
  11. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/scope_key.py +12 -4
  12. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/transaction_checkpoint.py +3 -0
  13. objectstate-3.0.3/tests/test_resolution_precedence_order.py +315 -0
  14. objectstate-3.0.3/tests/test_resolved_values_follow_registered_ancestors.py +630 -0
  15. {objectstate-3.0.1 → objectstate-3.0.3}/.github/workflows/ci.yml +0 -0
  16. {objectstate-3.0.1 → objectstate-3.0.3}/.github/workflows/coverage-pages.yml +0 -0
  17. {objectstate-3.0.1 → objectstate-3.0.3}/.github/workflows/publish.yml +0 -0
  18. {objectstate-3.0.1 → objectstate-3.0.3}/.gitignore +0 -0
  19. {objectstate-3.0.1 → objectstate-3.0.3}/.readthedocs.yaml +0 -0
  20. {objectstate-3.0.1 → objectstate-3.0.3}/BADGE_SETUP.md +0 -0
  21. {objectstate-3.0.1 → objectstate-3.0.3}/LICENSE +0 -0
  22. {objectstate-3.0.1 → objectstate-3.0.3}/README.md +0 -0
  23. {objectstate-3.0.1 → objectstate-3.0.3}/docs/.gitignore +0 -0
  24. {objectstate-3.0.1 → objectstate-3.0.3}/docs/Makefile +0 -0
  25. {objectstate-3.0.1 → objectstate-3.0.3}/docs/api/modules.rst +0 -0
  26. {objectstate-3.0.1 → objectstate-3.0.3}/docs/architectural_fixes.rst +0 -0
  27. {objectstate-3.0.1 → objectstate-3.0.3}/docs/architecture.rst +0 -0
  28. {objectstate-3.0.1 → objectstate-3.0.3}/docs/archive/DOCUMENTATION_AUDIT.md +0 -0
  29. {objectstate-3.0.1 → objectstate-3.0.3}/docs/archive/DOCUMENTATION_FIXES.md +0 -0
  30. {objectstate-3.0.1 → objectstate-3.0.3}/docs/archive/README.md +0 -0
  31. {objectstate-3.0.1 → objectstate-3.0.3}/docs/conf.py +0 -0
  32. {objectstate-3.0.1 → objectstate-3.0.3}/docs/examples/atomic_operations.rst +0 -0
  33. {objectstate-3.0.1 → objectstate-3.0.3}/docs/examples/basic.rst +0 -0
  34. {objectstate-3.0.1 → objectstate-3.0.3}/docs/examples/dual_axis.rst +0 -0
  35. {objectstate-3.0.1 → objectstate-3.0.3}/docs/examples/index.rst +0 -0
  36. {objectstate-3.0.1 → objectstate-3.0.3}/docs/examples/ui.rst +0 -0
  37. {objectstate-3.0.1 → objectstate-3.0.3}/docs/index.rst +0 -0
  38. {objectstate-3.0.1 → objectstate-3.0.3}/docs/make.bat +0 -0
  39. {objectstate-3.0.1 → objectstate-3.0.3}/docs/performance/canonical-context-type-resolution-20261005.json +0 -0
  40. {objectstate-3.0.1 → objectstate-3.0.3}/docs/provenance.rst +0 -0
  41. {objectstate-3.0.1 → objectstate-3.0.3}/docs/quickstart.rst +0 -0
  42. {objectstate-3.0.1 → objectstate-3.0.3}/docs/state_management.rst +0 -0
  43. {objectstate-3.0.1 → objectstate-3.0.3}/docs/undo_redo.rst +0 -0
  44. {objectstate-3.0.1 → objectstate-3.0.3}/paper.bib +0 -0
  45. {objectstate-3.0.1 → objectstate-3.0.3}/paper.md +0 -0
  46. {objectstate-3.0.1 → objectstate-3.0.3}/plans/n_dimensional_inheritance.md +0 -0
  47. {objectstate-3.0.1 → objectstate-3.0.3}/pyproject.toml +0 -0
  48. {objectstate-3.0.1 → objectstate-3.0.3}/scripts/check_ruff_added_lines.py +0 -0
  49. {objectstate-3.0.1 → objectstate-3.0.3}/scripts/release.py +0 -0
  50. {objectstate-3.0.1 → objectstate-3.0.3}/scripts/update_and_release.py +0 -0
  51. {objectstate-3.0.1 → objectstate-3.0.3}/scripts/update_version.py +0 -0
  52. {objectstate-3.0.1 → objectstate-3.0.3}/scripts/verify_release_ready.py +0 -0
  53. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/codegen.py +0 -0
  54. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/collection_containers.py +0 -0
  55. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/config.py +0 -0
  56. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/construction_binding.py +0 -0
  57. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/context_manager.py +0 -0
  58. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/docs/object_state_lifecycle.rst +0 -0
  59. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/edit_session.py +0 -0
  60. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/field_access.py +0 -0
  61. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/global_config.py +0 -0
  62. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/inspection.py +0 -0
  63. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/lazy_factory.py +0 -0
  64. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/live_context_resolver.py +0 -0
  65. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/object_state_metadata.py +0 -0
  66. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/parameter_owner.py +0 -0
  67. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/parameter_structure.py +0 -0
  68. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/parametric_axes.py +0 -0
  69. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/placeholder.py +0 -0
  70. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/reified_generics.py +0 -0
  71. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/snapshot_model.py +0 -0
  72. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/subfield_semantics.py +0 -0
  73. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/time_travel_profile.py +0 -0
  74. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/token_cache.py +0 -0
  75. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/ui_visibility.py +0 -0
  76. {objectstate-3.0.1 → objectstate-3.0.3}/src/objectstate/value_semantics.py +0 -0
  77. {objectstate-3.0.1 → objectstate-3.0.3}/tests/__init__.py +0 -0
  78. {objectstate-3.0.1 → objectstate-3.0.3}/tests/conftest.py +0 -0
  79. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_atomic_block_context.py +0 -0
  80. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_authored_paths.py +0 -0
  81. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_callable_baseline.py +0 -0
  82. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_codegen.py +0 -0
  83. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_config.py +0 -0
  84. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_construction_history.py +0 -0
  85. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_context_failures_surface.py +0 -0
  86. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_context_manager.py +0 -0
  87. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_field_access.py +0 -0
  88. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_global_config.py +0 -0
  89. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_history_persistence.py +0 -0
  90. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_inspection.py +0 -0
  91. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_integration.py +0 -0
  92. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_lazy_counterpart_registry.py +0 -0
  93. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_lazy_factory.py +0 -0
  94. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_live_context_resolver_answers.py +0 -0
  95. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_object_state_metadata_contracts.py +0 -0
  96. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_object_state_restore.py +0 -0
  97. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_object_state_update_instance.py +0 -0
  98. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_one_field_resolution.py +0 -0
  99. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_parameter_owner.py +0 -0
  100. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_parametric_axes.py +0 -0
  101. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_placeholder.py +0 -0
  102. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_registry_subscription.py +0 -0
  103. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_reified_generics.py +0 -0
  104. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_release_readiness.py +0 -0
  105. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_resolved_change_notifications.py +0 -0
  106. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_scope_key.py +0 -0
  107. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_scopeless_state_resolution.py +0 -0
  108. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_subfield_semantics.py +0 -0
  109. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_timeline_projection.py +0 -0
  110. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_transaction_checkpoint.py +0 -0
  111. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_ui_visible_field_projection.py +0 -0
  112. {objectstate-3.0.1 → objectstate-3.0.3}/tests/test_undo_redo_cascade.py +0 -0
  113. {objectstate-3.0.1 → 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: 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: 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.1
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.1"
213
+ __version__ = "3.0.3"
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
 
@@ -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,6 +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]] = {}
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] = {}
229
235
 
230
236
  # === Saved baseline (2 attributes) ===
231
237
  self._saved_resolved: dict[str, Any] = {}
@@ -264,9 +270,8 @@ class ObjectState:
264
270
  # === Time-Travel ===
265
271
  # Instance-level time-travel removed - use ObjectStateRegistry class-level DAG instead
266
272
 
267
- # Initialize baselines (suppress flash during init)
268
- self._ensure_live_resolved(notify_flash=False)
269
- 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()
270
275
 
271
276
  # CRITICAL: Initialize _saved_parameters BEFORE _compute_resolved_snapshot(use_saved=True)
272
277
  # because that method reads from _saved_parameters to get raw values.
@@ -476,8 +481,7 @@ class ObjectState:
476
481
  def to_saved_resolved_object(self) -> Any:
477
482
  """Reconstruct this state as an object with saved resolved values."""
478
483
  self._check_and_sync_delegate()
479
- if not self._saved_resolved:
480
- self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
484
+ self._ensure_saved_resolved()
481
485
  return self._reconstruct_from_resolved(
482
486
  "", self._saved_resolved, self._extraction_target, self._parameter_structure
483
487
  )
@@ -720,45 +724,167 @@ class ObjectState:
720
724
  self._forwarding_to_parent = False
721
725
 
722
726
  def _ensure_live_resolved(self, notify_flash: bool = True) -> set[str]:
723
- """Ensure _live_resolved cache is populated.
724
-
725
- PERFORMANCE: Field-level invalidation only.
726
- - First access: full compute to populate cache
727
- - After update_parameter(): only recompute invalid fields
728
- - 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.
729
738
 
730
739
  Args:
731
- notify_flash: If True, fire on_resolved_changed callbacks for flash animations.
732
- Set to False during initialization to suppress flash.
733
-
734
- Returns:
735
- Set of paths that changed (for flash). Empty if no changes.
736
-
737
- 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``).
738
744
  """
739
- # First access - populate cache
740
745
  if self._live_resolved is None:
746
+ # First access - populate cache; there is no earlier value to compare with.
741
747
  self._live_resolved = self._compute_resolved_snapshot()
742
748
  self._invalid_fields.clear()
743
- return set() # First populate - no "changes" to flash
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()
744
755
 
745
- # Partial recompute for invalid fields only
746
- if self._invalid_fields:
747
- logger.debug(f"🔄 _ensure_live_resolved: scope={self.scope_id}, recomputing {len(self._invalid_fields)} invalid fields: {list(self._invalid_fields)}")
748
- changed_paths = self._recompute_invalid_fields()
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()
749
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
750
772
  else:
751
- logger.debug(f"🔄 _ensure_live_resolved: scope={self.scope_id}, no invalid fields to recompute")
752
- 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))
753
781
 
754
- if notify_flash and changed_paths:
755
- self._notify_resolved_changed(
756
- changed_paths,
757
- context="_ensure_live_resolved",
758
- )
782
+ if notify_flash and (live_resolved_again or saved_resolved_again):
783
+ self._sync_materialized_state(changed_value_fields=changed_paths)
759
784
 
760
785
  return changed_paths
761
786
 
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.
793
+
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.
797
+ """
798
+ from objectstate.config import get_base_config_type
799
+ from objectstate.global_config import get_live_global_config, get_saved_global_config
800
+
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)),
818
+ )
819
+
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
856
+ }
857
+
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.
874
+
875
+ Every resolved read makes this check itself. ``ObjectStateRegistry`` calls it
876
+ when registration changes so subscribers are told at once.
877
+ """
878
+ if self._live_resolved is not None:
879
+ self._ensure_live_resolved()
880
+
881
+ def _ensure_saved_resolved(self) -> None:
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()
885
+ elif not self._saved_resolved:
886
+ self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
887
+
762
888
  # DELETED: _create_nested_states() - No longer needed with flat storage
763
889
  # Nested ObjectStates are no longer created - flat storage handles all parameters
764
890
 
@@ -983,7 +1109,7 @@ class ObjectState:
983
1109
  )
984
1110
  # Establish the pre-mutation resolved authority even for a state that
985
1111
  # has not yet been displayed. Raw edits can preserve the resolved value.
986
- self._ensure_live_resolved(notify_flash=False)
1112
+ changed_before_the_edit = self._ensure_live_resolved(notify_flash=False)
987
1113
  ObjectStateRegistry.ensure_baseline_snapshot()
988
1114
 
989
1115
  # Update state directly (no type conversion - that's VIEW responsibility)
@@ -1051,7 +1177,7 @@ class ObjectState:
1051
1177
 
1052
1178
  # Materialize the origin once after its live context is authoritative,
1053
1179
  # then cascade only to descendants.
1054
- changed_paths = self._ensure_live_resolved(notify_flash=False)
1180
+ changed_paths = self._ensure_live_resolved(notify_flash=False) | changed_before_the_edit
1055
1181
  for changed_param in changed_semantic_params:
1056
1182
  # SCOPE + TYPE + FIELD AWARE INVALIDATION:
1057
1183
  # Get the CONTAINER type for this field (e.g., PartitionFilterConfig for
@@ -1157,9 +1283,7 @@ class ObjectState:
1157
1283
  # Auto-detect delegate changes before resolving values
1158
1284
  self._check_and_sync_delegate()
1159
1285
 
1160
- # Ensure saved resolved cache is populated
1161
- if not self._saved_resolved:
1162
- self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
1286
+ self._ensure_saved_resolved()
1163
1287
 
1164
1288
  # Check if this is a container/dataclass field (has subfields in _saved_resolved)
1165
1289
  prefix = f"{param_name}."
@@ -1190,8 +1314,7 @@ class ObjectState:
1190
1314
  self._check_and_sync_delegate()
1191
1315
  self._ensure_live_resolved()
1192
1316
  assert self._live_resolved is not None
1193
- if not self._saved_resolved:
1194
- self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
1317
+ self._ensure_saved_resolved()
1195
1318
 
1196
1319
  path = owner_field_path
1197
1320
  raw_value = self.parameters.get(path.value, MISSING)
@@ -1730,8 +1853,8 @@ class ObjectState:
1730
1853
  f"Updated ObjectState(scope={self.scope_id!r}) to new instance of type {type(new_instance).__name__}"
1731
1854
  )
1732
1855
 
1733
- def _recompute_invalid_fields(self) -> set[str]:
1734
- """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``.
1735
1858
 
1736
1859
  PERFORMANCE: For explicitly set values, use parameters directly.
1737
1860
  Only build context stack for inherited (None) values.
@@ -1785,14 +1908,6 @@ class ObjectState:
1785
1908
  from objectstate.dual_axis_resolver import resolve_with_provenance
1786
1909
  from objectstate.lazy_factory import has_lazy_resolution
1787
1910
 
1788
- # Get ancestor objects for context stack building
1789
- # CRITICAL: Skip delegate sync to avoid re-entrant invalidate_cache() calls
1790
- # that would destroy _live_resolved while we're computing it.
1791
- ancestor_objects_with_scopes = ObjectStateRegistry.get_ancestor_objects_with_scopes(
1792
- self.scope_id,
1793
- skip_delegate_sync=True
1794
- )
1795
-
1796
1911
  # ARCHITECTURAL FIX: Reconstruct current object from parameters for real-time MRO.
1797
1912
  # Using object_instance (original saved state) gives stale values, breaking inheritance.
1798
1913
  # Calling to_object() with sync_delegate=False reconstructs from CURRENT parameters
@@ -1801,7 +1916,7 @@ class ObjectState:
1801
1916
 
1802
1917
  with build_context_stack(
1803
1918
  object_instance=current_obj,
1804
- ancestor_objects_with_scopes=ancestor_objects_with_scopes,
1919
+ ancestor_objects_with_scopes=sources.ancestors,
1805
1920
  current_scope_id=self.scope_id,
1806
1921
  ):
1807
1922
  # For each inherited field, resolve using dual-axis resolution with provenance
@@ -2092,9 +2207,8 @@ class ObjectState:
2092
2207
  raise TypeError("ObjectState.parameters must remain a canonical flat dictionary.")
2093
2208
  if not isinstance(self._saved_parameters, dict):
2094
2209
  raise TypeError("ObjectState._saved_parameters must remain a canonical flat dictionary.")
2095
- ancestors = ObjectStateRegistry.get_ancestor_objects_with_scopes(
2096
- self.scope_id, use_saved=use_saved
2097
- )
2210
+ sources = self._resolution_sources(sync_delegates=True)[use_saved]
2211
+ self._resolved_from[use_saved] = sources
2098
2212
  current_obj = (
2099
2213
  self.saved_object if use_saved else self.to_object(update_delegate=False)
2100
2214
  )
@@ -2105,7 +2219,7 @@ class ObjectState:
2105
2219
  self._parameter_structure,
2106
2220
  current_obj,
2107
2221
  self.scope_id,
2108
- ancestors,
2222
+ sources.ancestors,
2109
2223
  use_saved=use_saved,
2110
2224
  )
2111
2225
  if not use_saved: