Skip to content

Isolate shared policy from later reforms and repair clone routing - #9463

Closed
MaxGhenis wants to merge 11 commits into
mainfrom
max/spm-runtime-isolation-20260914
Closed

MaxGhenis wants to merge 11 commits into
mainfrom
max/spm-runtime-isolation-20260914

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Why

The Astra review of country 2.0.1 reproduced four simulation-isolation defects on the shipping runtime, plus a county input-type inconsistency and two documentation errors. Ordinary simulations deliberately share the default policy state: share_spm_policy hands each one the shipped parameter tree, its warm at-instant caches and the shared instance's variable objects, because rebuilding a 130,000-parameter tree per household request is not affordable (a clone takes 1.5–2.0 s; a warm household build takes 0.03 s). What was shared was not protected.

1 (P1). A later parameter reform contaminated every other simulation. Core's Simulation.apply_reform calls reform.apply(system) directly instead of constructing the reform. That bypasses the defensive parameter clone in Reform.__init__ and edits whatever tree the system hands it. Reproduction: two ordinary single-person simulations on $50,000 both calculate $4,016 of income tax. After a.apply_reform({"gov.irs.deductions.standard.amount.SINGLE": {"2024": 100_000}}), a newly built, unreformed simulation calculates $3,718, and the module-level system's own SINGLE standard deduction has moved from 14,600 to 100,000. The same contamination leaks between tests in one process: running this PR's tests against the old code also breaks five unrelated pre-existing tests.

2 (P1). A reformed clone's calc() calculated the original. Core keeps self.calc = self.calculate and self.df = self.calculate_dataframe as bound methods in the instance dictionary (simulations/simulation.py:223-224), and its clone copies that dictionary verbatim (:1381-1416). So every alias on the clone still called the original. With income tax neutralized on the clone, clone.calculate("income_tax", 2024) returned [0.0] while clone.calc("income_tax", 2024) returned [4016.0]. clone.calc("spm_unit_spm_threshold", 2025) also filed its SPM receipt in the original simulation's provenance.

3 (P2). A variable a reform added could not take input. A population asks its entity for a variable before it builds a holder, and an entity resolves names through the system it is bound to. The shared system's entities still belonged to the shared instance, so set_input("clone_only_income", 2024, [123]) after AddedInputReform raised VariableNotFoundError for a variable the simulation's own registry held.

4 (P2). Traced parameter accesses were misattributed. Core writes the requesting simulation's tracer, trace and branch_name onto the root parameter node and caches the resulting TracingParameterNodeAtInstant there. On a shared tree, the second traced request reuses the first request's node. On core 3.30.2 it is worse than Astra found on 3.32.5: a traced simulation recorded zero parameter accesses. _calculate reads gov.abolitions and caches a non-tracing at-instant node before _run_formula ever marks the tree. The shared tree was also left holding a finished request's tracer for every later reader.

5 (low). The county FIPS text contract was not enforced. county_fips is documented as a five-digit string. Core maps a str variable onto the numpy object dtype, so the model stores an integer column as integers, and both readers of the column stringify before they ask the forecast provider. As a result, integer 36061 was silently accepted as New York County, while integer 6037 (Los Angeles County 06037 with its leading zero dropped) was reported as an absent county. Tightening is_county_fips alone would have changed nothing, because no model path hands it anything but text.

Documentation. The weight sanity check summed a weight variable through a weighted series, which multiplies each value by its own weight. With household weights of 2 and 3 it reported 13 households instead of 5, and trillions on the default population. The legacy enhanced-CPS bullet claimed that every resource output over the file fails closed. The test it cites checks county formatting, the count of unclassified units and the threshold, nothing else. Since the capped housing subsidy began requesting the SPM housing portion only for assisted units, and none of the 18 unclassified units is assisted, household net income and benefits do compute over the file (household_net_income sums to 6,944,802,734,080 across its 41,314 households).

What

