From 8e5d8569aa47703f40e066252fc6dca5ff0e9d50 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 14:11:32 -0400 Subject: [PATCH 1/3] Add regressions for pre-2018 AMT child rules --- .../exemption/amt_exemption.yaml | 125 +++++++++++++++++- .../income/amt_income_less_exemptions.yaml | 82 +++++++++++- .../kiddie_tax/amt_kiddie_tax_applies.yaml | 33 ++++- 3 files changed, 232 insertions(+), 8 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/exemption/amt_exemption.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/exemption/amt_exemption.yaml index 12d413489fb..5f2d2b7f233 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/exemption/amt_exemption.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/exemption/amt_exemption.yaml @@ -140,7 +140,7 @@ employment_income: 0 filing_status: JOINT output: - amt_kiddie_tax_applies: true + amt_kiddie_tax_applies: false # 2025 Form 6251 Exemption Worksheet line 1: 137,000 for joint filers. amt_exemption: 137_000 @@ -176,7 +176,7 @@ employment_income: 0 filing_status: JOINT output: - amt_kiddie_tax_applies: true + amt_kiddie_tax_applies: false amt_exemption: 140_200 - name: 2026 single 18-year-old in the phase-out range @@ -191,3 +191,124 @@ # exemption_phase_out = 0.5 * 100,000 = 50,000 # amt_exemption = max(0, 90,100 - 50,000) = 40,100 amt_exemption: 40_100 + +# IRC 1(g)(2)(C), 59(j); 2017 Form 6251 lines 28-35, instructions p. 11: +# https://www.irs.gov/pub/irs-prior/i6251--2017.pdf#page=11 +# The model backdates some 2018 parameters into 2017. Supply the historical +# upstream return amounts to isolate the AMT formulas; wages still compute +# adjusted_earnings and the erroneous child cap, rather than supplying the cap. +# 2017 regular rates: https://www.irs.gov/pub/irs-drop/rp-16-55.pdf#page=7 +# Standard deduction: https://www.irs.gov/pub/irs-drop/rp-16-55.pdf#page=15 +# Personal exemptions: https://www.irs.gov/pub/irs-drop/rp-16-55.pdf#page=18 +- name: A joint return by young spouses receives the ordinary 2017 AMT exemption + absolute_error_margin: 0.01 + period: 2017 + input: + people: + head: + age: 18 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + employment_income: 12_000 + taxable_interest_income: 121_000 + spouse: + age: 17 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + employment_income: 17_000 + tax_units: + unit: + members: [head, spouse] + filing_status: JOINT + tax_unit_itemizes: false + adjusted_gross_income: 150_000 + standard_deduction: 12_700 + taxable_income_deductions: 12_700 + exemptions: 8_100 + regular_tax_before_credits: 23_777.50 + households: + household: + members: [head, spouse] + state_code: TX + output: + # AGI = 12,000 + 17,000 + 121,000 = 150,000. + # Regular taxable income = 150,000 - 12,700 - 2 * 4,050 = 129,200. + # AMTI = 150,000; below joint phase-out threshold 160,900. + # IRC 1(g)(2)(C) excludes joint returns, so the 59(j) cap cannot apply. + # AMT base = 150,000 - 84,500 = 65,500; tentative tax = 17,030. + # Regular tax = 1,865 + 8,587.50 + 13,325 = 23,777.50; AMT = 0. + taxable_income: 129_200 + amt_income: 150_000 + amt_exemption: 84_500 + amt_income_less_exemptions: 65_500 + amt_base_tax: 17_030 + alternative_minimum_tax: 0 + amt_kiddie_tax_applies: false + +# Post-2017 confirmation controls: the SECURE Act override makes the +# historical predicate defect irrelevant to the federal exemption. +- name: Young joint filers receive the full exemption in 2021 + absolute_error_margin: 0.01 + period: 2021 + input: + people: + head: + age: 18 + is_tax_unit_head: true + is_tax_unit_dependent: false + employment_income: 12_000 + spouse: + age: 17 + is_tax_unit_spouse: true + is_tax_unit_dependent: false + employment_income: 17_000 + tax_units: + unit: + members: [head, spouse] + filing_status: JOINT + tax_unit_itemizes: false + households: + household: + members: [head, spouse] + state_code: TX + output: + adjusted_gross_income: 29_000 + taxable_income: 3_900 + amt_income: 29_000 + amt_exemption: 114_600 + amt_income_less_exemptions: 0 + alternative_minimum_tax: 0 + +- name: Young joint filers receive the full exemption in 2022 + absolute_error_margin: 0.01 + period: 2022 + input: + people: + head: + age: 18 + is_tax_unit_head: true + is_tax_unit_dependent: false + employment_income: 12_000 + spouse: + age: 17 + is_tax_unit_spouse: true + is_tax_unit_dependent: false + employment_income: 17_000 + tax_units: + unit: + members: [head, spouse] + filing_status: JOINT + tax_unit_itemizes: false + households: + household: + members: [head, spouse] + state_code: TX + output: + adjusted_gross_income: 29_000 + taxable_income: 3_100 + amt_income: 29_000 + amt_exemption: 118_100 + amt_income_less_exemptions: 0 + alternative_minimum_tax: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.yaml index 2d42b4f2e3c..0166fa4a004 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.yaml @@ -44,7 +44,7 @@ output: amt_income_less_exemptions: 1_000_000 -- name: Kiddie tax filer before 2018 uses regular taxable income and the child limitation +- name: Kiddie tax filer before 2018 uses AMTI and the child limitation period: 2017 input: amt_income: 200_000 @@ -55,8 +55,8 @@ output: # amt_exemption = min(54,300 - 0.25 * (200,000 - 120,700), 10,000 + 7,500) # = min(34,475, 17,500) = 17,500 - # 190,000 - 17,500 = 172,500 - amt_income_less_exemptions: 172_500 + # 200,000 - 17,500 = 182,500 + amt_income_less_exemptions: 182_500 - name: 2025 single 18-year-old with no earnings uses AMTI and the full exemption period: 2025 @@ -95,6 +95,80 @@ employment_income: 0 filing_status: JOINT output: - amt_kiddie_tax_applies: true + amt_kiddie_tax_applies: false # 100,000 is below the 137,000 joint exemption. amt_income_less_exemptions: 0 + +# IRC 55(b), 56(b)(1)(D), 59(j); 2017 Form 6251 lines 28-31: +# https://www.irs.gov/pub/irs-prior/f6251--2017.pdf#page=1 +# Supply sourced 2017 upstream deductions because the model backdates its +# 2018 standard-deduction parameter. The filer is not claimable elsewhere. +# Standard deduction: https://www.irs.gov/pub/irs-drop/rp-16-55.pdf#page=15 +# Personal exemption: https://www.irs.gov/pub/irs-drop/rp-16-55.pdf#page=18 +- name: A child filer uses AMTI rather than regular taxable income before 2018 + absolute_error_margin: 0.01 + period: 2017 + input: + people: + head: + age: 17 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + taxable_interest_income: 50_000 + tax_units: + unit: + members: [head] + filing_status: SINGLE + tax_unit_itemizes: false + adjusted_gross_income: 50_000 + standard_deduction: 6_350 + taxable_income_deductions: 6_350 + exemptions: 4_050 + households: + household: + members: [head] + state_code: TX + output: + # Assume at least one living parent, as required by IRC 1(g)(2)(B). + # Earned income = 0; ordinary single exemption = 54,300. + # Section 59(j) limits ONLY the exemption: min(54,300, 0 + 7,500). + # No AMT deductions: AMTI = 50,000; base = 50,000 - 7,500 = 42,500. + # 26% * 42,500 = 11,050, irrespective of regular-tax deductions. + taxable_income: 39_600 + amt_income: 50_000 + amt_exemption: 7_500 + amt_income_less_exemptions: 42_500 + amt_base_tax: 11_050 + +# Positive-base control distinguishes AMTI from regular taxable income; +# a below-exemption child alone would not detect the wrong base. +- name: A 2025 child filer uses AMTI with the full ordinary exemption + absolute_error_margin: 0.01 + period: 2025 + input: + people: + head: + age: 17 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + taxable_interest_income: 200_000 + tax_units: + unit: + members: [head] + filing_status: SINGLE + tax_unit_itemizes: false + households: + household: + members: [head] + state_code: TX + output: + # 2025 single exemption 88,100; no child cap under IRC 55(d)(4)(A)(iii). + # AMTI 200,000 - 88,100 = 111,900; 26% * 111,900 = 29,094. + # Using regular taxable income would wrongly give 184,250 - 88,100. + taxable_income: 184_250 + amt_income: 200_000 + amt_exemption: 88_100 + amt_income_less_exemptions: 111_900 + amt_base_tax: 29_094 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/kiddie_tax/amt_kiddie_tax_applies.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/kiddie_tax/amt_kiddie_tax_applies.yaml index 2a8ebeebdf4..5ced3a87fc4 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/kiddie_tax/amt_kiddie_tax_applies.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/kiddie_tax/amt_kiddie_tax_applies.yaml @@ -3,6 +3,7 @@ period: 2022 input: age_head: 18 + filing_status: SINGLE output: amt_kiddie_tax_applies: true @@ -14,13 +15,14 @@ output: amt_kiddie_tax_applies: false -- name: Kiddie tax applies when both head and spouse under 19 +- name: Kiddie tax does not apply to a joint return when both spouses are under 19 period: 2022 input: age_head: 18 age_spouse: 18 + filing_status: JOINT output: - amt_kiddie_tax_applies: true + amt_kiddie_tax_applies: false - name: Kiddie tax does not apply when head is 19+ (non-student) period: 2022 @@ -104,3 +106,30 @@ members: [person1, person2] output: amt_kiddie_tax_applies: false + +- name: A young separate filer remains subject to the child limitation before 2018 + period: 2017 + input: + age_head: 18 + filing_status: SEPARATE + output: + amt_kiddie_tax_applies: true + +- name: Young full-time students filing jointly are excluded before 2018 + period: 2017 + input: + people: + head: + age: 22 + is_full_time_college_student: true + is_tax_unit_head: true + spouse: + age: 23 + is_full_time_college_student: true + is_tax_unit_spouse: true + tax_units: + unit: + members: [head, spouse] + filing_status: JOINT + output: + amt_kiddie_tax_applies: false From 7b01cbff09da0347fe16c3e711116cce00c5de90 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 14:23:29 -0400 Subject: [PATCH 2/3] Fix historical child AMT base and joint-return exemption --- changelog.d/amt-child-pre-2018.fixed.md | 1 + .../income/amt_income_less_exemptions.yaml | 2 +- .../test_post_2017_child_amt_invariance.py | 202 ++++++++++++++++++ .../income/amt_income_less_exemptions.py | 24 +-- .../kiddie_tax/amt_kiddie_tax_applies.py | 7 +- 5 files changed, 219 insertions(+), 17 deletions(-) create mode 100644 changelog.d/amt-child-pre-2018.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/test_post_2017_child_amt_invariance.py diff --git a/changelog.d/amt-child-pre-2018.fixed.md b/changelog.d/amt-child-pre-2018.fixed.md new file mode 100644 index 00000000000..dc65b06dd9d --- /dev/null +++ b/changelog.d/amt-child-pre-2018.fixed.md @@ -0,0 +1 @@ +Correct pre-2018 AMT for child filers by applying the limited exemption to AMT income and excluding joint returns from the child exemption cap. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.yaml index 0166fa4a004..c514d5d8849 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.yaml @@ -70,7 +70,7 @@ amt_kiddie_tax_applies: true amt_exemption: 88_100 # 2025 Form 6251 line 6 = line 4 - line 5 = 200,000 - 88,100. - # The pre-2018 child treatment would give 190,000 - 9,550 = 180,450. + # Applying a child cap would give 200,000 - 9,550 = 190,450. amt_income_less_exemptions: 111_900 - name: 2026 single 18-year-old with no earnings uses AMTI and the full exemption diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/test_post_2017_child_amt_invariance.py b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/test_post_2017_child_amt_invariance.py new file mode 100644 index 00000000000..24080e56065 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/test_post_2017_child_amt_invariance.py @@ -0,0 +1,202 @@ +"""The historical child AMT fixes leave post-2017 AMT unchanged. + +Compare the model to both formulas as they stood before these fixes, rather +than assuming that equal AMTI and regular taxable income conceal a change. +Section 55(d)(4)(A)(iii) disapplies section 59(j) from 2018 onward. +""" + +from itertools import product + +import hypothesis +import numpy as np +import pytest +from hypothesis import strategies as st + +from policyengine_core.reforms import Reform +from policyengine_core.variables import Variable +from policyengine_us import Simulation +from policyengine_us.system import system + +STATUSES = ( + "SINGLE", + "JOINT", + "SEPARATE", + "HEAD_OF_HOUSEHOLD", + "SURVIVING_SPOUSE", +) +OUTPUTS = ( + "amt_exemption", + "amt_income_less_exemptions", + "amt_base_tax", + "alternative_minimum_tax", +) + + +class PreviousChildAmtFormulas(Reform): + """Restore the two formulas from main before the historical fixes.""" + + def apply(self): + class amt_kiddie_tax_applies(Variable): + def formula(tax_unit, period, parameters): + p = parameters(period).gov.irs.dependent.ineligible_age + person = tax_unit.members + is_student = person("is_full_time_student", period) + head_is_student = tax_unit.any( + is_student & person("is_tax_unit_head", period) + ) + spouse_is_student = tax_unit.any( + is_student & person("is_tax_unit_spouse", period) + ) + age_head = tax_unit("age_head", period) + age_spouse = tax_unit("age_spouse", period) + head_age_limit = np.where(head_is_student, p.student, p.non_student) + spouse_age_limit = np.where(spouse_is_student, p.student, p.non_student) + young_head = (age_head != 0) & (age_head < head_age_limit) + no_or_young_spouse = (age_spouse == 0) | (age_spouse < spouse_age_limit) + return young_head & no_or_young_spouse + + class amt_income_less_exemptions(Variable): + def formula(tax_unit, period, parameters): + p = parameters(period).gov.irs.income.amt + amt_income = tax_unit("amt_income", period) + if p.exemption.child.in_effect: + applied_income = np.where( + tax_unit("amt_kiddie_tax_applies", period), + tax_unit("taxable_income", period), + amt_income, + ) + else: + applied_income = amt_income + return np.maximum(0, applied_income - tax_unit("amt_exemption", period)) + + self.update_variable(amt_kiddie_tax_applies) + self.update_variable(amt_income_less_exemptions) + + +@pytest.fixture(scope="module") +def previous_system(): + return PreviousChildAmtFormulas(system) + + +def boundary_cases(year): + """Include every status, age branch, phaseout stage, and tax comparison.""" + p = system.parameters(str(year)).gov.irs.income.amt.exemption + for status, (age, student) in product( + STATUSES, ((17, False), (18, False), (22, True), (40, False)) + ): + exemption = p.amount[status] + phaseout_start = p.phase_out.start[status] + incomes = ( + 0, + 50_000, + exemption + 100_000, + phaseout_start, + phaseout_start + exemption / (2 * p.phase_out.rate), + phaseout_start + exemption / p.phase_out.rate + 1, + ) + for income, regular_tax_fraction in product(incomes, (0, 0.2, 1)): + yield { + "status": status, + "age": age, + "student": student, + "amt_income": income, + "deductions": 25_000, + "regular_tax": income * regular_tax_fraction, + } + + +def situation(cases, year): + people, tax_units, households = {}, {}, {} + for i, case in enumerate(cases): + joint = case["status"] == "JOINT" + members = [f"head_{i}"] + for role in ("head", "spouse") if joint else ("head",): + person_id = f"{role}_{i}" + people[person_id] = { + "age": {year: case["age"]}, + "is_full_time_student": {year: case["student"]}, + "is_tax_unit_head": {year: role == "head"}, + "is_tax_unit_spouse": {year: role == "spouse"}, + "is_tax_unit_dependent": {year: False}, + } + if role == "spouse": + members.append(person_id) + tax_units[f"unit_{i}"] = { + "members": members, + "filing_status": {year: case["status"]}, + "age_head": {year: case["age"]}, + "age_spouse": {year: case["age"] if joint else 0}, + "amt_income": {year: case["amt_income"]}, + "taxable_income": {year: max(0, case["amt_income"] - case["deductions"])}, + "regular_tax_before_credits": {year: case["regular_tax"]}, + "amt_part_iii_required": {year: False}, + "amt_tax_including_cg": {year: 0}, + "foreign_earned_income_exclusion": {year: 0}, + "foreign_tax_credit_potential": {year: 0}, + "form_4972_lumpsum_distributions": {year: 0}, + } + households[f"household_{i}"] = { + "members": members, + "state_code": {year: "TX"}, + } + return {"people": people, "tax_units": tax_units, "households": households} + + +case_strategy = st.fixed_dictionaries( + { + "status": st.sampled_from(STATUSES), + "age": st.integers(17, 40), + "student": st.booleans(), + "amt_income": st.integers(0, 3_000_000), + "deductions": st.integers(0, 100_000), + "regular_tax": st.integers(0, 1_000_000), + } +) + + +@hypothesis.settings(max_examples=8, deadline=None, derandomize=True) +@hypothesis.given( + year=st.integers(2018, 2030), + extra_cases=st.lists(case_strategy, max_size=8), +) +@hypothesis.example(year=2018, extra_cases=[]) +@hypothesis.example(year=2019, extra_cases=[]) +@hypothesis.example(year=2020, extra_cases=[]) +@hypothesis.example(year=2021, extra_cases=[]) +@hypothesis.example(year=2022, extra_cases=[]) +@hypothesis.example(year=2023, extra_cases=[]) +@hypothesis.example(year=2024, extra_cases=[]) +@hypothesis.example(year=2025, extra_cases=[]) +@hypothesis.example(year=2026, extra_cases=[]) +def test_post_2017_amt_is_unchanged(previous_system, year, extra_cases): + cases = [*boundary_cases(year), *extra_cases] + inputs = situation(cases, year) + current = Simulation(tax_benefit_system=system, situation=inputs) + previous = Simulation(tax_benefit_system=previous_system, situation=inputs) + + for variable in OUTPUTS: + np.testing.assert_array_equal( + current.calculate(variable, year), + previous.calculate(variable, year), + err_msg=f"{variable} changed in {year}", + ) + + # Confirm the joint-return fix actually changes eligibility in this vector, + # while the statutory post-2017 switch keeps all AMT outputs identical. + eligibility_changed = current.calculate( + "amt_kiddie_tax_applies", year + ) != previous.calculate("amt_kiddie_tax_applies", year) + assert eligibility_changed.any() + filing_status = current.calculate("filing_status", year) + assert np.all(filing_status.decode_to_str()[eligibility_changed] == "JOINT") + + exemption = current.calculate("amt_exemption", year) + base_exemption = system.parameters(str(year)).gov.irs.income.amt.exemption.amount[ + filing_status + ] + assert np.any(exemption == base_exemption) + assert np.any((exemption > 0) & (exemption < base_exemption)) + assert np.any(exemption == 0) + amt = current.calculate("alternative_minimum_tax", year) + assert np.any(amt == 0) + assert np.any(amt > 0) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.py b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.py index 05d9a5c8670..d77d0c1105b 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.py @@ -7,33 +7,27 @@ class amt_income_less_exemptions(Variable): definition_period = YEAR label = "Alternative Minimum Tax Income less exemptions" unit = USD - documentation = "Alternative Minimum Tax (AMT) income less exemptions" + documentation = ( + "AMT income less the exemption, including the limited child exemption " + "under IRC 59(j) before 2018." + ) reference = [ "https://www.law.cornell.edu/uscode/text/26/55#b_1_B", + "https://www.law.cornell.edu/uscode/text/26/59#j", "https://www.law.cornell.edu/uscode/text/26/55#d_4_A_iii", + "https://www.irs.gov/pub/irs-prior/i6251--2017.pdf#page=10", "https://www.irs.gov/pub/irs-pdf/i6251.pdf#page=9", ] def formula(tax_unit, period, parameters): - p = parameters(period).gov.irs.income.amt # Form 6251, Part I # Line 4 amt_income = tax_unit("amt_income", period) - if p.exemption.child.in_effect: - # For filers subject to the kiddie tax, the deductions are not - # added back - taxable_income = tax_unit("taxable_income", period) - kiddie_tax_applies = tax_unit("amt_kiddie_tax_applies", period) - applied_income = where(kiddie_tax_applies, taxable_income, amt_income) - else: - # IRC 55(d)(4)(A)(iii) turns off the child limitation for taxable - # years beginning after 2017; line 6 is line 4 less line 5 for - # every filer. - applied_income = amt_income # Form 6251, Part II top # Line 5 amt_exemption = tax_unit("amt_exemption", period) - # Line 6 - return max_(0, applied_income - amt_exemption) + # Line 6 (2017 lines 28-30): IRC 59(j) limits only the exemption, + # not the AMTI base. amt_exemption applies that cap before 2018. + return max_(0, amt_income - amt_exemption) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/kiddie_tax/amt_kiddie_tax_applies.py b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/kiddie_tax/amt_kiddie_tax_applies.py index 48785e4a738..26b07ed8708 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/kiddie_tax/amt_kiddie_tax_applies.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/kiddie_tax/amt_kiddie_tax_applies.py @@ -13,6 +13,8 @@ class amt_kiddie_tax_applies(Variable): ) reference = [ "https://www.law.cornell.edu/uscode/text/26/1#g_2_A", + "https://www.law.cornell.edu/uscode/text/26/1#g_2_C", + "https://www.irs.gov/pub/irs-prior/i6251--2017.pdf#page=11", "https://www.irs.gov/pub/irs-prior/p929--2021.pdf", ] @@ -46,4 +48,7 @@ def formula(tax_unit, period, parameters): # Check spouse age: spouse must be young (or not exist - age == 0) no_or_young_spouse = (age_spouse == 0) | (age_spouse < spouse_age_limit) - return young_head & no_or_young_spouse + # IRC 1(g)(2)(C) excludes children filing joint returns. + filing_status = tax_unit("filing_status", period) + joint = filing_status == filing_status.possible_values.JOINT + return young_head & no_or_young_spouse & ~joint From e7b010e8098c18291405c962718f6395868ada6b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 18:24:31 -0400 Subject: [PATCH 3/3] Check all post-2017 years in each AMT property example --- .../test_post_2017_child_amt_invariance.py | 113 +++++++++++------- 1 file changed, 69 insertions(+), 44 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/test_post_2017_child_amt_invariance.py b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/test_post_2017_child_amt_invariance.py index 24080e56065..ba8a136424e 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/test_post_2017_child_amt_invariance.py +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/test_post_2017_child_amt_invariance.py @@ -30,6 +30,7 @@ "amt_base_tax", "alternative_minimum_tax", ) +YEARS = tuple(range(2018, 2031)) class PreviousChildAmtFormulas(Reform): @@ -142,61 +143,85 @@ def situation(cases, year): return {"people": people, "tax_units": tax_units, "households": households} +def multi_year_situation(extra_cases): + """Merge annual inputs while keeping each cohort's membership fixed.""" + inputs = {"people": {}, "tax_units": {}, "households": {}} + for year_index, year in enumerate(YEARS): + cases = [ + *boundary_cases(year), + *( + { + "status": case["status"], + "age": case["age"], + "student": case["student"], + **case["finances"][year_index], + } + for case in extra_cases + ), + ] + for entity_type, entities in situation(cases, year).items(): + for entity_id, fields in entities.items(): + merged = inputs[entity_type].setdefault(entity_id, {}) + for variable, value in fields.items(): + if variable == "members": + assert merged.setdefault(variable, value) == value + else: + merged.setdefault(variable, {}).update(value) + return inputs + + +financial_strategy = st.fixed_dictionaries( + { + "amt_income": st.integers(0, 3_000_000), + "deductions": st.integers(0, 100_000), + "regular_tax": st.integers(0, 1_000_000), + } +) case_strategy = st.fixed_dictionaries( { "status": st.sampled_from(STATUSES), "age": st.integers(17, 40), "student": st.booleans(), - "amt_income": st.integers(0, 3_000_000), - "deductions": st.integers(0, 100_000), - "regular_tax": st.integers(0, 1_000_000), + "finances": st.lists( + financial_strategy, min_size=len(YEARS), max_size=len(YEARS) + ), } ) -@hypothesis.settings(max_examples=8, deadline=None, derandomize=True) +@hypothesis.settings(max_examples=3, deadline=None, derandomize=True) @hypothesis.given( - year=st.integers(2018, 2030), - extra_cases=st.lists(case_strategy, max_size=8), + extra_cases=st.lists(case_strategy, min_size=1, max_size=4), ) -@hypothesis.example(year=2018, extra_cases=[]) -@hypothesis.example(year=2019, extra_cases=[]) -@hypothesis.example(year=2020, extra_cases=[]) -@hypothesis.example(year=2021, extra_cases=[]) -@hypothesis.example(year=2022, extra_cases=[]) -@hypothesis.example(year=2023, extra_cases=[]) -@hypothesis.example(year=2024, extra_cases=[]) -@hypothesis.example(year=2025, extra_cases=[]) -@hypothesis.example(year=2026, extra_cases=[]) -def test_post_2017_amt_is_unchanged(previous_system, year, extra_cases): - cases = [*boundary_cases(year), *extra_cases] - inputs = situation(cases, year) +def test_post_2017_amt_is_unchanged(previous_system, extra_cases): + inputs = multi_year_situation(extra_cases) current = Simulation(tax_benefit_system=system, situation=inputs) previous = Simulation(tax_benefit_system=previous_system, situation=inputs) - for variable in OUTPUTS: - np.testing.assert_array_equal( - current.calculate(variable, year), - previous.calculate(variable, year), - err_msg=f"{variable} changed in {year}", - ) - - # Confirm the joint-return fix actually changes eligibility in this vector, - # while the statutory post-2017 switch keeps all AMT outputs identical. - eligibility_changed = current.calculate( - "amt_kiddie_tax_applies", year - ) != previous.calculate("amt_kiddie_tax_applies", year) - assert eligibility_changed.any() - filing_status = current.calculate("filing_status", year) - assert np.all(filing_status.decode_to_str()[eligibility_changed] == "JOINT") - - exemption = current.calculate("amt_exemption", year) - base_exemption = system.parameters(str(year)).gov.irs.income.amt.exemption.amount[ - filing_status - ] - assert np.any(exemption == base_exemption) - assert np.any((exemption > 0) & (exemption < base_exemption)) - assert np.any(exemption == 0) - amt = current.calculate("alternative_minimum_tax", year) - assert np.any(amt == 0) - assert np.any(amt > 0) + for year in YEARS: + for variable in OUTPUTS: + np.testing.assert_array_equal( + current.calculate(variable, year), + previous.calculate(variable, year), + err_msg=f"{variable} changed in {year}", + ) + + # Confirm the joint-return fix actually changes eligibility in this + # vector, while the statutory switch keeps post-2017 AMT identical. + eligibility_changed = current.calculate( + "amt_kiddie_tax_applies", year + ) != previous.calculate("amt_kiddie_tax_applies", year) + assert eligibility_changed.any() + filing_status = current.calculate("filing_status", year) + assert np.all(filing_status.decode_to_str()[eligibility_changed] == "JOINT") + + exemption = current.calculate("amt_exemption", year) + base_exemption = system.parameters( + str(year) + ).gov.irs.income.amt.exemption.amount[filing_status] + assert np.any(exemption == base_exemption) + assert np.any((exemption > 0) & (exemption < base_exemption)) + assert np.any(exemption == 0) + amt = current.calculate("alternative_minimum_tax", year) + assert np.any(amt == 0) + assert np.any(amt > 0)