Skip to content

Trace parameter reads per call and stop test reruns leaking module fixtures - #574

Merged
MaxGhenis merged 3 commits into
masterfrom
fix/rerun-leaked-traced-system
Oct 3, 2026
Merged

MaxGhenis merged 3 commits into
masterfrom
fix/rerun-leaked-traced-system

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On #560's Windows CI (runs 36967463690, 36968947271, 36969160069, 36972147369) a flaky test was rerun, and afterwards tests/core/test_parameters.py::test_get_at_instant got a TracingParameterNodeAtInstant and tests/core/test_reforms.py::test_modify_parameters overflowed the stack, both on the same system object. Three defects chained together. This PR fixes each one, so breaking any single link would already have prevented the failure.

1. A rerun leaked the module-scoped tax_benefit_system into later modules (test tooling)

make test runs pytest --reruns 2. The installed plugin is pytest-rerunfailures 14.0, the newest the old <15 pin allows, and the lock pairs it with pytest 9.1.1. Before a rerun it empties item.session._setupstate.stack (_remove_failed_setup_state_from_session) without running the finalizers on it. pytest registers a fixture's finalizer with its scope node only when it creates the value (FixtureDef.execute returns cached_result early otherwise). So the rerun module's tax_benefit_system is never torn down, and every later module gets the same cached object.

