Skip to content

4.0.0: the state registered at the global scope holds the global config (#56) - #58

Merged
trissim merged 1 commit into
mainfrom
refactor/56-one-holder-of-the-global-config
Oct 12, 2026
Merged

trissim merged 1 commit into
mainfrom
refactor/56-one-holder-of-the-global-config

Conversation

@trissim

@trissim trissim commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #56

What was wrong

The global config had two holders that callers had to keep in step: two per-thread stores (_live_global_config_contexts, _saved_global_config_contexts) and the parameters of the state registered at scope "" for a global config type. The state wrote the stores from five places (update_parameter only if a live value was already stored, update_object_instance, restore_saved, time travel, mark_saved); registering wrote nothing; four setters under four names wrote the stores without telling any state. Two copies kept equal by convention are not a single source (P10 Proposition 2.2: agreement cannot orient a source edge).

Decision: the registered global state is the holder

A state's value is its flat parameters / _saved_parameters; undo, dirty fields, checkpoints and notifications are all defined on them, and the object is already derived from them for every other ancestor (object_for_resolution). Making the store the holder would mean a state with no parameters of its own, unlike every other state. So the direction is: parameters -> object -> global config. get_live_global_config, get_saved_global_config, get_base_global_config (so config_context and build_context_stack) and _resolution_sources ask ObjectStateRegistry.global_config_state(config_type) at read time. Nothing is copied, so nothing is written: the five write sites are deleted, not merged. A process without states uses the same three functions; with no state registered they answer from the object set_global_config stored.

One write route

set_global_config(config_type, config). With a global state registered it is state.update_object_instance(config); without one it stores the object and tells registered states at once. A stored object that disagrees with a registered state cannot be made.

Registration

Registering a global state makes its object the global config with no setter call (also when nothing was stored). Unregistering it, or time travel to a point where it was not registered, ends that at once: the global config is again what was stored while no state held it, or the declared defaults. Because the lookup is made at read time, every way the registry changes (register, unregister, unregister_scope_and_descendants, clear, time travel, time-travel rollback) is covered without a hook.

Threads: one rule

The global config is one value per process, like the registry. The per-thread stores and ResolutionSources.thread are gone, so a state's resolved values depend only on its place in the registered hierarchy (the rule from #53), not on the reading thread. Before, a thread that had not called a setter itself resolved plain lazy objects against the declared defaults. Nothing is synchronised (it never was): edits belong in one thread. spawn_thread_with_context copies the entered config_context layers; the global config needs no copying.

Alternative not taken: keep the stored object per thread while no state is registered. Example where they differ: two threads in a stateless process each call set_global_config with a different config and then compile. Per process (this PR): both read the config set last. Per thread: each reads its own, and a third thread that set nothing reads the declared defaults.

Removed (see CHANGELOG.md for what to call instead)

ensure_global_config_context, set_global_config_for_editing, set_live_global_config, set_saved_global_config, get_current_global_config, clear_current_temp_global and its four calls, GlobalContextValues, ObjectState.prepare_history_global_context, rebuild_lazy_config_with_new_global_reference (no caller anywhere). Added: set_global_config, forget_stored_global_config, ObjectStateRegistry.global_config_state. _resolve_again_below is now resolve_again_below.

Behaviour changes for callers (pyqt-reactive, OpenHCS)

  • set_global_config beside a registered global state replaces the state's object and drops its unsaved edits. Calls that only repeated what the state held must be deleted.
  • A global edit, replacement, restore or time travel no longer leaves the caller's entered config_context; a block entered before the change keeps its layers until it is left.
  • An unregistered global state no longer changes the global config.
  • get_live_global_config returns the object the state builds from its parameters, not the instance first passed in.
  • Worker threads read the process's global config.

Tests

tests/test_one_holder_of_the_global_config.py: every sequence of up to three operations (edit, save, restore, replace the object, set_global_config, register, unregister), with and without the state registered at the start, each operation made in the calling thread or (sequences up to two) in another thread. After every operation: the global config is the holder's object and holds its values; a registered and an unregistered descendant agree by every read kind (stored, stored on the built object, attribute access, live, saved, reported source, dirty fields); a plain lazy object agrees in contexts entered from the live and the saved global config; another thread reads the same; the registered descendant was told at once, the unregistered one is told the same on its read, and nobody is told when the value did not change. A three-value model predicts the values. Further tests: undo and redo across edit/save/restore/replace/set, time travel to before the state was registered, a global state never passed to a setter, a stateless process across threads, a context entered before a global edit.

Mutation checks: making the setter store beside a registered state fails 11 tests; making the stored setter tell nobody fails 4.

Full suite in a fresh venv: 395 passed. Ruff fatal checks and the added-lines check pass; sphinx -W builds.

Benchmark

One global state, one plate, N steps of 78 leaf fields; minimum over 3 interleaved runs, microseconds, 3.0.4 -> this PR.

N global edit plate edit global edit + read all global save global restore unchanged read
1 4809 -> 4953 (+3.0%) 4252 -> 4296 (+1.0%) 4863 -> 5063 (+4.1%) 14627 -> 14704 (+0.5%) 15925 -> 15770 (-1.0%) 9.6 -> 9.8
10 22888 -> 23018 (+0.6%) 22365 -> 22133 (-1.0%) 23216 -> 23216 (0.0%) 66894 -> 66353 (-0.8%) 69446 -> 69029 (-0.6%) 9.7 -> 9.8
100 220001 -> 217845 (-1.0%) 221100 -> 216656 (-2.0%) 223876 -> 222274 (-0.7%) 627932 -> 626635 (-0.2%) 640433 -> 633167 (-1.1%) 9.7 -> 9.7

A global edit costs about 0.15 ms more, once per edit and independent of N: the first read after it rebuilds the global object from the state's parameters (as for any other ancestor) where 3.0.4 patched one field into the stored copy. Everything else is within run-to-run noise (the machine was shared; single runs varied by more than these differences).

🤖 Generated with Claude Code

The global config had two holders: two per-thread stores (live, saved) and
the parameters of the state registered at scope "" for a global config type,
kept in step by five write sites in the state and by every application that
called a setter. Now there is one. While such a state is registered, the live
global config is the object its parameters describe and the saved one is the
object it last saved; get_live_global_config, get_saved_global_config,
config_context and every state's resolution ask the registered state. While
none is registered, the object given to set_global_config is both.

- set_global_config is the one outside write route. With a state registered it
  is state.update_object_instance; without one it stores the object and tells
  registered states at once.
- Registering a global state makes its object the global config with no setter
  call; unregistering, or time travel to before it was registered, ends that.
- One global config per process: the per-thread stores and the thread field of
  ResolutionSources are gone, so a state's resolved values no longer depend on
  the thread that reads them.
- Removed: ensure_global_config_context, set_global_config_for_editing,
  set_live_global_config, set_saved_global_config, get_current_global_config,
  clear_current_temp_global (and its four calls, which dropped the caller's
  entered context on a global edit), GlobalContextValues,
  ObjectState.prepare_history_global_context,
  rebuild_lazy_config_with_new_global_reference (no caller).

CHANGELOG.md lists each removed name with what to call instead.

Fixes #56

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 12, 2026 17:08

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 02393c4 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.

The state of the global config should be the single owner of the stored global config

2 participants