Skip to content

Resolved values follow every object they were resolved from (3.0.3) - #55

Merged
trissim merged 2 commits into
mainfrom
fix/53-resolved-values-follow-their-sources
Oct 12, 2026
Merged

trissim merged 2 commits into
mainfrom
fix/53-resolved-values-follow-their-sources

Conversation

@trissim

@trissim trissim commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Fixes cases 1 and 2 of #53. Case 3 is unchanged and stays a strict xfail.

What was wrong

3.0.2 made a resolved snapshot record the registered ancestor states it was computed from. Two other inputs were not covered:

  1. a state that is not registered was never told of an ancestor's edit, save or restore, because only registered states are walked;
  2. replacing the global config with set_live_global_config / set_saved_global_config told no state.

The relation

A resolved value is computed from the set of ancestors, what each stores for the field's (config type, field), and what the global config stores. Each snapshot (live and saved) now keeps exactly that as ResolutionSources: the global config instance of the resolving thread and the object each registered ancestor gives to resolution. Every resolved read compares those objects, by identity, with the ones the state would be given now.

  • Same objects: answer from the snapshot.
  • Another object at the same scope: the two objects are compared field by field, the way field resolution reads a layer (extract_all_configs), and only the state's unset fields that can inherit a differing (config type, field) are resolved again. The answer for a pair of objects is kept, so it is computed once per edit and not once per descendant.
  • Other scopes registered than before: every field is resolved again (as in 3.0.2).

There is one record (ObjectState._resolved_from), written where a snapshot is computed and compared on read. No caller announces anything, there is no refresh method, and nothing references a state that is not registered. The registry still prompts registered states so their subscribers hear at once; that prompt and a plain read run the same code.

Cost (78-field states, one ancestor, min of alternating runs on a loaded machine, ms)

3.0.2 this branch
one-field ancestor edit, 1 descendant 4.07 4.08
one-field ancestor edit, 10 descendants 22.9 23.0
one-field ancestor edit, 100 descendants 224 216
same, history off, 10 / 100 descendants 10.0 / 94 9.4 / 89
edit + 4 reads on each of 100 descendants 223 218
edit + save, 1 / 10 / 100 descendants 12.9 / 64 / 632 12.3 / 64 / 621
read of an unchanged state: get_resolved_value 8.6 us 9.7 us
read of an unchanged state: get_provenance 3.3 us 4.2 us
ancestor edit + read of an unregistered descendant 4.06 (stale value) 5.03 (current value)

The read-path check costs about 1 us per resolved read. Comparing two 78-field ancestor objects costs 88 us, which is why the answer is kept per pair (0.2 us afterwards).

Limits (documented in docs/state_propagation.rst)

  • Identity comparison does not see a source changed in place. to_object() returns the same function object for a function-backed state, so states below one follow its edits only when they are registered.
  • The global config is stored per thread. A snapshot follows the global config of the thread that resolved it; a read from another thread answers from the snapshot.
  • Case 3 of A state resolved inside an entered config_context keeps that context's values (cases 1 and 2 fixed in 3.0.3) #53: a state built or resolved inside an entered config_context takes that context's layers into its snapshot. Because source changes are now found on read, a pending change first read inside an entered context is resolved there.

Tests

tests/test_resolved_values_follow_registered_ancestors.py: the two xfails pass; added unregistered edit/save/restore, undo and redo, subscribers of an unregistered state told on its next read, an unregistered state is not kept alive, only the edited field is resolved again, a field the descendant sets itself is not resolved again, an unchanged read resolves nothing, an edit two levels up (every registration combination), global config replaced (live, saved, both; registered or not), replaced with equal values resolves nothing, replaced then an ancestor edited, a global state edit two levels down, another thread reading or setting its own global config.

Local: 371 passed, 1 xfailed on Python 3.12 and 3.11 in fresh venvs.

🤖 Generated with Claude Code

trissim and others added 2 commits October 12, 2026 12:17
A resolved snapshot now keeps the objects it was computed from
(ResolutionSources): the global config instance of the resolving thread and
the object of each state registered above. Every resolved read compares them
by identity with the objects the state would be given now. When one differs,
the two objects are compared field by field the way field resolution reads a
layer, and only the unset fields that can inherit a differing
(config type, field) are resolved again. The answer for a pair of objects is
kept, so it is computed once per edit, not once per descendant.

This replaces the comparison of registered ancestor states from 3.0.2 and
covers two inputs it did not (issue #53, cases 1 and 2): a state that is not
registered follows edits, saves and restores of its ancestors, and replacing
the global config through the setters reaches existing states.

The saved snapshot is resolved in full when its sources change (on save,
restore, replacement), as the registry already did. The registry's field
invalidation no longer resolves the saved snapshot a second time.

Case 3 of #53 (a state resolved inside an entered config_context keeps that
context's values) is unchanged and stays an expected failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@trissim
trissim merged commit 9e711f8 into main Oct 12, 2026
11 checks passed
Copilot AI balanced review requested due to automatic review settings October 12, 2026 16:20

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.

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