pytest-rerunfailures restores those finalizers from 15.0 (pytest-dev/pytest-rerunfailures#278, "fix compatibility with pytest 8.2 by restoring deleted finalizers"). 16.2 is the first release that declares pytest 9 support. Fix: the dev extra and the smoke job now require pytest-rerunfailures>=16.2,<17, and uv.lock moves to 16.7. The lock also syncs its stale policyengine-core version line (3.32.11 → 3.32.12, matching pyproject.toml).

2. Tracing switched tracing on in the shared parameter tree (library)

When a traced simulation ran a formula, Simulation._run_formula set trace, tracer and branch_name on tax_benefit_system.parameters (the "soft-recast") and never reset them. The consequences:

  • The system stayed traced. The root node caches one node per instant, and after the soft-recast that is a TracingParameterNodeAtInstant holding the tracer and branch name of that moment. Every later simulation on the system, traced or not, read through it. A second traced simulation's reads went to the first simulation's tracer, and a branch's reads were filed under another branch's name. ParameterNode.clone copies trace and tracer, so clones traced too.
  • Traces missed formula reads. Simulation._calculate reads parameters(period) (the abolition check) before any formula runs, which caches an untraced node at that instant. So on master a traced income_tax for 2017-01 records no parameter reads at all. The soft-recast only reached instants first read after it.

policyengine-us's SharedParameterPolicy (spm.py) lends one parameter tree to several systems and documents that writing to it "must not happen at all"; the soft-recast was such a write.

Fix: a traced simulation's formulas now get a TracingParameterNode, a per-call view of the tree. Calling it at an instant returns a TracingParameterNodeAtInstant with this simulation's tracer and branch name. Other attributes are read from the wrapped node, which is never modified. Untraced simulations are unchanged: they still get tax_benefit_system.parameters itself.

Behaviour change: traces now record the parameters a formula reads through its parameters argument, at every instant. Reads made elsewhere aren't recorded: the abolition check, and code that reaches simulation.tax_benefit_system.parameters directly. On master those were recorded only at instants first read after the soft-recast. policyengine.py's derive reads only node.children, so it is unaffected.

3. TracingParameterNodeAtInstant could not be copied (library)

copy.copy, copy.deepcopy and pickle all recursed. Each creates the instance with __new__ and then probes it (__setstate__, __deepcopy__, ...), and __getattr__ read self.parameter_node_at_instant, which isn't set yet, so it called itself without end. Reform.modify_parameters deep-copies the baseline's parameter tree, so once a traced node sat in the root's at-instant cache, any reform that called modify_parameters on that system crashed. Fix: both wrappers raise AttributeError for the copy and pickle protocol names (__deepcopy__, __setstate__, ...), for __slots__ (pickle protocols 0 and 1 read it from the instance), and for any lookup on an instance that hasn't been filled in. Every other name, special names included, is still delegated, so NumPy reads the array protocol through a wrapped vectorial node as before. Copy, deepcopy and pickle at every protocol now round-trip.

4. Traced parameter nodes could not be iterated (library, found in review)

TracingParameterNodeAtInstant has __getitem__ but had no __iter__, so for name in node, list(node) and "x" in node fell back to __getitem__(0) and raised KeyError: 0. On master this mostly stayed hidden, because the formula usually got an untraced node at the period it calculates. Since this PR traces every formula read, it would have surfaced. Fix: the wrapper iterates the node it wraps (names only, so no parameter reads are recorded), and both wrappers repr as the node they wrap.

Options considered

Option Verdict
(1) Function-scoped tax_benefit_system Not adopted. Measured: 228 tests in 25 modules use it; construction takes 7.2 ms median (p90 10.7 ms), so about 1.5 s more on a 40–70 s local suite. It would also need five module-scoped autouse fixtures (in test_cycles, test_formulas, test_reforms, test_opt_out_cache and test_calculate_output) made function-scoped, and it fails Hypothesis' function_scoped_fixture health check in test_branch_shared_arrays_differential.py. The plugin bump fixes the leak for every shared fixture at every scope; function scope would cover this one fixture only.
Bump pytest-rerunfailures Adopted. It fixes the cause, and 14.0 doesn't support the pytest 9 that CI installs.
(2) Stop tracing from changing the shared system Adopted, as a per-call TracingParameterNode rather than restoring flags afterwards. Restoring would still leave traced nodes in the at-instant caches, and it races when threads share a system.
(3) Make TracingParameterNodeAtInstant copy-safe Adopted.

Reproduction

  • End to end, before: I put a temporary module that fails once ahead of test_branch_shared_arrays.py and ran it with test_parameters.py and test_reforms.py under --reruns 2 with 14.0. All four modules got the same system object. test_get_at_instant failed (TracingParameterNodeAtInstant) and test_modify_parameters raised RecursionError, which is the CI failure.
  • After, with 16.7: each module gets its own system and all 92 tests pass.
  • After, with 14.0 kept but this PR's library code: the four modules still share one system object, but nothing traces it, so all 92 tests pass. The library fixes break the chain on their own.
  • tests/core/test_rerun_fixture_isolation.py runs a two-module suite in a subprocess with the installed plugin, once with a test that passes on its rerun and once with one that fails every attempt. With 14.0 both cases fail (assert 'test_a_rerun' == 'test_b_later'); with 15.0, 16.2 and 16.7 both pass.
  • tests/core/test_tracing_parameter_isolation.py: 28 of its first 30 tests fail against master's library code; the other two are non-regression checks. The 3 tests added after review (iteration and in in a traced formula, np.asarray of a traced vectorial node, repr) fail on the first review head c5075d31 and pass now.
  • tests/core/test_tracing_parameter_isolation_properties.py: against master, Hypothesis shrinks the failure to a single traced calculation that leaves the system traced.

Invariants

These hold for every input and are stated and executed in the new tests:

  • Tracing a simulation leaves its system's parameter tree as an untraced one: no trace/tracer/branch_name, only plain cached at-instant nodes, and clones and deep copies plain too.
  • A traced simulation records exactly the parameter reads it would record on a fresh system, so the record never depends on history. A branch records only reads such a calculation makes, all under its own branch name. (Property test over random sequences of traced, untraced, branch and clone steps.)
  • Tracing never changes a value. A differential test compares traced and untraced runs for every formula variable of the country template, and the property test compares each step with an untraced reference system.
  • A rerun never hands a module-scoped fixture to a later module, and the earlier module's instance is torn down first.

Tests

  • New: tests/core/test_tracing_parameter_isolation.py (49), tests/core/test_tracing_parameter_isolation_properties.py (Hypothesis, 40 examples), tests/core/test_rerun_fixture_isolation.py (2), and helpers in tests/fixtures/tracing.py.
  • Full suite with the Makefile command (coverage run ... --reruns 2 --reruns-delay 5): running locally; this PR's CI is the authoritative run.
  • ruff format --check . and ruff check pass.

Review

An independent GPT-6.1 Sol review of c5075d31 reproduced the four mechanism claims: the fixture leak under 14.0 but not 16.2 or 16.7, the shared tracing state and missed reads on master, the copy recursion, and the 16.2 floor. It asked for changes on two regressions, now fixed in d77a007c (sections 3 and 4). It also raised two nits:

  • the property test's branch step accepted missing reads. It now has the parent calculate an input, so the branch must record exactly a fresh calculation's reads under its own name. The reviewer's mutation (branch reads sent to an inactive tracer) now fails it.
  • isinstance(parameters, ParameterNode) is false for a traced formula's parameters. This is documented in TracingParameterNode's docstring, as TracingParameterNodeAtInstant was never a ParameterNodeAtInstant either. The reviewer's scan of PE-US/UK found no use of it.

A delta review of d77a007c (GPT-6.1 Sol, 130 tests run) confirmed those four fixes. It found one more: narrowing the guard let __slots__ through, so wrapping a slotted node broke pickle protocols 0 and 1. 43c2b5bf guards __slots__, and the copy tests now pickle at every protocol. The new slotted-node test wraps a slotted root node; its protocol 0 and 1 cases fail on d77a007c and pass now. A second delta review of 43c2b5bf (GPT-6.1 Sol, 146 tests run) approved it. It also round-tripped a slotted instant node through every protocol in a separate reproduction, which the submitted test does not cover directly.

Follow-up

VectorialParameterNodeAtInstant has the same copy flaw in its own __getattr__, on master and untouched here: copy and pickle recurse, and deepcopy returns a bare recarray. It's filed as a separate task.

axiom: n/a: engine tracing and test tooling, no policy change

🤖 Generated with Claude Code

MaxGhenis and others added 3 commits October 2, 2026 16:09
Simulation._run_formula no longer sets trace, tracer and branch_name on the
shared parameter tree when a simulation traces. Its formulas get a per-call
TracingParameterNode instead, so the tax-benefit system, its clones and every
other simulation on it stay untraced, and each traced simulation and branch
records its own parameter reads under its own branch name.

TracingParameterNodeAtInstant (and the new wrapper) answer special names and
lookups on unfilled instances with AttributeError, so copy, deepcopy and
pickle no longer recurse (Reform.modify_parameters deep-copies the tree).

The dev extra and the smoke job require pytest-rerunfailures>=16.2,<17: 14.0
empties pytest's setup stack before a rerun without running its finalizers,
so a module-scoped fixture of the rerun module was handed to every later
module (pytest-dev/pytest-rerunfailures#278 restores them from 15.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #574 found two regressions in traced mode: a formula iterating a
parameter node, or testing membership with `in`, got KeyError: 0 (the
wrapper had __getitem__ but no __iter__), and the blanket guard on special
names stopped NumPy reading the array protocol through a wrapped vectorial
node. The guard now covers only the copy and pickle protocol and unfilled
instances; the wrappers define __iter__ and __repr__ from the node they wrap.

The property test's branch step now has the parent calculate an input, so
the branch must record exactly a fresh calculation's reads under its name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The delta review of #574 found that pickle protocols 0 and 1 read
__slots__ from the instance: delegated to a slotted wrapped node, it made
the wrapper look like a slotted class without __getstate__, and pickling
raised TypeError. __slots__ describes the wrapper's own layout, so the
guard answers it with AttributeError like the copy and pickle protocol.
The copy tests now pickle with every protocol, and a slotted node is
covered for both wrappers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Merge audit, at head 43c2b5b:

  • CI: 18/18 checks pass. That covers Lint, changelog, Test on Ubuntu and Windows for Python 3.11–3.14, and the core and country smoke jobs on both OSes.
  • Mergeability: MERGEABLE/CLEAN against master. Master moved by Give a storage a set of shared keys only while it shares an array #578 and a version bump after this branch was cut, with no conflict.
  • Independent reviews (GPT-6.1 Sol through Subfleet; the author is Claude Opus 5.5):
    1. c5075d31: REQUEST_CHANGES. Traced-node iteration, NumPy conversion, and two nits. Fixed in d77a007c.
    2. Delta d77a007c: REQUEST_CHANGES. __slots__ broke pickle protocols 0 and 1. Fixed in 43c2b5bf.
    3. Delta 43c2b5bf: APPROVE, 146 tests run. Its two nonblocking nits (test count, and the scope of the slotted test) are corrected in the PR body.
  • Scope: engine tracing and test tooling, with no policy or published-number change.

@MaxGhenis
MaxGhenis merged commit 7bc16a5 into master Oct 3, 2026
18 checks passed
@MaxGhenis
MaxGhenis deleted the fix/rerun-leaked-traced-system branch October 3, 2026 16:08
MaxGhenis added a commit that referenced this pull request Oct 3, 2026
VectorialParameterNodeAtInstant.__getattr__ delegated every missing
attribute to its recarray. copy and pickle probe a new, empty instance
for __setstate__, so the lookup of self.vector recursed; deepcopy found
the vector's __deepcopy__ and returned a bare recarray. A reform that
calls modify_parameters deep-copies the baseline tree, cached
fancy-indexing nodes included, and got a recarray in their place.

Mirror #574's guard: answer the copy and pickle protocol names (and
__slots__) and any lookup on an unfilled instance with AttributeError,
still delegating NumPy's array protocol. Build a child read by name with
the node's name and instant (it passed one of three required arguments),
and leave the id-keyed enum lookup cache out of copied state.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 6, 2026
Brings in #562 (carry over only inputs), #563 and #582 (uprate only from
inputs), #578 (shared-key sets), #574 (per-call parameter tracing) and #584.