Changes are in policyengine_us/spm.py unless noted otherwise.

  • Copy-on-write parameter tree. Every simulation's system carries a read barrier: a per-base SharedParameter<Base> subclass whose parameters is a property. Whether anyone else reads a tree is recorded on the tree, and the mark is one-way. share_spm_policy, a branch copying its parent's system, and tracing's shallow root copy all mark it. Reform application arms the barrier. The first read of a marked tree inside that window clones it through core, which leaves the lender's tree and warm caches untouched. modify_parameters, load_extension and add_abolition_parameters, the three core methods that edit the live tree in place, detach outright. A variable-only reform never reads parameters, so the structural reform every simulation re-applies at its own start instant still pays nothing. Branches created with clone_system=False share their parent's policy deliberately, so they follow a parent's detach, matched on the children of their root. A branch's own reform detaches rather than rewriting its parent's tree.
  • Clone alias routing. clone() rebinds every bound-method alias it copied to the clone, by method name, so an alias core adds later is repaired as well.
  • Private entities. share_spm_policy shallow-copies the entities and binds them to the private system, keeping one object per key across entities, person_entity and group_entities. clone_spm_system now removes the second object per group-entity key that core's clone creates.
  • Traced parameter receipts. A traced simulation on a shared tree gets its own shallow root: the same children, its own at-instant cache and trace fields, primed as traced. That costs microseconds instead of a 1.5 s tree clone. It also repairs the core ordering bug that recorded no accesses at all. On a tree nobody else reads, the root is marked traced in place, which keeps each parameter's parent pointing at the root whose cache an edit has to clear.
  • County FIPS. The provider records, by (year, county), which counties the model received as something other than text, at the one point that still sees the type. It rejects them before its own memo when a county measurement needs them. Re-setting the input re-derives that year's record, so a corrected input goes through. Only a clone inherits the record, and it also covers a reform simulation's baseline arm. Nothing is scanned when the selection is not county-based. is_county_fips enforces the text contract for direct callers. Anyone who never asks for a county is still never asked for one: geography_kind="national" computes over the same population.
  • Pinned systems. tools/pinned_tbs.py keys its cache on the parameter tree as well as the system, because a system now keeps its identity while detaching a tree. NY's decoupled EITC and CTC would otherwise have gone on reading pre-reform federal parameters.
  • Pickling. The shared-policy class is published under its own name, so systems built on it still pickle.
  • Docs. docs/usage/microsimulation.md is corrected, along with the docstring of test_legacy_enhanced_cps_lacks_source_backed_spm_independence_roles. That test now asserts its premise instead of stating it in prose: none of the 18 unclassified units receives housing assistance, out of 870 assisted units.

Adversarial review of this change

Before asking for review, I ran a four-lens adversarial review of the first pushed head (c7e2f9a179), covering copy-on-write, clones and entities, tracing and county, and performance and test quality. Every raised finding was then verified independently. 27 were raised and 18 confirmed, including real regressions in that first pass:

  • Tracing gave each branch its own root, so a traced simulation stranded its shared-policy branches on the unreformed tree. The model's own formulas create such branches (itemizing vs not itemizing, the state EITC refundability branches, marginal tax rates), so a traced calculate → apply_reform → calculate returned pre-reform numbers. The verifier's constructed case was off by $2,860.
  • Ownership was tracked on the borrowing system rather than the tree. A lender that had detached kept writing in place for its borrowers, and a branch's reform rewrote its parent's tree.
  • A shallow traced root kept serving stale values after an in-place edit.
  • A tuple of variable-only reforms paid for a full tree clone: 1.29 s, now 0.001 s.
  • The county record never cleared, was not keyed by period, leaked into new simulations and skipped the baseline arm.

All 18 are fixed. Each has a test that fails against c7e2f9a179 and passes here, confirmed by running them against that head's spm.py. Two residuals are documented rather than fixed:

  • A county rejection is scoped to a county code and year, not to a row, because the county has already been stringified before it reaches the provider.
  • Core's holder.set_input bypasses the country's set_input, so that one channel behaves exactly as before.

Verification

Run locally on macOS with Python 3.14, policyengine-core 3.30.2 and spm-calculator 1.0.0, rebased on upstream/main at 2cc6f045ab.

Run Result
uv run ruff format . && uv run ruff check . clean
pytest policyengine_us/tests/ --ignore=…/policy/contrib --ignore=…/microsimulation (the test-other-python job; covers tests/core and tests/unit) 581 passed, 1 skipped, exit 0
pytest policyengine_us/tests/microsimulation (the test-microsimulation job) 82 passed, 4 skipped, exit 0
make test-yaml-reform exit 0
make test-yaml-structural exit 0
test_batched.py …/baseline/gov/states/ny (exercises the pinned NY systems) 567 passed, exit 0
test_batched.py …/baseline/household exit 0
New and changed tests against upstream/main's spm.py test_spm_system.py alone: 9 failed, 45 passed, where the failures are exactly the new county and entity tests and every pre-existing test passes; the isolation file: 19 of 25 failed

