Skip to content

3.0.2: resolved values follow the states registered above a state - #54

Merged
trissim merged 3 commits into
mainfrom
fix/registration-invalidates-descendants
Oct 12, 2026
Merged

trissim merged 3 commits into
mainfrom
fix/registration-invalidates-descendants

Conversation

@trissim

@trissim trissim commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Defect

An ObjectState built before its ancestor state was registered kept the resolved values it computed at build time (the outer default) after the ancestor registered. The same held for unregistering an ancestor, replacing it at its scope, and clearing the registry.

Cause

A state resolves its inherited fields from the states registered at its ancestor scopes, read from ObjectStateRegistry when the snapshot is computed. Nothing tied the snapshot to that set: register, unregister and unregister_scope_and_descendants told no descendant, and the snapshot did not record what it was computed from. Only edits of an ancestor's values reached descendants (invalidate_by_type_and_scope).

Fix

  • ObjectStateRegistry.registered_ancestors(scope_id) is the one definition of the chain; get_ancestor_objects and get_ancestor_objects_with_scopes derive from it (two copies of the loop removed).
  • Each resolved snapshot records the registered ancestors it was computed from (ObjectState._ancestors_resolved_from, written in _compute_resolved_snapshot). _ensure_live_resolved and the saved reads compare them with today's and resolve live and saved again in full when they differ, then update dirty fields and notify.
  • register, unregister and unregister_scope_and_descendants ask registered states below the changed scope to make that comparison at once (_resolve_again_below), so on_resolved_changed subscribers hear without a read. A state whose ancestors did not change does no work; registering a state does not resolve it again.

No caller invalidates anything and there is no refresh method to remember. The per-read cost is one walk of the scope's prefixes.

Tests

tests/test_resolved_values_follow_registered_ancestors.py: 30 of its 31 passing tests fail on main. Read kinds are asserted separately: stored value, raw field of the built object, uncached attribute access, state.fields, live and saved resolved values and objects, source (scope, class), dirty fields.

Mutations covered: ancestor registered late (descendant never registered / registered before / after it), unregistered (unregister, cascade, clear), registered again with another value, replaced at its scope, registered with unsaved edits (live and saved differ), edit / save / restore of the ancestor, descendant scope_id moved under another ancestor, a context entered after the build, a field invalidation pending together with an ancestor change, notification at registration, no work when ancestors are unchanged.

Open, as strict xfail with reasons (#53): unregistered descendant after an ancestor edit; global config replaced through the setters; a state resolved inside an entered context. Widening the comparison to the ancestors' objects would fix the first two at about 4x the cost per descendant per ancestor edit (measured, in #53).

Precedence order (owner decision: class first)

Release

3.0.2. Full suite in a fresh venv (Python 3.12): 343 passed, 5 xfailed.

🤖 Generated with Claude Code

trissim and others added 3 commits October 12, 2026 11:23
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 12, 2026 15:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@trissim
trissim merged commit 6b28b51 into main Oct 12, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants