From 3ff3ac8f8001c30a68e5986d795e6e59b0d1121c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 11:22:51 -0400 Subject: [PATCH 1/3] Resolved values follow the states registered above a state A state resolved its inherited fields once, from the ancestors registered when it was built, and nothing compared that with later registration: register and unregister told no descendant. A step built before its plate was registered kept the outer value until something else invalidated it. Each resolved snapshot now records the registered ancestors it was computed from (ObjectStateRegistry.registered_ancestors, the one definition of that chain). Every resolved read compares them with today's and resolves both snapshots again when they differ. register, unregister and unregister_scope_and_descendants ask the registered states below the changed scope to make that comparison at once, so subscribers are told without a read. Three related cases stay open as strict xfail tests (#53). Co-Authored-By: Claude Opus 5.5 --- src/objectstate/object_state.py | 75 +++- src/objectstate/object_state_registry.py | 93 +++-- ...lved_values_follow_registered_ancestors.py | 341 ++++++++++++++++++ 3 files changed, 463 insertions(+), 46 deletions(-) create mode 100644 tests/test_resolved_values_follow_registered_ancestors.py diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index 56feae0..7a64505 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -226,6 +226,10 @@ def __init__( # Maps dotted_path → (source_scope_id, source_type) for inherited fields # source_type may differ from local container_type due to MRO inheritance self._live_provenance: dict[str, tuple[str | None, type | None]] = {} + # The registered ancestors each resolved snapshot was computed from, keyed by + # ``use_saved``. A snapshot is current only while ``_registered_ancestors()`` + # still returns the same states. + self._ancestors_resolved_from: dict[bool, tuple[ObjectState, ...]] = {} # === Saved baseline (2 attributes) === self._saved_resolved: dict[str, Any] = {} @@ -476,8 +480,7 @@ def to_resolved_object(self) -> Any: def to_saved_resolved_object(self) -> Any: """Reconstruct this state as an object with saved resolved values.""" self._check_and_sync_delegate() - if not self._saved_resolved: - self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) + self._ensure_saved_resolved() return self._reconstruct_from_resolved( "", self._saved_resolved, self._extraction_target, self._parameter_structure ) @@ -742,6 +745,12 @@ def _ensure_live_resolved(self, notify_flash: bool = True) -> set[str]: self._invalid_fields.clear() return set() # First populate - no "changes" to flash + if not self._resolved_from_registered_ancestors(): + # Registration changed above this state: every inherited field may have + # another source, so both snapshots are resolved again in full. Pending + # field invalidations are covered by the full pass. + return self._resolve_again_from_registered_ancestors(notify=notify_flash) + # Partial recompute for invalid fields only if self._invalid_fields: 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 @@ def _ensure_live_resolved(self, notify_flash: bool = True) -> set[str]: return changed_paths + def _registered_ancestors(self) -> tuple[ObjectState, ...]: + """The other states this state's resolved values are computed from. + + This state is left out: registering it adds no value it does not hold. + """ + return tuple( + state + for _, state in ObjectStateRegistry.registered_ancestors(self.scope_id) + if state is not self + ) + + def _resolved_from_registered_ancestors(self) -> bool: + """Whether both resolved snapshots were computed from today's ancestors.""" + ancestors = self._registered_ancestors() + return all( + self._ancestors_resolved_from.get(use_saved) == ancestors + for use_saved in (False, True) + ) + + def _resolve_again_from_registered_ancestors(self, *, notify: bool) -> set[str]: + """Resolve the live and saved snapshots in full; return the changed live paths.""" + previous = self._live_resolved or {} + self._live_resolved = self._compute_resolved_snapshot() + self._invalid_fields.clear() + self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) + changed_values = { + path: (previous.get(path), value) + for path, value in self._live_resolved.items() + if previous.get(path) != value + } + if changed_values: + self._set_last_changed_values(changed_values) + changed_paths = self.notification_fields_for_value_changes(changed_values) + self._sync_materialized_state( + changed_value_fields=changed_paths, emit_notifications=notify + ) + return changed_paths + + def resolve_again_if_ancestors_changed(self) -> None: + """Resolve again when other states are registered above this one than before. + + Every resolved read makes this check itself. ``ObjectStateRegistry`` calls it + when registration changes so subscribers are told at once. + """ + if self._live_resolved is not None: + self._ensure_live_resolved() + + def _ensure_saved_resolved(self) -> None: + """Make ``_saved_resolved`` hold every field, resolved from today's ancestors.""" + if self._live_resolved is not None and not self._resolved_from_registered_ancestors(): + self._resolve_again_from_registered_ancestors(notify=True) + elif not self._saved_resolved: + self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) + # DELETED: _create_nested_states() - No longer needed with flat storage # Nested ObjectStates are no longer created - flat storage handles all parameters @@ -1157,9 +1220,7 @@ def get_saved_resolved_value(self, param_name: str) -> Any: # Auto-detect delegate changes before resolving values self._check_and_sync_delegate() - # Ensure saved resolved cache is populated - if not self._saved_resolved: - self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) + self._ensure_saved_resolved() # Check if this is a container/dataclass field (has subfields in _saved_resolved) prefix = f"{param_name}." @@ -1190,8 +1251,7 @@ def subfield_semantics( self._check_and_sync_delegate() self._ensure_live_resolved() assert self._live_resolved is not None - if not self._saved_resolved: - self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) + self._ensure_saved_resolved() path = owner_field_path raw_value = self.parameters.get(path.value, MISSING) @@ -2092,6 +2152,7 @@ def _compute_resolved_snapshot(self, use_saved: bool = False) -> dict[str, Any]: raise TypeError("ObjectState.parameters must remain a canonical flat dictionary.") if not isinstance(self._saved_parameters, dict): raise TypeError("ObjectState._saved_parameters must remain a canonical flat dictionary.") + self._ancestors_resolved_from[use_saved] = self._registered_ancestors() ancestors = ObjectStateRegistry.get_ancestor_objects_with_scopes( self.scope_id, use_saved=use_saved ) diff --git a/src/objectstate/object_state_registry.py b/src/objectstate/object_state_registry.py index a041bd1..f29ba68 100644 --- a/src/objectstate/object_state_registry.py +++ b/src/objectstate/object_state_registry.py @@ -17,6 +17,7 @@ Tuple, TYPE_CHECKING, Generator, + Iterable, TypeAlias, ) import copy @@ -409,6 +410,7 @@ def register(cls, state: 'ObjectState', _skip_snapshot: bool = False) -> None: cls._states[key] = state cls.mark_snapshot_dirty_scope(key) logger.debug(f"Registered ObjectState: scope={key}, type={type(state.object_instance).__name__}") + cls._resolve_again_below((key,)) # Fire callbacks for UI binding cls._fire_register_callbacks(key, state) @@ -433,6 +435,7 @@ def unregister(cls, state: 'ObjectState', _skip_snapshot: bool = False) -> None: # that reference this ObjectState. Graveyard persists for session lifetime. cls._graveyard[key] = removed_state logger.debug(f"Unregistered ObjectState: scope={key} (moved to graveyard)") + cls._resolve_again_below((key,)) # Fire callbacks for UI binding cls._fire_unregister_callbacks(key, state) @@ -440,6 +443,36 @@ def unregister(cls, state: 'ObjectState', _skip_snapshot: bool = False) -> None: # Structural unregistration is not a user-edit boundary. The # graveyard preserves state for explicit operation snapshots. + @classmethod + def _resolve_again_below(cls, changed_scopes: Iterable[ScopeKey]) -> None: + """Tell registered states at or below ``changed_scopes`` that registration changed. + + Each state compares the ancestors registered now with the ones its resolved + values were computed from (``ObjectState.resolve_again_if_ancestors_changed``), + so a state whose ancestors are the same does no work. This call only makes + the comparison happen now, so subscribers hear of changed values without + waiting for the next read. + """ + changed_scopes = tuple(changed_scopes) + for state in tuple(cls._states.values()): + if any(scope.contains(state.scope_id) for scope in changed_scopes): + state.resolve_again_if_ancestors_changed() + + @classmethod + def registered_ancestors( + cls, scope_id: str | None + ) -> tuple[tuple[ScopeKey, 'ObjectState'], ...]: + """The states registered at the global scope, each prefix of ``scope_id`` and + ``scope_id`` itself, least specific first. + + Resolved values of a state at ``scope_id`` are computed from these states. + """ + return tuple( + (ancestor_key, cls._states[ancestor_key]) + for ancestor_key in cls._normalize_scope_id(scope_id).ancestors() + if ancestor_key in cls._states + ) + @classmethod def unregister_scope_and_descendants(cls, scope_id: Optional[str], _skip_snapshot: bool = False) -> int: """Unregister an ObjectState and all its descendants from the registry. @@ -474,8 +507,9 @@ def unregister_scope_and_descendants(cls, scope_id: Optional[str], _skip_snapsho # NOTE: Unlike plain unregister(), this is often used for hierarchical deletes. # When history is enabled we must preserve removed states for time-travel (undo/redo), # otherwise snapshots may reference scopes that can no longer be resurrected. - for key in scopes_to_delete: - state = cls._states.pop(key) + removed = [(key, cls._states.pop(key)) for key in scopes_to_delete] + cls._resolve_again_below(scopes_to_delete) + for key, state in removed: if cls._history_enabled: cls._graveyard[key] = state logger.debug(f"Unregistered ObjectState (cascade): scope={key} (moved to graveyard)") @@ -669,21 +703,10 @@ def get_ancestor_objects(cls, scope_id: Optional[str], use_saved: bool = False) List of objects from ancestor scopes, ordered least→most specific. Each object is from state.object_instance (saved) or state.to_object() (live). """ - scope_key = cls._normalize_scope_id(scope_id) - - # Get objects from ancestor scopes, least-specific to most-specific - objects = [] - for ancestor_key in scope_key.ancestors(): - state = cls._states.get(ancestor_key) - if state: - if use_saved: - # Return saved baseline (saved_object handles delegation correctly) - objects.append(state.saved_object) - else: - # Return live state with current edits - objects.append(state.to_object(update_delegate=False)) - - return objects + return [ + state.saved_object if use_saved else state.to_object(update_delegate=False) + for _, state in cls.registered_ancestors(scope_id) + ] @classmethod def get_ancestor_objects_with_scopes( @@ -714,28 +737,20 @@ def get_ancestor_objects_with_scopes( Returns: List of (scope_id, object) tuples from ancestor scopes, ordered least→most specific. """ - scope_key = cls._normalize_scope_id(scope_id) - - # Get (scope_id, object) tuples from ancestor scopes - results: List[Tuple[str, Any]] = [] - for ancestor_key in scope_key.ancestors(): - state = cls._states.get(ancestor_key) - if state: - # Get object - either with or without delegate sync - 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}") - - if use_saved: - # For saved baseline, we want object_instance (the original saved state) - # Not reconstructed, no delegate sync needed - obj = state._extraction_target - else: - # For live state, reconstruct from current parameters - # Control whether delegate sync happens - obj = state.to_object(update_delegate=False, sync_delegate=not skip_delegate_sync) - - results.append((ancestor_key, obj)) - - return results + # Saved: the object as last saved, without delegate sync or reconstruction. + # Live: rebuilt from the current parameters; ``skip_delegate_sync`` decides + # whether the delegate is synchronised first. + return [ + ( + ancestor_key, + state._extraction_target + if use_saved + else state.to_object( + update_delegate=False, sync_delegate=not skip_delegate_sync + ), + ) + for ancestor_key, state in cls.registered_ancestors(scope_id) + ] # ========== SCOPE + TYPE + FIELD AWARE INVALIDATION ========== diff --git a/tests/test_resolved_values_follow_registered_ancestors.py b/tests/test_resolved_values_follow_registered_ancestors.py new file mode 100644 index 0000000..41e28b5 --- /dev/null +++ b/tests/test_resolved_values_follow_registered_ancestors.py @@ -0,0 +1,341 @@ +"""A state's resolved values follow the states registered above it. + +A step at ``plate::step`` leaves ``settings.value`` unset, so the value comes from +the plate state registered at ``plate`` when there is one and from the global +config otherwise. Which states are registered can change after the step state was +built; every read of the step must then answer from the states registered now, +whatever the order of building and registering was. + +The kinds of read are kept apart (``Reads``): the stored value never changes here, +the live and saved resolved values differ while an ancestor has unsaved edits, and +the reported source names the scope and class that supplied the value. + +Mutations covered: an ancestor registered after the descendant was built (in every +order of registering the two), unregistered (alone, with its descendants, by +clearing the registry), registered again with another value, replaced at its scope, +registered with unsaved edits; an ancestor's value edited, saved and restored; the +descendant's ``scope_id`` moved under another ancestor; a context entered after +the build. The three cases at the end of the file are known to fail and say why. +""" + +from dataclasses import dataclass, field +from typing import NamedTuple + +import pytest + +from objectstate import ( + LazyDataclassFactory, + ObjectState, + ObjectStateRegistry, + build_context_stack, + config_context, + mark_global_config_type, + set_base_config_type, + set_global_config_for_editing, + set_live_global_config, +) + + +@dataclass +class Settings: + value: str | None = None + other: str | None = None + + +@dataclass +class GlobalConfig: + settings: Settings = field(default_factory=Settings) + + +mark_global_config_type(GlobalConfig) +LazySettings = LazyDataclassFactory.make_lazy_simple(Settings) + + +@dataclass +class Holder: + """A plate or a step: one lazy ``Settings``.""" + + settings: LazySettings = field(default_factory=LazySettings) + + +GLOBAL, PLATE, OTHER_PLATE, STEP = "", "plate", "other", "plate::step" +PATH = "settings.value" + + +class Reads(NamedTuple): + """Every kind of read of ``settings.value`` on one state.""" + + stored: str | None # what the state's own parameters hold + stored_on_object: str | None # the raw field of the object the state builds + uncached: str | None # plain attribute access on that object among today's ancestors + attribute: str | None # plain attribute access through ``state.fields`` + live: str | None # get_resolved_value + live_object: str | None # attribute of to_resolved_object() + saved: str | None # get_saved_resolved_value + saved_object: str | None # attribute of to_saved_resolved_object() + source: tuple | None # get_provenance: (scope, class) that supplied the live value + dirty: frozenset # fields whose live and saved resolved values differ + + +def _reads(state: ObjectState) -> Reads: + built = state.to_object() + ancestors = ObjectStateRegistry.get_ancestor_objects_with_scopes(state.scope_id) + with build_context_stack(built, ancestors, state.scope_id): + uncached = built.settings.value + return Reads( + stored=state.parameters[PATH], + stored_on_object=object.__getattribute__(built.settings, "value"), + uncached=uncached, + attribute=state.fields.settings.value, + live=state.get_resolved_value(PATH), + live_object=state.to_resolved_object().settings.value, + saved=state.get_saved_resolved_value(PATH), + saved_object=state.to_saved_resolved_object().settings.value, + source=state.get_provenance(PATH), + dirty=frozenset(state.dirty_fields), + ) + + +def _inherited(live: str, scope: str, *, saved: str | None = None) -> Reads: + """The reads of a state that stores nothing and inherits from ``scope``.""" + saved = live if saved is None else saved + return Reads( + stored=None, + stored_on_object=None, + uncached=live, + attribute=live, + live=live, + live_object=live, + saved=saved, + saved_object=saved, + source=(scope, Settings), + dirty=frozenset() if live == saved else frozenset({PATH}), + ) + + +FROM_GLOBAL = _inherited("global", GLOBAL) + + +def _plate_state(value: str, scope: str = PLATE) -> ObjectState: + return ObjectState(Holder(settings=LazySettings(value=value)), scope_id=scope) + + +def _step_state() -> ObjectState: + return ObjectState(Holder(), scope_id=STEP) + + +@pytest.fixture(autouse=True) +def _global_config_and_empty_registry(): + ObjectStateRegistry.clear() + set_base_config_type(GlobalConfig) + set_global_config_for_editing(GlobalConfig, GlobalConfig(settings=Settings(value="global"))) + yield + ObjectStateRegistry.clear() + + +STEP_REGISTRATION = ("never", "before the plate", "after the plate") + + +@pytest.fixture(params=STEP_REGISTRATION) +def step_and_plate(request): + """A step state built before its plate is registered, then the plate registered. + + The step state itself is never registered, or registered before or after the + plate: a state reads its ancestors from the registry in all three cases. + """ + step = _step_state() + assert _reads(step) == FROM_GLOBAL + plate = _plate_state("plate") + if request.param == "before the plate": + ObjectStateRegistry.register(step) + ObjectStateRegistry.register(plate) + if request.param == "after the plate": + ObjectStateRegistry.register(step) + return step, plate + + +def test_ancestor_registered_after_the_descendant_was_built_supplies_the_value(step_and_plate): + step, _ = step_and_plate + assert _reads(step) == _inherited("plate", PLATE) + + +def test_both_orders_of_building_give_the_same_reads(step_and_plate): + late_ancestor_step, _ = step_and_plate + ancestor_first_step = _step_state() + assert _reads(late_ancestor_step) == _reads(ancestor_first_step) + + +@pytest.mark.parametrize( + "remove", + [ + lambda plate: ObjectStateRegistry.unregister(plate), + lambda plate: ObjectStateRegistry.unregister_scope_and_descendants(PLATE), + lambda plate: ObjectStateRegistry.clear(), + ], + ids=["unregister", "unregister_scope_and_descendants", "clear"], +) +def test_unregistering_the_ancestor_takes_its_value_back(step_and_plate, remove): + step, plate = step_and_plate + assert _reads(step) == _inherited("plate", PLATE) + remove(plate) + assert _reads(step) == FROM_GLOBAL + + +def test_ancestor_registered_again_with_another_value(step_and_plate): + step, plate = step_and_plate + assert _reads(step) == _inherited("plate", PLATE) + + ObjectStateRegistry.unregister(plate) + ObjectStateRegistry.register(_plate_state("second")) + assert _reads(step) == _inherited("second", PLATE) + + # Replaced at its scope without unregistering first. + ObjectStateRegistry.register(_plate_state("third")) + assert _reads(step) == _inherited("third", PLATE) + + # The first state again, as it was. + ObjectStateRegistry.register(plate) + assert _reads(step) == _inherited("plate", PLATE) + + +def test_ancestor_registered_with_unsaved_edits_separates_live_from_saved(): + step = _step_state() + ObjectStateRegistry.register(step) + plate = _plate_state("plate") + plate.update_parameter(PATH, "edited") + + ObjectStateRegistry.register(plate) + + assert step.dirty_fields == {PATH} # materialised at registration, before any read + assert _reads(step) == _inherited("edited", PLATE, saved="plate") + + +def test_registered_descendant_is_told_at_registration_and_unregistration(): + step = _step_state() + ObjectStateRegistry.register(step) + changes: list[set[str]] = [] + step.on_resolved_changed(changes.append) + + plate = _plate_state("plate") + ObjectStateRegistry.register(plate) + assert changes == [{PATH}] + + ObjectStateRegistry.unregister(plate) + assert changes == [{PATH}, {PATH}] + + +def test_registration_that_changes_no_ancestor_resolves_nothing_again(): + plate = _plate_state("plate") + ObjectStateRegistry.register(plate) + step = _step_state() + changes: list[set[str]] = [] + step.on_resolved_changed(changes.append) + live_snapshot = step._live_resolved + before = _reads(step) + + ObjectStateRegistry.register(step) # the state itself + ObjectStateRegistry.register(_plate_state("elsewhere", OTHER_PLATE)) # not above it + ObjectStateRegistry.register(_step_state_at(f"{PLATE}::sibling")) # beside it + + assert _reads(step) == before + assert step._live_resolved is live_snapshot + assert changes == [] + + +def _step_state_at(scope: str) -> ObjectState: + return ObjectState(Holder(), scope_id=scope) + + +def test_edit_save_and_restore_of_a_late_registered_ancestor(step_and_plate): + step, plate = step_and_plate + ObjectStateRegistry.register(step) + + plate.update_parameter(PATH, "edited") + assert _reads(step) == _inherited("edited", PLATE, saved="plate") + + plate.mark_saved() + assert _reads(step) == _inherited("edited", PLATE) + + plate.update_parameter(PATH, "again") + assert _reads(step) == _inherited("again", PLATE, saved="edited") + + plate.restore_saved() + assert _reads(step) == _inherited("edited", PLATE) + + +def test_field_edited_in_the_ancestor_before_it_was_registered_is_not_lost(): + """A field invalidated on the descendant and a changed ancestor in one read.""" + step = _step_state() + ObjectStateRegistry.register(step) + step.invalidate_field("settings.other") + + ObjectStateRegistry.clear() + ObjectStateRegistry._states[PLATE] = ObjectState( + Holder(settings=LazySettings(value="plate", other="other")), scope_id=PLATE + ) # put in place without telling any state, as time travel does + + assert step.get_resolved_value("settings.other") == "other" + assert _reads(step) == _inherited("plate", PLATE) + + +def test_scope_moved_under_another_ancestor(step_and_plate): + step, _ = step_and_plate + ObjectStateRegistry.register(_plate_state("elsewhere", OTHER_PLATE)) + + step.scope_id = f"{OTHER_PLATE}::step" + assert _reads(step) == _inherited("elsewhere", OTHER_PLATE) + + step.scope_id = STEP + assert _reads(step) == _inherited("plate", PLATE) + + +def test_context_entered_after_the_build_changes_no_read(step_and_plate): + step, _ = step_and_plate + expected = _inherited("plate", PLATE) + assert _reads(step) == expected + + with config_context(Holder(settings=LazySettings(value="ambient")), scope_id="ambient"): + inside = _reads(step) + + # ``uncached`` is resolved inside the entered context on purpose; see the last + # test in this file for what that shows. + assert inside._replace(uncached="plate") == expected + assert _reads(step) == expected + + +# --- Known to fail -------------------------------------------------------------- +# The three cases below are the same kind of fault (a resolved snapshot outliving +# something it was computed from) with another input than registration. Comparing +# registered ancestors does not cover them; issue #53 says why each is open. + +OPEN = "open, see https://github.com/OpenHCSDev/ObjectState/issues/53: " + + +@pytest.mark.xfail(strict=True, reason=OPEN + "only registered states are told of an ancestor edit") +def test_unregistered_descendant_follows_an_edit_of_its_ancestor(step_and_plate): + step, plate = step_and_plate + ObjectStateRegistry.unregister(step) + assert _reads(step) == _inherited("plate", PLATE) + + plate.update_parameter(PATH, "edited") + + assert _reads(step) == _inherited("edited", PLATE, saved="plate") + + +@pytest.mark.xfail(strict=True, reason=OPEN + "no state is told when the global config is replaced") +def test_global_config_replaced_after_the_build(): + step = _step_state() + ObjectStateRegistry.register(step) + + set_live_global_config(GlobalConfig, GlobalConfig(settings=Settings(value="replaced"))) + + assert _reads(step) == _inherited("replaced", GLOBAL, saved="global") + + +@pytest.mark.xfail(strict=True, reason=OPEN + "a state resolved inside a context keeps its values") +def test_resolving_inside_an_entered_context_does_not_take_its_values(): + entered = Holder(settings=LazySettings(value="ambient")) + with config_context(entered, scope_id="ambient"): + step = _step_state() + + assert _reads(step) == FROM_GLOBAL From d85e69cd7d68d65449d61b5f5e21ceac89410ad3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 11:22:51 -0400 Subject: [PATCH 2/3] State the precedence order once: class first Owner decision: the MRO is the outer loop, scopes the inner one. The order is stated with a Leaf/Base, plate/global example in context_system.rst; the resolver docstring and configuration_framework.rst refer to it. The sentence in configuration_framework.rst that described scope first is corrected. The crossed-pair tests are the policy now, not a characterisation; assertions are unchanged, and so is the resolver. Co-Authored-By: Claude Opus 5.5 --- .../docs/configuration_framework.rst | 2 +- src/objectstate/docs/context_system.rst | 35 ++++++++++++++++--- src/objectstate/dual_axis_resolver.py | 13 ++++--- tests/test_resolution_precedence_order.py | 24 +++++-------- 4 files changed, 50 insertions(+), 24 deletions(-) diff --git a/src/objectstate/docs/configuration_framework.rst b/src/objectstate/docs/configuration_framework.rst index 8c8cae8..3a31ff2 100644 --- a/src/objectstate/docs/configuration_framework.rst +++ b/src/objectstate/docs/configuration_framework.rst @@ -149,7 +149,7 @@ Usage Example ) ) -The lazy configs resolve through dual-axis algorithm: check ``AppConfig`` context first, then ``GlobalAppConfig`` context, walking MRO at each level. +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. Framework Modules ---------------- diff --git a/src/objectstate/docs/context_system.rst b/src/objectstate/docs/context_system.rst index 6a47754..2812ec8 100644 --- a/src/objectstate/docs/context_system.rst +++ b/src/objectstate/docs/context_system.rst @@ -195,10 +195,37 @@ The dual-axis resolver reads the layer stack of the active context: value, source_scope, source_type = resolve_with_provenance(LazyDatabaseConfig, "port") -For each type in the container type's MRO, most specific first, it asks every layer from -innermost to outermost for a config of that type and returns the first value that is not -``None``, together with the layer's scope id and the type it was read from. Attribute access -on a lazy config returns the ``value`` of this same result. +Attribute access on a lazy config returns the ``value`` of this same result. + +.. _precedence-order: + +Precedence Order +~~~~~~~~~~~~~~~~ + +Resolution is class first. For each class in the MRO of the type being read, most specific +first, the resolver asks every layer from the innermost scope to the outermost for a config +of that class and returns the first value that is not ``None``, together with the layer's +scope id and the class it was read from. Only when no layer holds a value for a class is +the next, less specific class tried. + +So the class decides before the scope does. Take ``Leaf(Base)``, a field ``value`` read on +``Leaf`` in a step, and two layers that hold a value: + +========================== ====================== ===================== +Layer (scope) ``Base.value`` ``Leaf.value`` +========================== ====================== ===================== +plate (inner) ``"base at plate"`` ``None`` +global (outer) ``None`` ``"leaf at global"`` +========================== ====================== ===================== + +The read returns ``"leaf at global"`` with source ``("", Leaf)``: ``Leaf`` is tried in +every scope before ``Base`` is tried in any, although the plate is the nearer scope. A +value on the same class in a nearer scope does win: with ``Leaf.value`` also set on the +plate, the plate's value is returned. + +This is the only statement of the order. ``objectstate.dual_axis_resolver`` implements it +in ``_resolve_in_layers``; ``tests/test_resolution_precedence_order.py`` checks it for +every set of (class, scope) sites. Usage Pattern ------------ diff --git a/src/objectstate/dual_axis_resolver.py b/src/objectstate/dual_axis_resolver.py index 7e1fe04..b36733b 100644 --- a/src/objectstate/dual_axis_resolver.py +++ b/src/objectstate/dual_axis_resolver.py @@ -151,11 +151,16 @@ def resolve_in_stack(stack, container_type: type, field_name: str) -> ResolvedFi def _resolve_in_layers(layers: tuple, container_type: type, field_name: str) -> ResolvedField: - """Walk the container type's MRO and, for each type, the layers from inner to outer. + """Resolve class first: the MRO is the outer loop, the layers the inner one. - MRO order dominates layer order: ``None`` in an inner layer means "inherit", so - every outer layer is asked for the same type before a less specific type is - tried. A layer that masks the layers outside it ends the walk outward. + For each class in the container type's MRO, most specific first, every layer is + asked from the innermost scope to the outermost; the first value that is not + ``None`` wins. ``None`` in an inner layer means "inherit", so a less specific + class is tried only after every layer was asked for the more specific one. + A layer that masks the layers outside it ends the walk outward. + + The order is stated with an example in ``docs/context_system.rst`` under + "Precedence Order"; this function is its implementation. """ container_base = _normalize_to_base(container_type) diff --git a/tests/test_resolution_precedence_order.py b/tests/test_resolution_precedence_order.py index 901fc0b..1749917 100644 --- a/tests/test_resolution_precedence_order.py +++ b/tests/test_resolution_precedence_order.py @@ -13,9 +13,9 @@ Which axis is the outer loop only shows on a crossed pair: a value on a less specific class in a more specific scope against a value on a more specific class in -a less specific scope, with no site most specific on both. The crossed tests here -are a CHARACTERISATION OF TODAY'S BEHAVIOUR (class first), not a statement of -intended policy: the documentation states both orders. See +a less specific scope, with no site most specific on both. The policy is class +first: the more specific class wins such a pair. It is stated in +``docs/context_system.rst`` under "Precedence Order" and checked by ``test_crossed_pair_is_won_by_the_more_specific_class``. """ @@ -244,16 +244,10 @@ def test_winner_is_the_first_site_of_one_strict_total_order(): ids=[f"{_value_at(a)} vs {_value_at(b)}" for a, b in CROSSED_PAIRS], ) def test_crossed_pair_is_won_by_the_more_specific_class(inner_scope_site, specific_class_site): - """CHARACTERISATION of today's behaviour, not a statement of intended policy. - - ``_resolve_in_layers`` walks the MRO outside and the layers inside, so a value on - ``Leaf`` in the global config beats a value on ``Base`` set on the plate. Its - docstring and ``docs/context_system.rst`` describe that order; - ``docs/configuration_framework.rst`` describes the opposite one ("check - ``AppConfig`` context first, then ``GlobalAppConfig`` context, walking MRO at each - level"). Until the owner settles which is intended, this test only records what - the resolver does. If the scope is meant to be the outer loop, flip the expected - winner here and in ``test_all_sites_are_ordered_class_first_then_scope``. + """The policy: class first. + + The MRO is the outer loop and the layers the inner one, so a value on ``Leaf`` + in the global config beats a value on ``Base`` set on the plate. """ assert inner_scope_site[0] is not specific_class_site[0] assert inner_scope_site[1] != specific_class_site[1] @@ -268,7 +262,7 @@ def test_crossed_pair_is_won_by_the_more_specific_class(inner_scope_site, specif def test_all_sites_are_ordered_class_first_then_scope(): - """CHARACTERISATION: the one order is the product order with the class outer.""" + """The one order is the product order with the class outer.""" for sites in ALL_SITE_SETS: if sites: assert _winner(sites) == min(sites, key=ALL_SITES.index), sites @@ -279,7 +273,7 @@ def test_read_kinds_of_an_editing_state_stay_distinct_and_agree_on_a_crossed_pai The stored value stays ``None``; the resolved value and the source are the resolver's answer for the same crossed pair. Which site that is, is the - characterised class-first order (see the crossed-pair test above). + class-first order (see the crossed-pair test above). """ crossed = frozenset({(Base, PLATE), (Leaf, GLOBAL)}) plate_only = frozenset({(Base, PLATE)}) From 240df994e37aed830c3a75d85299569bb066f84f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 11:22:51 -0400 Subject: [PATCH 3/3] 3.0.2 Co-Authored-By: Claude Opus 5.5 --- src/objectstate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/objectstate/__init__.py b/src/objectstate/__init__.py index 931a7f5..af5c2be 100644 --- a/src/objectstate/__init__.py +++ b/src/objectstate/__init__.py @@ -210,7 +210,7 @@ 'ObjectStateEditSession', ] -__version__ = "3.0.1" +__version__ = "3.0.2" __author__ = 'OpenHCS Team' __description__ = 'Generic configuration framework for lazy dataclass resolution'