The microsimulation count is one lower than before the rebase because upstream's MA CCFA revert (#9458) collapsed a two-case parametrized test into one.

Performance is unchanged for ordinary simulations. A warm household Simulation(situation=…) build takes 0.02–0.05 s and a warm calculate about 0.2 s. Tests pin that an ordinary simulation, a variable-only reform and a tuple of them all keep sharing the tree. A parameter reform pays the detach once. Traced calculations are about 13% slower, because tracing now actually records parameter accesses, which it previously never did on this core.

Not addressed here

These Astra findings need model or release decisions rather than runtime isolation, and are left for PR B:

  • Finding 1: SPM geography changes CARE eligibility for an assisted household.
  • Finding 6: dataset rejection omits the public poverty aliases.
  • Finding 7: the batch runner can report success after pytest exits non-zero.
  • Finding 8: the country default enforces neither immutability nor model certification.
  • Heavy-subdirectory splitting omits .yml files.

🤖 Generated with Claude Code

MaxGhenis and others added 11 commits September 14, 2026 16:20
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four simulation-isolation defects in the shared-policy path:

1. A later apply_reform mutated the shared parameter tree, so a reform on
   one simulation changed unrelated ones. The shared system now holds its
   tree copy-on-write: reform application arms a read barrier and the first
   read inside it clones the tree, leaving the lender's tree and warm
   at-instant caches untouched. Cloning a 130,000-parameter tree takes
   seconds, so it stays lazy: the structural reform every simulation
   re-applies only rebinds variables and never reads parameters.

2. Core keeps calc/df as bound methods in the instance dict and copies them
   verbatim when cloning, so clone.calc() calculated the original simulation
   under the original's policy and recorded the original's SPM receipts.
   clone() now rebinds every copied bound-method alias to the clone.

3. Entities resolve variable names through the system they are bound to, so
   a shared system's entities sent holder lookups to the lending system's
   registry and set_input could not find a variable a reform had added to
   this simulation. Shared policy now gets private entities bound to itself.

4. Core writes a traced request's tracer onto the root parameter node and
   caches the tracing at-instant node there, so a shared tree mixed up whose
   trace a parameter access belonged to. A traced simulation now gets a
   private root node - the same children, its own at-instant cache and trace
   fields - primed as traced, because _calculate reads gov.abolitions before
   _run_formula marks the tree and no access was recorded at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
county_fips is documented as a five-digit string, but core maps a str
variable onto the numpy object dtype, so the model stores an integer
column as integers and both readers stringify before asking the forecast
provider. An integer county code for a state without a leading zero was
therefore accepted silently as that county, while the identical mistake
for California (6037 for Los Angeles County 06037) was reported as an
absent county. The provider now records which counties the model was
handed as something other than text - at the one point that still sees
the type - and rejects them when a county measurement needs them, so
both fail as SPM_GEOGRAPHY_REQUIRED naming the same fix, and a caller
who never asks for a county is still never asked for one.

is_county_fips itself now enforces the text contract for direct callers
instead of stringifying whatever arrives.

Hardening from the isolation change:
- A shared-policy system clones through an ordinary copy, because core's
  clone writes the cloned tree into the instance dictionary where the
  copy-on-write property would shadow it.
- The shared-policy class is published under its own name so systems
  built from it still pickle.
- The pinned tax-benefit-system cache keys on the parameter tree as well
  as the system, since a shared system keeps its identity while
  detaching a private tree.

Regression tests reproduce each finding: every one fails on the parent
commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The weight sanity check summed a weight variable through a weighted
series, which multiplies each value by its own weight: with household
weights of 2 and 3 the documented snippet reports 13 households rather
than 5, and on the default population it reports trillions. Read the
weights unweighted, and say why, naming count() as the alternative.

The legacy enhanced-CPS bullet claimed every resource output over the
file fails closed. The test it points at asserts county formatting, the
count of unclassified units and the threshold only; and since the capped
housing subsidy began asking for the SPM housing portion only for units
receiving housing assistance - which none of the 18 unclassified units
does - household net income and benefits compute over the file
(household_net_income sums to 6,944,802,734,080 over its 41,314
households). Say what the test checks, and assert the housing-assistance
premise in the test so the rationale is enforced rather than asserted in
prose.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
modify_parameters, load_extension and add_abolition_parameters all edit
the live parameter tree rather than a copy of it, and none of them goes
through the reform path that arms the copy-on-write barrier. Detach
outright in each; it costs nothing on a system that already owns its
tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Sharing a system whose parameter tree is None no longer leaves the
  property with nothing to read.
