From 76a7d238880f9ba865c64eb097ad509ffc1640c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:48:22 +0000 Subject: [PATCH 1/2] Keep inputs when NY and AL recompute federal credits under pinned rules ny_eitc (TY2021), al_federal_income_tax_deduction (TY2021), ny_ctc_pre_2024 and ny_ctc_pre_2024_eligible each deleted the branch's arrays for every variable whose name matched a substring list. Core's Simulation.delete_arrays deletes inputs too, so takes_up_eitc and eitc_passive_income_also_in_earned_income fell back to their defaults in the branch: a 2021 New York EITC non-taker still got the state credit. Add drop_recomputed_values to period_branch, which applies the same name selection through drop_inherited_values and so keeps every array set_input stored, and use it in all four formulas. A supplied value for a recomputed formula variable now carries into the branch too; two YAML cases that relied on the branch discarding a supplied eitc or ctc no longer supply it. Co-Authored-By: Claude Opus 5.5 --- ...it-recompute-branches-keep-inputs.fixed.md | 1 + ...t_credit_recompute_branches_keep_inputs.py | 86 +++++++++++++++++++ .../al_federal_income_tax_deduction.yaml | 59 ++++++++++++- .../states/ny/tax/income/credits/ny_ctc.yaml | 3 +- .../states/ny/tax/income/credits/ny_eitc.yaml | 50 +++++++++++ policyengine_us/tools/period_branch.py | 27 +++++- .../al_federal_income_tax_deduction.py | 25 +++--- .../tax/income/credits/ctc/ny_ctc_pre_2024.py | 10 +-- .../credits/ctc/ny_ctc_pre_2024_eligible.py | 10 +-- .../states/ny/tax/income/credits/ny_eitc.py | 7 +- 10 files changed, 245 insertions(+), 33 deletions(-) create mode 100644 changelog.d/credit-recompute-branches-keep-inputs.fixed.md create mode 100644 policyengine_us/tests/core/test_credit_recompute_branches_keep_inputs.py diff --git a/changelog.d/credit-recompute-branches-keep-inputs.fixed.md b/changelog.d/credit-recompute-branches-keep-inputs.fixed.md new file mode 100644 index 00000000000..d55a640801e --- /dev/null +++ b/changelog.d/credit-recompute-branches-keep-inputs.fixed.md @@ -0,0 +1 @@ +Keep inputs such as EITC take-up when New York and Alabama recompute federal credits under pinned rules, so a 2021 New York filer who does not take up the federal EITC no longer gets the New York EITC. diff --git a/policyengine_us/tests/core/test_credit_recompute_branches_keep_inputs.py b/policyengine_us/tests/core/test_credit_recompute_branches_keep_inputs.py new file mode 100644 index 00000000000..d3653a7d82e --- /dev/null +++ b/policyengine_us/tests/core/test_credit_recompute_branches_keep_inputs.py @@ -0,0 +1,86 @@ +"""Pinned-rules credit branches read the inputs their parent was given. + +Four formulas recompute a federal credit under pinned rules in a branch: +``ny_eitc`` (pre-ARPA EITC, TY2021), ``al_federal_income_tax_deduction`` +(2020 IRC, TY2021), and ``ny_ctc_pre_2024`` and ``ny_ctc_pre_2024_eligible`` +(pre-TCJA CTC). Each drops the branch's values for the variables it +recalculates, chosen by name. Deleting them with ``Simulation.delete_arrays`` +also deleted inputs with matching names, so the branch fell back to their +defaults: an EITC non-taker got the New York EITC, for example. + +For each branch, the test supplies an input whose name matches the branch's +selection and checks the state output follows the input, not its default. +YAML can't separate the two readings for the CTC branches, because no plain +input has "ctc" in its name; there the input is a supplied value for a +formula variable, which the branch must keep too. +""" + +import pytest + +from policyengine_us import Simulation + + +def _parent_and_child(year, state, wages, child_age, tax_unit=None): + tax_unit = dict(tax_unit or {}) + return { + "people": { + "parent": { + "age": {year: 35}, + "employment_income": {year: wages}, + }, + "child": {"age": {year: child_age}}, + }, + "tax_units": { + "tax_unit": {"members": ["parent", "child"], **tax_unit}, + }, + "spm_units": {"spm_unit": {"members": ["parent", "child"]}}, + "households": { + "household": { + "members": ["parent", "child"], + "state_code": {year: state}, + } + }, + } + + +def _calculate(situation, variable, year): + return float(Simulation(situation=situation).calculate(variable, year)[0]) + + +@pytest.mark.parametrize("takes_up", [True, False]) +def test_ny_pre_arpa_eitc_branch_reads_takes_up_eitc(takes_up): + situation = _parent_and_child( + 2021, "NY", 10_000, 3, {"takes_up_eitc": {2021: takes_up}} + ) + # Pre-ARPA EITC: 34% x $10,000 = $3,400; NY matches 30% = $1,020. + expected = 1_020 if takes_up else 0 + assert _calculate(situation, "ny_eitc", 2021) == pytest.approx(expected) + + +@pytest.mark.parametrize("takes_up, expected", [(True, 258.87), (False, 380)]) +def test_al_2020_irc_branch_reads_takes_up_eitc(takes_up, expected): + # Part II: $2,380 federal tax - $2,000 nonrefundable 2020-rule CTC = $380, + # less the 2020-rule EITC of $121.13 only when the filer takes it up. + situation = _parent_and_child( + 2021, "AL", 41_000, 3, {"takes_up_eitc": {2021: takes_up}} + ) + assert _calculate( + situation, "al_federal_income_tax_deduction", 2021 + ) == pytest.approx(expected, abs=0.01) + + +def test_ny_pre_tcja_ctc_eligibility_branch_reads_supplied_qualifying_child(): + situation = _parent_and_child(2023, "NY", 30_000, 6) + assert _calculate(situation, "ny_ctc_pre_2024_eligible", 2023) == 1 + situation["people"]["child"]["ctc_qualifying_child"] = {2023: False} + assert _calculate(situation, "ny_ctc_pre_2024_eligible", 2023) == 0 + + +def test_ny_pre_tcja_ctc_branch_reads_supplied_ctc(): + situation = _parent_and_child(2023, "NY", 30_000, 6) + # Pre-TCJA CTC $1,000 for one child; NY pays 33% = $330. + assert _calculate(situation, "ny_ctc_pre_2024", 2023) == pytest.approx(330) + # A supplied federal CTC of zero carries into the pinned recomputation, + # leaving the $100-per-child minimum. + situation["tax_units"]["tax_unit"]["ctc"] = {2023: 0} + assert _calculate(situation, "ny_ctc_pre_2024", 2023) == pytest.approx(100) diff --git a/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/al_federal_income_tax_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/al_federal_income_tax_deduction.yaml index 3587902612d..61002806163 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/al_federal_income_tax_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/al_federal_income_tax_deduction.yaml @@ -74,10 +74,11 @@ # - Part II line-6 floor applies BEFORE adding NIIT, protecting the $200 NIIT. # - Part II subtracts only the refundable AOC (0.4 * 200 = 80), not the full $200. # Childless -> the 2020-IRC branch recomputes CTC/CDCC/EITC to 0 and there are - # no non-refundable add-backs. + # no non-refundable add-backs. The EITC is calculated, not supplied: a + # supplied eitc carries into the 2020-IRC branch too. # Hand arithmetic: # federal_tax = 100; niit = 200 - # Part I = max(100 - eitc(200) - full_AOC(200) - refundable_ctc(0), 0) = 0 + # Part I = max(100 - eitc(0) - full_AOC(200) - refundable_ctc(0), 0) = 0 # regular_tax_basis = 100 - 200 = -100 # line_6 = max(-100, 0) = 0 <- floor before NIIT # refundable_AOC = 0.4 * 200 = 80 @@ -90,7 +91,6 @@ input: income_tax_before_refundable_credits: 100 net_investment_income_tax: 200 - eitc: 200 american_opportunity_credit: 200 state_code: AL output: @@ -286,3 +286,56 @@ dependent_care_assistance_exclusion: 9_000 cdcc: 3_500 al_federal_income_tax_deduction: 4_390 + +- name: 2021 head of household, one child - Part II recomputes the EITC only for a taker + period: 2021 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + employment_income: 41_000 + child: + age: 3 + tax_units: + tax_unit: + members: [parent, child] + takes_up_eitc: true + households: + household: + members: [parent, child] + state_code: AL + output: + # Federal tax: $41,000 - $18,800 standard deduction = $22,200 taxable; + # 10% x 14,200 + 12% x 8,000 = $2,380. + income_tax_before_refundable_credits: 2_380 + # Part I: 2,380 - 3,600 refundable CTC - 185.50 EITC < 0, so 0. + # Part II (2020 IRC): 2,380 - 2,000 nonrefundable 2020 CTC = 380 (line 6); + # 2020 EITC 3,584 - 15.98% x (41,000 - 19,330) = 121.13; the 2020 CTC is + # all nonrefundable here, so 380 - 121.13 = 258.87. + al_federal_income_tax_deduction: 258.87 + +- name: 2021 head of household, one child, no EITC take-up - Part II recomputes no EITC + period: 2021 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + employment_income: 41_000 + child: + age: 3 + tax_units: + tax_unit: + members: [parent, child] + takes_up_eitc: false + households: + household: + members: [parent, child] + state_code: AL + output: + eitc: 0 + # Part I: 2,380 - 3,600 refundable CTC < 0, so 0. Part II: line 6 is + # 380 and the 2020-rule EITC is zero for a filer who does not take it + # up, so 380. + al_federal_income_tax_deduction: 380 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_ctc.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_ctc.yaml index 3b265ec581f..2d3d74a47d4 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_ctc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_ctc.yaml @@ -319,7 +319,8 @@ tax_unit: members: [parent, child] adjusted_gross_income: 50_000 - ctc: 0 + # The federal CTC is calculated, not supplied: a supplied ctc + # carries into the pre-TCJA recomputation too. households: household: members: [parent, child] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_eitc.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_eitc.yaml index 56bdd57f9fc..e4dda61f788 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_eitc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_eitc.yaml @@ -226,3 +226,53 @@ adjusted_gross_income: 41_500 ny_recomputed_federal_agi: 41_800 ny_eitc: 0 + +- name: 2021 NY EITC for a parent who takes up the federal EITC + period: 2021 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + employment_income: 10_000 + child: + age: 3 + tax_units: + tax_unit: + members: [parent, child] + takes_up_eitc: true + households: + household: + members: [parent, child] + state_code: NY + output: + # Tax Law 606(d) and the 2021 IT-215 instructions: 30% of the federal + # credit computed without ARPA. One child: 34% phase-in x $10,000 = + # $3,400, under the $3,618 maximum. 30% x 3,400 = $1,020, less the + # household credit capped at NY tax before credits, which is zero. + eitc: 3_400 + ny_income_tax_before_credits: 0 + ny_eitc: 1_020 + +- name: 2021 NY EITC is zero for a parent who does not take up the federal EITC + period: 2021 + input: + people: + parent: + age: 35 + employment_income: 10_000 + child: + age: 3 + tax_units: + tax_unit: + members: [parent, child] + takes_up_eitc: false + households: + household: + members: [parent, child] + state_code: NY + output: + # The pre-ARPA recomputation reads the same take-up input, so the + # recomputed federal credit, and 30% of it, is zero. + eitc: 0 + ny_eitc: 0 diff --git a/policyengine_us/tools/period_branch.py b/policyengine_us/tools/period_branch.py index 93d598229bb..df1aefc5f94 100644 --- a/policyengine_us/tools/period_branch.py +++ b/policyengine_us/tools/period_branch.py @@ -28,7 +28,8 @@ ``get_branch_for_period`` is the same with no inputs, for branches that change the tax-benefit system rather than inputs: the caller swaps the system -and deletes the variables it recalculates. +and drops the values of the variables it recalculates with +``drop_recomputed_values``, which keeps inputs. """ from typing import Dict, Iterable, Optional, Set, Tuple, Union @@ -176,6 +177,27 @@ def drop_inherited_values( branch._fast_cache = {} +def drop_recomputed_values(branch: Simulation, name_parts: Iterable[str]) -> None: + """Drop formula results for variables a pinned-rules branch recalculates. + + A branch that swaps in a pinned tax-benefit system recalculates every + variable whose name contains one of ``name_parts``. Its inherited values + for those variables are dropped, but inputs are kept, so the branch reads + the same take-up flags and other supplied values as its parent. Deleting + with ``Simulation.delete_arrays`` would delete those inputs too, and the + branch would fall back to their defaults. + """ + name_parts = tuple(name_parts) + drop_inherited_values( + branch, + [ + variable + for variable in branch.tax_benefit_system.variables + if any(part in variable for part in name_parts) + ], + ) + + def get_override_branch( simulation: Simulation, name: str, @@ -239,7 +261,8 @@ def get_branch_for_period( """Return ``simulation``'s branch ``name`` for ``period``, created per period. For branches that change the tax-benefit system rather than inputs: the - caller swaps the system and deletes the variables it recalculates. Within + caller swaps the system and drops the variables it recalculates with + ``drop_recomputed_values``. Within a period the branch is shared; a branch left from another period is dropped and created again from the parent as it stands now. """ 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 02c98792366..ba496628c72 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 @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.period_branch import drop_recomputed_values from policyengine_us.tools.pinned_tbs import ( CREDIT_DEPENDENT_VARIABLES, IRC_2020_RECOMPUTED_VARIABLES, @@ -60,18 +61,18 @@ def formula(tax_unit, period, parameters): simulation = tax_unit.simulation 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", - *CREDIT_DEPENDENT_VARIABLES, - *IRC_2020_RECOMPUTED_VARIABLES, - ) - ): - branch.delete_arrays(variable) + # Recalculate the credits under the 2020 IRC, but keep inputs such as + # takes_up_eitc. + drop_recomputed_values( + branch, + [ + "ctc", + "cdcc", + "eitc", + *CREDIT_DEPENDENT_VARIABLES, + *IRC_2020_RECOMPUTED_VARIABLES, + ], + ) recomputed_ctc = branch.tax_unit("ctc", period) recomputed_refundable_ctc = branch.tax_unit("refundable_ctc", period) # Under the 2020 IRC the CDCC is non-refundable (limited by tax), so the 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 9a8de7bd691..d7ab1311576 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 @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.period_branch import drop_recomputed_values from policyengine_us.tools.pinned_tbs import ( CREDIT_DEPENDENT_VARIABLES, get_pre_tcja_ctc_tbs, @@ -53,12 +54,9 @@ def formula(tax_unit, period, parameters): simulation.tax_benefit_system ) branch_parameters = pre_tcja_ctc.tax_benefit_system.parameters - # Delete all arrays from pre-TCJA CTC branch. - for variable in pre_tcja_ctc.tax_benefit_system.variables: - if "ctc" in variable or any( - name in variable for name in CREDIT_DEPENDENT_VARIABLES - ): - pre_tcja_ctc.delete_arrays(variable) + # Recalculate every CTC variable under the pinned rules, but keep + # inputs. + drop_recomputed_values(pre_tcja_ctc, ["ctc", *CREDIT_DEPENDENT_VARIABLES]) # Calculate pre-TCJA CTC. IT-213 Worksheet A, line 1 counts every # child on form line 4, including children under the NY minimum # age; lines 9-12 below prorate the result. 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 3381ec65067..344180a7e50 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 @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.period_branch import drop_recomputed_values from policyengine_us.tools.pinned_tbs import ( CREDIT_DEPENDENT_VARIABLES, get_pre_tcja_ctc_tbs, @@ -42,12 +43,9 @@ def formula(tax_unit, period, parameters): pre_tcja_ctc.tax_benefit_system = get_pre_tcja_ctc_tbs( simulation.tax_benefit_system ) - # Delete all arrays from pre-TCJA CTC branch. - for variable in pre_tcja_ctc.tax_benefit_system.variables: - if "ctc" in variable or any( - name in variable for name in CREDIT_DEPENDENT_VARIABLES - ): - pre_tcja_ctc.delete_arrays(variable) + # Recalculate every CTC variable under the pinned rules, but keep + # inputs. + drop_recomputed_values(pre_tcja_ctc, ["ctc", *CREDIT_DEPENDENT_VARIABLES]) qualifies_for_federal_ctc = pre_tcja_ctc.person( "ctc_qualifying_child", period ) 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 c2abee94cf2..082218e4238 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 @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.period_branch import drop_recomputed_values from policyengine_us.tools.pinned_tbs import get_pre_arpa_eitc_tbs from policyengine_us.variables.gov.states.ny.tax.income.adjusted_gross_income.ny_recomputed_federal_agi import ( get_ny_recomputed_branch, @@ -33,9 +34,9 @@ def formula(tax_unit, period, parameters): branch.tax_benefit_system = get_pre_arpa_eitc_tbs( simulation.tax_benefit_system ) - for variable in branch.tax_benefit_system.variables: - if "eitc" in variable: - branch.delete_arrays(variable) + # Recalculate every EITC variable under the pinned rules, but + # keep inputs such as takes_up_eitc. + drop_recomputed_values(branch, ["eitc"]) federal_eitc = branch.tax_unit("eitc", period) else: # IT-215: the federal credit recomputed with recomputed federal From 65b1bf6216700ee2835972151eb1574d731ca152 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:47:33 +0000 Subject: [PATCH 2/2] Recompute supplied formula variables in pinned-rules credit branches A supplied eitc, ctc or ctc_qualifying_child is the current-law amount, which the pre-ARPA, 2020-IRC and pre-TCJA branches must replace, so drop_recomputed_values now deletes every formula variable in its selection (supplied or not, as main did) and keeps only plain inputs such as takes_up_eitc, which the pinned rules don't change. Restore the supplied eitc and ctc inputs the previous head removed from the AL and NY CTC YAML cases, add order-dependent NY cases that list the current-law federal credit before the state credit, and replace the Python test file with YAML cases. Co-Authored-By: Claude Opus 5.5 --- ...it-recompute-branches-keep-inputs.fixed.md | 2 +- ...t_credit_recompute_branches_keep_inputs.py | 86 ------------------- .../al_federal_income_tax_deduction.yaml | 6 +- .../states/ny/tax/income/credits/ny_ctc.yaml | 3 +- .../tax/income/credits/ny_ctc_pre_2024.yaml | 55 ++++++++++++ .../states/ny/tax/income/credits/ny_eitc.yaml | 54 ++++++++++++ policyengine_us/tools/period_branch.py | 32 +++---- .../al_federal_income_tax_deduction.py | 3 +- .../tax/income/credits/ctc/ny_ctc_pre_2024.py | 4 +- .../credits/ctc/ny_ctc_pre_2024_eligible.py | 4 +- .../states/ny/tax/income/credits/ny_eitc.py | 5 +- 11 files changed, 137 insertions(+), 117 deletions(-) delete mode 100644 policyengine_us/tests/core/test_credit_recompute_branches_keep_inputs.py diff --git a/changelog.d/credit-recompute-branches-keep-inputs.fixed.md b/changelog.d/credit-recompute-branches-keep-inputs.fixed.md index d55a640801e..ee83378701b 100644 --- a/changelog.d/credit-recompute-branches-keep-inputs.fixed.md +++ b/changelog.d/credit-recompute-branches-keep-inputs.fixed.md @@ -1 +1 @@ -Keep inputs such as EITC take-up when New York and Alabama recompute federal credits under pinned rules, so a 2021 New York filer who does not take up the federal EITC no longer gets the New York EITC. +Keep plain inputs such as EITC take-up when New York and Alabama recompute federal credits under pinned rules, so a 2021 New York filer who does not take up the federal EITC no longer gets the New York EITC. diff --git a/policyengine_us/tests/core/test_credit_recompute_branches_keep_inputs.py b/policyengine_us/tests/core/test_credit_recompute_branches_keep_inputs.py deleted file mode 100644 index d3653a7d82e..00000000000 --- a/policyengine_us/tests/core/test_credit_recompute_branches_keep_inputs.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Pinned-rules credit branches read the inputs their parent was given. - -Four formulas recompute a federal credit under pinned rules in a branch: -``ny_eitc`` (pre-ARPA EITC, TY2021), ``al_federal_income_tax_deduction`` -(2020 IRC, TY2021), and ``ny_ctc_pre_2024`` and ``ny_ctc_pre_2024_eligible`` -(pre-TCJA CTC). Each drops the branch's values for the variables it -recalculates, chosen by name. Deleting them with ``Simulation.delete_arrays`` -also deleted inputs with matching names, so the branch fell back to their -defaults: an EITC non-taker got the New York EITC, for example. - -For each branch, the test supplies an input whose name matches the branch's -selection and checks the state output follows the input, not its default. -YAML can't separate the two readings for the CTC branches, because no plain -input has "ctc" in its name; there the input is a supplied value for a -formula variable, which the branch must keep too. -""" - -import pytest - -from policyengine_us import Simulation - - -def _parent_and_child(year, state, wages, child_age, tax_unit=None): - tax_unit = dict(tax_unit or {}) - return { - "people": { - "parent": { - "age": {year: 35}, - "employment_income": {year: wages}, - }, - "child": {"age": {year: child_age}}, - }, - "tax_units": { - "tax_unit": {"members": ["parent", "child"], **tax_unit}, - }, - "spm_units": {"spm_unit": {"members": ["parent", "child"]}}, - "households": { - "household": { - "members": ["parent", "child"], - "state_code": {year: state}, - } - }, - } - - -def _calculate(situation, variable, year): - return float(Simulation(situation=situation).calculate(variable, year)[0]) - - -@pytest.mark.parametrize("takes_up", [True, False]) -def test_ny_pre_arpa_eitc_branch_reads_takes_up_eitc(takes_up): - situation = _parent_and_child( - 2021, "NY", 10_000, 3, {"takes_up_eitc": {2021: takes_up}} - ) - # Pre-ARPA EITC: 34% x $10,000 = $3,400; NY matches 30% = $1,020. - expected = 1_020 if takes_up else 0 - assert _calculate(situation, "ny_eitc", 2021) == pytest.approx(expected) - - -@pytest.mark.parametrize("takes_up, expected", [(True, 258.87), (False, 380)]) -def test_al_2020_irc_branch_reads_takes_up_eitc(takes_up, expected): - # Part II: $2,380 federal tax - $2,000 nonrefundable 2020-rule CTC = $380, - # less the 2020-rule EITC of $121.13 only when the filer takes it up. - situation = _parent_and_child( - 2021, "AL", 41_000, 3, {"takes_up_eitc": {2021: takes_up}} - ) - assert _calculate( - situation, "al_federal_income_tax_deduction", 2021 - ) == pytest.approx(expected, abs=0.01) - - -def test_ny_pre_tcja_ctc_eligibility_branch_reads_supplied_qualifying_child(): - situation = _parent_and_child(2023, "NY", 30_000, 6) - assert _calculate(situation, "ny_ctc_pre_2024_eligible", 2023) == 1 - situation["people"]["child"]["ctc_qualifying_child"] = {2023: False} - assert _calculate(situation, "ny_ctc_pre_2024_eligible", 2023) == 0 - - -def test_ny_pre_tcja_ctc_branch_reads_supplied_ctc(): - situation = _parent_and_child(2023, "NY", 30_000, 6) - # Pre-TCJA CTC $1,000 for one child; NY pays 33% = $330. - assert _calculate(situation, "ny_ctc_pre_2024", 2023) == pytest.approx(330) - # A supplied federal CTC of zero carries into the pinned recomputation, - # leaving the $100-per-child minimum. - situation["tax_units"]["tax_unit"]["ctc"] = {2023: 0} - assert _calculate(situation, "ny_ctc_pre_2024", 2023) == pytest.approx(100) diff --git a/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/al_federal_income_tax_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/al_federal_income_tax_deduction.yaml index 61002806163..1d7847bfa54 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/al_federal_income_tax_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/al_federal_income_tax_deduction.yaml @@ -74,11 +74,10 @@ # - Part II line-6 floor applies BEFORE adding NIIT, protecting the $200 NIIT. # - Part II subtracts only the refundable AOC (0.4 * 200 = 80), not the full $200. # Childless -> the 2020-IRC branch recomputes CTC/CDCC/EITC to 0 and there are - # no non-refundable add-backs. The EITC is calculated, not supplied: a - # supplied eitc carries into the 2020-IRC branch too. + # no non-refundable add-backs. # Hand arithmetic: # federal_tax = 100; niit = 200 - # Part I = max(100 - eitc(0) - full_AOC(200) - refundable_ctc(0), 0) = 0 + # Part I = max(100 - eitc(200) - full_AOC(200) - refundable_ctc(0), 0) = 0 # regular_tax_basis = 100 - 200 = -100 # line_6 = max(-100, 0) = 0 <- floor before NIIT # refundable_AOC = 0.4 * 200 = 80 @@ -91,6 +90,7 @@ input: income_tax_before_refundable_credits: 100 net_investment_income_tax: 200 + eitc: 200 american_opportunity_credit: 200 state_code: AL output: diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_ctc.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_ctc.yaml index 2d3d74a47d4..3b265ec581f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_ctc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_ctc.yaml @@ -319,8 +319,7 @@ tax_unit: members: [parent, child] adjusted_gross_income: 50_000 - # The federal CTC is calculated, not supplied: a supplied ctc - # carries into the pre-TCJA recomputation too. + ctc: 0 households: household: members: [parent, child] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_ctc_pre_2024.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_ctc_pre_2024.yaml index 7ac587c9883..2ea0c7a1f73 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_ctc_pre_2024.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_ctc_pre_2024.yaml @@ -405,3 +405,58 @@ output: ny_ctc_pre_2024_eligible: false ny_ctc_pre_2024: 0 + +# The pre-TCJA branch copies the parent's cache, so this case lists the +# current-law federal credit first: the branch must recompute it, not read it. +- name: 2023 pre-TCJA credit recomputes the federal CTC after the current-law CTC + period: 2023 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + employment_income: 30_000 + child: + age: 3 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: NY + output: + # Current law: $2,000 per child. + ctc: 2_000 + # Pre-TCJA: $1,000 per child, under the $75,000 head-of-household + # phase-out threshold. Federal tax is (30,000 - 20,800) x 10% = 920, so + # 920 is nonrefundable and the other 80 refundable (15% x (30,000 - + # 3,000) = 4,050 exceeds it): 1,000. 33% x 1,000 = 330, above the $100 + # minimum. The 2023 minimum age is zero, so the child aged 3 counts. + ny_ctc_pre_2024: 330 + +# A supplied ctc_qualifying_child is the current-law determination; the +# pre-TCJA branch recomputes it under the pinned rules. +- name: 2023 pre-TCJA eligibility recomputes a supplied federal qualifying-child flag + period: 2023 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + employment_income: 30_000 + child: + age: 6 + ctc_qualifying_child: false + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: NY + output: + # The child qualifies under the pre-TCJA rules, so the credit is the + # same 33% x 1,000 = 330 as above. + ny_ctc_pre_2024_eligible: true + ny_ctc_pre_2024: 330 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_eitc.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_eitc.yaml index e4dda61f788..3b2b65ff605 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_eitc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/credits/ny_eitc.yaml @@ -276,3 +276,57 @@ # recomputed federal credit, and 30% of it, is zero. eitc: 0 ny_eitc: 0 + +# The pre-ARPA branch copies the parent's cache, so this case lists the +# current-law federal credit first: the branch must recompute it, not read it. +- name: 2021 NY EITC recomputes the federal credit without ARPA after the ARPA credit + period: 2021 + absolute_error_margin: 0.01 + input: + people: + filer: + age: 30 + employment_income: 9_000 + households: + household: + members: [filer] + state_code: NY + output: + # ARPA childless credit (gov.irs.credits.eitc.phase_in_rate and .max, + # 2021, no children): 15.3% x 9,000 = 1,377, under the 1,502 maximum, + # and below the 11,610 ARPA phase-out start. + eitc: 1_377 + # The model's NY tax before credits and household credit for this filer. + ny_income_tax_before_credits: 40 + ny_household_credit: 40 + # Pre-ARPA (Rev. Proc. 2020-45): gov.states.ny.tax.income.credits.eitc + # .pre_arpa childless_max 543 and childless_phase_out_start 8,880, and + # the 2020 childless phase-out rate of 7.65% (eitc.phase_out.rate): + # 543 - 7.65% x (9,000 - 8,880) = 533.82; 30% = 160.146, less the + # household credit of 40 (capped at NY tax of 40) = 120.15. + ny_eitc: 120.15 + +# A supplied eitc is the current-law (ARPA) amount; the pre-ARPA branch +# recomputes the federal credit rather than reading it. +- name: 2021 NY EITC recomputes the federal credit when the ARPA credit is supplied + period: 2021 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + employment_income: 10_000 + child: + age: 3 + tax_units: + tax_unit: + members: [parent, child] + eitc: 500 + households: + household: + members: [parent, child] + state_code: NY + output: + # Pre-ARPA one-child credit: 34% x 10,000 = 3,400, under the 3,618 + # maximum; 30% = 1,020, less a household credit capped at NY tax of 0. + ny_eitc: 1_020 diff --git a/policyengine_us/tools/period_branch.py b/policyengine_us/tools/period_branch.py index df1aefc5f94..f81448ca771 100644 --- a/policyengine_us/tools/period_branch.py +++ b/policyengine_us/tools/period_branch.py @@ -28,8 +28,8 @@ ``get_branch_for_period`` is the same with no inputs, for branches that change the tax-benefit system rather than inputs: the caller swaps the system -and drops the values of the variables it recalculates with -``drop_recomputed_values``, which keeps inputs. +and drops the values of the formula variables it recalculates with +``drop_recomputed_values``, which keeps plain inputs. """ from typing import Dict, Iterable, Optional, Set, Tuple, Union @@ -178,24 +178,20 @@ def drop_inherited_values( def drop_recomputed_values(branch: Simulation, name_parts: Iterable[str]) -> None: - """Drop formula results for variables a pinned-rules branch recalculates. + """Drop the values of formula variables a pinned-rules branch recalculates. A branch that swaps in a pinned tax-benefit system recalculates every - variable whose name contains one of ``name_parts``. Its inherited values - for those variables are dropped, but inputs are kept, so the branch reads - the same take-up flags and other supplied values as its parent. Deleting - with ``Simulation.delete_arrays`` would delete those inputs too, and the - branch would fall back to their defaults. + formula variable whose name contains one of ``name_parts``, including one + the caller supplied: a supplied value is the current-law amount, which the + pinned rules replace. Plain inputs (no formula), such as ``takes_up_eitc``, + are facts the pinned rules don't change, so they are kept. """ name_parts = tuple(name_parts) - drop_inherited_values( - branch, - [ - variable - for variable in branch.tax_benefit_system.variables - if any(part in variable for part in name_parts) - ], - ) + for name, variable in branch.tax_benefit_system.variables.items(): + if any(part in name for part in name_parts) and ( + variable.formulas or variable.adds or variable.subtracts + ): + branch.delete_arrays(name) def get_override_branch( @@ -261,8 +257,8 @@ def get_branch_for_period( """Return ``simulation``'s branch ``name`` for ``period``, created per period. For branches that change the tax-benefit system rather than inputs: the - caller swaps the system and drops the variables it recalculates with - ``drop_recomputed_values``. Within + caller swaps the system and drops the formula variables it recalculates + with ``drop_recomputed_values``. Within a period the branch is shared; a branch left from another period is dropped and created again from the parent as it stands now. """ 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 ba496628c72..d7c710f9c51 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 @@ -61,7 +61,8 @@ def formula(tax_unit, period, parameters): simulation = tax_unit.simulation branch = get_branch_for_period(simulation, "al_2020_irc", period) branch.tax_benefit_system = get_2020_irc_tbs(simulation.tax_benefit_system) - # Recalculate the credits under the 2020 IRC, but keep inputs such as + # Recalculate the credits' formula variables under the 2020 IRC, a + # supplied current-law value included; keep plain inputs such as # takes_up_eitc. drop_recomputed_values( branch, 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 d7ab1311576..ed95653cc32 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 @@ -54,8 +54,8 @@ def formula(tax_unit, period, parameters): simulation.tax_benefit_system ) branch_parameters = pre_tcja_ctc.tax_benefit_system.parameters - # Recalculate every CTC variable under the pinned rules, but keep - # inputs. + # Recalculate every CTC formula variable under the pinned rules, a + # supplied current-law value included. drop_recomputed_values(pre_tcja_ctc, ["ctc", *CREDIT_DEPENDENT_VARIABLES]) # Calculate pre-TCJA CTC. IT-213 Worksheet A, line 1 counts every # child on form line 4, including children under the NY minimum 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 344180a7e50..9fbe2773061 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 @@ -43,8 +43,8 @@ def formula(tax_unit, period, parameters): pre_tcja_ctc.tax_benefit_system = get_pre_tcja_ctc_tbs( simulation.tax_benefit_system ) - # Recalculate every CTC variable under the pinned rules, but keep - # inputs. + # Recalculate every CTC formula variable under the pinned rules, a + # supplied current-law value included. drop_recomputed_values(pre_tcja_ctc, ["ctc", *CREDIT_DEPENDENT_VARIABLES]) qualifies_for_federal_ctc = pre_tcja_ctc.person( "ctc_qualifying_child", period 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 082218e4238..eb430ea9c13 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 @@ -34,8 +34,9 @@ def formula(tax_unit, period, parameters): branch.tax_benefit_system = get_pre_arpa_eitc_tbs( simulation.tax_benefit_system ) - # Recalculate every EITC variable under the pinned rules, but - # keep inputs such as takes_up_eitc. + # Recalculate every EITC formula variable under the pinned rules, + # a supplied current-law eitc included; keep plain inputs such as + # takes_up_eitc. drop_recomputed_values(branch, ["eitc"]) federal_eitc = branch.tax_unit("eitc", period) else: