Skip to content

One field resolution with its source; caches owned by the context (#29, #33) - #49

Open
trissim wants to merge 3 commits into
fix/34-31-context-stack-loudfrom
fix/29-33-one-resolver
Open

trissim wants to merge 3 commits into
fix/34-31-context-stack-loudfrom
fix/29-33-one-resolver

Conversation

@trissim

@trissim trissim commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #29
Fixes #33

Based on fix/34-31-context-stack-loud (PR #41); the diff shows only this work.

What changed

One resolution (#29). resolve_with_provenance(container_type, field_name) is the only function that walks scopes and MRO for a field. It returns a ResolvedField(value, source_scope, source_type) named tuple. Lazy __getattribute__ and _resolve_field_value return its .value; ObjectState unpacks all three as before. resolve_field_inheritance (the merged-config walk) is deleted, along with the "stage 2" merged-config lookup in __getattribute__.

The layer stack is the value resolution reads. context_layer_stack now holds a ContextLayerStack of ContextLayer(scope_id, obj, masks_outer_layers):

  • every object entered with config_context is a layer, with or without a scope_id (source scope None when it has none);
  • the global config a context starts from is the outermost layer, under scope "";
  • a layer entered with mask_with_none=True hides the layers outside it.

Caches belong to the context (#33). _mro_resolution_cache and _extract_all_configs_cache are deleted. Answers are kept in ContextLayerStack.resolved_fields, keyed by (container_type, field_name); each layer keeps its own extracted configs (ContextLayer.configs). A new stack is made on every config_context entry, so answers are dropped with the stack. config_context clears nothing.

Loud global lookup. get_base_global_config no longer returns None on ImportError; it raises. The same handler is removed from _normalize_type and _is_global_type.

Acceptance criteria

#29

Criterion How it is met
The reproducer prints the same value on both lines test_attribute_read_and_provenance_agree_when_a_layer_has_no_scope_id: read 2, resolver (2, None, WellFilterConfig). Fails on the base branch.
For each arrangement build_context_stack can produce, the attribute read equals the value ObjectState reports test_attribute_read_equals_what_object_state_reports (8 arrangements: global alone, ancestor supplying the same type, ancestor supplying a base type only, two ancestors, a global config among the ancestors, the object's own value, saved global differing from live, nothing supplying a value) compares reads inside build_context_stack with ObjectState.resolve_saved_object, values and provenance. ..._for_a_global_config covers the masked global arrangement.
A search shows one function that walks scopes and MRO grep -n "__mro__" src/objectstate/dual_axis_resolver.py: the layer walk is _resolve_in_layers only (called by resolve_with_provenance / resolve_in_stack); declared_default walks the MRO for class defaults, with no scopes.

#33

Criterion How it is met
config_context contains no cache-clearing calls Both clear functions are deleted; grep -n "clear_.*cache" src/objectstate/context_manager.py src/objectstate/dual_axis_resolver.py returns nothing.
A test resolves in one thread, enters and exits a context in a second thread, and the first thread's entry is still used test_answers_survive_another_thread_entering_and_leaving_a_context: the second resolve returns the identical ResolvedField object.
No cache key contains id(...) grep -n "id(" src/objectstate/dual_axis_resolver.py src/objectstate/context_manager.py returns nothing. test_answers_are_dropped_with_the_context_they_were_computed_from shows the stack is collected after exit.

Paper results

  • [P10] Definition 2.1, Proposition 6.1: the resolver result carries the source that supplied the executed value; attribute access is a projection of that same record.
  • [P10] Proposition 2.2, [P9] Factorization criterion: two walks that usually agree do not make one source; the second walk is deleted, not reconciled.
  • [P10] Theorem 4.1: returning scope and type makes the source a function of the observation (zero tag price).
  • [P1] Theorem 4.7: the merged-config walk was a second implementation, not a fast path.
  • [P9] Theorem 4.3: clearing on every entry kept the caches in sync but did not make them owned; [P9] Theorem 5.5: no side table maps answers back to a context, the stack holds them.
  • [P9] Proposition 7.1: the process-wide cache was one location written by every thread's context; it is now per context value.

Behaviour changes for callers (pyqt-reactive, OpenHCS)

  • resolve_field_inheritance, clear_mro_resolution_cache, invalidate_mro_cache_for_field, clear_extract_all_configs_cache, set_current_temp_global and the _has_concrete_field_override export are removed. resolve_with_provenance and ResolvedField are exported from objectstate.
  • get_context_layer_stack() / context_layer_stack.get() return a ContextLayerStack (.layers of ContextLayer), not a tuple of (scope_id, obj). The stack now includes unscoped layers and the root global layer.
  • resolve_with_provenance raises NoActiveContextError with no active context (it returned (None, None, None)).
  • Attribute reads now come from the layers by config type, not from the merged config by field name. A layer attribute holding a config whose name is not a field of the global config now supplies values to reads (it already did for ObjectState). A layer object holding two instances of one config type now raises on a read, as it already did for ObjectState.
  • ObjectState resolved values now include inherited declared defaults where a read returned them (it reported None before). Provenance tuples are unchanged.
  • build_context_stack passes use_live to every layer. Before, use_live=False merged the saved global over the live one, so reads could see live values where the saved one was None. It no longer enters the global config a second time; when called inside an active context it no longer pushes the global config on top of it.
  • clear_current_temp_global() also empties the layer stack.
  • extract_all_configs reads stored field values (no lazy resolution of the object being read) and is uncached.
  • _resolve_field_value no longer skips the same-type lookup when the instance value is not None.
  • An ObjectState without a scope id now has its own object as a layer, so its fields inherit from sibling configs of the same object (see the OpenHCS test below).
  • Answers are computed on first read in a context and kept until that context exits; mask_with_none hides all outer layers for resolution.

Files outside the named set

  • object_state_registry.py: removed the three references to the deleted cache functions (8 lines).
  • __init__.py: exports. Docs: docs/provenance.rst, src/objectstate/docs/context_system.rst.

Performance

Benchmark: 8 lazy field reads (same type, via MRO, three layers) on CPython 3.12, best of 5, lowest of three alternating runs on a loaded machine (run-to-run noise is about 20%).

base this PR
warm read in one context 1.36 us 0.44 us
enter 3 layers + 8 first reads 130 us 130 us
enter 3 layers, no reads 90 us 79 us

The only added cache is _declared_config_fields, keyed by the dataclass type it describes.

Tests

  • ObjectState suite: 263 passed (248 on the base branch plus 15 new). Of the new tests, 8 fail on the base branch.
  • CI lint: ruff check src/ --select E9,F63,F7,F82 passes; scripts/check_ruff_added_lines.py against the base branch reports no diagnostics (local ruff 0.16.9; CI pins 0.16.0).
  • OpenHCS tests/unit with this branch as external/ObjectState (first commit of this PR, same resolver code): 11565 passed, 62 failed. The 52 failures outside the MCP-stdio and ack-route tests were rerun together on the base branch and on this branch: 5 fail on both, 46 pass on both, and one fails only here:
    • tests/unit/pyqt_gui/test_source_bindings_editor.py::test_inline_source_bindings_dropdown_edit_flashes_only_changed_cell. It builds ObjectState(PipelineConfig()) with no scope id. On the base branch the state's own object was not a layer for ObjectState (no scope id), so step_source_bindings_config.source_filters stayed () from the global config while an attribute read returned the pipeline's source_bindings_config.source_filters through the MRO. Now both return the pipeline value, so the edit also flashes the inheriting step field. This is the Attribute reads and displayed provenance can resolve the same field to different values #29 divergence; the OpenHCS test needs the second path added.
      The 10 MCP-stdio and ack-route failures were not rerun on the base branch.

🤖 Generated with Claude Code

trissim and others added 3 commits October 12, 2026 00:41
…ayer stack

Attribute access and ObjectState both read resolve_with_provenance. The merged-config
walk (resolve_field_inheritance) and the process-wide caches are deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 05:59

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 commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #50 (release/3.0.0-context-resolution), which merges this branch unchanged together with #41, #49, #40 and #42 for the 3.0.0 release. Left open; review and merge decisions happen on #50.

This branch has not been deployed

No deployments
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