- Two bases with the same class name get distinct published names, so
  pickle cannot restore one shared-policy class as the other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The country's __init__ resumes after core's and now reads self.trace to
decide whether this simulation needs its own parameter-tree root. Core's
real __init__ sets it; the stand-in has to as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An adversarial review of the copy-on-write design found five ways it
could still hand a simulation the wrong parameter values.

- A system's own history says nothing about who else holds the tree it
  points at, so a system that took a private copy and then lent it to a
  second simulation went on writing to it in place. Sharing is now
  recorded on the tree, one-way: a tree that has ever had a second
  reader is never written to in place again. Core's ParameterNode.clone
  copies the instance dictionary, so a fresh private clone is explicitly
  unmarked.
- Every simulation's own system now carries the barrier, not only the
  ones borrowing the shipped policy, because the tree a simulation
  starts out owning acquires readers later - a branch copies the system,
  and another simulation can be built on it.
- Branches were moved onto a detached tree by root identity, which a
  traced branch can never match because tracing gives it a shallow copy
  of the root. A traced simulation therefore stranded every shared-policy
  branch on the unreformed tree - including the branches the model's own
  formulas create, so a traced calculate/apply_reform/calculate sequence
  reported pre-reform numbers. Branches are matched on the children of
  their root instead, and an adopting branch keeps sharing, so its own
  reform detaches rather than rewriting its parent's policy.
- A traced root is now only copied when the tree is shared. A shallow
  copy keeps the original's children, whose parents still point at the
  original root, so an in-place edit cleared that root's at-instant cache
  and left the traced copy serving pre-reform values.
- Core recurses into apply_reform for each member of a reform tuple.
  Reading parameters in the nested call fell inside the outer call's
  armed window and detached unconditionally, so a tuple of variable-only
  reforms paid for a full tree clone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The review found the record was a monotone, unkeyed blacklist: correcting
the input left the rejection in place, a period's mistake rejected every
period, and a snapshot taken for a brand-new simulation carried the
lender's rejection into a simulation that supplied nothing wrong.

It is now keyed on (year, county) and replaced per year, so re-setting
county_fips for a period re-derives that period's record from what the
model holds. Only a clone - which keeps this simulation's holders -
inherits it; a new simulation reads its own inputs. A reform
simulation's baseline arm, which core hands a provider that never sees
an input, is recorded alongside its reform arm. Nothing is recorded or
scanned at all when the selection is not county-based, and each write
scans only the period it wrote rather than every period known so far.

The rejection is still scoped to a county code rather than a row,
because the county reaching a provider has already been stringified by
the variable that read the column; the message now says which county and
which year.

Also tighten the enhanced-CPS assisted-unit assertion from "more than
none" to the count the file actually holds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The borrowing simulation sent a different county, so it passed even
while a new simulation inherited its lender's rejection. Send the same
county as text, which is the case the leak rejected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Sep 15, 2026
The review's F5. Both were run against this branch's mechanism before deciding:

    test_cached_formula_trace_binds_replacement_tracer[False]  FAILED
    test_cached_formula_trace_binds_replacement_tracer[True]   FAILED
    test_system_parameter_lookup_does_not_cache_tracer         FAILED

One root cause, observed directly. Core memoises the
`TracingParameterNodeAtInstant` in the root parameter node's
`_at_instant_cache`, bound to whichever tracer built it. #9463 isolates a
traced request by giving it its own root with an empty at-instant cache, keyed
on the `trace` setter; replacing a tracer in place afterwards - a bare
`simulation.tracer = FullTracer()`, or a `parameters.tracer` write on a system -
reaches none of that, and core's per-formula soft recast in
`Simulation._run_formula` rewrites only the root's own `trace`/`tracer` fields,
not the memo. So the second tracer records nothing: for the system-lookup case,
iteration 0 records one parameter and iteration 1 records zero, with the access
going to the first tracer's closed tree.

Passing them means rebinding that memo at lookup, which is #9448's mechanism
rather than this one, so they are not forced. The boundary is recorded on
`test_traced_simulations_record_their_own_parameter_accesses`, which covers the
supported path, with the note that the only bare tracer write left in this
package is `tools/branched_simulation.py`, which nothing constructs.