One input record instead of two: the storages' `_inputs` (memory) and
`_derived` (disk) from #562 replace this branch's `_input_keys`, so an input
is a value stored without `derived=True`, as on master. Sequence numbers stay
alongside. Disk storage takes master's `_path_to_write` (a clone never writes
over a file it shares) in place of this branch's per-store file names, so the
process tokens and restore ordering go. Dumps write master's
`derived_periods.txt` (branch-aware) and restore inputs first, then calculated
values under one later number. `apply_reform` marks every value set_input did
not store as derived and drops it, keeping master's semantics without wiping
and replaying. Tests that expected a calculated result to carry over now check
that the result is cached, since only inputs carry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit to MaxGhenis/policyengine-core that referenced this pull request Oct 6, 2026
Brings in PolicyEngine#562, PolicyEngine#563, PolicyEngine#582 (input/derived storage marks, input-only
carry-over and uprating), PolicyEngine#574, PolicyEngine#584 (parameter tracing and copy guards),
PolicyEngine#578 (shared keys only while sharing) and PolicyEngine#585 (temporary storage
directories).

Conflict in holders/holder.py, resolved by keeping master's bookkeeping:
- _set keeps master's ``derived`` mark (stored with the value, and a
  derived value is never redirected to the running set_input's branch)
  and this branch's ``is_input``, which decides what the record names.
