Skip to content

A state resolved inside an entered config_context keeps that context's values (cases 1 and 2 fixed in 3.0.3) #53

Closed

Description

@trissim

Found while fixing the late-ancestor fault (a state built before its ancestor was registered kept its old resolved values). That fix compares the registered ancestors a resolved snapshot was computed from with those registered now. Three more inputs of a resolved snapshot are not covered by it. Each has a strict xfail test in tests/test_resolved_values_follow_registered_ancestors.py.

  1. Unregistered descendant, ancestor edited/saved/restored. ObjectStateRegistry.invalidate_by_type_and_scope walks _states, so a state that reads its ancestors from the registry but is not itself registered is never told. Test: test_unregistered_descendant_follows_an_edit_of_its_ancestor.
  2. Global config replaced after the build with set_live_global_config / set_saved_global_config and no global state edit to announce it. Test: test_global_config_replaced_after_the_build.
  3. Resolving inside an entered context. A state built (or resolved again) inside with config_context(...) takes that context's values into its snapshot and keeps them after the block. Test: test_resolving_inside_an_entered_context_does_not_take_its_values. This is a different question (should a state's values depend on the caller's context at all?) and needs an owner decision.

Why 1 and 2 were not fixed by widening the comparison to the ancestors' objects and the global config instance: every ancestor edit then makes each descendant resolve all its fields again, live and saved, where today one field is resolved. Measured on OpenHCS types (a FunctionStep state, 78 fields, under PipelineConfig and GlobalPipelineConfig): one field 0.80 ms; full live 1.42 ms plus full saved 1.70 ms. That is about 4x per descendant per keystroke in a plate or global config editor.

A fix that keeps field-level cost needs each state to publish what changed (a revision per state with the changed fields), so a descendant can pull exactly the fields that moved.

Activity

  1. trissim commented on Oct 12, 2026

    @trissim
    ContributorAuthor

    OpenHCS call sites relevant to item 2 (read-only search, not verified by running): openhcs/authoring/session/session.py:468 (adopt_global_config, on every global config save) and :1030, :1055 (each dataset initialisation) call ensure_global_config_context while states exist. Item 1: external/pyqt-reactive object_form_document_renderer.py:95-100 keeps a never-registered state with a plate scope in a form. Item 3: openhcs/pyqt_gui/windows/dual_editor_tabs.py:52-66 builds a step form inside config_context(...).

  2. trissim commented on Oct 12, 2026

    @trissim
    ContributorAuthor

    Cases 1 and 2 are fixed in 3.0.3 (#55, merge 9e711f8). Their two xfail tests pass. This issue stays open for case 3 only.

    How 1 and 2 were fixed. Each resolved snapshot keeps the objects it was computed from: the global config instance of the resolving thread and the object of every 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 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. One-field ancestor edit with 1 / 10 / 100 descendants of 78 fields: 4.07 / 22.9 / 224 ms in 3.0.2, 4.08 / 23.0 / 216 ms now. The check costs about 1 us per resolved read.

    Case 3, still open: a state resolved inside an entered config_context

    What happens today (3.0.3, measured). ObjectState resolves by entering its own layers with build_context_stack: the global config, then each registered ancestor, then the state's object. config_context puts the global config at the bottom only when no context is active. When the caller has already entered one, the state's layers are stacked on top of the caller's instead. So the caller's layers sit below every ancestor: they beat the global config and lose to any ancestor that sets the field.

    Example (plate registered, sets other and leaves value unset; global config has value="global"):

    with config_context(Holder(settings=LazySettings(value="ambient", other="ambient other")), scope_id="ambient"):
        step = ObjectState(Holder(), scope_id="plate::step")
    
    step.get_resolved_value("settings.value")        # "ambient", source ("ambient", Settings)
    step.get_saved_resolved_value("settings.value")  # "ambient"
    step.get_resolved_value("settings.other")        # "plate other", source ("plate", Settings)

    The same state built outside the block reads "global" from ("", Settings). After the block the first state keeps "ambient", and its reported source names a scope where no state is registered. The value then lasts until that field is resolved again for another reason, and what replaces it depends on where that happens:

    • a state built outside and only read inside a block is not affected (the snapshot answers);
    • a field resolved again inside a block takes the block's value. Measured: a registered step reads "global"; inside with config_context(... value="ambient4") the global config is replaced and the step is read; it reads "ambient4" from ("ambient4", Settings), is dirty, and keeps that after the block;
    • a field resolved again outside a block loses the block's value, one field at a time, so a state can hold some fields from the block and some from outside.

    Since 3.0.3 a pending source change is found on the next read, so "resolved again inside a block" now includes a plain read inside a block that follows an ancestor edit or a global config replacement. Before, it took a registry invalidation made inside the block.

    Choice A: a state's values depend only on its place in the registered hierarchy. The state never reads the caller's stack: it builds its layers from the global config, the registered ancestors and its own object, whatever is entered around the call. In the example the step reads "global" inside and after the block, like the one built outside, and the xfail test passes. Plain lazy objects that no state owns keep resolving against the entered context. What it breaks: any caller that enters a context to feed values to a state it builds inside. OpenHCS openhcs/pyqt_gui/windows/dual_editor_tabs.py:52-66 builds a step form inside config_context(...) (found by search, not run); if that context carries values that no registered state holds, the form would stop showing them and they would have to come from a registered ancestor.

    Choice B: the entered context is an input. The values of the example are intended. Then the entered layers are a source like the ancestors and have to be recorded with the snapshot, so that a read outside the block (or inside another one) sees other sources and resolves again. In the example the step reads "ambient" inside the block and "global" after it, and a state built outside reads "ambient" while it is read inside. Consequences: a state's values, dirty fields and notifications change as callers enter and leave contexts around reads; the saved snapshot does too; and a read inside a block costs a resolution whenever the previous read was outside.

    Today's behaviour is neither: the block's values are taken at whatever moment a field happens to be resolved and are kept afterwards.

  3. changed the title [-]Resolved snapshots outlive three inputs other than registration[/-] [+]A state resolved inside an entered config_context keeps that context's values (cases 1 and 2 fixed in 3.0.3)[/+] on Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions