Repository navigation
Trace parameter reads per call and stop test reruns leaking module fixtures - #574
Merged
Merged
Conversation
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>
Contributor
Author
|
Merge audit, at head 43c2b5b:
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_instantgot aTracingParameterNodeAtInstantandtests/core/test_reforms.py::test_modify_parametersoverflowed 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_systeminto later modules (test tooling)make testrunspytest --reruns 2. The installed plugin is pytest-rerunfailures 14.0, the newest the old<15pin allows, and the lock pairs it with pytest 9.1.1. Before a rerun it emptiesitem.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.executereturnscached_resultearly otherwise). So the rerun module'stax_benefit_systemis 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
devextra and the smoke job now requirepytest-rerunfailures>=16.2,<17, anduv.lockmoves to 16.7. The lock also syncs its stalepolicyengine-coreversion line (3.32.11 → 3.32.12, matchingpyproject.toml).2. Tracing switched tracing on in the shared parameter tree (library)
When a traced simulation ran a formula,
Simulation._run_formulasettrace,tracerandbranch_nameontax_benefit_system.parameters(the "soft-recast") and never reset them. The consequences:TracingParameterNodeAtInstantholding 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.clonecopiestraceandtracer, so clones traced too.Simulation._calculatereadsparameters(period)(the abolition check) before any formula runs, which caches an untraced node at that instant. So on master a tracedincome_taxfor 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 aTracingParameterNodeAtInstantwith 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 gettax_benefit_system.parametersitself.Behaviour change: traces now record the parameters a formula reads through its
parametersargument, at every instant. Reads made elsewhere aren't recorded: the abolition check, and code that reachessimulation.tax_benefit_system.parametersdirectly. On master those were recorded only at instants first read after the soft-recast. policyengine.py'sderivereads onlynode.children, so it is unaffected.3.
TracingParameterNodeAtInstantcould not be copied (library)copy.copy,copy.deepcopyandpickleall recursed. Each creates the instance with__new__and then probes it (__setstate__,__deepcopy__, ...), and__getattr__readself.parameter_node_at_instant, which isn't set yet, so it called itself without end.Reform.modify_parametersdeep-copies the baseline's parameter tree, so once a traced node sat in the root's at-instant cache, any reform that calledmodify_parameterson that system crashed. Fix: both wrappers raiseAttributeErrorfor 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)
TracingParameterNodeAtInstanthas__getitem__but had no__iter__, sofor name in node,list(node)and"x" in nodefell back to__getitem__(0)and raisedKeyError: 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 wrappersrepras the node they wrap.Options considered
tax_benefit_systemtest_cycles,test_formulas,test_reforms,test_opt_out_cacheandtest_calculate_output) made function-scoped, and it fails Hypothesis'function_scoped_fixturehealth check intest_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.TracingParameterNoderather than restoring flags afterwards. Restoring would still leave traced nodes in the at-instant caches, and it races when threads share a system.TracingParameterNodeAtInstantcopy-safeReproduction
test_branch_shared_arrays.pyand ran it withtest_parameters.pyandtest_reforms.pyunder--reruns 2with 14.0. All four modules got the same system object.test_get_at_instantfailed (TracingParameterNodeAtInstant) andtest_modify_parametersraisedRecursionError, which is the CI failure.tests/core/test_rerun_fixture_isolation.pyruns 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 andinin a traced formula,np.asarrayof a traced vectorial node,repr) fail on the first review headc5075d31and 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:
trace/tracer/branch_name, only plain cached at-instant nodes, and clones and deep copies plain too.Tests
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 intests/fixtures/tracing.py.coverage run ... --reruns 2 --reruns-delay 5): running locally; this PR's CI is the authoritative run.ruff format --check .andruff checkpass.Review
An independent GPT-6.1 Sol review of
c5075d31reproduced 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 ind77a007c(sections 3 and 4). It also raised two nits:isinstance(parameters, ParameterNode)is false for a traced formula'sparameters. This is documented inTracingParameterNode's docstring, asTracingParameterNodeAtInstantwas never aParameterNodeAtInstanteither. 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.43c2b5bfguards__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 ond77a007cand pass now. A second delta review of43c2b5bf(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
VectorialParameterNodeAtInstanthas the same copy flaw in its own__getattr__, on master and untouched here:copyandpicklerecurse, anddeepcopyreturns a barerecarray. It's filed as a separate task.axiom: n/a: engine tracing and test tooling, no policy change
🤖 Generated with Claude Code