- put_in_cache keeps master's guard (a derived value never replaces a
  readable input) and passes ``is_input=False``.
- master's ``Holder._stores`` (storage ``has``) replaces this branch's
  equivalent ``_stores``.

simulation.py merged cleanly but copied the record in ``clone`` twice
(PolicyEngine#562 added the same copy); kept master's copy and added the
``_user_input_contexts`` reset next to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 6, 2026
TaxBenefitSystem.get_parameters_at_instant cached whatever the parameter
tree returned. While the tree is marked traced that is a tracing wrapper
holding the reading simulation's tracer, so the system kept one tracer, and
the results it recorded, per date; served a node cached before tracing
started untraced; and recorded later reads, after tracing stopped or under a
later tracer, in the first one.

Since #574 core's simulations trace through a per-call view and never mark
the shared tree, so the round-3 scenario (traced YAML cases at distinct
dates whose formula calls get_parameters_at_instant) no longer leaks; its
test fails on 3c7d199 and passes on the merge. Code outside core still
marks the tree (policyengine-us's isolate_parameter_tracing sets trace,
tracer and branch_name on the root), and then the system cache kept 16
tracers after 16 traced cases at distinct dates.

The system cache now caches the plain node and, while the tree is traced,
wraps it for that read only, as ParameterNode._get_at_instant does. Tests:
example tests across 16 distinct dates, a Hypothesis differential property
of the system's path against the tree's, and two YAML runs at 16 distinct
dates with weak references to tracers and recorded results. The docs now say
each system still caches its parameters at every distinct instant read.

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

1 participant