From bb0aa303163c19821135145ece340e847aeb883e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 11:22:36 -0400 Subject: [PATCH 1/6] Create formula branches again for each period 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 --- .../test-multi-year-carry-over.fixed.md | 1 + .../tests/core/test_multi_year_simulation.py | 160 ++++++++++++++++++ policyengine_us/tools/general.py | 1 + policyengine_us/tools/period_branch.py | 27 +++ .../refundable/ctc_limiting_tax_liability.py | 2 +- .../deductions/tax_liability_if_itemizing.py | 2 +- .../tax_liability_if_not_itemizing.py | 2 +- .../al_federal_income_tax_deduction.py | 2 +- ...ome_tax_if_claiming_non_refundable_eitc.py | 4 +- ..._income_tax_if_claiming_refundable_eitc.py | 4 +- ...ax_if_receiving_aged_or_disabled_credit.py | 4 +- ...if_receiving_aged_or_disabled_deduction.py | 4 +- .../tax/income/credits/ctc/ny_ctc_pre_2024.py | 2 +- .../credits/ctc/ny_ctc_pre_2024_eligible.py | 2 +- .../states/ny/tax/income/credits/ny_eitc.py | 2 +- ...ome_tax_if_claiming_non_refundable_eitc.py | 4 +- ..._income_tax_if_claiming_refundable_eitc.py | 4 +- 17 files changed, 214 insertions(+), 13 deletions(-) create mode 100644 changelog.d/test-multi-year-carry-over.fixed.md create mode 100644 policyengine_us/tests/core/test_multi_year_simulation.py create mode 100644 policyengine_us/tools/period_branch.py diff --git a/changelog.d/test-multi-year-carry-over.fixed.md b/changelog.d/test-multi-year-carry-over.fixed.md new file mode 100644 index 00000000000..eda0cfed62b --- /dev/null +++ b/changelog.d/test-multi-year-carry-over.fixed.md @@ -0,0 +1 @@ +Calculate each period's itemization, SALT and state refundability branches from a fresh branch, so a later year calculated in the same simulation matches a simulation that calculates only that year. diff --git a/policyengine_us/tests/core/test_multi_year_simulation.py b/policyengine_us/tests/core/test_multi_year_simulation.py new file mode 100644 index 00000000000..eeb2d27ba46 --- /dev/null +++ b/policyengine_us/tests/core/test_multi_year_simulation.py @@ -0,0 +1,160 @@ +"""A later year must not depend on which earlier years one simulation calculated. + +Inputs here are given for 2024 only, as in a single-year dataset such as the +Enhanced CPS: later years carry inputs over or uprate them. Two defects made a +later year depend on the years calculated before it. + +- ``monthly_age`` reads ``age`` one month at a time, and core caches each of + those month values as a twelfth of the year's age. policyengine-core 3.24.0 + through 3.32.11 carried the latest-starting cached period of any unit into a + later year, so once a month of 2024 had been calculated every person's 2025 + age was a twelfth of their 2024 age. Fixed in policyengine-core. +- The itemization, SALT and state refundability branches were kept across + periods. A branch copies its parent's cached arrays when it is created, so + a branch created in 2024 answered 2025 from a copy without any of the + parent's 2025 values, unlike the branch a 2025-only simulation creates. + ``get_branch_for_period`` creates them again for each period. + +Each test compares a simulation that calculates the base year first with a +fresh simulation that calculates only the later year. +""" + +import numpy as np +import pytest +from policyengine_core.periods import period + +from policyengine_us import Simulation + +BASE_YEAR = 2024 +LATER_YEARS = (2025, 2027) +STATES = {"il": "IL", "ca": "CA", "tx": "TX"} + +PEOPLE = { + # An Illinois single parent with two young children in paid childcare: + # Illinois CCAP reads monthly_age. + "il_parent": {"age": 34, "employment_income": 30_000}, + "il_child_1": {"age": 3, "pre_subsidy_childcare_expenses": 9_000}, + "il_child_2": {"age": 7, "pre_subsidy_childcare_expenses": 4_000}, + # A California couple who itemize, so the itemizing and not_itemizing + # branches decide their income tax. + "ca_head": { + "age": 52, + "employment_income": 180_000, + "deductible_mortgage_interest": 25_000, + "real_estate_taxes": 15_000, + "charitable_cash_donations": 10_000, + }, + "ca_spouse": {"age": 50, "employment_income": 60_000}, + "ca_child": {"age": 15}, + # A Texas retiree, whose age decides the senior deductions. + "tx_retiree": { + "age": 70, + "social_security_retirement": 24_000, + "taxable_interest_income": 8_000, + }, +} +UNITS = { + "il": ["il_parent", "il_child_1", "il_child_2"], + "ca": ["ca_head", "ca_spouse", "ca_child"], + "tx": ["tx_retiree"], +} +MARITAL_UNITS = [ + ["il_parent"], + ["il_child_1"], + ["il_child_2"], + ["ca_head", "ca_spouse"], + ["ca_child"], + ["tx_retiree"], +] + +VARIABLES = [ + "age", + "is_tax_unit_dependent", + "filing_status", + "adjusted_gross_income", + "taxable_income", + "tax_unit_itemizes", + "tax_liability_if_itemizing", + "tax_liability_if_not_itemizing", + "ctc_limiting_tax_liability", + "income_tax", + "eitc", + "refundable_ctc", + "state_income_tax", + "household_net_income", +] + + +def _situation(): + # Geography has formulas (state_code from state_fips), so it is not + # carried forward: set it for every year calculated. + years = (BASE_YEAR, *LATER_YEARS) + return { + "people": { + name: {variable: {BASE_YEAR: value} for variable, value in inputs.items()} + for name, inputs in PEOPLE.items() + }, + "tax_units": {key: {"members": members} for key, members in UNITS.items()}, + "spm_units": {key: {"members": members} for key, members in UNITS.items()}, + "families": {key: {"members": members} for key, members in UNITS.items()}, + "marital_units": { + f"marital_unit_{i}": {"members": members} + for i, members in enumerate(MARITAL_UNITS) + }, + "households": { + key: { + "members": members, + "state_code": {year: STATES[key] for year in years}, + } + for key, members in UNITS.items() + }, + } + + +def _input_ages(): + return np.array([inputs["age"] for inputs in PEOPLE.values()], dtype=float) + + +def _later_year_values(simulation, year): + return { + variable: np.asarray(simulation.calculate(variable, year)) + for variable in VARIABLES + } + + +def _assert_same(result, fresh, year): + for variable in VARIABLES: + np.testing.assert_array_equal( + result[variable], + fresh[variable], + err_msg=f"{variable} for {year} depends on earlier years calculated", + ) + + +@pytest.mark.parametrize("month", ["2024-01", "2024-06", "2024-12"]) +def test_monthly_age_does_not_change_later_age(month): + simulation = Simulation(situation=_situation()) + np.testing.assert_array_equal( + simulation.calculate("monthly_age", month), _input_ages() + ) + for year in LATER_YEARS: + np.testing.assert_array_equal(simulation.calculate("age", year), _input_ages()) + + +@pytest.mark.parametrize("year", LATER_YEARS) +def test_later_year_matches_single_year_simulation(year): + fresh = _later_year_values(Simulation(situation=_situation()), year) + np.testing.assert_array_equal(fresh["age"], _input_ages()) + + simulation = Simulation(situation=_situation()) + _later_year_values(simulation, BASE_YEAR) + # Request every month of the base year too, as monthly programs do for + # people they cover. + for month in range(1, 13): + simulation.calculate("monthly_age", f"{BASE_YEAR}-{month:02d}") + + _assert_same(_later_year_values(simulation, year), fresh, year) + # The itemization branches were created again for the later year rather + # than kept from the base year. + for name in ("itemizing", "not_itemizing", "no_salt"): + assert simulation.branches[name].branch_period == period(year) diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index 7ad450690e3..5e94f398da9 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -1,6 +1,7 @@ from policyengine_core.model_api import * from policyengine_us.entities import * from policyengine_us.tools.branched_simulation import BranchedSimulation +from policyengine_us.tools.period_branch import get_branch_for_period from pathlib import Path import pandas as pd from policyengine_us.typing import Formula diff --git a/policyengine_us/tools/period_branch.py b/policyengine_us/tools/period_branch.py new file mode 100644 index 00000000000..fa516601f3b --- /dev/null +++ b/policyengine_us/tools/period_branch.py @@ -0,0 +1,27 @@ +from policyengine_core.periods import Period +from policyengine_core.simulations import Simulation + + +def get_branch_for_period(simulation: Simulation, name: str, period: Period): + """Return ``simulation``'s branch ``name`` for calculating ``period``. + + A branch copies its parent's cached arrays when it is created and never + sees the parent's later calculations, and an input set on it does not + clear values it already copied. What a branch computes for a period + therefore depends on what its parent had calculated when the branch was + created. A branch kept from another period answers this period from a copy + taken before any of this period was calculated, unlike the branch a + simulation calculating only this period would create, so a later year came + out differently when an earlier year had been calculated first. + + Within a period the branch is shared as before. A branch left from another + period is dropped, and the branch is created again from the parent as it + stands now. + """ + branch = simulation.branches.get(name) + if branch is not None and getattr(branch, "branch_period", period) != period: + del simulation.branches[name] + branch = simulation.get_branch(name) + if branch is not simulation: + branch.branch_period = period + return branch diff --git a/policyengine_us/variables/gov/irs/credits/ctc/refundable/ctc_limiting_tax_liability.py b/policyengine_us/variables/gov/irs/credits/ctc/refundable/ctc_limiting_tax_liability.py index 796ce992c1d..6a00eddc72c 100644 --- a/policyengine_us/variables/gov/irs/credits/ctc/refundable/ctc_limiting_tax_liability.py +++ b/policyengine_us/variables/gov/irs/credits/ctc/refundable/ctc_limiting_tax_liability.py @@ -11,7 +11,7 @@ class ctc_limiting_tax_liability(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation - no_salt_branch = simulation.get_branch("no_salt") + no_salt_branch = get_branch_for_period(simulation, "no_salt", period) no_salt_branch.set_input("salt_deduction", period, np.zeros(tax_unit.count)) # Propagate the parent's itemization determination so the # no_salt branch doesn't re-enter diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py index 775823dd9c3..db3bc7182f3 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py @@ -10,7 +10,7 @@ class tax_liability_if_itemizing(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation - itemized_branch = simulation.get_branch("itemizing") + itemized_branch = get_branch_for_period(simulation, "itemizing", period) itemized_branch.set_input( "tax_unit_itemizes", period, np.ones((tax_unit.count,), dtype=bool) ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py index eca0caec429..cc3738fd771 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py @@ -11,7 +11,7 @@ class tax_liability_if_not_itemizing(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation - non_itemized_branch = simulation.get_branch("not_itemizing") + non_itemized_branch = get_branch_for_period(simulation, "not_itemizing", period) non_itemized_branch.set_input( "tax_unit_itemizes", period, diff --git a/policyengine_us/variables/gov/states/al/tax/income/deductions/federal_income_tax/al_federal_income_tax_deduction.py b/policyengine_us/variables/gov/states/al/tax/income/deductions/federal_income_tax/al_federal_income_tax_deduction.py index e8e93ffa639..46a0b93b1c8 100644 --- a/policyengine_us/variables/gov/states/al/tax/income/deductions/federal_income_tax/al_federal_income_tax_deduction.py +++ b/policyengine_us/variables/gov/states/al/tax/income/deductions/federal_income_tax/al_federal_income_tax_deduction.py @@ -52,7 +52,7 @@ def formula(tax_unit, period, parameters): actual_non_refundable_cdcc = tax_unit("cdcc", period) * (not cdcc_is_refundable) simulation = tax_unit.simulation - branch = simulation.get_branch("al_2020_irc") + branch = get_branch_for_period(simulation, "al_2020_irc", period) branch.tax_benefit_system = get_2020_irc_tbs(simulation.tax_benefit_system) for variable in branch.tax_benefit_system.variables: if any(key in variable for key in ("ctc", "cdcc", "eitc")): diff --git a/policyengine_us/variables/gov/states/de/tax/income/credits/eitc/refundability_calculation/de_income_tax_if_claiming_non_refundable_eitc.py b/policyengine_us/variables/gov/states/de/tax/income/credits/eitc/refundability_calculation/de_income_tax_if_claiming_non_refundable_eitc.py index 94782f0ddac..36dccc9258e 100644 --- a/policyengine_us/variables/gov/states/de/tax/income/credits/eitc/refundability_calculation/de_income_tax_if_claiming_non_refundable_eitc.py +++ b/policyengine_us/variables/gov/states/de/tax/income/credits/eitc/refundability_calculation/de_income_tax_if_claiming_non_refundable_eitc.py @@ -11,7 +11,9 @@ class de_income_tax_if_claiming_non_refundable_eitc(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation - non_refundable_branch = simulation.get_branch("de_non_refundable_eitc") + non_refundable_branch = get_branch_for_period( + simulation, "de_non_refundable_eitc", period + ) non_refundable_branch.set_input( "de_claims_refundable_eitc", period, diff --git a/policyengine_us/variables/gov/states/de/tax/income/credits/eitc/refundability_calculation/de_income_tax_if_claiming_refundable_eitc.py b/policyengine_us/variables/gov/states/de/tax/income/credits/eitc/refundability_calculation/de_income_tax_if_claiming_refundable_eitc.py index cec2dfa4451..ad4437284ea 100644 --- a/policyengine_us/variables/gov/states/de/tax/income/credits/eitc/refundability_calculation/de_income_tax_if_claiming_refundable_eitc.py +++ b/policyengine_us/variables/gov/states/de/tax/income/credits/eitc/refundability_calculation/de_income_tax_if_claiming_refundable_eitc.py @@ -11,7 +11,9 @@ class de_income_tax_if_claiming_refundable_eitc(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation - refundable_branch = simulation.get_branch("de_refundable_eitc") + refundable_branch = get_branch_for_period( + simulation, "de_refundable_eitc", period + ) refundable_branch.set_input( "de_claims_refundable_eitc", period, diff --git a/policyengine_us/variables/gov/states/id/tax/income/id_income_tax_if_receiving_aged_or_disabled_credit.py b/policyengine_us/variables/gov/states/id/tax/income/id_income_tax_if_receiving_aged_or_disabled_credit.py index c024963bf58..a838a5d8a5d 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/id_income_tax_if_receiving_aged_or_disabled_credit.py +++ b/policyengine_us/variables/gov/states/id/tax/income/id_income_tax_if_receiving_aged_or_disabled_credit.py @@ -13,7 +13,9 @@ class id_income_tax_if_receiving_aged_or_disabled_credit(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation - branch = simulation.get_branch("id_receives_aged_or_disabled_credit_branch") + branch = get_branch_for_period( + simulation, "id_receives_aged_or_disabled_credit_branch", period + ) branch.set_input( "id_receives_aged_or_disabled_credit", period, diff --git a/policyengine_us/variables/gov/states/id/tax/income/id_income_tax_if_receiving_aged_or_disabled_deduction.py b/policyengine_us/variables/gov/states/id/tax/income/id_income_tax_if_receiving_aged_or_disabled_deduction.py index 259be96edbe..e38bf9414bf 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/id_income_tax_if_receiving_aged_or_disabled_deduction.py +++ b/policyengine_us/variables/gov/states/id/tax/income/id_income_tax_if_receiving_aged_or_disabled_deduction.py @@ -13,7 +13,9 @@ class id_income_tax_if_receiving_aged_or_disabled_deduction(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation - branch = simulation.get_branch("id_receives_aged_or_disabled_deduction_branch") + branch = get_branch_for_period( + simulation, "id_receives_aged_or_disabled_deduction_branch", period + ) branch.set_input( "id_receives_aged_or_disabled_credit", period, diff --git a/policyengine_us/variables/gov/states/ny/tax/income/credits/ctc/ny_ctc_pre_2024.py b/policyengine_us/variables/gov/states/ny/tax/income/credits/ctc/ny_ctc_pre_2024.py index 0688b6389bb..c331a3336b8 100644 --- a/policyengine_us/variables/gov/states/ny/tax/income/credits/ctc/ny_ctc_pre_2024.py +++ b/policyengine_us/variables/gov/states/ny/tax/income/credits/ctc/ny_ctc_pre_2024.py @@ -29,7 +29,7 @@ def formula(tax_unit, period, parameters): # Initialize pre-TCJA CTC branch with cached pinned parameters # (one clone per process; see tools/pinned_tbs.py, issue #8114). simulation = tax_unit.simulation - pre_tcja_ctc = simulation.get_branch("pre_tcja_ctc") + pre_tcja_ctc = get_branch_for_period(simulation, "pre_tcja_ctc", period) pre_tcja_ctc.tax_benefit_system = get_pre_tcja_ctc_tbs( simulation.tax_benefit_system ) diff --git a/policyengine_us/variables/gov/states/ny/tax/income/credits/ctc/ny_ctc_pre_2024_eligible.py b/policyengine_us/variables/gov/states/ny/tax/income/credits/ctc/ny_ctc_pre_2024_eligible.py index 3364c58fd18..c7c3b398e65 100644 --- a/policyengine_us/variables/gov/states/ny/tax/income/credits/ctc/ny_ctc_pre_2024_eligible.py +++ b/policyengine_us/variables/gov/states/ny/tax/income/credits/ctc/ny_ctc_pre_2024_eligible.py @@ -28,7 +28,7 @@ def formula(tax_unit, period, parameters): # Initialize pre-TCJA CTC branch for eligibility check with # cached pinned parameters (see tools/pinned_tbs.py, issue #8114). simulation = tax_unit.simulation - pre_tcja_ctc = simulation.get_branch("pre_tcja_ctc") + pre_tcja_ctc = get_branch_for_period(simulation, "pre_tcja_ctc", period) pre_tcja_ctc.tax_benefit_system = get_pre_tcja_ctc_tbs( simulation.tax_benefit_system ) diff --git a/policyengine_us/variables/gov/states/ny/tax/income/credits/ny_eitc.py b/policyengine_us/variables/gov/states/ny/tax/income/credits/ny_eitc.py index a45b7d86eb2..20c38b23cdb 100644 --- a/policyengine_us/variables/gov/states/ny/tax/income/credits/ny_eitc.py +++ b/policyengine_us/variables/gov/states/ny/tax/income/credits/ny_eitc.py @@ -23,7 +23,7 @@ def formula(tax_unit, period, parameters): # does not conform. Recompute the federal EITC with # pre-ARPA (2020) parameter values. simulation = tax_unit.simulation - branch = simulation.get_branch("ny_pre_arpa_eitc") + branch = get_branch_for_period(simulation, "ny_pre_arpa_eitc", period) branch.tax_benefit_system = get_pre_arpa_eitc_tbs( simulation.tax_benefit_system ) diff --git a/policyengine_us/variables/gov/states/va/tax/income/credits/eitc/refundability_calculation/va_income_tax_if_claiming_non_refundable_eitc.py b/policyengine_us/variables/gov/states/va/tax/income/credits/eitc/refundability_calculation/va_income_tax_if_claiming_non_refundable_eitc.py index cef69a3c26c..b53d63491c1 100644 --- a/policyengine_us/variables/gov/states/va/tax/income/credits/eitc/refundability_calculation/va_income_tax_if_claiming_non_refundable_eitc.py +++ b/policyengine_us/variables/gov/states/va/tax/income/credits/eitc/refundability_calculation/va_income_tax_if_claiming_non_refundable_eitc.py @@ -11,7 +11,9 @@ class va_income_tax_if_claiming_non_refundable_eitc(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation - non_refundable_branch = simulation.get_branch("va_non_refundable_eitc") + non_refundable_branch = get_branch_for_period( + simulation, "va_non_refundable_eitc", period + ) non_refundable_branch.set_input( "va_claims_refundable_eitc", period, diff --git a/policyengine_us/variables/gov/states/va/tax/income/credits/eitc/refundability_calculation/va_income_tax_if_claiming_refundable_eitc.py b/policyengine_us/variables/gov/states/va/tax/income/credits/eitc/refundability_calculation/va_income_tax_if_claiming_refundable_eitc.py index b510db847a2..1b363d70c96 100644 --- a/policyengine_us/variables/gov/states/va/tax/income/credits/eitc/refundability_calculation/va_income_tax_if_claiming_refundable_eitc.py +++ b/policyengine_us/variables/gov/states/va/tax/income/credits/eitc/refundability_calculation/va_income_tax_if_claiming_refundable_eitc.py @@ -11,7 +11,9 @@ class va_income_tax_if_claiming_refundable_eitc(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation - refundable_branch = simulation.get_branch("va_refundable_eitc") + refundable_branch = get_branch_for_period( + simulation, "va_refundable_eitc", period + ) refundable_branch.set_input( "va_claims_refundable_eitc", period, From 7d7f145a66e6ddc140aa2552617bf0addb314c39 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 13:49:52 -0400 Subject: [PATCH 2/6] Assert the later year gets new branches; say a month after January Review of bb0aa303: 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 --- .../tests/core/test_multi_year_simulation.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/core/test_multi_year_simulation.py b/policyengine_us/tests/core/test_multi_year_simulation.py index eeb2d27ba46..78ef1908d64 100644 --- a/policyengine_us/tests/core/test_multi_year_simulation.py +++ b/policyengine_us/tests/core/test_multi_year_simulation.py @@ -7,8 +7,9 @@ - ``monthly_age`` reads ``age`` one month at a time, and core caches each of those month values as a twelfth of the year's age. policyengine-core 3.24.0 through 3.32.11 carried the latest-starting cached period of any unit into a - later year, so once a month of 2024 had been calculated every person's 2025 - age was a twelfth of their 2024 age. Fixed in policyengine-core. + later year, so once a month of 2024 after January had been calculated, + every person's 2025 age was a twelfth of their 2024 age. Fixed in + policyengine-core. - The itemization, SALT and state refundability branches were kept across periods. A branch copies its parent's cached arrays when it is created, so a branch created in 2024 answered 2025 from a copy without any of the @@ -148,6 +149,8 @@ def test_later_year_matches_single_year_simulation(year): simulation = Simulation(situation=_situation()) _later_year_values(simulation, BASE_YEAR) + BRANCHES = ("itemizing", "not_itemizing", "no_salt") + base_year_branches = {name: simulation.branches[name] for name in BRANCHES} # Request every month of the base year too, as monthly programs do for # people they cover. for month in range(1, 13): @@ -156,5 +159,6 @@ def test_later_year_matches_single_year_simulation(year): _assert_same(_later_year_values(simulation, year), fresh, year) # The itemization branches were created again for the later year rather # than kept from the base year. - for name in ("itemizing", "not_itemizing", "no_salt"): + for name in BRANCHES: + assert simulation.branches[name] is not base_year_branches[name] assert simulation.branches[name].branch_period == period(year) From a31b2bcea96283e2e4cf7ff0a39d9509ce7e0273 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 05:25:09 -0400 Subject: [PATCH 3/6] Test the branch fix on any core; skip age cases until core has its fix 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 --- .../tests/core/test_multi_year_simulation.py | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/core/test_multi_year_simulation.py b/policyengine_us/tests/core/test_multi_year_simulation.py index 78ef1908d64..d125a134ae1 100644 --- a/policyengine_us/tests/core/test_multi_year_simulation.py +++ b/policyengine_us/tests/core/test_multi_year_simulation.py @@ -17,7 +17,9 @@ ``get_branch_for_period`` creates them again for each period. Each test compares a simulation that calculates the base year first with a -fresh simulation that calculates only the later year. +fresh simulation that calculates only the later year. The branch test gives +ages for every year so it runs on any core; the age tests skip until the +installed policyengine-core has the carry-over fix. """ import numpy as np @@ -86,11 +88,11 @@ ] -def _situation(): +def _situation(age_every_year=False): # Geography has formulas (state_code from state_fips), so it is not # carried forward: set it for every year calculated. years = (BASE_YEAR, *LATER_YEARS) - return { + situation = { "people": { name: {variable: {BASE_YEAR: value} for variable, value in inputs.items()} for name, inputs in PEOPLE.items() @@ -110,6 +112,11 @@ def _situation(): for key, members in UNITS.items() }, } + if age_every_year: + # Give age for every year, so no later year carries age over. + for name, person in situation["people"].items(): + person["age"] = {year: PEOPLE[name]["age"] for year in years} + return situation def _input_ages(): @@ -132,6 +139,39 @@ def _assert_same(result, fresh, year): ) +@pytest.fixture(scope="module") +def core_carries_age_over(): + """Skip the age cases on a policyengine-core that still carries a month's + twelfth into a later year (3.24.0 through 3.32.x). Remove this guard once + the core minimum includes the fix.""" + simulation = Simulation(situation=_situation()) + simulation.calculate("monthly_age", f"{BASE_YEAR}-12") + if not np.array_equal(simulation.calculate("age", BASE_YEAR + 1), _input_ages()): + pytest.skip( + "needs a policyengine-core release with the auto-carry-over fix " + "(PolicyEngine/policyengine-core#557 or #562)" + ) + + +@pytest.mark.parametrize("year", LATER_YEARS) +def test_formula_branches_are_created_again_for_a_later_year(year): + """The branch defect alone, on any core: ages are given for every year.""" + fresh = _later_year_values( + Simulation(situation=_situation(age_every_year=True)), year + ) + + simulation = Simulation(situation=_situation(age_every_year=True)) + _later_year_values(simulation, BASE_YEAR) + branches = ("itemizing", "not_itemizing", "no_salt") + base_year_branches = {name: simulation.branches[name] for name in branches} + + _assert_same(_later_year_values(simulation, year), fresh, year) + for name in branches: + assert simulation.branches[name] is not base_year_branches[name] + assert simulation.branches[name].branch_period == period(year) + + +@pytest.mark.usefixtures("core_carries_age_over") @pytest.mark.parametrize("month", ["2024-01", "2024-06", "2024-12"]) def test_monthly_age_does_not_change_later_age(month): simulation = Simulation(situation=_situation()) @@ -142,6 +182,7 @@ def test_monthly_age_does_not_change_later_age(month): np.testing.assert_array_equal(simulation.calculate("age", year), _input_ages()) +@pytest.mark.usefixtures("core_carries_age_over") @pytest.mark.parametrize("year", LATER_YEARS) def test_later_year_matches_single_year_simulation(year): fresh = _later_year_values(Simulation(situation=_situation()), year) From 7b23c22c365a591d97afc7e90a6c1452b3c20f6f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 07:43:38 -0400 Subject: [PATCH 4/6] Name the skip guard for what it requires; core 3.32.12 still has the bug Review of a31b2bce: 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 --- changelog.d/test-multi-year-carry-over.fixed.md | 2 +- .../tests/core/test_multi_year_simulation.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/changelog.d/test-multi-year-carry-over.fixed.md b/changelog.d/test-multi-year-carry-over.fixed.md index eda0cfed62b..840262e7738 100644 --- a/changelog.d/test-multi-year-carry-over.fixed.md +++ b/changelog.d/test-multi-year-carry-over.fixed.md @@ -1 +1 @@ -Calculate each period's itemization, SALT and state refundability branches from a fresh branch, so a later year calculated in the same simulation matches a simulation that calculates only that year. +Create formula branches (itemizing, not itemizing, no SALT, and the Delaware, Virginia, Idaho, Alabama and New York comparison branches) again for each period, so a later year calculated in the same simulation matches a simulation that calculates only that year. diff --git a/policyengine_us/tests/core/test_multi_year_simulation.py b/policyengine_us/tests/core/test_multi_year_simulation.py index d125a134ae1..12ea6cd98c3 100644 --- a/policyengine_us/tests/core/test_multi_year_simulation.py +++ b/policyengine_us/tests/core/test_multi_year_simulation.py @@ -6,7 +6,7 @@ - ``monthly_age`` reads ``age`` one month at a time, and core caches each of those month values as a twelfth of the year's age. policyengine-core 3.24.0 - through 3.32.11 carried the latest-starting cached period of any unit into a + through 3.32.12 carried the latest-starting cached period of any unit into a later year, so once a month of 2024 after January had been calculated, every person's 2025 age was a twelfth of their 2024 age. Fixed in policyengine-core. @@ -140,10 +140,11 @@ def _assert_same(result, fresh, year): @pytest.fixture(scope="module") -def core_carries_age_over(): +def requires_core_carry_over_fix(): """Skip the age cases on a policyengine-core that still carries a month's - twelfth into a later year (3.24.0 through 3.32.x). Remove this guard once - the core minimum includes the fix.""" + twelfth into a later year (3.24.0 through 3.32.12). Remove this guard once + the core minimum includes the fix: until then a core that brought the bug + back would show here as skips, not failures.""" simulation = Simulation(situation=_situation()) simulation.calculate("monthly_age", f"{BASE_YEAR}-12") if not np.array_equal(simulation.calculate("age", BASE_YEAR + 1), _input_ages()): @@ -171,7 +172,7 @@ def test_formula_branches_are_created_again_for_a_later_year(year): assert simulation.branches[name].branch_period == period(year) -@pytest.mark.usefixtures("core_carries_age_over") +@pytest.mark.usefixtures("requires_core_carry_over_fix") @pytest.mark.parametrize("month", ["2024-01", "2024-06", "2024-12"]) def test_monthly_age_does_not_change_later_age(month): simulation = Simulation(situation=_situation()) @@ -182,7 +183,7 @@ def test_monthly_age_does_not_change_later_age(month): np.testing.assert_array_equal(simulation.calculate("age", year), _input_ages()) -@pytest.mark.usefixtures("core_carries_age_over") +@pytest.mark.usefixtures("requires_core_carry_over_fix") @pytest.mark.parametrize("year", LATER_YEARS) def test_later_year_matches_single_year_simulation(year): fresh = _later_year_values(Simulation(situation=_situation()), year) From d6ab9b735afbd29c258c18c21aa5d3bf737b9a47 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 15:46:22 -0400 Subject: [PATCH 5/6] Test the state comparison branches across years Review of a31b2bce: 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 --- .../tests/core/test_multi_year_simulation.py | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/policyengine_us/tests/core/test_multi_year_simulation.py b/policyengine_us/tests/core/test_multi_year_simulation.py index 12ea6cd98c3..8cada324878 100644 --- a/policyengine_us/tests/core/test_multi_year_simulation.py +++ b/policyengine_us/tests/core/test_multi_year_simulation.py @@ -204,3 +204,96 @@ def test_later_year_matches_single_year_simulation(year): for name in BRANCHES: assert simulation.branches[name] is not base_year_branches[name] assert simulation.branches[name].branch_period == period(year) + + +# State comparison branches: one household per state, with ages given for +# every year so the cases run on any core. + +STATE_BRANCH_CASES = [ + ( + "DE", + ("de_refundable_eitc", "de_non_refundable_eitc"), + (2024, 2025), + "de_income_tax", + ), + ( + "VA", + ("va_refundable_eitc", "va_non_refundable_eitc"), + (2024, 2025), + "va_income_tax", + ), + ( + "ID", + ( + "id_receives_aged_or_disabled_credit_branch", + "id_receives_aged_or_disabled_deduction_branch", + ), + (2024, 2025), + "id_income_tax", + ), + ("NY", ("pre_tcja_ctc",), (2023, 2024), "ny_income_tax"), +] + + +def _state_situation(state, years): + """A low-earning parent aged 67 with one child: EITC-eligible, aged for + Idaho's credit, and with a child for New York's CTC.""" + members = ["parent", "child"] + return { + "people": { + "parent": { + "age": {year: 67 for year in years}, + "employment_income": {years[0]: 25_000}, + }, + "child": {"age": {year: 5 for year in years}}, + }, + "tax_units": {"tax_unit": {"members": members}}, + "spm_units": {"spm_unit": {"members": members}}, + "families": {"family": {"members": members}}, + "marital_units": { + "parent_unit": {"members": ["parent"]}, + "child_unit": {"members": ["child"]}, + }, + "households": { + "household": { + "members": members, + "state_code": {year: state for year in years}, + } + }, + } + + +@pytest.mark.parametrize( + "state,branches,years,variable", + STATE_BRANCH_CASES, + ids=[case[0] for case in STATE_BRANCH_CASES], +) +def test_state_comparison_branches_are_created_again_for_a_later_year( + state, branches, years, variable +): + first_year, later_year = years + fresh = Simulation(situation=_state_situation(state, years)).calculate( + variable, later_year + ) + + simulation = Simulation(situation=_state_situation(state, years)) + simulation.calculate(variable, first_year) + first_year_branches = {name: simulation.branches[name] for name in branches} + + np.testing.assert_array_equal(simulation.calculate(variable, later_year), fresh) + for name in branches: + assert simulation.branches[name] is not first_year_branches[name] + assert simulation.branches[name].branch_period == period(later_year) + + +@pytest.mark.parametrize( + "state,branch,variable", + [("AL", "al_2020_irc", "al_income_tax"), ("NY", "ny_pre_arpa_eitc", "ny_eitc")], + ids=["AL", "NY"], +) +def test_2021_only_branches_take_their_period(state, branch, variable): + """Alabama's 2020-IRC and New York's pre-ARPA EITC branches apply in 2021 + only, so a later year never reuses them; they still record their period.""" + simulation = Simulation(situation=_state_situation(state, (2021,))) + simulation.calculate(variable, 2021) + assert simulation.branches[branch].branch_period == period(2021) From cac51503be637c1d8834b40905c4a55a4219de64 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:42:13 -0400 Subject: [PATCH 6/6] Test that state branches are created only where their rules apply 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 d6ab9b73). Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_multi_year_simulation.py | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/core/test_multi_year_simulation.py b/policyengine_us/tests/core/test_multi_year_simulation.py index 8cada324878..af43f9fade7 100644 --- a/policyengine_us/tests/core/test_multi_year_simulation.py +++ b/policyengine_us/tests/core/test_multi_year_simulation.py @@ -236,8 +236,10 @@ def test_later_year_matches_single_year_simulation(year): def _state_situation(state, years): - """A low-earning parent aged 67 with one child: EITC-eligible, aged for - Idaho's credit, and with a child for New York's CTC.""" + """A low-earning parent aged 67 with one child. Each state's income tax + reaches its comparison branches for this household, which the tests check + by asserting the branches exist; eligibility for the credits they compare + does not matter for that.""" members = ["parent", "child"] return { "people": { @@ -297,3 +299,31 @@ def test_2021_only_branches_take_their_period(state, branch, variable): simulation = Simulation(situation=_state_situation(state, (2021,))) simulation.calculate(variable, 2021) assert simulation.branches[branch].branch_period == period(2021) + + +def test_state_branches_are_not_created_where_their_rules_do_not_apply(): + """Each state branch is created only in years its rule applies: Alabama's + 2020-IRC recompute only for 2021, New York's pre-TCJA CTC branch only for + the pre-2024 credit under pre-TCJA rules.""" + alabama = Simulation(situation=_state_situation("AL", (2022,))) + alabama.calculate("al_income_tax", 2022) + assert "al_2020_irc" not in alabama.branches + + # The post-2024 New York credit applies from 2025, so the pre-2024 + # credit, and its branch, are not used. + new_york = Simulation(situation=_state_situation("NY", (2025,))) + assert not new_york.calculate("ny_ctc_pre_2024_eligible", 2025).any() + assert "pre_tcja_ctc" not in new_york.branches + + # Without pre-TCJA rules the pre-2024 credit reads the federal CTC itself. + without_pre_tcja = Simulation( + situation=_state_situation("NY", (2024,)), + reform={ + "gov.states.ny.tax.income.credits.ctc.pre_tcja": { + "2024-01-01.2100-12-31": False + } + }, + ) + without_pre_tcja.calculate("ny_ctc_pre_2024_eligible", 2024) + without_pre_tcja.calculate("ny_ctc_pre_2024", 2024) + assert "pre_tcja_ctc" not in without_pre_tcja.branches