The policy-family docstring said #9463 reverses child-to-parent propagation,
full stop. It reverses it for parameter reforms; a variable-only reform on a
shared-policy branch still reaches its parent and siblings, and
`test_a_branch_variable_reform_is_not_isolated_from_its_parent` pins that. The
correction also records what the dropped cases actually carried: #9448's
`test_child_reform_updates_warm_parent_and_sibling` parametrizes a parameter
dictionary against a root-replacing `modify_parameters` reform, so those cases
are contradicted rather than merely duplicated, and the drop stands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Superseded by #9467, which carried this work to main (see its provenance table).

@MaxGhenis MaxGhenis closed this Sep 15, 2026
MaxGhenis added a commit to DTrim99/policyengine-us that referenced this pull request Sep 15, 2026
Carries PR PolicyEngine#9463 (head d8cbd4b) as-is:
detach_parameters, _rebind_method_aliases, bind_private_entities and
isolate_parameter_tracing in policyengine_us/spm.py, the county FIPS
text contract, the microsimulation documentation correction, and all 28
regression tests (21 isolation plus 7 county).

The tools/pinned_tbs.py cache key gains the parameter tree because a
shared-policy system now keeps its identity while detaching its tree;
test_pinned_systems_are_rebuilt_when_their_source_tree_detaches covers
it and the New York EITC and CTC variables consume it, so it is
production code rather than lane bookkeeping. PR PolicyEngine#9463's PROGRESS.md is
not carried.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MaxGhenis added a commit to DTrim99/policyengine-us that referenced this pull request Sep 15, 2026
From PolicyEngine#9448, reduced. General household benefits, CBO means-tested transfers
and the three Tlaib household-benefit overrides now add actual
`housing_assistance` instead of the Census SPM's capped housing subsidy, so a
change to an SPM threshold no longer moves a non-SPM result. SPM resources keep
the capped valuation, now allocated across SPM units by member share.

CARE/FERA countable income excludes housing subsidies from the August 14, 2014
parameter date of CPUC D.14-08-030; annual values resolve at January 1, so
calendar 2014 retains the earlier modelled inclusion (using the actual benefit)
and calendar 2015 first applies the exclusion.

Also carries PolicyEngine#9448's release-candidate wheel tooling and the isolated build
backend pin.

