From fcecf1f6f6115d53977edb895b0c75cb1854b9d9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 13:49:56 -0400 Subject: [PATCH 1/6] Keep dependents' above-the-line deductions off the filer's AGI irs_gross_income leaves tax unit dependents' income off the filer's return, but self_employment_tax_ald, self_employed_health_insurance_ald and self_employed_pension_contribution_ald summed every member's person-level amount, and above_the_line_deductions summed every member's IRA contributions, early withdrawal penalty, educator expenses, adoption assistance and education bond interest. A dependent's Schedule SE and Schedule 1 deductions belong on the dependent's own return. - The three self-employment aggregates sum their _person variables over non-dependents; alimony_expense_ald masks dependents. - above_the_line_deductions sums gov.irs.ald.deductions with tax_unit_non_dep_add. - Readers that sum the ALD list themselves do the same: taxable_ss_magi, taxable_uc_agi, student_loan_interest_ald_magi, adjusted_gross_income_person and ma_part_b_agi. - mo_adjusted_gross_income takes personal ALDs over non-dependents, so its remaining-ALD allocation never goes negative. - medicaid_adjusted_gross_income_person attributes a dependent's own person-level deductions to the dependent instead of the head. - ma_gross_income_loss_adjustment leaves out dependents' losses, as the federal gross income it adjusts does. The _person variables keep each person's own amount, which a dependent's own return (dependent_taxable_ss_magi) uses. Co-Authored-By: Claude Opus 5.5 --- .../fix-dependent-ald-filer-agi.fixed.md | 1 + .../tests/core/test_ald_determinism.py | 12 +- ...nt_above_the_line_deductions_invariants.py | 396 ++++++++++++++++++ ...medicaid_adjusted_gross_income_person.yaml | 33 ++ .../dependent_above_the_line_deductions.yaml | 241 +++++++++++ .../adjusted_gross_income/ma_part_b_agi.yaml | 26 ++ .../ma_gross_income_loss_adjustment.yaml | 26 ++ .../mo_adjusted_gross_income.yaml | 28 ++ .../medicaid_adjusted_gross_income_person.py | 15 +- .../above_the_line_deductions.py | 13 +- .../alimony_expense_ald.py | 4 +- .../self_employed_health_insurance_ald.py | 10 +- .../self_employed_pension_contribution_ald.py | 15 +- .../self_employment_tax_ald.py | 17 +- .../student_loan_interest_ald_magi.py | 2 +- .../adjusted_gross_income_person.py | 7 +- .../social_security/taxable_ss_magi.py | 3 +- .../unemployment_insurance/taxable_uc_agi.py | 5 +- .../adjusted_gross_income/ma_part_b_agi.py | 4 +- .../ma_gross_income_loss_adjustment.py | 16 +- .../mo_adjusted_gross_income.py | 8 +- 21 files changed, 853 insertions(+), 29 deletions(-) create mode 100644 changelog.d/fix-dependent-ald-filer-agi.fixed.md create mode 100644 policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependent_above_the_line_deductions.yaml diff --git a/changelog.d/fix-dependent-ald-filer-agi.fixed.md b/changelog.d/fix-dependent-ald-filer-agi.fixed.md new file mode 100644 index 00000000000..a00da41f5ff --- /dev/null +++ b/changelog.d/fix-dependent-ald-filer-agi.fixed.md @@ -0,0 +1 @@ +Keep tax unit dependents' above-the-line deductions (the deductible part of self-employment tax, self-employed health insurance and retirement plans, IRA contributions, early withdrawal penalties, educator expenses, alimony paid, adoption assistance and education bond interest) off the filer's AGI, Social Security and unemployment MAGIs and per-person AGI, and off the Missouri, Massachusetts and Medicaid AGIs built from them; also keep dependents' business losses out of Massachusetts gross income. diff --git a/policyengine_us/tests/core/test_ald_determinism.py b/policyengine_us/tests/core/test_ald_determinism.py index 3ef4ff22cca..082e610c11e 100644 --- a/policyengine_us/tests/core/test_ald_determinism.py +++ b/policyengine_us/tests/core/test_ald_determinism.py @@ -85,13 +85,19 @@ class ReachedTaxUnitSum(Exception): person = Person() def capture_add(entity, period, variables): - if entity is person.tax_unit: - captured.extend(variables) - raise ReachedTaxUnitSum + # Tax-unit deductions must be summed over non-dependents only, through + # tax_unit_non_dep_add, never with a plain add over every member. + assert entity is person assert variables == [f"{name}_person" for name in PERSON_ALDS] return np.array([0], dtype=np.float32) + def capture_tax_unit_add(entity, period, variables): + assert entity is person.tax_unit + captured.extend(variables) + raise ReachedTaxUnitSum + monkeypatch.setattr(module, "add", capture_add) + monkeypatch.setattr(module, "tax_unit_non_dep_add", capture_tax_unit_add) with pytest.raises(ReachedTaxUnitSum): getattr(module, variable_name).formula(person, 2026, lambda period: parameters) diff --git a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py new file mode 100644 index 00000000000..7f5bc0bc4bf --- /dev/null +++ b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py @@ -0,0 +1,396 @@ +"""Invariants for dependents' above-the-line deductions in the filer's AGI. + +A tax unit dependent's deductions belong on the dependent's own return: their +Schedule SE and Schedule 1 deductions (the deductible part of self-employment +tax, self-employed health insurance and retirement plans, the IRA deduction, +the penalty on early withdrawal of savings, educator expenses, alimony paid) +are figured with the dependent's own income. `irs_gross_income` already +leaves dependents' income off the filer's return, so every sum of +above-the-line deductions over a tax unit must leave dependents out too. + +Hypothesis draws batches of tax units (single, head of household with +dependents, joint with and without dependents, in Texas, Missouri and +Massachusetts), and a seeded population of 200 such units adds breadth. Each +batch runs as one vectorized simulation, twice: with +the dependents' deduction inputs as drawn and with them set to zero. For +every tax unit: + +1. The dependents' deduction inputs never change the filer's AGI, + above-the-line deductions, any tax-unit deduction aggregate, the Social + Security and unemployment compensation MAGIs, taxable Social Security, + Massachusetts gross income and Part B AGI, or the head's and spouse's + per-person AGI, + student loan interest MAGI and deduction, Medicaid AGI and Missouri AGI. +2. Accounting identities: the per-person AGIs of the members sum to the tax + unit's AGI, and so do the head's and spouse's Medicaid AGIs. +3. Differential: `above_the_line_deductions` and each aggregate equal an + independent numpy sum over the head and spouse. For units without + dependents this is also the previous all-member sum, so they see no change. +4. Each person's own deduction amounts are still computed for dependents + (half of each person's self-employment tax, for example), so a + dependent's own return can use them, and a dependent's Medicaid AGI is + their own gross income less their own person-level deductions. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars + +# Person-level inputs behind the deductions a dependent could claim on their +# own return. +DEPENDENT_DEDUCTION_INPUTS = [ + "self_employment_income", + "self_employed_health_insurance_premiums", + "self_employed_pension_contributions", + "traditional_ira_contributions", + "early_withdrawal_penalty", + "educator_expense", + "alimony_expense", + "qualified_adoption_assistance_expense", + "us_bonds_for_higher_ed", + "student_loan_interest", +] +# Person-level deduction amounts that make up above_the_line_deductions, +# other than loss_ald and alimony_expense_ald. +PERSON_DEDUCTIONS = [ + "self_employment_tax_ald_person", + "self_employed_health_insurance_ald_person", + "self_employed_pension_contribution_ald_person", + "student_loan_interest_ald", + "early_withdrawal_penalty", + "educator_expense", + "traditional_ira_contributions", + "qualified_adoption_assistance_expense", + "us_bonds_for_higher_ed", +] +AGGREGATES = { + "self_employment_tax_ald": "self_employment_tax_ald_person", + "self_employed_health_insurance_ald": "self_employed_health_insurance_ald_person", + "self_employed_pension_contribution_ald": "self_employed_pension_contribution_ald_person", +} +TAX_UNIT_OUTPUTS = [ + "adjusted_gross_income", + "above_the_line_deductions", + *AGGREGATES, + "alimony_expense_ald", + "loss_ald", + "taxable_ss_magi", + "taxable_uc_agi", + "tax_unit_taxable_social_security", + "ma_gross_income", + "ma_part_b_agi", +] +# Per-person outputs whose head and spouse values must not depend on the +# dependents' deductions. +FILER_PERSON_OUTPUTS = [ + "adjusted_gross_income_person", + "student_loan_interest_ald_magi", + "student_loan_interest_ald", + "medicaid_adjusted_gross_income_person", + "mo_adjusted_gross_income", +] +PERSON_OUTPUTS = [ + *FILER_PERSON_OUTPUTS, + *PERSON_DEDUCTIONS, + "self_employment_tax", + "alimony_expense", + "divorce_year", + "medicaid_irs_gross_income", + "irs_gross_income", +] + +amount = st.integers(0, 40_000).map(float) +small = st.one_of(st.just(0.0), st.integers(1, 7_000).map(float)) + + +@st.composite +def person_amounts(draw, *, dependent): + has = st.booleans() + return { + "employment_income": draw(st.one_of(st.just(0.0), amount)), + # Losses are loss_ald's business; draw mostly gains with some losses. + "self_employment_income": ( + draw(st.integers(-10_000, 60_000).map(float)) if draw(has) else 0.0 + ), + "self_employed_health_insurance_premiums": draw(small), + "self_employed_pension_contributions": draw(small), + "traditional_ira_contributions": draw(small), + "early_withdrawal_penalty": draw(st.integers(0, 500).map(float)), + "educator_expense": draw(st.integers(0, 300).map(float)), + "alimony_expense": draw(small), + "divorce_year": draw(st.sampled_from([2010, 2018, 2020])), + "qualified_adoption_assistance_expense": draw(small), + "us_bonds_for_higher_ed": draw(small), + "student_loan_interest": draw(st.integers(0, 3_000).map(float)), + "taxable_interest_income": draw(st.integers(0, 2_000).map(float)), + "social_security_retirement": ( + 0.0 if dependent else draw(st.one_of(st.just(0.0), amount)) + ), + "taxable_private_pension_income": ( + 0.0 if dependent else draw(st.one_of(st.just(0.0), amount)) + ), + "unemployment_compensation": draw( + st.one_of(st.just(0.0), st.integers(1, 15_000).map(float)) + ), + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "hoh", "joint", "joint_dependents"])) + n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 + return { + "state": draw(st.sampled_from(["TX", "MO", "MA"])), + "head": draw(person_amounts(dependent=False)), + "spouse": ( + draw(person_amounts(dependent=False)) if kind.startswith("joint") else None + ), + "dependents": [ + { + "age": draw(st.integers(5, 23)), + **draw(person_amounts(dependent=True)), + } + for _ in range(n_dependents) + ], + } + + +SEED = 20261005 + + +def _seeded_amounts(rng, *, dependent): + def some(high, p=0.5): + return float(round(rng.uniform(1, high))) if rng.random() < p else 0.0 + + return { + "employment_income": some(40_000, 0.6), + "self_employment_income": ( + float(round(rng.uniform(-10_000, 60_000))) if rng.random() < 0.6 else 0.0 + ), + "self_employed_health_insurance_premiums": some(7_000), + "self_employed_pension_contributions": some(7_000), + "traditional_ira_contributions": some(7_000), + "early_withdrawal_penalty": some(500), + "educator_expense": some(300), + "alimony_expense": some(7_000, 0.3), + "divorce_year": int(rng.choice([2010, 2018, 2020])), + "qualified_adoption_assistance_expense": some(7_000, 0.2), + "us_bonds_for_higher_ed": some(7_000, 0.2), + "student_loan_interest": some(3_000), + "taxable_interest_income": some(2_000), + "social_security_retirement": 0.0 if dependent else some(40_000, 0.3), + "taxable_private_pension_income": 0.0 if dependent else some(40_000, 0.3), + "unemployment_compensation": some(15_000, 0.2), + } + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + units = [] + for _ in range(n): + kind = rng.choice(["single", "hoh", "joint", "joint_dependents"]) + n_dependents = ( + int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + ) + units.append( + { + "state": str(rng.choice(["TX", "MO", "MA"])), + "head": _seeded_amounts(rng, dependent=False), + "spouse": ( + _seeded_amounts(rng, dependent=False) + if kind.startswith("joint") + else None + ), + "dependents": [ + { + "age": int(rng.integers(5, 24)), + **_seeded_amounts(rng, dependent=True), + } + for _ in range(n_dependents) + ], + } + ) + return units + + +def _situation(units, year, *, zero_dependents): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + for i, u in enumerate(units): + head = f"head_{i}" + people[head] = { + "age": 50, + "is_tax_unit_head": True, + "is_tax_unit_spouse": False, + "is_tax_unit_dependent": False, + **u["head"], + } + members, couple = [head], [head] + if u["spouse"] is not None: + spouse = f"spouse_{i}" + people[spouse] = { + "age": 48, + "is_tax_unit_head": False, + "is_tax_unit_spouse": True, + "is_tax_unit_dependent": False, + **u["spouse"], + } + members.append(spouse) + couple.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": couple} + for j, d in enumerate(u["dependents"]): + child = f"dependent_{i}_{j}" + amounts = {k: v for k, v in d.items() if k != "age"} + if zero_dependents: + amounts.update({name: 0.0 for name in DEPENDENT_DEDUCTION_INPUTS}) + people[child] = { + "age": d["age"], + "is_full_time_student": d["age"] >= 19, + "is_tax_unit_head": False, + "is_tax_unit_spouse": False, + "is_tax_unit_dependent": True, + **amounts, + } + members.append(child) + groups["marital_units"][f"single_{i}_{j}"] = {"members": [child]} + groups["tax_units"][f"tax_unit_{i}"] = {"members": members} + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: u["state"]}, + } + people = { + name: {k: {year: v} for k, v in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _run(units, year, *, zero_dependents): + sim = Simulation(situation=_situation(units, year, zero_dependents=zero_dependents)) + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in TAX_UNIT_OUTPUTS + PERSON_OUTPUTS + } + out["filing_status"] = np.asarray(sim.calculate("filing_status", year)) + out["is_dependent"] = np.asarray( + sim.calculate("is_tax_unit_dependent", year), dtype=bool + ) + out["unit"] = sim.populations["tax_unit"].members_entity_id + return out + + +def _unit_sum(run, values): + return np.bincount( + run["unit"], weights=values, minlength=len(run["adjusted_gross_income"]) + ) + + +def _check(units, year): + run = _run(units, year, zero_dependents=False) + zeroed = _run(units, year, zero_dependents=True) + filer = ~run["is_dependent"] + has_dependent = _unit_sum(run, run["is_dependent"].astype(float)) > 0 + + # 1. Dependents' deduction inputs never reach the filer's return. + assert (run["filing_status"] == zeroed["filing_status"]).all() + for name in TAX_UNIT_OUTPUTS: + np.testing.assert_allclose( + run[name], zeroed[name], atol=TOLERANCE, err_msg=name + ) + for name in FILER_PERSON_OUTPUTS: + np.testing.assert_allclose( + run[name][filer], zeroed[name][filer], atol=TOLERANCE, err_msg=name + ) + + # 2. Accounting identities. + np.testing.assert_allclose( + _unit_sum(run, run["adjusted_gross_income_person"]), + run["adjusted_gross_income"], + atol=TOLERANCE, + ) + np.testing.assert_allclose( + _unit_sum(run, filer * run["medicaid_adjusted_gross_income_person"]), + run["adjusted_gross_income"], + atol=TOLERANCE, + ) + + # 3. Differential against numpy sums over the head and spouse. Alimony + # is deductible for divorces before 2019 (26 USC 215, repealed by Pub. L. + # 115-97 sec. 11051 for later instruments). + alimony = run["alimony_expense"] * (run["divorce_year"] < 2019) + np.testing.assert_allclose( + run["alimony_expense_ald"], _unit_sum(run, filer * alimony), atol=TOLERANCE + ) + for aggregate, person_amount in AGGREGATES.items(): + np.testing.assert_allclose( + run[aggregate], + _unit_sum(run, filer * run[person_amount]), + atol=TOLERANCE, + err_msg=aggregate, + ) + person_total = sum(run[name] for name in PERSON_DEDUCTIONS) + reference = run["loss_ald"] + _unit_sum(run, filer * (person_total + alimony)) + np.testing.assert_allclose( + run["above_the_line_deductions"], reference, atol=TOLERANCE + ) + # Units without dependents: the previous all-member sum, unchanged. + all_members = run["loss_ald"] + _unit_sum(run, person_total + alimony) + np.testing.assert_allclose( + run["above_the_line_deductions"][~has_dependent], + all_members[~has_dependent], + atol=TOLERANCE, + ) + np.testing.assert_allclose( + run["adjusted_gross_income"], + _unit_sum(run, run["irs_gross_income"]) - reference, + atol=TOLERANCE, + ) + + # 4. Each person's own amounts survive for their own return. + np.testing.assert_allclose( + run["self_employment_tax_ald_person"], + 0.5 * run["self_employment_tax"], + atol=TOLERANCE, + ) + dependent = run["is_dependent"] + own_person_deductions = person_total - run["student_loan_interest_ald"] + np.testing.assert_allclose( + run["medicaid_adjusted_gross_income_person"][dependent], + (run["medicaid_irs_gross_income"] - own_person_deductions)[dependent], + atol=TOLERANCE, + ) + assert (run["student_loan_interest_ald"][dependent] == 0).all() + assert (run["adjusted_gross_income_person"][dependent] == 0).all() + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(tax_units(), min_size=10, max_size=40)) +def test_dependent_deductions_stay_off_the_filers_return_2025(units): + _check(units, 2025) + + +@settings(**SETTINGS) +@given(st.lists(tax_units(), min_size=10, max_size=40)) +def test_dependent_deductions_stay_off_the_filers_return_2020(units): + # 2020 adds the unemployment compensation exclusion, which reads + # taxable_uc_agi, and the tuition and fees deduction. + _check(units, 2020) + + +@pytest.mark.parametrize("year", [2020, 2025]) +def test_seeded_population(year): + _check(_seeded_units(), year) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml index dd5d926b7f3..766f45397c4 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml @@ -38,3 +38,36 @@ output: medicaid_adjusted_gross_income_person: [59_450, 39_450, 5_000] absolute_error_margin: 0.01 + +- name: Case 3, a dependent's own deductions reduce the dependent's AGI, not the head's. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_tax_unit_head: true + employment_income: 50_000 + student: + age: 23 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + employment_income: 6_000 + taxable_interest_income: 500 + traditional_ira_contributions: 2_000 + early_withdrawal_penalty: 100 + educator_expense: 300 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + # Each person's own Form 1040, line 11. Parent: 50,000 - 0. Student: + # line 9 = 6,000 + 500 = 6,500; Schedule 1, line 11 = 300, line 18 = 100, + # line 20 = 2,000, line 26 = 2,400; line 11 = 4,100. + medicaid_adjusted_gross_income_person: [50_000, 4_100] diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependent_above_the_line_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependent_above_the_line_deductions.yaml new file mode 100644 index 00000000000..b8aa750b8a5 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependent_above_the_line_deductions.yaml @@ -0,0 +1,241 @@ +# A tax unit dependent's above-the-line deductions belong on the dependent's +# own return, not the filer's. irs_gross_income already leaves the +# dependent's income off the filer's return; these tests check that the +# dependent's deductions are left off too. Only spouses aggregate on one +# return (26 USC 6013(a)); 26 USC 1(g)(7) moves only a child's interest and +# dividend gross income to a parent's return. Even under that election, the +# Instructions for Form 8814 (2025), p. 2, list "Penalty on early withdrawal +# of child's savings" among the deductions the parent cannot take. +# +# Expected values follow the TY2025 forms, figured in cents: +# - Schedule SE (Form 1040) 2025, lines 2-13, filed by each person with +# self-employment income: +# https://www.irs.gov/pub/irs-prior/f1040sse--2025.pdf +# - Schedule 1 (Form 1040) 2025, Part II, lines 11-26 (adjustments): +# https://www.irs.gov/pub/irs-prior/f1040s1--2025.pdf +# - Form 1040 (2025) lines 9-11, and the Social Security Benefits Worksheet +# and Student Loan Interest Deduction Worksheet in the 2025 Form 1040 +# instructions: https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=32 +# and #page=99. +# The model does not round Schedule SE lines to cents, so the margin is 0.01. + +- name: Child's Schedule C income and self-employment tax stay off the head of household parent's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 17 + self_employment_income: 10_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + is_tax_unit_dependent: [false, true] + # Child's own Schedule SE: line 2 = line 3 = 10,000; line 4a = line 6 = + # 10,000 x 0.9235 = 9,235.00; line 9 = 176,100 (no wages); line 10 = + # 9,235 x 0.124 = 1,145.14; line 11 = 9,235 x 0.029 = 267.82; + # line 12 = 1,412.96; line 13 = 1,412.96 x 0.50 = 706.48. Line 13 goes on + # the child's own Schedule 1, line 15. + self_employment_tax: [0, 1_412.96] + self_employment_tax_ald_person: [0, 706.48] + # Parent's Schedule 1, line 15: no self-employment tax of the parent's. + self_employment_tax_ald: 0 + # Parent's Schedule 1, line 26, and Form 1040, line 10. + above_the_line_deductions: 0 + # Parent's Form 1040: line 1a = line 9 = 40,000; line 11 = 40,000 - 0. + adjusted_gross_income: 40_000 + adjusted_gross_income_person: [40_000, 0] + +- name: Spouse's self-employment deductions stay on the joint return and the child's do not + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 44 + self_employment_income: 20_000 + child: + age: 16 + self_employment_income: 8_000 + self_employed_health_insurance_premiums: 1_000 + self_employed_pension_contributions: 500 + tax_units: + tax_unit: + members: [head, spouse, child] + households: + household: + members: [head, spouse, child] + state_code: TX + output: + filing_status: JOINT + is_tax_unit_dependent: [false, false, true] + # Spouse's Schedule SE: line 2 = 20,000; line 4a = line 6 = 18,470.00; + # line 10 = 18,470 x 0.124 = 2,290.28; line 11 = 18,470 x 0.029 = + # 535.63; line 12 = 2,825.91; line 13 = 1,412.96 (1,412.955 unrounded). + # Child's own Schedule SE: line 2 = 8,000; line 4a = line 6 = 7,388.00; + # line 10 = 916.11; line 11 = 214.25; line 12 = 1,130.36; + # line 13 = 565.18. The child's line 13, health insurance (1,000) and + # SEP contribution (500) go on the child's own Schedule 1, lines 15-17. + self_employment_tax: [0, 2_825.91, 1_130.36] + self_employment_tax_ald_person: [0, 1_412.96, 565.18] + self_employed_health_insurance_ald_person: [0, 0, 1_000] + self_employed_pension_contribution_ald_person: [0, 0, 500] + # Joint return's Schedule 1: line 15 = the spouse's 1,412.96 only; + # lines 16 and 17 = 0; line 26 = 1,412.96. + self_employment_tax_ald: 1_412.96 + self_employed_pension_contribution_ald: 0 + self_employed_health_insurance_ald: 0 + above_the_line_deductions: 1_412.96 + # Joint Form 1040: line 1a = 60,000; Schedule 1, line 3 = 20,000; line 9 + # = 80,000; line 10 = 1,412.96; line 11 = 78,587.04. + adjusted_gross_income: 78_587.04 + adjusted_gross_income_person: [60_000, 18_587.04, 0] + +- name: Student dependent's IRA deduction, early withdrawal penalty and educator expenses stay off the parent's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_tax_unit_head: true + employment_income: 50_000 + student: + age: 23 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + employment_income: 6_000 + taxable_interest_income: 500 + traditional_ira_contributions: 2_000 + early_withdrawal_penalty: 100 + educator_expense: 300 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + filing_status: HEAD_OF_HOUSEHOLD + is_tax_unit_dependent: [false, true] + # The student's own Schedule 1: line 11 = 300, line 18 = 100, line 20 = + # 2,000, line 26 = 2,400. The parent's Schedule 1, line 26 = 0. + above_the_line_deductions: 0 + # Parent's Form 1040: line 9 = 50,000; line 10 = 0; line 11 = 50,000. + adjusted_gross_income: 50_000 + adjusted_gross_income_person: [50_000, 0] + +- name: Dependent parent's alimony paid stays off the filer's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + filer: + age: 45 + is_tax_unit_head: true + employment_income: 50_000 + mother: + age: 72 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + alimony_expense: 3_000 + divorce_year: 2010 + tax_units: + tax_unit: + members: [filer, mother] + households: + household: + members: [filer, mother] + state_code: TX + output: + filing_status: HEAD_OF_HOUSEHOLD + # The mother's pre-2019 alimony goes on her own Schedule 1, line 19a. + alimony_expense_ald: 0 + above_the_line_deductions: 0 + adjusted_gross_income: 50_000 + +- name: Grandchild's self-employment tax does not lower the grandparent's taxable Social Security + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 67 + social_security_retirement: 24_000 + taxable_private_pension_income: 20_000 + grandchild: + age: 17 + self_employment_income: 10_000 + tax_units: + tax_unit: + members: [grandparent, grandchild] + households: + household: + members: [grandparent, grandchild] + state_code: TX + output: + filing_status: HEAD_OF_HOUSEHOLD + # Social Security Benefits Worksheet (2025): line 1 = 24,000; line 2 = + # 12,000; line 3 = Form 1040 line 5b = 20,000; line 4 = 0; line 5 = + # 32,000; line 6 = the grandparent's Schedule 1, lines 11-20, 23, 25 = 0 + # (the grandchild's line 15 deduction is on the grandchild's return). + # Line 3 + line 4 - line 6: + taxable_ss_magi: 20_000 + # Line 7 = 32,000; line 8 = 25,000; line 9 = 7,000; line 10 = 9,000; + # line 11 = 0; line 12 = 7,000; line 13 = 3,500; line 14 = 3,500; + # line 15 = 0; line 16 = 3,500; line 17 = 20,400; line 18 = 3,500. + tax_unit_taxable_social_security: 3_500 + # Form 1040: line 9 = 20,000 + 3,500 = 23,500; line 10 = 0; line 11. + adjusted_gross_income: 23_500 + +- name: Dependent's IRA deduction does not lower the parent's student loan interest MAGI + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_tax_unit_head: true + employment_income: 91_000 + student_loan_interest: 2_500 + student: + age: 20 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + employment_income: 6_000 + traditional_ira_contributions: 2_000 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + filing_status: HEAD_OF_HOUSEHOLD + # Student Loan Interest Deduction Worksheet (2025): line 1 = 2,500; + # line 2 = Form 1040 line 9 = 91,000; line 3 = the parent's Schedule 1, + # lines 11-20, 23, 25 = 0; line 4 = 91,000. + student_loan_interest_ald_magi: [91_000, 0] + # Line 5 = 85,000; line 6 = 6,000; line 7 = 6,000 / 15,000 = 0.400; + # line 8 = 1,000; line 9 = 1,500 (Schedule 1, line 21). + student_loan_interest_ald: [1_500, 0] + # Form 1040: line 9 = 91,000; line 10 = 1,500; line 11 = 89,500. + adjusted_gross_income: 89_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.yaml index 838ec7604af..e350f4cb091 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.yaml @@ -7,3 +7,29 @@ traditional_ira_contributions_desired: 1_000 output: ma_part_b_agi: 10_000 + +- name: Dependent's self-employed health insurance deduction does not reduce the parent's Part B AGI + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 17 + self_employment_income: 10_000 + self_employed_health_insurance_premiums: 1_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: MA + output: + # Part B gross income starts from federal gross income, which leaves out + # the child's Schedule C income, so the child's self-employed health + # insurance deduction is left out too: it is on the child's own return. + ma_part_b_gross_income: 40_000 + ma_part_b_agi: 40_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/ma_gross_income_loss_adjustment.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/ma_gross_income_loss_adjustment.yaml index f217778eca9..29b26502099 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/ma_gross_income_loss_adjustment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/ma_gross_income_loss_adjustment.yaml @@ -5,3 +5,29 @@ state_code: MA output: ma_gross_income_loss_adjustment: -10_000 + +- name: A dependent's Schedule C loss stays on the dependent's own Form 1 + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 40_000 + rental_income: 2_000 + child: + age: 17 + self_employment_income: -3_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: MA + output: + # Federal gross income leaves the child out, so the child's loss is not + # subtracted on the parent's line 6a: MA total 5.0% income is the + # parent's wages (line 3) plus rental income (line 7). + ma_gross_income_loss_adjustment: 0 + ma_gross_income: 42_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.yaml index 5a918062d68..4a0f71ad81f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.yaml @@ -47,3 +47,31 @@ state_code: MO output: mo_adjusted_gross_income: 0 # AGI cannot be negative + +- name: Dependent's self-employment tax deduction neither reduces nor raises the parent's Missouri AGI + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 17 + self_employment_income: 10_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: MO + output: + # The child's Schedule SE line 13 (706.48) is on the child's own federal + # and Missouri returns. The parent's federal AGI (Form 1040, line 11) is + # 40,000 and has no Missouri additions or subtractions. The parent's + # personal deductions and the tax unit's deductions both leave the child + # out, so the remaining deductions allocated between spouses are 0, not + # -706.48. + adjusted_gross_income: 40_000 + mo_adjusted_gross_income: [40_000, 0] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py index 4b83de2ba8b..0b8562829b4 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py @@ -31,7 +31,18 @@ def formula(person, period, parameters): for ald in all_alds if ald not in TAX_UNIT_AGI_ALDS_WITH_PERSON_LEVEL_EQUIVALENTS ] - ald_sum_taxunit = add(person.tax_unit, period, other_alds) + # The head's and spouse's deductions are shared between them, as on + # their return. A tax unit dependent's own person-level deductions, + # such as their IRA deduction, reduce the dependent's own AGI, since + # the dependent's income is figured on the dependent's own return. + ald_sum_taxunit = tax_unit_non_dep_add(person.tax_unit, period, other_alds) + person_level_other_alds = [ + ald + for ald in other_alds + if person.entity.get_variable(ald, check_existence=True).entity.is_person + ] + is_dependent = person("is_tax_unit_dependent", period) + dependent_own_ald = is_dependent * add(person, period, person_level_other_alds) filing_status = person.tax_unit("filing_status", period) frac = where( filing_status == filing_status.possible_values.JOINT, @@ -40,7 +51,7 @@ def formula(person, period, parameters): ) head_or_spouse = person("is_tax_unit_head_or_spouse", period) shared_ald = head_or_spouse * ald_sum_taxunit * frac - agi = gross_income - ald_sum_person - shared_ald + agi = gross_income - ald_sum_person - dependent_own_ald - shared_ald if parameters(period).gov.contrib.ubi_center.basic_income.taxable: basic_income = person.tax_unit("basic_income", period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions.py index e7b18296389..e184d5d2747 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions.py @@ -7,9 +7,18 @@ class above_the_line_deductions(Variable): label = "Above-the-line deductions" unit = USD documentation = ( - "Deductions applied to reach adjusted gross income from gross income." + "Deductions applied to reach adjusted gross income from gross income " + "(Schedule 1, line 26). A tax unit dependent's own deductions, such as " + "the deductible part of their self-employment tax, their IRA " + "deduction or their penalty on early withdrawal of savings, are on " + "the dependent's own return, as their income is." ) definition_period = YEAR reference = "https://www.law.cornell.edu/uscode/text/26/62" - adds = "gov.irs.ald.deductions" + def formula(tax_unit, period, parameters): + deductions = parameters(period).gov.irs.ald.deductions + # Person-level deductions are summed over the head and spouse only, + # as irs_gross_income sums their income only. Tax-unit-level + # deductions already describe this return. + return tax_unit_non_dep_add(tax_unit, period, deductions) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald.py index 5fede15f1a6..9c7d5d34f6c 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald.py @@ -16,4 +16,6 @@ def formula(tax_unit, period, parameters): alimony_expense = person("alimony_expense", period) p = parameters(period).gov.irs.ald.alimony_expense eligible_person = p.divorce_year_threshold.calc(divorce_year) - return tax_unit.sum(alimony_expense * eligible_person) + # A tax unit dependent who pays alimony deducts it on their own return. + not_dependent = ~person("is_tax_unit_dependent", period) + return tax_unit.sum(alimony_expense * eligible_person * not_dependent) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.py index af773521b85..67cbe52df57 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.py @@ -7,9 +7,15 @@ class self_employed_health_insurance_ald(Variable): label = "Self-employed health insurance ALD" unit = USD documentation = ( - "Above-the-line deduction for self-employed health insurance contributions." + "Above-the-line deduction for the head's and spouse's self-employed " + "health insurance (Schedule 1, line 17). A tax unit dependent who is " + "self-employed takes this deduction, limited to their own earned " + "income, on their own return." ) definition_period = YEAR reference = "https://www.law.cornell.edu/uscode/text/26/162#l" - adds = ["self_employed_health_insurance_ald_person"] + def formula(tax_unit, period, parameters): + return tax_unit_non_dep_sum( + "self_employed_health_insurance_ald_person", tax_unit, period + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.py index 5ac5dcf9496..9281488390d 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.py @@ -7,9 +7,18 @@ class self_employed_pension_contribution_ald(Variable): label = "Self-employed pension contribution ALD" unit = USD documentation = ( - "Above-the-line deduction for self-employed pension plan contributions." + "Above-the-line deduction for the head's and spouse's self-employed " + "SEP, SIMPLE and qualified plan contributions (Schedule 1, line 16). " + "A tax unit dependent who is self-employed takes this deduction on " + "their own return." ) definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/162#l" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/62#a_6", + "https://www.law.cornell.edu/uscode/text/26/404", + ) - adds = ["self_employed_pension_contribution_ald_person"] + def formula(tax_unit, period, parameters): + return tax_unit_non_dep_sum( + "self_employed_pension_contribution_ald_person", tax_unit, period + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_tax_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_tax_ald.py index 9049293d1f6..d0515850b92 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_tax_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_tax_ald.py @@ -6,8 +6,19 @@ class self_employment_tax_ald(Variable): entity = TaxUnit label = "Self-employment tax ALD deduction" unit = USD - documentation = "Above-the-line deduction for self-employment tax" + documentation = ( + "Above-the-line deduction for the deductible part of the head's and " + "spouse's self-employment tax (Schedule 1, line 15). A tax unit " + "dependent figures their own self-employment tax on their own " + "Schedule SE and deducts its deductible part on their own return." + ) definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/164#f" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/164#f", + "https://www.irs.gov/pub/irs-prior/f1040sse--2025.pdf", + ) - adds = ["self_employment_tax_ald_person"] + def formula(tax_unit, period, parameters): + # A dependent's self-employment income is left off this return by + # irs_gross_income, so the deduction for their tax is left off too. + return tax_unit_non_dep_sum("self_employment_tax_ald_person", tax_unit, period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py index d4d6df1142e..deae2072d98 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py @@ -35,7 +35,7 @@ def formula(person, period, parameters): - set(person_alds) - set(p_irs.ald.student_loan_interest.magi.excluded_alds) ) - ald_sum_taxunit = add(person.tax_unit, period, other_alds) + ald_sum_taxunit = tax_unit_non_dep_add(person.tax_unit, period, other_alds) filing_status = person.tax_unit("filing_status", period) joint = filing_status == filing_status.possible_values.JOINT frac = where(joint, 0.5, 1.0) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py index 1ca12f01e0a..1a6911854c2 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py @@ -11,6 +11,9 @@ class adjusted_gross_income_person(Variable): def formula(person, period, parameters): gross_income = person("irs_gross_income", period) + # A tax unit dependent's deductions are on their own return, as + # irs_gross_income leaves their income off this one. + not_dependent = ~person("is_tax_unit_dependent", period) # calculate ald sums by person PERSON_ALDS = [ "self_employment_tax_ald", @@ -18,11 +21,11 @@ def formula(person, period, parameters): "self_employed_pension_contribution_ald", ] person_ald_vars = [f"{ald}_person" for ald in PERSON_ALDS] - ald_sum_person = add(person, period, person_ald_vars) + ald_sum_person = not_dependent * add(person, period, person_ald_vars) # split other alds evenly between head and spouse all_alds = parameters(period).gov.irs.ald.deductions other_alds = sorted(set(all_alds) - set(PERSON_ALDS)) - ald_sum_taxunit = add(person.tax_unit, period, other_alds) + ald_sum_taxunit = tax_unit_non_dep_add(person.tax_unit, period, other_alds) is_head = person("is_tax_unit_head", period) is_spouse = person("is_tax_unit_spouse", period) fstatus = person.tax_unit("filing_status", period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py index 0e8a6beb1fb..c6eeb38f7d6 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py @@ -42,5 +42,6 @@ def formula(tax_unit, period, parameters): for deduction in above_the_line_deductions if deduction not in revoked_deductions ] - total_deductions = add(tax_unit, period, deductions) + # A tax unit dependent's deductions are on their own return. + total_deductions = tax_unit_non_dep_add(tax_unit, period, deductions) return max_(0, gross_income - total_deductions) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/unemployment_insurance/taxable_uc_agi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/unemployment_insurance/taxable_uc_agi.py index cf332450c08..d2dd776c94d 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/unemployment_insurance/taxable_uc_agi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/unemployment_insurance/taxable_uc_agi.py @@ -26,5 +26,8 @@ def formula(tax_unit, period, parameters): gross_income += not_dependent * max_(0, add(person, period, [source])) gross_income = tax_unit.sum(gross_income) above_the_line_deductions = irs.ald.deductions - total_deductions = add(tax_unit, period, above_the_line_deductions) + # A tax unit dependent's deductions are on their own return. + total_deductions = tax_unit_non_dep_add( + tax_unit, period, above_the_line_deductions + ) return max_(0, gross_income - total_deductions) diff --git a/policyengine_us/variables/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.py b/policyengine_us/variables/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.py index fa66c70a8e6..4d55f2a34b8 100644 --- a/policyengine_us/variables/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.py +++ b/policyengine_us/variables/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.py @@ -20,7 +20,9 @@ def formula(tax_unit, period, parameters): for deduction in federal_deductions if deduction not in disallowed_deductions ] - deduction_value = add(tax_unit, period, deductions) + # Part B gross income starts from irs_gross_income, which leaves out + # tax unit dependents, so their deductions are left out too. + deduction_value = tax_unit_non_dep_add(tax_unit, period, deductions) # U.S. government bond interest is exempt from MA tax. us_govt_interest = add(tax_unit, period, ["us_govt_interest"]) return max_( diff --git a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income_loss_adjustment.py b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income_loss_adjustment.py index 720c6be2ced..c09ea512110 100644 --- a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income_loss_adjustment.py +++ b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income_loss_adjustment.py @@ -16,14 +16,20 @@ def formula(tax_unit, period, parameters): # zero, so this variable captures the losses that were dropped. # Line 10 instruction: "Be sure to subtract any losses # in lines 6 or 7." + # irs_gross_income leaves out tax unit dependents, whose items are + # on their own returns, so their losses are left out here too. # Line 6a: Business/profession loss (Schedule C) - se_income = add(tax_unit, period, ["total_self_employment_income"]) + se_income = tax_unit_non_dep_add( + tax_unit, period, ["total_self_employment_income"] + ) # Line 6b: Farm loss (Schedule F) - farm = add(tax_unit, period, ["farm_operations_income"]) + farm = tax_unit_non_dep_add(tax_unit, period, ["farm_operations_income"]) # Line 7: Rental, partnership, S-corp, farm rent losses - rental = add(tax_unit, period, ["rental_income"]) - partnership = add(tax_unit, period, ["partnership_s_corp_income"]) - farm_rent = add(tax_unit, period, ["farm_rent_income"]) + rental = tax_unit_non_dep_add(tax_unit, period, ["rental_income"]) + partnership = tax_unit_non_dep_add( + tax_unit, period, ["partnership_s_corp_income"] + ) + farm_rent = tax_unit_non_dep_add(tax_unit, period, ["farm_rent_income"]) return ( min_(se_income, 0) + min_(farm, 0) diff --git a/policyengine_us/variables/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.py b/policyengine_us/variables/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.py index 12ee4121a06..2c10fab998c 100644 --- a/policyengine_us/variables/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.py +++ b/policyengine_us/variables/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.py @@ -23,8 +23,12 @@ def formula(person, period, parameters): "self_employed_pension_contribution_ald_person", ] tax_unit = person.tax_unit - ind_total_personal_alds = add(person, period, PERSONAL_ALDS) - unit_total_personal_alds = add(tax_unit, period, PERSONAL_ALDS) + # A tax unit dependent's deductions are on their own return, and are + # left out of above_the_line_deductions, as their income is left out + # of irs_gross_income. + not_dependent = ~person("is_tax_unit_dependent", period) + ind_total_personal_alds = not_dependent * add(person, period, PERSONAL_ALDS) + unit_total_personal_alds = tax_unit.sum(ind_total_personal_alds) # ... subtract remaining ALDs by adhoc allocation between spouses unit_total_alds = tax_unit("above_the_line_deductions", period) unit_remaining_alds = unit_total_alds - unit_total_personal_alds From 1a97062090eccc16d913dfbf838e1f1d9178bd79 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 13:53:50 -0400 Subject: [PATCH 2/6] Test that the ALD total stays between 0 and the all-member sum Co-Authored-By: Claude Opus 5.5 --- .../test_dependent_above_the_line_deductions_invariants.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py index 7f5bc0bc4bf..e1342a66bad 100644 --- a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py @@ -25,7 +25,8 @@ unit's AGI, and so do the head's and spouse's Medicaid AGIs. 3. Differential: `above_the_line_deductions` and each aggregate equal an independent numpy sum over the head and spouse. For units without - dependents this is also the previous all-member sum, so they see no change. + dependents this is also the previous all-member sum, so they see no change; + for others it is between 0 and that sum. 4. Each person's own deduction amounts are still computed for dependents (half of each person's self-employment tax, for example), so a dependent's own return can use them, and a dependent's Medicaid AGI is @@ -338,7 +339,10 @@ def _check(units, year): run["above_the_line_deductions"], reference, atol=TOLERANCE ) # Units without dependents: the previous all-member sum, unchanged. + # Units with dependents: never more than it, and never negative. all_members = run["loss_ald"] + _unit_sum(run, person_total + alimony) + assert (run["above_the_line_deductions"] >= -TOLERANCE).all() + assert (run["above_the_line_deductions"] <= all_members + TOLERANCE).all() np.testing.assert_allclose( run["above_the_line_deductions"][~has_dependent], all_members[~has_dependent], From 49d6476ea07ba9c9c46715b3a4eed2185b8459f0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 23:00:26 -0400 Subject: [PATCH 3/6] Address review r1: state readers, filer amounts, alimony, tests - Fix the stale TAXSIM v10 expectation for tax unit 16: the 500 of Schedule C income is a dependent's, so AGI is the joint wages 252,502. - Alabama and New Jersey count dependents' income, so they now add the new dependents_self_employed_health_insurance_ald and dependents_self_employed_pension_contribution_ald to the filer's aggregates; both match main again. - Mississippi subtracts each person's own _person SE health insurance and plan deductions, which also removes a double count: every member used to subtract the tax unit's total. - Minnesota's property tax refund household income adds back and subtracts retirement contributions of the head and spouse only, as its starting federal AGI does. - Employer adoption assistance and education savings bond interest are the filer's even when recorded on a dependent (26 USC 137(a); Form 8815), so gov.irs.ald.filer_amounts_recorded_on_dependents lists them and tax_unit_non_dep_add sums them over every member. - New alimony_expense_ald_person: a dependent's pre-2019 alimony reduces the dependent's own Medicaid AGI and dependent_taxable_ss_magi. - Tests: TY2020 tuition, adoption and bond cases; AL, MS, NJ, MN and Medicaid alimony cases; the property tests draw tuition, cover MN and sum the filer amounts over every member. - self_employed_pension_contribution_ald_person cites 62(a)(6) and 404. Co-Authored-By: Claude Opus 5.5 --- .../fix-dependent-ald-filer-agi.fixed.md | 2 +- .../filer_amounts_recorded_on_dependents.yaml | 24 +++++ .../states/al/tax/income/agi/deductions.yaml | 6 ++ .../tax/income/adjustments/adjustments.yaml | 4 +- .../tests/core/test_ald_determinism.py | 3 +- ...nt_above_the_line_deductions_invariants.py | 50 ++++++---- ...medicaid_adjusted_gross_income_person.yaml | 31 ++++++ .../dependent_above_the_line_deductions.yaml | 96 +++++++++++++++++++ .../gov/states/al/tax/income/al_agi.yaml | 28 ++++++ .../mn_homestead_credit_refund.yaml | 35 +++++++ .../gov/states/ms/tax/income/ms_agi.yaml | 53 ++++++++++ .../ms/tax/income/ms_agi_adjustments.yaml | 4 +- .../nj_medical_expense_deduction.yaml | 25 +++++ .../policy/contrib/taxsim/taxsim_v10.yaml | 5 +- policyengine_us/tools/general.py | 14 ++- .../medicaid_adjusted_gross_income_person.py | 16 +++- .../above_the_line_deductions.py | 12 ++- .../alimony_expense_ald.py | 8 +- .../alimony_expense_ald_person.py | 21 ++++ ...ents_self_employed_health_insurance_ald.py | 21 ++++ ..._self_employed_pension_contribution_ald.py | 23 +++++ ...mployed_pension_contribution_ald_person.py | 5 +- .../student_loan_interest_ald_magi.py | 7 +- .../adjusted_gross_income_person.py | 10 +- .../dependent_taxable_ss_magi.py | 5 +- .../social_security/taxable_ss_magi.py | 7 +- .../unemployment_insurance/taxable_uc_agi.py | 5 +- .../adjusted_gross_income/ma_part_b_agi.py | 7 +- ...omestead_credit_refund_household_income.py | 9 +- .../nj_medical_expense_deduction.py | 11 ++- 30 files changed, 492 insertions(+), 55 deletions(-) create mode 100644 policyengine_us/parameters/gov/irs/ald/filer_amounts_recorded_on_dependents.yaml create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald_person.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependents_self_employed_health_insurance_ald.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependents_self_employed_pension_contribution_ald.py diff --git a/changelog.d/fix-dependent-ald-filer-agi.fixed.md b/changelog.d/fix-dependent-ald-filer-agi.fixed.md index a00da41f5ff..99fa68113f2 100644 --- a/changelog.d/fix-dependent-ald-filer-agi.fixed.md +++ b/changelog.d/fix-dependent-ald-filer-agi.fixed.md @@ -1 +1 @@ -Keep tax unit dependents' above-the-line deductions (the deductible part of self-employment tax, self-employed health insurance and retirement plans, IRA contributions, early withdrawal penalties, educator expenses, alimony paid, adoption assistance and education bond interest) off the filer's AGI, Social Security and unemployment MAGIs and per-person AGI, and off the Missouri, Massachusetts and Medicaid AGIs built from them; also keep dependents' business losses out of Massachusetts gross income. +Keep tax unit dependents' above-the-line deductions (the deductible part of self-employment tax, self-employed health insurance and retirement plans, IRA contributions, early withdrawal penalties, educator expenses and alimony paid) off the filer's AGI, Social Security and unemployment MAGIs, per-person AGI and student loan interest MAGI, and off the Missouri, Massachusetts, Minnesota property tax refund and Medicaid incomes built from them. Employer adoption assistance and education savings bond interest stay on the filer's return wherever they are recorded. A dependent's own deductions now reduce the dependent's Medicaid AGI, Massachusetts gross income no longer subtracts dependents' business losses, and Mississippi subtracts each person's own self-employed health insurance and retirement plan deductions instead of the tax unit's total for every member. diff --git a/policyengine_us/parameters/gov/irs/ald/filer_amounts_recorded_on_dependents.yaml b/policyengine_us/parameters/gov/irs/ald/filer_amounts_recorded_on_dependents.yaml new file mode 100644 index 00000000000..e89a6e270da --- /dev/null +++ b/policyengine_us/parameters/gov/irs/ald/filer_amounts_recorded_on_dependents.yaml @@ -0,0 +1,24 @@ +description: Above-the-line amounts that belong to the filer even when they are recorded on a tax unit dependent, so they are summed over every tax unit member rather than over the head and spouse only. +values: + 2010-01-01: + # Employer adoption assistance is excluded for the adopting employee + # (26 USC 137(a)); households record it on the adopted child, as the Ohio + # adoption credit reads it. + - qualified_adoption_assistance_expense + # Savings bond interest used for education is excluded for the bond's + # owner. "A bond bought by a parent and issued in the name of his or her + # child under age 24 does not qualify for the exclusion by the parent or + # child" (Form 8815), so an amount recorded on a dependent student is the + # parent's. + - us_bonds_for_higher_ed +metadata: + unit: list + period: year + label: Above-the-line amounts summed over dependents + reference: + - title: 26 U.S. Code § 137 - Adoption assistance programs + href: https://www.law.cornell.edu/uscode/text/26/137 + - title: 26 U.S. Code § 135 - Income from United States savings bonds used to pay higher education tuition and fees + href: https://www.law.cornell.edu/uscode/text/26/135 + - title: Form 8815 (2025), Who Can Take the Exclusion + href: https://www.irs.gov/pub/irs-prior/f8815--2025.pdf#page=3 diff --git a/policyengine_us/parameters/gov/states/al/tax/income/agi/deductions.yaml b/policyengine_us/parameters/gov/states/al/tax/income/agi/deductions.yaml index 71199b5dbfc..03b60f72f2e 100644 --- a/policyengine_us/parameters/gov/states/al/tax/income/agi/deductions.yaml +++ b/policyengine_us/parameters/gov/states/al/tax/income/agi/deductions.yaml @@ -6,8 +6,11 @@ values: - alimony_expense # Line 4 - early_withdrawal_penalty # Line 5 - self_employed_health_insurance_ald # Line 7 + # Alabama AGI counts dependents' income, so their deductions too. + - dependents_self_employed_health_insurance_ald - health_savings_account_ald # Line 8 - self_employed_pension_contribution_ald # Line 9 + - dependents_self_employed_pension_contribution_ald - us_govt_interest - taxable_public_pension_income 2023-01-01: @@ -16,8 +19,11 @@ values: - alimony_expense # Line 4 - early_withdrawal_penalty # Line 5 - self_employed_health_insurance_ald # Line 7 + # Alabama AGI counts dependents' income, so their deductions too. + - dependents_self_employed_health_insurance_ald - health_savings_account_ald # Line 8 - self_employed_pension_contribution_ald # Line 9 + - dependents_self_employed_pension_contribution_ald - us_govt_interest - al_retirement_exemption #Schedule RS Line 10 - taxable_public_pension_income diff --git a/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml b/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml index aa7765cbb4f..ee20c3dc989 100644 --- a/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml +++ b/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml @@ -5,9 +5,9 @@ values: - ms_self_employment_adjustment # Line 61 - ms_national_guard_or_reserve_pay_adjustment # Line 55 - alimony_expense # Line 53 - - self_employed_pension_contribution_ald # Line 51 + - self_employed_pension_contribution_ald_person # Line 51 - early_withdrawal_penalty # Line 52 - - self_employed_health_insurance_ald # Line 56 + - self_employed_health_insurance_ald_person # Line 56 - health_savings_account_ald # Line 57 - ms_retirement_income_exemption - us_govt_interest_person diff --git a/policyengine_us/tests/core/test_ald_determinism.py b/policyengine_us/tests/core/test_ald_determinism.py index 082e610c11e..58269f0372b 100644 --- a/policyengine_us/tests/core/test_ald_determinism.py +++ b/policyengine_us/tests/core/test_ald_determinism.py @@ -63,6 +63,7 @@ def test_tax_unit_deductions_are_ordered_unique_and_exclude_person_alds( gross_income=SimpleNamespace(sources=[]), ald=SimpleNamespace( deductions=deductions, + filer_amounts_recorded_on_dependents=[], student_loan_interest=SimpleNamespace(magi=magi), ), ) @@ -91,7 +92,7 @@ def capture_add(entity, period, variables): assert variables == [f"{name}_person" for name in PERSON_ALDS] return np.array([0], dtype=np.float32) - def capture_tax_unit_add(entity, period, variables): + def capture_tax_unit_add(entity, period, variables, include_dependents=()): assert entity is person.tax_unit captured.extend(variables) raise ReachedTaxUnitSum diff --git a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py index e1342a66bad..42beb80fe4c 100644 --- a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py @@ -7,10 +7,15 @@ are figured with the dependent's own income. `irs_gross_income` already leaves dependents' income off the filer's return, so every sum of above-the-line deductions over a tax unit must leave dependents out too. +Three amounts are the filer's whatever member they are recorded on, and are +summed over every member: employer adoption assistance and education savings +bond interest (`gov.irs.ald.filer_amounts_recorded_on_dependents`), and, +through 2020, a dependent's tuition in the tuition and fees deduction. Hypothesis draws batches of tax units (single, head of household with -dependents, joint with and without dependents, in Texas, Missouri and -Massachusetts), and a seeded population of 200 such units adds breadth. Each +dependents, joint with and without dependents, in Texas, Missouri, +Massachusetts and Minnesota), and a seeded population of 200 such units adds +breadth. Each batch runs as one vectorized simulation, twice: with the dependents' deduction inputs as drawn and with them set to zero. For every tax unit: @@ -18,13 +23,14 @@ 1. The dependents' deduction inputs never change the filer's AGI, above-the-line deductions, any tax-unit deduction aggregate, the Social Security and unemployment compensation MAGIs, taxable Social Security, - Massachusetts gross income and Part B AGI, or the head's and spouse's - per-person AGI, - student loan interest MAGI and deduction, Medicaid AGI and Missouri AGI. + Massachusetts gross income and Part B AGI, Minnesota property tax refund + household income, or the head's and spouse's per-person AGI, student loan + interest MAGI and deduction, Medicaid AGI and Missouri AGI. 2. Accounting identities: the per-person AGIs of the members sum to the tax unit's AGI, and so do the head's and spouse's Medicaid AGIs. 3. Differential: `above_the_line_deductions` and each aggregate equal an - independent numpy sum over the head and spouse. For units without + independent numpy sum over the head and spouse (plus the three filer + amounts over every member). For units without dependents this is also the previous all-member sum, so they see no change; for others it is between 0 and that sum. 4. Each person's own deduction amounts are still computed for dependents @@ -52,20 +58,22 @@ "early_withdrawal_penalty", "educator_expense", "alimony_expense", - "qualified_adoption_assistance_expense", - "us_bonds_for_higher_ed", "student_loan_interest", ] -# Person-level deduction amounts that make up above_the_line_deductions, -# other than loss_ald and alimony_expense_ald. +# Person-level deduction amounts in above_the_line_deductions that belong on +# each person's own return. PERSON_DEDUCTIONS = [ "self_employment_tax_ald_person", "self_employed_health_insurance_ald_person", "self_employed_pension_contribution_ald_person", + "alimony_expense_ald_person", "student_loan_interest_ald", "early_withdrawal_penalty", "educator_expense", "traditional_ira_contributions", +] +# Person-level amounts that are the filer's wherever they are recorded. +FILER_AMOUNTS = [ "qualified_adoption_assistance_expense", "us_bonds_for_higher_ed", ] @@ -73,18 +81,20 @@ "self_employment_tax_ald": "self_employment_tax_ald_person", "self_employed_health_insurance_ald": "self_employed_health_insurance_ald_person", "self_employed_pension_contribution_ald": "self_employed_pension_contribution_ald_person", + "alimony_expense_ald": "alimony_expense_ald_person", } TAX_UNIT_OUTPUTS = [ "adjusted_gross_income", "above_the_line_deductions", *AGGREGATES, - "alimony_expense_ald", "loss_ald", + "capped_qualified_tuition_expenses_ald", "taxable_ss_magi", "taxable_uc_agi", "tax_unit_taxable_social_security", "ma_gross_income", "ma_part_b_agi", + "mn_homestead_credit_refund_household_income", ] # Per-person outputs whose head and spouse values must not depend on the # dependents' deductions. @@ -98,6 +108,7 @@ PERSON_OUTPUTS = [ *FILER_PERSON_OUTPUTS, *PERSON_DEDUCTIONS, + *FILER_AMOUNTS, "self_employment_tax", "alimony_expense", "divorce_year", @@ -128,6 +139,7 @@ def person_amounts(draw, *, dependent): "qualified_adoption_assistance_expense": draw(small), "us_bonds_for_higher_ed": draw(small), "student_loan_interest": draw(st.integers(0, 3_000).map(float)), + "qualified_tuition_expenses": draw(small), "taxable_interest_income": draw(st.integers(0, 2_000).map(float)), "social_security_retirement": ( 0.0 if dependent else draw(st.one_of(st.just(0.0), amount)) @@ -146,7 +158,7 @@ def tax_units(draw): kind = draw(st.sampled_from(["single", "hoh", "joint", "joint_dependents"])) n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 return { - "state": draw(st.sampled_from(["TX", "MO", "MA"])), + "state": draw(st.sampled_from(["TX", "MO", "MA", "MN"])), "head": draw(person_amounts(dependent=False)), "spouse": ( draw(person_amounts(dependent=False)) if kind.startswith("joint") else None @@ -183,6 +195,7 @@ def some(high, p=0.5): "qualified_adoption_assistance_expense": some(7_000, 0.2), "us_bonds_for_higher_ed": some(7_000, 0.2), "student_loan_interest": some(3_000), + "qualified_tuition_expenses": some(7_000, 0.3), "taxable_interest_income": some(2_000), "social_security_retirement": 0.0 if dependent else some(40_000, 0.3), "taxable_private_pension_income": 0.0 if dependent else some(40_000, 0.3), @@ -200,7 +213,7 @@ def _seeded_units(n=200): ) units.append( { - "state": str(rng.choice(["TX", "MO", "MA"])), + "state": str(rng.choice(["TX", "MO", "MA", "MN"])), "head": _seeded_amounts(rng, dependent=False), "spouse": ( _seeded_amounts(rng, dependent=False) @@ -322,9 +335,10 @@ def _check(units, year): # 3. Differential against numpy sums over the head and spouse. Alimony # is deductible for divorces before 2019 (26 USC 215, repealed by Pub. L. # 115-97 sec. 11051 for later instruments). - alimony = run["alimony_expense"] * (run["divorce_year"] < 2019) np.testing.assert_allclose( - run["alimony_expense_ald"], _unit_sum(run, filer * alimony), atol=TOLERANCE + run["alimony_expense_ald_person"], + run["alimony_expense"] * (run["divorce_year"] < 2019), + atol=TOLERANCE, ) for aggregate, person_amount in AGGREGATES.items(): np.testing.assert_allclose( @@ -334,13 +348,15 @@ def _check(units, year): err_msg=aggregate, ) person_total = sum(run[name] for name in PERSON_DEDUCTIONS) - reference = run["loss_ald"] + _unit_sum(run, filer * (person_total + alimony)) + filer_amounts = sum(run[name] for name in FILER_AMOUNTS) + unit_level = run["loss_ald"] + run["capped_qualified_tuition_expenses_ald"] + reference = unit_level + _unit_sum(run, filer * person_total + filer_amounts) np.testing.assert_allclose( run["above_the_line_deductions"], reference, atol=TOLERANCE ) # Units without dependents: the previous all-member sum, unchanged. # Units with dependents: never more than it, and never negative. - all_members = run["loss_ald"] + _unit_sum(run, person_total + alimony) + all_members = unit_level + _unit_sum(run, person_total + filer_amounts) assert (run["above_the_line_deductions"] >= -TOLERANCE).all() assert (run["above_the_line_deductions"] <= all_members + TOLERANCE).all() np.testing.assert_allclose( diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml index 766f45397c4..04a511a887f 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml @@ -71,3 +71,34 @@ # line 9 = 6,000 + 500 = 6,500; Schedule 1, line 11 = 300, line 18 = 100, # line 20 = 2,000, line 26 = 2,400; line 11 = 4,100. medicaid_adjusted_gross_income_person: [50_000, 4_100] + +- name: Case 4, a dependent's pre-2019 alimony reduces the dependent's own AGI. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + filer: + age: 45 + is_tax_unit_head: true + employment_income: 50_000 + mother: + age: 72 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + taxable_private_pension_income: 4_000 + alimony_expense: 3_000 + divorce_year: 2010 + tax_units: + tax_unit: + members: [filer, mother] + households: + household: + members: [filer, mother] + state_code: TX + output: + # Each person's own Form 1040 line 11. Filer: 50,000. Mother: pension + # 4,000 less alimony paid under a 2010 divorce (Schedule 1, line 19a) + # 3,000 = 1,000. + alimony_expense_ald_person: [0, 3_000] + medicaid_adjusted_gross_income_person: [50_000, 1_000] diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependent_above_the_line_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependent_above_the_line_deductions.yaml index b8aa750b8a5..7ca510b8424 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependent_above_the_line_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependent_above_the_line_deductions.yaml @@ -18,6 +18,12 @@ # instructions: https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=32 # and #page=99. # The model does not round Schedule SE lines to cents, so the margin is 0.01. +# +# Three amounts stay on the filer's return whatever member they are recorded +# on (the last three cases): a dependent's tuition in the tuition and fees +# deduction (Form 8917, through 2020), employer adoption assistance recorded on +# the adopted child, and education savings bond interest recorded on the +# student (gov.irs.ald.filer_amounts_recorded_on_dependents). - name: Child's Schedule C income and self-employment tax stay off the head of household parent's return period: 2025 @@ -239,3 +245,93 @@ student_loan_interest_ald: [1_500, 0] # Form 1040: line 9 = 91,000; line 10 = 1,500; line 11 = 89,500. adjusted_gross_income: 89_500 + +- name: A dependent student's tuition stays in the parent's tuition and fees deduction + period: 2020 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_tax_unit_head: true + employment_income: 50_000 + student: + age: 20 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + qualified_tuition_expenses: 3_000 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + # Form 8917 (Rev. January 2020), on the parent's return, which claims the + # student as a dependent: line 1(c) = line 2 = 3,000; line 3 = 50,000; + # line 4 = 0; line 5 = 50,000, not more than 65,000, so line 6 = the + # smaller of 3,000 or 4,000 = 3,000. + capped_qualified_tuition_expenses_ald: 3_000 + above_the_line_deductions: 3_000 + adjusted_gross_income: 47_000 + +- name: Employer adoption assistance recorded on the adopted child stays on the parent's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_tax_unit_head: true + employment_income: 50_000 + child: + age: 3 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + qualified_adoption_assistance_expense: 5_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + # The 26 USC 137(a) exclusion is the adopting employee's. + above_the_line_deductions: 5_000 + adjusted_gross_income: 45_000 + +- name: Education savings bond interest recorded on the student stays on the parent's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_tax_unit_head: true + employment_income: 50_000 + taxable_interest_income: 2_000 + student: + age: 20 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + us_bonds_for_higher_ed: 1_000 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + # A bond issued in the name of a child under 24 does not qualify (Form + # 8815, 2025, p. 3), so the exclusion is the parent's: 52,000 - 1,000. + above_the_line_deductions: 1_000 + adjusted_gross_income: 51_000 + diff --git a/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/al_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/al_agi.yaml index 7aa7440aa51..d1b72912602 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/al_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/al_agi.yaml @@ -40,3 +40,31 @@ output: al_agi: 60_000 al_income_tax: 2_442.75 + +- name: A dependent's self-employed health insurance and plan deductions follow their income into Alabama AGI + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 17 + self_employment_income: 10_000 + self_employed_health_insurance_premiums: 1_000 + self_employed_pension_contributions: 500 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: AL + output: + # Alabama AGI sums every member's income, so it counts the child's 10,000 + # of Schedule C income and subtracts the child's own self-employed health + # insurance (1,000) and SEP (500) deductions: 50,000 - 1,500. The federal + # AGI leaves all of them on the child's return. + adjusted_gross_income: 40_000 + al_agi: 48_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund.yaml b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund.yaml index f1f3015d458..eeed87ba1f7 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund.yaml @@ -156,3 +156,38 @@ traditional_ira_contributions: 3_500 roth_ira_contributions: 3_500 mn_homestead_credit_refund_household_income: 46_500 + +- name: Case 8, a dependent's IRA contribution is not added back to the parent's household income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_tax_unit_head: true + employment_income: 60_000 + traditional_401k_contributions: 7_000 + student: + age: 20 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + employment_income: 6_000 + traditional_ira_contributions: 2_000 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: MN + output: + # Federal AGI is the parent's 60,000 - 7,000 of 401(k) deferrals; the + # student's wages and IRA deduction are on the student's own return. + adjusted_gross_income: 53_000 + # 53,000 + the parent's 401(k) addition (7,000) - the retirement + # subtraction (7,000) - the dependent subtraction (7,280). The student's + # IRA contribution is neither added back nor subtracted, as their wages + # are not in AGI. + mn_homestead_credit_refund_household_income: 45_720 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi.yaml index 688e81a7fca..7b0434e0af8 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi.yaml @@ -41,3 +41,56 @@ state_code: MS output: ms_agi: 100_000 + +- name: A dependent's self-employment adjustments are subtracted once, with the dependent's income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 17 + self_employment_income: 10_000 + self_employed_health_insurance_premiums: 1_000 + self_employed_pension_contributions: 500 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: MS + output: + # The child's own adjustments: one-half of self-employment tax (Schedule + # SE line 13, 706.48), health insurance (1,000) and SEP (500), 2,206.48. + ms_agi_adjustments: [0, 2_206.48] + # The child's income and adjustments reach the parent's Mississippi AGI + # once: 40,000 + 10,000 - 2,206.48. + ms_agi: [47_793.52, 0] + +- name: Each spouse subtracts only their own self-employed health insurance + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + self_employment_income: 30_000 + self_employed_health_insurance_premiums: 3_000 + spouse: + age: 44 + employment_income: 20_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MS + output: + # Head: one-half of self-employment tax (30,000 x 0.9235 x 0.153 / 2 = + # 2,119.43) plus health insurance 3,000. The spouse has no adjustment. + ms_agi_adjustments: [5_119.43, 0] + ms_agi: [24_880.57, 20_000] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi_adjustments.yaml b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi_adjustments.yaml index 37294ec1b31..c0950606470 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi_adjustments.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi_adjustments.yaml @@ -2,9 +2,9 @@ period: 2025 input: state_code: MS - self_employed_pension_contribution_ald: 100 + self_employed_pension_contribution_ald_person: 100 early_withdrawal_penalty: 50 - self_employed_health_insurance_ald: 200 + self_employed_health_insurance_ald_person: 200 health_savings_account_ald: 150 output: ms_agi_adjustments: 500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml index 1888f420be9..ddcd2cb333c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml @@ -30,3 +30,28 @@ state_code: NJ output: nj_medical_expense_deduction: 2_600 + +- name: A dependent's self-employed health insurance counts where New Jersey counts the dependent's income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 17 + self_employment_income: 10_000 + self_employed_health_insurance_premiums: 1_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: NJ + output: + # New Jersey AGI includes the child's 10,000 of Schedule C income, so the + # child's 1,000 of self-employed health insurance is deducted too. + nj_agi: 50_000 + nj_medical_expense_deduction: 1_000 diff --git a/policyengine_us/tests/policy/contrib/taxsim/taxsim_v10.yaml b/policyengine_us/tests/policy/contrib/taxsim/taxsim_v10.yaml index ff8421fa8a0..3c1a9bab57f 100644 --- a/policyengine_us/tests/policy/contrib/taxsim/taxsim_v10.yaml +++ b/policyengine_us/tests/policy/contrib/taxsim/taxsim_v10.yaml @@ -1307,7 +1307,10 @@ taxsim_childcare: 0 taxsim_scorp: 0 output: - taxsim_v10: 252_466.67 + # Joint wages 92,500 + 160,002. The 500 of Schedule C income is + # dependent person_3's (TAXSIM's psemp and ssemp are 0), so half of + # its self-employment tax is deducted on person_3's own return. + taxsim_v10: 252_502 - name: Tax unit 17 (CPS ID 1325801) matches TAXSIM35 outputs diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index 4bf2cf005d8..2349d72f08a 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -16,24 +16,28 @@ def tax_unit_non_dep_sum(var, tax_unit, period): ) -def tax_unit_non_dep_add(tax_unit, period, variables): +def tax_unit_non_dep_add(tax_unit, period, variables, include_dependents=()): """ Add variables over a tax unit's head and spouse, leaving out dependents. Like `add`, but a person-level variable is summed only over members who are not tax unit dependents, whose items belong on their own returns. A tax-unit-level variable is added as is, so it must already describe the - filer's own return. + filer's own return. Person-level variables named in `include_dependents` + are summed over every member: they are the filer's amounts even when + recorded on a dependent. Variables are added in the order given. """ total = 0 for variable in variables: variable_entity = tax_unit.entity.get_variable( variable, check_existence=True ).entity - if variable_entity.is_person: - total = total + tax_unit_non_dep_sum(variable, tax_unit, period) - else: + if not variable_entity.is_person: total = total + tax_unit(variable, period) + elif variable in include_dependents: + total = total + tax_unit.sum(tax_unit.members(variable, period)) + else: + total = total + tax_unit_non_dep_sum(variable, tax_unit, period) return total diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py index 0b8562829b4..1ec6d2ef4d8 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py @@ -5,12 +5,14 @@ "self_employment_tax_ald_person", "self_employed_health_insurance_ald_person", "self_employed_pension_contribution_ald_person", + "alimony_expense_ald_person", ] TAX_UNIT_AGI_ALDS_WITH_PERSON_LEVEL_EQUIVALENTS = ( "self_employment_tax_ald", "self_employed_health_insurance_ald", "self_employed_pension_contribution_ald", + "alimony_expense_ald", ) @@ -25,7 +27,8 @@ class medicaid_adjusted_gross_income_person(Variable): def formula(person, period, parameters): gross_income = person("medicaid_irs_gross_income", period) ald_sum_person = add(person, period, PERSON_LEVEL_MEDICAID_AGI_ALDS) - all_alds = parameters(period).gov.irs.ald.deductions + p = parameters(period).gov.irs.ald + all_alds = p.deductions other_alds = [ ald for ald in all_alds @@ -35,11 +38,20 @@ def formula(person, period, parameters): # their return. A tax unit dependent's own person-level deductions, # such as their IRA deduction, reduce the dependent's own AGI, since # the dependent's income is figured on the dependent's own return. - ald_sum_taxunit = tax_unit_non_dep_add(person.tax_unit, period, other_alds) + # Amounts that are the filer's even when recorded on a dependent stay + # with the head and spouse. + filer_amounts = p.filer_amounts_recorded_on_dependents + ald_sum_taxunit = tax_unit_non_dep_add( + person.tax_unit, + period, + other_alds, + include_dependents=filer_amounts, + ) person_level_other_alds = [ ald for ald in other_alds if person.entity.get_variable(ald, check_existence=True).entity.is_person + and ald not in filer_amounts ] is_dependent = person("is_tax_unit_dependent", period) dependent_own_ald = is_dependent * add(person, period, person_level_other_alds) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions.py index e184d5d2747..0b6cb864bbb 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions.py @@ -17,8 +17,14 @@ class above_the_line_deductions(Variable): reference = "https://www.law.cornell.edu/uscode/text/26/62" def formula(tax_unit, period, parameters): - deductions = parameters(period).gov.irs.ald.deductions + p = parameters(period).gov.irs.ald # Person-level deductions are summed over the head and spouse only, - # as irs_gross_income sums their income only. Tax-unit-level + # as irs_gross_income sums their income only, except amounts that are + # the filer's even when recorded on a dependent. Tax-unit-level # deductions already describe this return. - return tax_unit_non_dep_add(tax_unit, period, deductions) + return tax_unit_non_dep_add( + tax_unit, + period, + p.deductions, + include_dependents=p.filer_amounts_recorded_on_dependents, + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald.py index 9c7d5d34f6c..ae7ecd83085 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald.py @@ -11,11 +11,5 @@ class alimony_expense_ald(Variable): reference = "https://www.irs.gov/taxtopics/tc452" def formula(tax_unit, period, parameters): - person = tax_unit.members - divorce_year = person("divorce_year", period) - alimony_expense = person("alimony_expense", period) - p = parameters(period).gov.irs.ald.alimony_expense - eligible_person = p.divorce_year_threshold.calc(divorce_year) # A tax unit dependent who pays alimony deducts it on their own return. - not_dependent = ~person("is_tax_unit_dependent", period) - return tax_unit.sum(alimony_expense * eligible_person * not_dependent) + return tax_unit_non_dep_sum("alimony_expense_ald_person", tax_unit, period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald_person.py new file mode 100644 index 00000000000..b454cbceff0 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald_person.py @@ -0,0 +1,21 @@ +from policyengine_us.model_api import * + + +class alimony_expense_ald_person(Variable): + value_type = float + entity = Person + label = "Alimony expense ALD for each person" + unit = USD + documentation = ( + "Each person's own above-the-line deduction for alimony paid under a " + "divorce or separation instrument executed before 2019 (Schedule 1, " + "line 19a), on that person's own return." + ) + definition_period = YEAR + reference = "https://www.irs.gov/taxtopics/tc452" + + def formula(person, period, parameters): + divorce_year = person("divorce_year", period) + alimony_expense = person("alimony_expense", period) + p = parameters(period).gov.irs.ald.alimony_expense + return alimony_expense * p.divorce_year_threshold.calc(divorce_year) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependents_self_employed_health_insurance_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependents_self_employed_health_insurance_ald.py new file mode 100644 index 00000000000..92e33dcc21b --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependents_self_employed_health_insurance_ald.py @@ -0,0 +1,21 @@ +from policyengine_us.model_api import * + + +class dependents_self_employed_health_insurance_ald(Variable): + value_type = float + entity = TaxUnit + label = "Tax unit dependents' self-employed health insurance deductions" + unit = USD + documentation = ( + "The self-employed health insurance deductions (Schedule 1, line 17) of the tax " + "unit's dependents, which belong on the dependents' own returns and " + "are left out of self_employed_health_insurance_ald. For state " + "income definitions that count dependents' income." + ) + definition_period = YEAR + + def formula(tax_unit, period, parameters): + person = tax_unit.members + is_dependent = person("is_tax_unit_dependent", period) + amount = person("self_employed_health_insurance_ald_person", period) + return tax_unit.sum(is_dependent * amount) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependents_self_employed_pension_contribution_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependents_self_employed_pension_contribution_ald.py new file mode 100644 index 00000000000..8ad672c26ca --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependents_self_employed_pension_contribution_ald.py @@ -0,0 +1,23 @@ +from policyengine_us.model_api import * + + +class dependents_self_employed_pension_contribution_ald(Variable): + value_type = float + entity = TaxUnit + label = ( + "Tax unit dependents' self-employed SEP, SIMPLE and qualified plan deductions" + ) + unit = USD + documentation = ( + "The self-employed SEP, SIMPLE and qualified plan deductions (Schedule 1, line 16) of the tax " + "unit's dependents, which belong on the dependents' own returns and " + "are left out of self_employed_pension_contribution_ald. For state " + "income definitions that count dependents' income." + ) + definition_period = YEAR + + def formula(tax_unit, period, parameters): + person = tax_unit.members + is_dependent = person("is_tax_unit_dependent", period) + amount = person("self_employed_pension_contribution_ald_person", period) + return tax_unit.sum(is_dependent * amount) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald_person.py index e027dd1a7c2..33b247938f7 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald_person.py @@ -8,7 +8,10 @@ class self_employed_pension_contribution_ald_person(Variable): unit = USD documentation = "Personal above-the-line deduction for self-employed pension plan contributions." definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/162#l" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/62#a_6", + "https://www.law.cornell.edu/uscode/text/26/404", + ) def formula(person, period, parameters): earnings = max_(0, person("total_self_employment_income", period)) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py index deae2072d98..b29ddf98a29 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py @@ -35,7 +35,12 @@ def formula(person, period, parameters): - set(person_alds) - set(p_irs.ald.student_loan_interest.magi.excluded_alds) ) - ald_sum_taxunit = tax_unit_non_dep_add(person.tax_unit, period, other_alds) + ald_sum_taxunit = tax_unit_non_dep_add( + person.tax_unit, + period, + other_alds, + include_dependents=p_irs.ald.filer_amounts_recorded_on_dependents, + ) filing_status = person.tax_unit("filing_status", period) joint = filing_status == filing_status.possible_values.JOINT frac = where(joint, 0.5, 1.0) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py index 1a6911854c2..710424f0fdd 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py @@ -23,9 +23,15 @@ def formula(person, period, parameters): person_ald_vars = [f"{ald}_person" for ald in PERSON_ALDS] ald_sum_person = not_dependent * add(person, period, person_ald_vars) # split other alds evenly between head and spouse - all_alds = parameters(period).gov.irs.ald.deductions + p = parameters(period).gov.irs.ald + all_alds = p.deductions other_alds = sorted(set(all_alds) - set(PERSON_ALDS)) - ald_sum_taxunit = tax_unit_non_dep_add(person.tax_unit, period, other_alds) + ald_sum_taxunit = tax_unit_non_dep_add( + person.tax_unit, + period, + other_alds, + include_dependents=p.filer_amounts_recorded_on_dependents, + ) is_head = person("is_tax_unit_head", period) is_spouse = person("is_tax_unit_spouse", period) fstatus = person.tax_unit("filing_status", period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py index 932e2011bb4..5d750d90555 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py @@ -22,6 +22,7 @@ "educator_expense": "educator_expense", "early_withdrawal_penalty": "early_withdrawal_penalty", "traditional_ira_contributions": "traditional_ira_contributions", + "alimony_expense_ald": "alimony_expense_ald_person", } @@ -37,8 +38,8 @@ class dependent_taxable_ss_magi(Variable): 221, 911, 931 and 933, plus tax-exempt interest. Unlike the qualifying relative gross income test, losses and above-the-line deductions reduce it. Above-the-line deductions the model records only for the tax unit - (alimony paid, health savings account contributions) are not attributed - to dependents, and the IRC 461(l) excess business loss limit is not + (health savings account contributions) are not attributed to + dependents, and the IRC 461(l) excess business loss limit is not applied. """ definition_period = YEAR diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py index c6eeb38f7d6..1aaf3f09dec 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py @@ -43,5 +43,10 @@ def formula(tax_unit, period, parameters): if deduction not in revoked_deductions ] # A tax unit dependent's deductions are on their own return. - total_deductions = tax_unit_non_dep_add(tax_unit, period, deductions) + total_deductions = tax_unit_non_dep_add( + tax_unit, + period, + deductions, + include_dependents=irs.ald.filer_amounts_recorded_on_dependents, + ) return max_(0, gross_income - total_deductions) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/unemployment_insurance/taxable_uc_agi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/unemployment_insurance/taxable_uc_agi.py index d2dd776c94d..20045f03cf3 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/unemployment_insurance/taxable_uc_agi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/unemployment_insurance/taxable_uc_agi.py @@ -28,6 +28,9 @@ def formula(tax_unit, period, parameters): above_the_line_deductions = irs.ald.deductions # A tax unit dependent's deductions are on their own return. total_deductions = tax_unit_non_dep_add( - tax_unit, period, above_the_line_deductions + tax_unit, + period, + above_the_line_deductions, + include_dependents=irs.ald.filer_amounts_recorded_on_dependents, ) return max_(0, gross_income - total_deductions) diff --git a/policyengine_us/variables/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.py b/policyengine_us/variables/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.py index 4d55f2a34b8..1ab04f513ba 100644 --- a/policyengine_us/variables/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.py +++ b/policyengine_us/variables/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.py @@ -22,7 +22,12 @@ def formula(tax_unit, period, parameters): ] # Part B gross income starts from irs_gross_income, which leaves out # tax unit dependents, so their deductions are left out too. - deduction_value = tax_unit_non_dep_add(tax_unit, period, deductions) + deduction_value = tax_unit_non_dep_add( + tax_unit, + period, + deductions, + include_dependents=p.irs.ald.filer_amounts_recorded_on_dependents, + ) # U.S. government bond interest is exempt from MA tax. us_govt_interest = add(tax_unit, period, ["us_govt_interest"]) return max_( diff --git a/policyengine_us/variables/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund_household_income.py b/policyengine_us/variables/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund_household_income.py index 529863d2ed5..ef104baa938 100644 --- a/policyengine_us/variables/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund_household_income.py +++ b/policyengine_us/variables/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund_household_income.py @@ -41,7 +41,10 @@ def formula(tax_unit, period, parameters): max_(0, social_security - taxable_social_security) * ~is_dependent ) - retirement_contributions = add( + # Line 1 is the filer's federal AGI, which leaves out tax unit + # dependents, so the retirement add-backs, contributions and + # compensation count the head and spouse only. + retirement_contributions = tax_unit_non_dep_add( tax_unit, period, [ @@ -54,7 +57,7 @@ def formula(tax_unit, period, parameters): "self_employed_pension_contributions", ], ) - retirement_additions = add( + retirement_additions = tax_unit_non_dep_add( tax_unit, period, [ @@ -64,7 +67,7 @@ def formula(tax_unit, period, parameters): "self_employed_pension_contributions", ], ) - compensation = add( + compensation = tax_unit_non_dep_add( tax_unit, period, ["employment_income", "self_employment_income"] ) retirement_subtraction = min_( diff --git a/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py b/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py index cbd9e8c4fd8..03bbb514baf 100644 --- a/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py +++ b/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py @@ -11,8 +11,15 @@ class nj_medical_expense_deduction(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.states.nj.tax.income.deductions.medical_expenses - self_employed_medical_expense_deduction = tax_unit( - "self_employed_health_insurance_ald", period + # nj_agi counts every member's income, dependents included, so the + # dependents' self-employed health insurance deductions count too. + self_employed_medical_expense_deduction = add( + tax_unit, + period, + [ + "self_employed_health_insurance_ald", + "dependents_self_employed_health_insurance_ald", + ], ) medical_expenses = tax_unit("itemized_medical_expenses", period) agi = tax_unit("nj_agi", period) From 49c9a3ab3001f6ea5f20a4641f58c12651377df2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 02:24:12 -0400 Subject: [PATCH 4/6] Reconcile per-person deduction splits to the tax-unit amount Review r2 found two regressions from r1's person-level attribution: Mississippi read only the _person SE variables, dropping a supplied tax-unit self_employed_health_insurance_ald or self_employed_pension_contribution_ald (the original test's 500 became 200), and Medicaid subtracted alimony_expense_ald_person unconditionally, ignoring a tax-unit alimony input and a reform that removes alimony from gov.irs.ald.deductions. - New tools helper person_share_of_tax_unit_amount: each person's own amount, with any difference between the tax-unit amount and the head's and spouse's own amounts going to the head; dependents keep their own. - Mississippi lists the new ms_self_employed_health_insurance_adjustment and ms_self_employed_retirement_adjustment, built with it. The original ms_agi_adjustments test inputs are restored and pass (500). - Medicaid attributes each listed deduction with a person-level equivalent through the helper, and only when it is in the active list. - Tests: a tax-unit alimony input (YAML and Python) and a reform that drops alimony from the list; both fail at 49d6476 and pass here. - The bond entry in filer_amounts_recorded_on_dependents describes the input convention (a parent-owned bond's exclusion recorded on the student) instead of implying every dependent's amount is the parent's. Co-Authored-By: Claude Opus 5.5 --- .../filer_amounts_recorded_on_dependents.yaml | 10 ++- .../tax/income/adjustments/adjustments.yaml | 4 +- ...nt_above_the_line_deductions_invariants.py | 79 +++++++++++++++++++ ...medicaid_adjusted_gross_income_person.yaml | 21 +++++ .../ms/tax/income/ms_agi_adjustments.yaml | 4 +- policyengine_us/tools/general.py | 17 ++++ .../medicaid_adjusted_gross_income_person.py | 33 ++++---- ...lf_employed_health_insurance_adjustment.py | 26 ++++++ .../ms_self_employed_retirement_adjustment.py | 26 ++++++ 9 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_health_insurance_adjustment.py create mode 100644 policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_retirement_adjustment.py diff --git a/policyengine_us/parameters/gov/irs/ald/filer_amounts_recorded_on_dependents.yaml b/policyengine_us/parameters/gov/irs/ald/filer_amounts_recorded_on_dependents.yaml index e89a6e270da..51da800dcc9 100644 --- a/policyengine_us/parameters/gov/irs/ald/filer_amounts_recorded_on_dependents.yaml +++ b/policyengine_us/parameters/gov/irs/ald/filer_amounts_recorded_on_dependents.yaml @@ -6,10 +6,12 @@ values: # adoption credit reads it. - qualified_adoption_assistance_expense # Savings bond interest used for education is excluded for the bond's - # owner. "A bond bought by a parent and issued in the name of his or her - # child under age 24 does not qualify for the exclusion by the parent or - # child" (Form 8815), so an amount recorded on a dependent student is the - # parent's. + # owner, who must have been at least 24 when the bond was issued, and the + # expenses may be a dependent's. "A bond bought by a parent and issued in + # the name of his or her child under age 24 does not qualify for the + # exclusion by the parent or child" (Form 8815). By convention, an amount + # recorded on a dependent is the parent-owned bond's exclusion recorded on + # the student whose expenses it paid. - us_bonds_for_higher_ed metadata: unit: list diff --git a/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml b/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml index ee20c3dc989..6fcb76f5537 100644 --- a/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml +++ b/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml @@ -5,9 +5,9 @@ values: - ms_self_employment_adjustment # Line 61 - ms_national_guard_or_reserve_pay_adjustment # Line 55 - alimony_expense # Line 53 - - self_employed_pension_contribution_ald_person # Line 51 + - ms_self_employed_retirement_adjustment # Line 51 - early_withdrawal_penalty # Line 52 - - self_employed_health_insurance_ald_person # Line 56 + - ms_self_employed_health_insurance_adjustment # Line 56 - health_savings_account_ald # Line 57 - ms_retirement_income_exemption - us_govt_interest_person diff --git a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py index 42beb80fe4c..89eccda99ac 100644 --- a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py @@ -414,3 +414,82 @@ def test_dependent_deductions_stay_off_the_filers_return_2020(units): @pytest.mark.parametrize("year", [2020, 2025]) def test_seeded_population(year): _check(_seeded_units(), year) + + +def _single_filer(year, state, **inputs): + return { + "people": { + "filer": { + "age": {year: 45}, + "employment_income": {year: 50_000}, + "is_tax_unit_head": {year: True}, + } + }, + "tax_units": { + "tax_unit": { + "members": ["filer"], + **{name: {year: value} for name, value in inputs.items()}, + } + }, + "households": { + "household": {"members": ["filer"], "state_code": {year: state}} + }, + } + + +def test_person_splits_honor_tax_unit_inputs(): + # A tax-unit deduction supplied as an input still reaches the per-person + # Medicaid AGI and the Mississippi adjustments, attributed to the head. + sim = Simulation( + situation=_single_filer( + 2025, + "MS", + alimony_expense_ald=3_000, + self_employed_health_insurance_ald=200, + self_employed_pension_contribution_ald=100, + ) + ) + agi = sim.calculate("adjusted_gross_income", 2025) + assert np.allclose(agi, 50_000 - 3_000 - 200 - 100) + assert np.allclose( + sim.calculate("medicaid_adjusted_gross_income_person", 2025), agi + ) + assert np.allclose( + sim.calculate("ms_self_employed_health_insurance_adjustment", 2025), 200 + ) + assert np.allclose( + sim.calculate("ms_self_employed_retirement_adjustment", 2025), 100 + ) + + +def test_person_splits_follow_the_deduction_list(): + # Removing alimony from gov.irs.ald.deductions removes it from both the + # federal AGI and the Medicaid AGI built per person. + from policyengine_core.periods import instant + from policyengine_core.reforms import Reform + + class drop_alimony_deduction(Reform): + def apply(self): + def modify(parameters): + node = parameters.gov.irs.ald.deductions + start = instant("2025-01-01") + kept = [d for d in node(start) if d != "alimony_expense_ald"] + node.update(start=start, stop=instant("2025-12-31"), value=kept) + return parameters + + self.modify_parameters(modify) + + situation = _single_filer(2025, "TX") + situation["people"]["filer"].update( + alimony_expense={2025: 1_000}, divorce_year={2025: 2010} + ) + baseline = Simulation(situation=situation) + assert np.allclose(baseline.calculate("adjusted_gross_income", 2025), 49_000) + assert np.allclose( + baseline.calculate("medicaid_adjusted_gross_income_person", 2025), 49_000 + ) + reformed = Simulation(situation=situation, reform=drop_alimony_deduction) + assert np.allclose(reformed.calculate("adjusted_gross_income", 2025), 50_000) + assert np.allclose( + reformed.calculate("medicaid_adjusted_gross_income_person", 2025), 50_000 + ) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml index 04a511a887f..b35bcc9e710 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml @@ -102,3 +102,24 @@ # 3,000 = 1,000. alimony_expense_ald_person: [0, 3_000] medicaid_adjusted_gross_income_person: [50_000, 1_000] + +- name: Case 5, a tax-unit alimony deduction supplied as an input goes to the head. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + filer: + age: 45 + is_tax_unit_head: true + employment_income: 50_000 + tax_units: + tax_unit: + members: [filer] + alimony_expense_ald: 3_000 + households: + household: + members: [filer] + state_code: TX + output: + adjusted_gross_income: 47_000 + medicaid_adjusted_gross_income_person: 47_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi_adjustments.yaml b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi_adjustments.yaml index c0950606470..37294ec1b31 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi_adjustments.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi_adjustments.yaml @@ -2,9 +2,9 @@ period: 2025 input: state_code: MS - self_employed_pension_contribution_ald_person: 100 + self_employed_pension_contribution_ald: 100 early_withdrawal_penalty: 50 - self_employed_health_insurance_ald_person: 200 + self_employed_health_insurance_ald: 200 health_savings_account_ald: 150 output: ms_agi_adjustments: 500 diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index 2349d72f08a..03f8482d688 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -41,6 +41,23 @@ def tax_unit_non_dep_add(tax_unit, period, variables, include_dependents=()): return total +def person_share_of_tax_unit_amount(person, period, tax_unit_variable, person_variable): + """ + Attribute a tax unit's amount to its members. + + Each member gets their own person-level amount. Any difference between + the tax unit's amount and its head's and spouse's own amounts, as when the + tax-unit variable is an input or a reform changes its formula, goes to + the head. Dependents keep their own amounts, which a tax-unit amount that + describes the filer's return leaves out. + """ + own = person(person_variable, period) + not_dependent = ~person("is_tax_unit_dependent", period) + filers_own = person.tax_unit.sum(own * not_dependent) + residual = person.tax_unit(tax_unit_variable, period) - filers_own + return own + person("is_tax_unit_head", period) * residual + + def sum_contained_tax_units(var, population, period): tax_unit = population.members.tax_unit.reference_entity values = tax_unit(var, period) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py index 1ec6d2ef4d8..7cb76af407c 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py @@ -1,19 +1,13 @@ from policyengine_us.model_api import * -PERSON_LEVEL_MEDICAID_AGI_ALDS = [ - "self_employment_tax_ald_person", - "self_employed_health_insurance_ald_person", - "self_employed_pension_contribution_ald_person", - "alimony_expense_ald_person", -] - -TAX_UNIT_AGI_ALDS_WITH_PERSON_LEVEL_EQUIVALENTS = ( - "self_employment_tax_ald", - "self_employed_health_insurance_ald", - "self_employed_pension_contribution_ald", - "alimony_expense_ald", -) +# Above-the-line deductions the model attributes to the person who has them. +TAX_UNIT_AGI_ALDS_WITH_PERSON_LEVEL_EQUIVALENTS = { + "self_employment_tax_ald": "self_employment_tax_ald_person", + "self_employed_health_insurance_ald": "self_employed_health_insurance_ald_person", + "self_employed_pension_contribution_ald": "self_employed_pension_contribution_ald_person", + "alimony_expense_ald": "alimony_expense_ald_person", +} class medicaid_adjusted_gross_income_person(Variable): @@ -26,9 +20,20 @@ class medicaid_adjusted_gross_income_person(Variable): def formula(person, period, parameters): gross_income = person("medicaid_irs_gross_income", period) - ald_sum_person = add(person, period, PERSON_LEVEL_MEDICAID_AGI_ALDS) p = parameters(period).gov.irs.ald all_alds = p.deductions + # Each listed deduction with a person-level equivalent goes to the + # person who has it, reconciled to the tax-unit amount (an input or a + # reform of it) with any difference going to the head. + ald_sum_person = 0 + for ald in all_alds: + if ald in TAX_UNIT_AGI_ALDS_WITH_PERSON_LEVEL_EQUIVALENTS: + ald_sum_person = ald_sum_person + person_share_of_tax_unit_amount( + person, + period, + ald, + TAX_UNIT_AGI_ALDS_WITH_PERSON_LEVEL_EQUIVALENTS[ald], + ) other_alds = [ ald for ald in all_alds diff --git a/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_health_insurance_adjustment.py b/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_health_insurance_adjustment.py new file mode 100644 index 00000000000..c67e19c063c --- /dev/null +++ b/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_health_insurance_adjustment.py @@ -0,0 +1,26 @@ +from policyengine_us.model_api import * + + +class ms_self_employed_health_insurance_adjustment(Variable): + value_type = float + entity = Person + label = "Mississippi self-employed health insurance adjustment" + unit = USD + documentation = ( + "Each person's own self-employed health insurance deduction (Form " + "80-105 line 56). Mississippi reports each spouse's income and " + "adjustments separately, so the tax unit's deduction is not repeated " + "for every member; any part of it beyond the head's and spouse's own " + "amounts is the head's." + ) + definition_period = YEAR + reference = "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=12" + defined_for = StateCode.MS + + def formula(person, period, parameters): + return person_share_of_tax_unit_amount( + person, + period, + "self_employed_health_insurance_ald", + "self_employed_health_insurance_ald_person", + ) diff --git a/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_retirement_adjustment.py b/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_retirement_adjustment.py new file mode 100644 index 00000000000..9ca3aa1bc59 --- /dev/null +++ b/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_retirement_adjustment.py @@ -0,0 +1,26 @@ +from policyengine_us.model_api import * + + +class ms_self_employed_retirement_adjustment(Variable): + value_type = float + entity = Person + label = "Mississippi self-employed retirement plan adjustment" + unit = USD + documentation = ( + "Each person's own self-employed SEP, SIMPLE and qualified plan " + "deduction (Form 80-105 line 51). Mississippi reports each spouse's " + "income and adjustments separately, so the tax unit's deduction is not " + "repeated for every member; any part of it beyond the head's and " + "spouse's own amounts is the head's." + ) + definition_period = YEAR + reference = "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=12" + defined_for = StateCode.MS + + def formula(person, period, parameters): + return person_share_of_tax_unit_amount( + person, + period, + "self_employed_pension_contribution_ald", + "self_employed_pension_contribution_ald_person", + ) From 7bcaed062b9404f514d784cc49cfa981faeac6f9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 02:49:52 -0400 Subject: [PATCH 5/6] Share a reduced tax-unit deduction in proportion, never negatively Review r3: when a tax-unit deduction (an input or a reformed formula) was below the head's and spouse's own amounts, the helper put the whole negative residual on the head. The spouse's own deduction then drove their Mississippi AGI and Medicaid MAGI below the per-person floors, and the head's negative share added income, inflating Mississippi AGI and Medicaid household income (by 70.65 in the reviewer's joint case). person_share_of_tax_unit_amount now keeps own amounts when they sum to the tax-unit amount (compared in its float32 precision), and otherwise scales the head's and spouse's own amounts to sum to it, giving the head the whole amount only when they have none. Tests: a joint Mississippi couple with the tax-unit SE health insurance deduction set to 0 and to 500 below the spouse's 1,000, and a reform that zeroes the aggregate's formula; each matches a couple whose own amount is that deduction in Mississippi AGI, Medicaid MAGI and Medicaid household income. All three fail at 49c9a3a. Co-Authored-By: Claude Opus 5.5 --- ...nt_above_the_line_deductions_invariants.py | 89 +++++++++++++++++++ policyengine_us/tools/general.py | 35 ++++++-- .../medicaid_adjusted_gross_income_person.py | 4 +- ...lf_employed_health_insurance_adjustment.py | 4 +- .../ms_self_employed_retirement_adjustment.py | 4 +- 5 files changed, 121 insertions(+), 15 deletions(-) diff --git a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py index 89eccda99ac..b985bf626f1 100644 --- a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py @@ -493,3 +493,92 @@ def modify(parameters): assert np.allclose( reformed.calculate("medicaid_adjusted_gross_income_person", 2025), 50_000 ) + + +def _ms_couple(premiums, **tax_unit_inputs): + year = 2025 + return { + "people": { + "head": { + "age": {year: 45}, + "is_tax_unit_head": {year: True}, + "employment_income": {year: 1_000}, + }, + "spouse": { + "age": {year: 43}, + "is_tax_unit_spouse": {year: True}, + "self_employment_income": {year: 1_000}, + "self_employed_health_insurance_premiums": {year: premiums}, + }, + }, + "tax_units": { + "tax_unit": { + "members": ["head", "spouse"], + **{name: {year: value} for name, value in tax_unit_inputs.items()}, + } + }, + "marital_units": {"couple": {"members": ["head", "spouse"]}}, + "households": { + "household": {"members": ["head", "spouse"], "state_code": {year: "MS"}} + }, + } + + +SPLIT_OUTPUTS = [ + "ms_self_employed_health_insurance_adjustment", + "ms_agi", + "medicaid_adjusted_gross_income_person", + "medicaid_magi_person", + "medicaid_household_income", + "adjusted_gross_income", +] + + +def _split_outputs(sim): + return {name: np.asarray(sim.calculate(name, 2025)) for name in SPLIT_OUTPUTS} + + +@pytest.mark.parametrize("aggregate", [0, 500]) +def test_reduced_tax_unit_amount_is_shared_without_negative_shares(aggregate): + # A tax-unit deduction set below the spouse's own amount is shared in + # proportion to the filers' own amounts, so no share turns negative and + # the per-person floors in Mississippi AGI and Medicaid MAGI cannot add + # income. The result equals a couple whose own amount is that deduction. + reduced = Simulation( + situation=_ms_couple(1_000, self_employed_health_insurance_ald=aggregate) + ) + control = Simulation(situation=_ms_couple(aggregate)) + shares = reduced.calculate("ms_self_employed_health_insurance_adjustment", 2025) + assert (np.asarray(shares) >= 0).all() + assert np.allclose(shares, [0, aggregate]) + for name, values in _split_outputs(control).items(): + np.testing.assert_allclose( + _split_outputs(reduced)[name], values, atol=TOLERANCE, err_msg=name + ) + + +def test_reformed_tax_unit_amount_is_shared_like_an_input(): + from policyengine_core.reforms import Reform + + from policyengine_us.model_api import TaxUnit, Variable + + class self_employed_health_insurance_ald(Variable): + value_type = float + entity = TaxUnit + definition_period = "year" + + def formula(tax_unit, period, parameters): + return tax_unit.filled_array(0) + + class repeal_self_employed_health_insurance(Reform): + def apply(self): + self.update_variable(self_employed_health_insurance_ald) + + reformed = Simulation( + situation=_ms_couple(1_000), reform=repeal_self_employed_health_insurance + ) + control = Simulation(situation=_ms_couple(0)) + for name, values in _split_outputs(control).items(): + np.testing.assert_allclose( + _split_outputs(reformed)[name], values, atol=TOLERANCE, err_msg=name + ) diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index 03f8482d688..13a40d76660 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -45,17 +45,34 @@ def person_share_of_tax_unit_amount(person, period, tax_unit_variable, person_va """ Attribute a tax unit's amount to its members. - Each member gets their own person-level amount. Any difference between - the tax unit's amount and its head's and spouse's own amounts, as when the - tax-unit variable is an input or a reform changes its formula, goes to - the head. Dependents keep their own amounts, which a tax-unit amount that - describes the filer's return leaves out. + Dependents keep their own person-level amounts, which a tax-unit amount + describing the filer's return leaves out. The head and spouse get their + own amounts when those sum to the tax unit's amount. When the tax-unit + amount differs, as when it is an input or a reform changes its formula, + the head's and spouse's own amounts are scaled to sum to it, so neither + share turns negative; if they have no own amounts, the head takes it. + Every tax unit is assumed to have a head. """ + # The person-level projector returns tax-unit values for each member. + tax_unit = person.tax_unit own = person(person_variable, period) - not_dependent = ~person("is_tax_unit_dependent", period) - filers_own = person.tax_unit.sum(own * not_dependent) - residual = person.tax_unit(tax_unit_variable, period) - filers_own - return own + person("is_tax_unit_head", period) * residual + filer = ~person("is_tax_unit_dependent", period) + filers_own = tax_unit.sum(own * filer) + amount = tax_unit(tax_unit_variable, period) + # The tax-unit amount is stored in float32, so compare in its precision. + matches = amount == filers_own.astype(amount.dtype) + has_own = filers_own > 0 + scale = np.divide( + amount, + filers_own, + out=np.zeros_like(filers_own, dtype=float), + where=has_own, + ) + reconciled = own * scale + person("is_tax_unit_head", period) * where( + has_own, 0, amount + ) + filer_share = where(matches, own, reconciled) + return where(filer, filer_share, own) def sum_contained_tax_units(var, population, period): diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py index 7cb76af407c..b1176492612 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py @@ -23,8 +23,8 @@ def formula(person, period, parameters): p = parameters(period).gov.irs.ald all_alds = p.deductions # Each listed deduction with a person-level equivalent goes to the - # person who has it, reconciled to the tax-unit amount (an input or a - # reform of it) with any difference going to the head. + # person who has it, reconciled to the tax-unit amount when that is an + # input or a reform changes it. ald_sum_person = 0 for ald in all_alds: if ald in TAX_UNIT_AGI_ALDS_WITH_PERSON_LEVEL_EQUIVALENTS: diff --git a/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_health_insurance_adjustment.py b/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_health_insurance_adjustment.py index c67e19c063c..b0de576e2e9 100644 --- a/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_health_insurance_adjustment.py +++ b/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_health_insurance_adjustment.py @@ -10,8 +10,8 @@ class ms_self_employed_health_insurance_adjustment(Variable): "Each person's own self-employed health insurance deduction (Form " "80-105 line 56). Mississippi reports each spouse's income and " "adjustments separately, so the tax unit's deduction is not repeated " - "for every member; any part of it beyond the head's and spouse's own " - "amounts is the head's." + "for every member. A tax-unit deduction that differs from the head's " + "and spouse's own amounts is shared in proportion to them." ) definition_period = YEAR reference = "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=12" diff --git a/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_retirement_adjustment.py b/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_retirement_adjustment.py index 9ca3aa1bc59..77ac9425c84 100644 --- a/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_retirement_adjustment.py +++ b/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_retirement_adjustment.py @@ -10,8 +10,8 @@ class ms_self_employed_retirement_adjustment(Variable): "Each person's own self-employed SEP, SIMPLE and qualified plan " "deduction (Form 80-105 line 51). Mississippi reports each spouse's " "income and adjustments separately, so the tax unit's deduction is not " - "repeated for every member; any part of it beyond the head's and " - "spouse's own amounts is the head's." + "repeated for every member. A tax-unit deduction that differs from the " + "head's and spouse's own amounts is shared in proportion to them." ) definition_period = YEAR reference = "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=12" From dd9a0542443adfed87b2f8d51c97b447e59efd2d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 03:05:23 -0400 Subject: [PATCH 6/6] State the sign precondition of the person-share helper Review r4: the proportional split keeps every share non-negative only when the own amounts and the tax-unit amount are non-negative, as they are for the deductions that use it. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tools/general.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index 13a40d76660..afd752d19f7 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -49,8 +49,9 @@ def person_share_of_tax_unit_amount(person, period, tax_unit_variable, person_va describing the filer's return leaves out. The head and spouse get their own amounts when those sum to the tax unit's amount. When the tax-unit amount differs, as when it is an input or a reform changes its formula, - the head's and spouse's own amounts are scaled to sum to it, so neither - share turns negative; if they have no own amounts, the head takes it. + the head's and spouse's own amounts are scaled to sum to it; if they have + no own amounts, the head takes it. With non-negative own amounts and a + non-negative tax-unit amount, as for deductions, no share is negative. Every tax unit is assumed to have a head. """ # The person-level projector returns tax-unit values for each member.