Skip to content

Calculate each year's formula branches fresh so multi-year simulations match single-year ones - #9738

Open
MaxGhenis wants to merge 8 commits into
mainfrom
test-multi-year-carry-over
Open

MaxGhenis wants to merge 8 commits into
mainfrom
test-multi-year-carry-over

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Can merge before the core fix. It should, because PolicyEngine/policyengine-core#562 is to land only with or after this PR: under #562, three-year runs that keep formula branches across years raise in 2027.

What and why

A Microsimulation that calculated 2024 and then 2025 gave badly wrong 2025 results. On the full Enhanced CPS, 2025 federal income tax was $3.9bn after 2024 had been calculated. On a 3,000-household subsample it was $31.6bn, against $1,846.9bn in a 2025-only simulation, and state income tax was −$19.9bn. Two defects caused it.

1. Ages at a twelfth of their value (policyengine-core, fixed in #557 or #562)

monthly_age reads age per month, and core caches each month value as a twelfth of the year's age. Core 3.24.0 through 3.32.12 carried the latest-starting cached period of any unit into a later year. So once any month after January of 2024 had been calculated, every person's 2025 age came out as age/12, which broke dependency, filing status, AGI and everything downstream. Single-year datasets such as the Enhanced CPS hold inputs for one year only, so they are exposed. Entity-level datasets (the Populace default) are extended to every year at load and are not.

2. Formula branches kept across periods (this PR)

A branch copies its parent's cached arrays when it is created and never sees the parent's later calculations. These branches were kept for the life of a simulation:

  • itemizing, not_itemizing, no_salt;
  • the DE and VA EITC refundability branches;
  • the ID aged-or-disabled branches;
  • al_2020_irc, ny_pre_arpa_eitc, pre_tcja_ctc.

In a second year they answered from a copy taken in the first year, before any of the second year existed. A 2025-only simulation creates them from its 2025 state instead. With core fixed, this still left 124 tax units' ctc_limiting_tax_liability and one tax unit's income tax different in 2025 on the subsample.

get_branch_for_period (policyengine_us/tools/period_branch.py) shares a branch within a period exactly as before, and creates it again from the parent when the period changes. It also releases the previous period's branch, so a two-year run forked 24 times instead of 39.

Invariants

  1. Order independence for one earlier year: the values a simulation calculates for year Y after calculating one earlier year match a simulation that calculates only Y. Tested with 2024 → 2025 and 2024 → 2027. Longer chains (2025 → 2026 → 2027) still differ at float32 rounding (max $0.25), from chained uprating; policyengine-core#563 targets that, not this PR.
  2. Single-year results are unchanged.

Evidence

Enhanced CPS 2024, 3,000-household subsample with a fixed seed, policyengine-core #557. Each row compares all 24 benchmark arrays (income tax, itemization, branch liabilities, CTC, EITC, state taxes, household net income, SNAP and more):

Comparison Arrays that differ
2025 from a 2024-then-2025 run vs a 2025-only run 0 of 24 (was 8 with only the core fix)
2025-only, this branch vs unpatched main 20ccd5a 0 of 24
2024, this branch vs unpatched main 0 of 24

Full Enhanced CPS, all households, with core #557 and this branch:

  • 2025 from a 2024-then-2025 run: $2,013.7bn income tax, 15.76m itemizers, $497.4bn state income tax. That is bitwise identical in all 24 arrays to a 2025-only run.
  • 2024: $2,102.7bn, the same as before.

Test plan

  • policyengine_us/tests/core/test_multi_year_simulation.py (new, 7 tests). Three households: an IL family in paid childcare, a CA itemizing couple, a TX retiree. Inputs are for 2024; the branch test also gives ages for every year. The two-year tests calculate 2024, then 2025 or 2027, in one simulation and compare 14 variables against single-year simulations. The monthly-age tests check that age survives.

    • test_formula_branches_are_created_again_for_a_later_year gives ages for every year, so it isolates the branch fix on any core. It also checks that the itemization branches are new objects for the later year.
    • The age cases skip (fixture requires_core_carry_over_fix) until the installed core has the carry-over fix. Core 3.32.12, released today, still has the bug. The guard's removal is tracked as a follow-up task.
    Core PE-US formulas Result
    current master this branch branch tests pass, age tests skip
    current master unpatched branch tests fail on tax_liability_if_itemizing and ctc_limiting_tax_liability
    Add Lifeline notebook to TOC #557 this branch all 7 pass
    Change WIC display name from WIC benefit value to WIC #562 this branch all 7 pass
  • policyengine_us/tests/test_ctc_itemizing_branch_cycle.py passes.

  • 371 YAML files (1,803 tests) covering itemization, CTC, and DE, VA, ID, AL and NY income tax pass; see the comment below.

  • Single-year results are bitwise identical to main on 3,000 Enhanced CPS households (24 arrays, 2024 and 2025).

  • ruff format --check and ruff check pass with the locked ruff 0.15.5.

  • Changelog fragment in changelog.d/.

axiom: n/a: simulation branch lifecycle (microsim infrastructure); no policy rule changes

🤖 Generated with Claude Code

A branch copies its parent's cached arrays when it is created and never
sees the parent's later calculations. The itemizing, not_itemizing,
no_salt, DE/VA EITC refundability, ID aged-or-disabled, AL 2020 IRC,
NY pre-ARPA EITC and pre-TCJA CTC branches were kept for the life of a
simulation, so in a second year they answered from a copy taken in the
first, before any of the second year had been calculated. A simulation
that calculates only that year creates them from its current state
instead, so the later year came out differently: on a 3,000-household
Enhanced CPS subsample, 124 tax units' ctc_limiting_tax_liability and one
tax unit's income tax in 2025.

get_branch_for_period shares a branch within a period, as before, and
creates it again from the parent when the period changes. Single-year
results are bitwise unchanged on that subsample, and year two of a
two-year simulation now matches a 2025-only simulation bitwise.

The regression test calculates 2024 and then 2025 or 2027 in one
simulation and compares against single-year simulations; it also covers
the policyengine-core carry-over defect (age at a twelfth of its value).

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

codecov Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.13%. Comparing base (909176a) to head (50ed421).
⚠️ Report is 132 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff             @@
##              main    #9738      +/-   ##
===========================================
- Coverage   100.00%   96.13%   -3.87%     
===========================================
  Files            4       13       +9     
  Lines           76      259     +183     
  Branches         2       13      +11     
===========================================
+ Hits            76      249     +173     
- Misses           0        6       +6     
- Partials         0        4       +4     
Flag Coverage Δ
unittests 96.13% <100.00%> (-3.87%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Targeted YAML run on bb0aa30 with policyengine-core #557: 371 files, 1803 passed, 0 failed (22.6 min). The files were every YAML under gov/irs/income/taxable_income/deductions, gov/irs/credits/ctc and gov/states/{de,va,id,al,ny}/tax/income, plus every YAML test that mentions itemizing, no_salt or tax_liability_if.

Review of bb0aa30: the age defect needs a month of 2024 after January
(January ties the year input on start), and the branch_period assertion
alone does not prove the branch was replaced. Assert identity too.

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

Copy link
Copy Markdown
Contributor Author

Independent review (Codex lane) on bb0aa30: REQUEST_CHANGES for one P3 docstring correction. It confirmed the rest:

  • the branch defect is real independently of core: with fixed core and unpatched formulas, IL tax_liability_if_itemizing is off by $168.35 in 2025 and $180.93 in 2027;
  • the period refresh is the right scope, and no regression was found in its probes (inherited markers, own-name returns, detached parameters, tracing, NY sharing);
  • coverage is complete: 13 calls, 12 branch names;
  • the test matrix discriminates both fixes.

Addressed in 7d7f145: the docstring now says "a month of 2024 after January", and the test now also asserts that the later year's branches are new objects, not only re-tagged. The core pin bump follows once policyengine-core#557 is released.

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

This PR's regression tests (test_multi_year_simulation.py and test_ctc_itemizing_branch_cycle.py, 7 tests) pass against both candidate core fixes: PolicyEngine/policyengine-core#562 at a80a3b96 and #557 at bab4325a. Whichever Max picks, the pin bump goes to its release.

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Like-for-like PE-US benchmark: core #557 vs #562. Setup: Enhanced CPS 2024, 3,000-household subsample (fixed seed), policyengine-us main 20ccd5a with and without policyengine-us#9738. Each cell compares all 24 benchmark arrays bitwise (income tax, itemization, both branch liabilities, CTC, EITC, DE/ID/VA and state income tax, household net income, SNAP and more).

Comparison core master b78b0ba9 (with #556) #557 at 7af22b16 #562 at a80a3b96
2024 single-year vs master n/a identical identical
2025 single-year vs master n/a identical identical
2025 from a 2024-then-2025 run vs 2025-only, PE-US main 20 of 24 differ (age at 1/12: 1,114 tax units' income tax change) 8 differ (formula-branch residual: 124 tax units' ctc_limiting_tax_liability, 1 tax unit's income tax) 8 differ (same residual)
Same, with PE-US #9738 n/a identical identical
#557 vs #562, 2025 from the two-year run with #9738 n/a identical identical

So on this model the two core PRs are interchangeable. Both remove the age/12 error, both leave single-year results bitwise unchanged, and both need PE-US #9738 for the second year to match exactly. They differ in scope:

Scripts and arrays: ~/reviews/pe-us-second-year-2026-10-01/cmp_bench.sh, rerun_a2.sh, cmp/ on the investigation host.

The age cases need a policyengine-core with the carry-over fix
(policyengine-core#557 or #562), so this PR could not merge before that
release. But #562 should land only with or after this PR: under it,
three-year policyengine-us runs that keep formula branches across years
raise in 2027. So test the branch fix on its own, with ages given for
every year so no age is carried over: on current core it fails on
unpatched formulas (tax_liability_if_itemizing, ctc_limiting_tax_liability)
and passes here. The age cases skip, by behaviour, while the installed
core still carries a month's twelfth into a later year; drop the guard
when the core minimum includes the fix. With core #557 or #562 all 7
pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 2, 2026 09:25
MaxGhenis and others added 2 commits October 2, 2026 07:43
Review of a31b2bc: 3.32.12 (published today) still carries the twelfth;
the fixture name read as the opposite of what it gates; the changelog
named only some of the branches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of a31b2bc: the ten state call sites had no test (patch coverage
23%; CI turns itemization branching off for coverage, so the federal
sites don't run there either). Delaware and Virginia EITC refundability,
Idaho aged/disabled and New York pre-TCJA CTC households now calculate
an earlier and a later year in one simulation and must match a
later-year-only simulation, with each branch created again and tagged
with the later period. The 2021-only Alabama 2020-IRC and New York
pre-ARPA EITC branches are checked to record their period. Ages are
given for every year so these run on any core.

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

Copy link
Copy Markdown
Contributor Author

CI follow-up.

The two exit-143 shards are not caused by this PR.

Codecov patch was red because nothing covered the ten state call sites, and CI turns itemization branching off for the coverage run. d6ab9b7 adds tests:

  • DE and VA EITC refundability, ID aged/disabled and NY pre-TCJA CTC households each calculate two years in one simulation and must match a later-year-only simulation, with each branch re-created and tagged with the later period;
  • the 2021-only AL 2020-IRC and NY pre-ARPA EITC branches are checked for their period tag.

Ages are given for every year, so these run on any core.

MaxGhenis and others added 2 commits October 2, 2026 23:01
codecov/project was red: the touched AL and NY formulas had lines no
test reached (AL's non-2021 return, NY's post-2024 early return and
non-pre-TCJA path). A new test asserts the al_2020_irc branch is not
created for 2022, and the pre_tcja_ctc branch is not created for 2025
(post-2024 credit) or when pre-TCJA rules are off; together the tests
now cover all lines and branches of those three files. Also correct the
state fixture's docstring: the household reaches each state's branches,
which is what the tests assert, but is not claimed eligible for Idaho's
aged credit (review of d6ab9b7).

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

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.

1 participant