Dropped from PolicyEngine#9448: its `spm.py` and `system.py` isolation hunks (PolicyEngine#9463 owns
the mechanism), its default-dataset hash and batch-runner hunks (PolicyEngine#9464 owns
those), its `docs/usage/microsimulation.md` edit (PolicyEngine#9463's is more specific),
its `reforms/reforms.py` change (no consumer) and its
`tests/core/test_spm_system.py` edits (they invert PolicyEngine#9463's contract).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MaxGhenis added a commit to DTrim99/policyengine-us that referenced this pull request Sep 15, 2026
…tatus

From PolicyEngine#9464, reduced.

`REJECTED_DATASET_INPUTS` extends the calculator's `FORMULA_OWNED_INPUTS` with
the country-side poverty aliases (`in_poverty`, `in_deep_poverty`,
`deep_poverty_line`, `deep_poverty_gap`, `person_in_poverty`) and with the two
SPM housing-allocation outputs PolicyEngine#9448 adds, so a dataset can no longer store a
formula-owned measurement under a name the calculator does not police.
`DATASET_SOURCE_INPUTS` keeps `is_spm_independent_minor_role` declarable by a
producer, and the two sets are asserted disjoint.

`DEFAULT_DATASET_SHA256` pins the certified build the mutable Hugging Face tag
must resolve to, verified once per process and only for the country default; an
explicit `dataset=` argument remains the caller's own artifact.

The batch runner now decides a batch on the child's real exit status, reported
through a marker written before interpreter teardown, and enumerates `.yml`
alongside `.yaml` everywhere it lists files.

Dropped from PolicyEngine#9464: `reported_housing_assistance` and the CPUC entry that
counted it, plus the three tests that pinned housing assistance as countable -
CPUC D.14-08-030 excludes housing subsidies outright, so there is nothing to
gate on receipt and the variable had no other consumer. Its citation research
is folded into the CPUC parameter instead.

Adaptations: the dataset guard is grafted onto PolicyEngine#9463's `set_input`;
`test_spm_system.py`'s import block is the union of both PRs' needs;
`test_care_eligibility_is_the_same_under_every_spm_geography` expects
`(40_700, True)` because $40,700 of countable income is inside the 2024
one-person CARE limit of $40,880 once the subsidy is excluded; and the two
counting non-vacuity tests are replaced by their exclusion analogues so the
suite still fails if the housing entry is silently dropped rather than excluded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MaxGhenis added a commit to DTrim99/policyengine-us that referenced this pull request Sep 15, 2026
Found while adjudicating whether PolicyEngine#9448's `test_spm_policy_family.py` could be
carried onto PolicyEngine#9463's mechanism, and reproduced here with public-API calls.

`clone_spm_system` hands core an ordinary instance to clone through -
`plain_policy_copy` -> `SharedParameterPolicy.unshared_copy()` resets
`__class__` to the base class - and never put the barrier back. So
`Simulation.clone()` returned a plain `CountryTaxBenefitSystem`, and
`apply_reform` on anything reading that tree took the unguarded path:

    clone = Simulation(situation=...).clone()
    clone.get_branch("probe").apply_reform({SINGLE standard deduction: 100_000})
    # clone's own SINGLE standard deduction moved 14,600 -> 100,000

That is the defect `SharedParameterPolicy` exists to prevent, and it
contradicts `test_a_branch_reform_does_not_rewrite_its_parents_policy`, which
only passed because it never cloned. `with_parameter_barrier`'s own docstring
already states the rule: every simulation's system carries the barrier, because
a tree it starts out owning can acquire readers later.

Bounded: a clone gets its own tree, so neither the shipped module-level system
nor the simulation the clone came from was reachable this way; the leak stayed
inside one cloned simulation's branch family. Verified before and after.

`test_cloning_a_shared_policy_system_stops_sharing_its_tree` asserted
`"shared_parameters" not in cloned.__dict__`, which pinned the defective
implementation rather than the invariant. It now asserts that the clone holds
its own tree behind the barrier; its two substantive assertions are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MaxGhenis added a commit to DTrim99/policyengine-us that referenced this pull request Sep 15, 2026
…mily tests

PolicyEngine#9448's `test_spm_policy_family.py` cannot be carried whole: run unchanged
against PolicyEngine#9463's mechanism, 16 of its 27 cases fail. They split four ways.

Nine assert PolicyEngine#9448's own internals - the `_parameter_view` /
`_parameter_source` / `parameters.modified` provenance protocol and
`get_parameters_at_instant` - which this branch does not implement, because
PolicyEngine#9463 owns the mechanism.

Six turn on `Simulation.tracer` being a plain attribute in core, so a bare
`simulation.tracer = X` re-primes nothing and X records no parameter
receipts. PolicyEngine#9463 hooks the `trace` setter, `__init__`, `get_branch`,
`apply_reform`, `_adopt_detached_parameters` and `clone`, but not a bare
`tracer` write. No production caller does one that matters, so these are
dropped rather than fixed here.

Four assert that a child branch's reform propagates up to its parent and
siblings. Stated plainly: that is stock policyengine-core behaviour -
`Simulation.clone` hands a `clone_system=False` branch the SAME
tax-benefit system object and `apply_reform` mutates it in place - so PolicyEngine#9463
is a deliberate deviation from core here, not a restoration of it. It is still
the right deviation to ship: every branch in this repository that actually
mutates policy already passes `clone_system=True`
(`tob_revenue_oasdi.py:42`, `behavioral_response_measurements.py:49`), while
the `clone_system=False` branches only set inputs, and a formula-internal
branch that silently moved its parent's answer would be order-dependent. But
it is a design decision, not something core's contract compels.

One depends on a `reforms/reforms.py` hunk this branch drops, which returns
`None` from `create_structural_reforms_from_parameters` when no structural
reform is active. That case is unreachable - `create_mi_surtax_reform` always
returns a reform, so the filtered tuple is never empty - and carrying the hunk
verbatim is unsafe: `CountryTaxBenefitSystem.__init__` packs the result into a
reform tuple, and `CountryTaxBenefitSystem(reform=((), None))` raises
`AttributeError: 'NoneType' object has no attribute 'apply'` (verified).

The eleven cases kept here are the mechanism-independent remainder: a nested
branch clone's inherited inputs and private reform, a construction-time
structural reform detaching before it mutates parameters, the warm-cache
contract for a caller-supplied system across clone hops and a reform wrapper,
an at-instant view refreshing after an in-place leaf update, and a reform
reading back its own edit before it returns. All eleven pass.

Also pins the boundary of the barrier in `test_spm_simulation_isolation.py`:
it covers the parameter tree, not the variable registry, so a variable-only
reform on a shared-policy branch does still reach its parent and siblings.
PolicyEngine#9463 never claimed otherwise, but nothing recorded the limit.

Co-Authored-By: Claude Fable 5.1 <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