From 9346e9e4fcd04d8d16fa520e89b2e970084eb873 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 22:42:53 -0400 Subject: [PATCH 01/28] Give each spouse their own above-the-line deductions in per-person AGI adjusted_gross_income_person split every above-the-line deduction other than the three self-employment ones 50/50 between head and spouse on a joint return. Each person now takes their own deductions through above_the_line_deductions_person; only tax-unit-level inputs (HSA, tuition and fees, domestic production, possession income) are divided equally. Adds alimony_expense_ald_person, loss_ald_person and limited_capital_loss_person, moves Medicaid's per-person AGI and Montana's Social Security worksheet onto the same attribution, and applies Montana's capital loss rule for spouses filing separately (MCA 15-30-2110(6)). Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-agi-person-ald-split.fixed.md | 1 + .../tests/core/test_ald_determinism.py | 86 ++- ...nt_above_the_line_deductions_invariants.py | 6 +- ...se_above_the_line_deductions_invariants.py | 525 ++++++++++++++++++ .../loss_ald_person.yaml | 126 +++++ .../adjusted_gross_income_person.yaml | 4 +- ...ed_gross_income_person_own_deductions.yaml | 316 +++++++++++ .../tax/income/dc_agi_spouse_deductions.yaml | 24 + .../dc_self_employment_loss_addition.yaml | 7 +- ..._pre_exclusions_agi_spouse_deductions.yaml | 27 + .../il/hfs/fpp/il_fpp_income_level.yaml | 24 + .../tax/income/ky_agi_spouse_deductions.yaml | 30 + .../mn_renters_credit_household_agi.yaml | 25 + .../income/mt_capital_loss_reallocation.yaml | 83 +++ ...joint_filing_credit_qualifying_income.yaml | 26 + .../states/va/tax/income/va_agi_person.yaml | 24 + ...r_citizen_disability_deduction_person.yaml | 24 + .../above_the_line_deductions/README.md | 16 +- .../above_the_line_deductions_person.py | 104 ++++ .../alimony_expense_ald_person.py | 17 +- .../limited_capital_loss_person.py | 41 ++ .../loss_ald_person.py | 71 +++ .../adjusted_gross_income_person.py | 40 +- .../states/mt/tax/income/base/mt_agi_indiv.py | 6 +- .../base/mt_applicable_ald_deductions.py | 25 +- .../base/mt_capital_loss_reallocation.py | 41 ++ 26 files changed, 1656 insertions(+), 63 deletions(-) create mode 100644 changelog.d/fix-agi-person-ald-split.fixed.md create mode 100644 policyengine_us/tests/core/test_spouse_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/loss_ald_person.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person_own_deductions.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_agi_spouse_deductions.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_pre_exclusions_agi_spouse_deductions.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_agi_spouse_deductions.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/mn_renters_credit_household_agi.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_capital_loss_reallocation.yaml create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss_person.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py create mode 100644 policyengine_us/variables/gov/states/mt/tax/income/base/mt_capital_loss_reallocation.py diff --git a/changelog.d/fix-agi-person-ald-split.fixed.md b/changelog.d/fix-agi-person-ald-split.fixed.md new file mode 100644 index 00000000000..dce561393e9 --- /dev/null +++ b/changelog.d/fix-agi-person-ald-split.fixed.md @@ -0,0 +1 @@ +Give each spouse their own above-the-line deductions in per-person AGI on a joint return (IRA, educator expenses, early withdrawal penalty, student loan interest, alimony paid, business and capital losses, and the rest), instead of splitting every deduction other than the self-employment ones 50/50; divide only tax-unit-level inputs (HSA, tuition and fees, domestic production, possession income) equally. This changes the state taxes and benefits that read each spouse's AGI (Kentucky, Montana, Delaware, Virginia and DC separate filing, Ohio's joint filing credit, West Virginia's senior deduction, state withholding, Medicaid household income, Illinois Family Planning). Montana's per-spouse AGI now follows its own capital loss rule (MCA 15-30-2110(6)). diff --git a/policyengine_us/tests/core/test_ald_determinism.py b/policyengine_us/tests/core/test_ald_determinism.py index 58269f0372b..89e4f10381f 100644 --- a/policyengine_us/tests/core/test_ald_determinism.py +++ b/policyengine_us/tests/core/test_ald_determinism.py @@ -29,13 +29,7 @@ ] -@pytest.mark.parametrize("variable_name", FORMULAS) -@pytest.mark.parametrize("reverse", [False, True]) -@pytest.mark.parametrize("empty", [False, True]) -def test_tax_unit_deductions_are_ordered_unique_and_exclude_person_alds( - monkeypatch, variable_name, reverse, empty -): - module = importlib.import_module(FORMULAS[variable_name]) +def _deductions(reverse, empty): deductions = ( [] if empty @@ -52,6 +46,17 @@ def test_tax_unit_deductions_are_ordered_unique_and_exclude_person_alds( ) if reverse: deductions.reverse() + return deductions + + +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("empty", [False, True]) +def test_tax_unit_deductions_are_ordered_unique_and_exclude_person_alds( + monkeypatch, reverse, empty +): + variable_name = "student_loan_interest_ald_magi" + module = importlib.import_module(FORMULAS[variable_name]) + deductions = _deductions(reverse, empty) magi = SimpleNamespace( person_alds=PERSON_ALDS, excluded_alds=["student_loan_interest_ald", "puerto_rico_income"], @@ -102,21 +107,64 @@ def capture_tax_unit_add(entity, period, variables, include_dependents=()): with pytest.raises(ReachedTaxUnitSum): getattr(module, variable_name).formula(person, 2026, lambda period: parameters) - if empty: - expected = [] - elif variable_name == "student_loan_interest_ald_magi": - expected = ["alimony_expense_ald", "educator_expense", "loss_ald"] - else: - expected = [ - "alimony_expense_ald", - "educator_expense", - "loss_ald", - "puerto_rico_income", - "student_loan_interest_ald", - ] + expected = [] if empty else ["alimony_expense_ald", "educator_expense", "loss_ald"] assert captured == expected +PERSON_LEVEL = {"educator_expense", "student_loan_interest_ald"} +WITH_PERSON_AMOUNTS = {"loss_ald", "alimony_expense_ald", *PERSON_ALDS} + + +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("empty", [False, True]) +def test_person_deductions_are_summed_in_sorted_unique_order(reverse, empty): + module = importlib.import_module( + f"{AGI_MODULE}.above_the_line_deductions.above_the_line_deductions_person" + ) + deductions = _deductions(reverse, empty) + parameters = SimpleNamespace( + gov=SimpleNamespace( + irs=SimpleNamespace(ald=SimpleNamespace(deductions=deductions)) + ) + ) + order = [] + + class Variable: + def __init__(self, is_person): + self.entity = SimpleNamespace(is_person=is_person) + + class Entity: + def get_variable(self, name, check_existence=False): + if name.endswith("_person"): + if name[: -len("_person")] in WITH_PERSON_AMOUNTS: + return Variable(True) + return None + order.append(name) + return Variable(name in PERSON_LEVEL) + + class TaxUnit: + def __call__(self, name, period): + return np.array([0], dtype=np.float32) + + def sum(self, values): + return np.asarray(values, dtype=np.float32) + + class Person: + entity = Entity() + tax_unit = TaxUnit() + + def __call__(self, name, period): + if name == "is_tax_unit_dependent": + return np.array([False]) + return np.array([0], dtype=np.float32) + + module.above_the_line_deductions_person.formula( + Person(), 2026, lambda period: parameters + ) + expected = [] if empty else sorted(set(deductions)) + assert order == expected + + def _household_results(): """Run real formulas at a float32 rounding boundary, with no dataset.""" from policyengine_us import Simulation 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 b985bf626f1..170ad0fefa5 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 @@ -36,7 +36,7 @@ 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. + their own gross income less their own deductions, alimony paid included. """ import numpy as np @@ -377,7 +377,9 @@ def _check(units, year): atol=TOLERANCE, ) dependent = run["is_dependent"] - own_person_deductions = person_total - run["student_loan_interest_ald"] + # Alimony a dependent pays under a pre-2019 instrument is one of their own + # deductions too. + own_person_deductions = person_total - run["student_loan_interest_ald"] + alimony np.testing.assert_allclose( run["medicaid_adjusted_gross_income_person"][dependent], (run["medicaid_irs_gross_income"] - own_person_deductions)[dependent], diff --git a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py new file mode 100644 index 00000000000..c5b1586f699 --- /dev/null +++ b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py @@ -0,0 +1,525 @@ +"""Invariants for each spouse's own above-the-line deductions. + +Each person's AGI (adjusted_gross_income_person) is their own gross income +less their own above-the-line deductions (above_the_line_deductions_person). +On a joint return a deduction that belongs to one spouse, such as their IRA +deduction, educator expenses, early withdrawal penalty, alimony paid, student +loan interest or business and capital losses, lowers only that spouse's AGI. +Only deductions recorded for the tax unit alone (here the HSA deduction and +the tuition and fees deduction) are divided equally. States that tax spouses +separately (Kentucky, Montana, Delaware, Virginia, Ohio's joint filing credit, +the District of Columbia, West Virginia's senior deduction) read these +amounts. + +Hypothesis draws batches of tax units (single, joint, joint with dependents) +in those states, with every kind of deduction input and occasional business +losses over the Section 461(l) limit; a seeded population of 200 units adds +breadth. Each batch runs as one vectorized simulation, once as drawn and once +with the spouse's own deduction inputs set to zero. For every tax unit: + +1. Accounting identities. The head's and spouse's parts add up to the tax + unit's amount: above_the_line_deductions_person to + above_the_line_deductions, loss_ald_person to loss_ald, + limited_capital_loss_person to limited_capital_loss and + alimony_expense_ald_person to alimony_expense_ald. The members' + adjusted_gross_income_person add up to adjusted_gross_income, and so do + the head's and spouse's Medicaid AGIs. Montana's capital loss + reallocation adds up to zero. +2. Differential. above_the_line_deductions_person equals an independent numpy + reference built from the inputs: each person's own person-level + deductions, their alimony under a pre-2019 instrument, their share of the + limited business loss by their own business losses and of the limited + capital loss by their own capital losses, and an equal share of the HSA + and tuition and fees deductions. +3. Own deductions. The spouse's deductions never add to the head's: the + head's above_the_line_deductions_person is never higher with the spouse's + deduction inputs than without them. It is the same, and so is the head's + AGI, when the head has no loss or student loan interest that shares a + return-level limit with the spouse's, and no income (Social Security, + unemployment compensation) taxed by reference to the couple's AGI. The + spouse's own AGI and the couple's AGI never fall when the spouse's + deduction inputs are removed. +4. Dependents. A dependent's adjusted_gross_income_person, loss_ald_person + and limited_capital_loss_person are zero, and their + above_the_line_deductions_person is their own person-level deductions. +5. Bounds. Every above_the_line_deductions_person is non-negative. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +# Values are float32, and a business loss over the Section 461(l) limit puts +# AGI in the millions, so allow a relative error as well as cents. +TOLERANCE = 0.05 # dollars +RTOL = 2e-6 + +STATES = ["KY", "MT", "DE", "VA", "OH", "DC", "WV"] + +# A person's inputs behind deductions that belong to them. +OWN_DEDUCTION_INPUTS = [ + "traditional_ira_contributions", + "early_withdrawal_penalty", + "educator_expense", + "alimony_expense", + "qualified_adoption_assistance_expense", + "us_bonds_for_higher_ed", + "student_loan_interest", + "self_employed_health_insurance_premiums", + "self_employed_pension_contributions", +] +# Inputs whose negative values are losses. +LOSS_INPUTS = [ + "self_employment_income", + "rental_income", + "long_term_capital_gains", + "short_term_capital_gains", +] +BUSINESS_SOURCES = [ + "total_self_employment_income", + "farm_operations_income", + "rental_income", + "farm_rent_income", + "estate_income", + "partnership_s_corp_income", +] +TAX_UNIT_OUTPUTS = [ + "adjusted_gross_income", + "above_the_line_deductions", + "loss_ald", + "limited_capital_loss", + "alimony_expense_ald", + "health_savings_account_ald", + "other_net_gain", +] +PERSON_OUTPUTS = [ + "above_the_line_deductions_person", + "adjusted_gross_income_person", + "medicaid_adjusted_gross_income_person", + "loss_ald_person", + "limited_capital_loss_person", + "alimony_expense_ald_person", + "mt_capital_loss_reallocation", + "irs_gross_income", + "capital_losses", + "alimony_expense", + "divorce_year", + "student_loan_interest", + "social_security", + "unemployment_compensation", + *BUSINESS_SOURCES, +] + +amount = st.integers(1, 40_000).map(float) +small = st.one_of(st.just(0.0), st.integers(1, 7_000).map(float)) + + +def _maybe(draw, strategy): + return draw(st.one_of(st.just(0.0), strategy)) + + +@st.composite +def person_amounts(draw, *, dependent): + big_loss = draw(st.integers(0, 19)) == 0 + return { + "employment_income": _maybe(draw, amount), + "self_employment_income": ( + -1_500_000.0 + if big_loss + else _maybe(draw, st.integers(-20_000, 60_000).map(float)) + ), + "rental_income": _maybe(draw, st.integers(-15_000, 20_000).map(float)), + "long_term_capital_gains": _maybe( + draw, st.integers(-15_000, 15_000).map(float) + ), + "short_term_capital_gains": _maybe(draw, st.integers(-5_000, 5_000).map(float)), + "taxable_interest_income": _maybe(draw, st.integers(1, 2_000).map(float)), + "traditional_ira_contributions": draw(small), + "early_withdrawal_penalty": draw(st.integers(0, 500).map(float)), + "educator_expense": draw(st.integers(0, 400).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": _maybe(draw, st.integers(1, 3_500).map(float)), + "self_employed_health_insurance_premiums": draw(small), + "self_employed_pension_contributions": draw(small), + "social_security_retirement": (0.0 if dependent else _maybe(draw, amount)), + "unemployment_compensation": _maybe(draw, st.integers(1, 15_000).map(float)), + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "joint", "joint", "joint_dependents"])) + return { + "state": draw(st.sampled_from(STATES)), + "health_savings_account_ald": _maybe(draw, st.integers(1, 8_000).map(float)), + "other_net_gain": _maybe(draw, st.integers(-5_000, 5_000).map(float)), + "head": draw(person_amounts(dependent=False)), + "spouse": (draw(person_amounts(dependent=False)) if kind != "single" else None), + "dependents": [ + {"age": draw(st.integers(5, 23)), **draw(person_amounts(dependent=True))} + for _ in range(draw(st.integers(1, 2)) if kind == "joint_dependents" else 0) + ], + } + + +SEED = 20261006 + + +def _seeded_amounts(rng, *, dependent): + def some(low, high, p=0.5): + return float(round(rng.uniform(low, high))) if rng.random() < p else 0.0 + + return { + "employment_income": some(1, 40_000, 0.6), + "self_employment_income": ( + -1_500_000.0 if rng.random() < 0.05 else some(-20_000, 60_000, 0.6) + ), + "rental_income": some(-15_000, 20_000, 0.3), + "long_term_capital_gains": some(-15_000, 15_000, 0.4), + "short_term_capital_gains": some(-5_000, 5_000, 0.3), + "taxable_interest_income": some(1, 2_000), + "traditional_ira_contributions": some(1, 7_000), + "early_withdrawal_penalty": some(1, 500), + "educator_expense": some(1, 400), + "alimony_expense": some(1, 7_000, 0.3), + "divorce_year": int(rng.choice([2010, 2018, 2020])), + "qualified_adoption_assistance_expense": some(1, 7_000, 0.2), + "us_bonds_for_higher_ed": some(1, 7_000, 0.2), + "student_loan_interest": some(1, 3_500, 0.4), + "self_employed_health_insurance_premiums": some(1, 7_000, 0.3), + "self_employed_pension_contributions": some(1, 7_000, 0.3), + "social_security_retirement": 0.0 if dependent else some(1, 40_000, 0.3), + "unemployment_compensation": some(1, 15_000, 0.2), + } + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + units = [] + for _ in range(n): + kind = rng.choice(["single", "joint", "joint", "joint_dependents"]) + units.append( + { + "state": str(rng.choice(STATES)), + "health_savings_account_ald": ( + float(round(rng.uniform(1, 8_000))) if rng.random() < 0.3 else 0.0 + ), + "other_net_gain": ( + float(round(rng.uniform(-5_000, 5_000))) + if rng.random() < 0.2 + else 0.0 + ), + "head": _seeded_amounts(rng, dependent=False), + "spouse": ( + _seeded_amounts(rng, dependent=False) if kind != "single" else None + ), + "dependents": [ + { + "age": int(rng.integers(5, 24)), + **_seeded_amounts(rng, dependent=True), + } + for _ in range( + int(rng.integers(1, 3)) if kind == "joint_dependents" else 0 + ) + ], + } + ) + return units + + +def _zero_own_deductions(amounts): + zeroed = {**amounts, **{name: 0.0 for name in OWN_DEDUCTION_INPUTS}} + for name in LOSS_INPUTS: + zeroed[name] = max(0.0, zeroed[name]) + return zeroed + + +def _situation(units, year, *, zero_spouse): + 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}" + amounts = u["spouse"] + if zero_spouse: + amounts = _zero_own_deductions(amounts) + people[spouse] = { + "age": 48, + "is_tax_unit_head": False, + "is_tax_unit_spouse": True, + "is_tax_unit_dependent": False, + **amounts, + } + 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}" + 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, + **{k: v for k, v in d.items() if k != "age"}, + } + members.append(child) + groups["marital_units"][f"single_{i}_{j}"] = {"members": [child]} + groups["tax_units"][f"tax_unit_{i}"] = { + "members": members, + "health_savings_account_ald": {year: u["health_savings_account_ald"]}, + "other_net_gain": {year: u["other_net_gain"]}, + } + 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_spouse): + sim = Simulation(situation=_situation(units, year, zero_spouse=zero_spouse)) + tbs = sim.tax_benefit_system + deductions = tbs.parameters(f"{year}-01-01").gov.irs.ald.deductions + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in TAX_UNIT_OUTPUTS + PERSON_OUTPUTS + } + # The person-level deductions and the person amounts behind the tax-unit + # deductions other than losses and alimony, which the reference below + # builds from the inputs. + own_names, shared_names = [], [] + for deduction in sorted(set(deductions)): + if tbs.variables[deduction].entity.is_person: + own_names.append(deduction) + elif deduction in ("loss_ald", "alimony_expense_ald"): + continue + elif f"{deduction}_person" in tbs.variables: + own_names.append(f"{deduction}_person") + else: + shared_names.append(deduction) + out["own_direct"] = sum( + np.asarray(sim.calculate(name, year), dtype=float) for name in own_names + ) + out["shared"] = sum( + (np.asarray(sim.calculate(name, year), dtype=float) for name in shared_names), + np.zeros(len(out["adjusted_gross_income"])), + ) + out["is_dependent"] = np.asarray( + sim.calculate("is_tax_unit_dependent", year), dtype=bool + ) + out["is_head"] = np.asarray(sim.calculate("is_tax_unit_head", year), dtype=bool) + out["is_spouse"] = np.asarray(sim.calculate("is_tax_unit_spouse", 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 _per_person(run, unit_values): + return np.asarray(unit_values)[run["unit"]] + + +def _share(amounts, totals, fallback): + return np.where( + totals > 0, + np.divide(amounts, totals, out=np.zeros_like(amounts), where=totals > 0), + fallback, + ) + + +def _reference(run): + """above_the_line_deductions_person, built from the inputs with numpy.""" + filer = ~run["is_dependent"] + filers = _per_person(run, _unit_sum(run, filer.astype(float))) + even = np.divide( + filer.astype(float), filers, out=np.zeros_like(filers), where=filers > 0 + ) + business_loss = filer * sum( + np.maximum(0, -run[source]) for source in BUSINESS_SOURCES + ) + business_loss = business_loss + even * np.maximum( + 0, -_per_person(run, run["other_net_gain"]) + ) + capital_loss = filer * run["capital_losses"] + limited_capital = _per_person(run, run["limited_capital_loss"]) + limited_business = np.maximum( + 0, _per_person(run, run["loss_ald"]) - limited_capital + ) + losses = limited_business * _share( + business_loss, _per_person(run, _unit_sum(run, business_loss)), even + ) + limited_capital * _share( + capital_loss, _per_person(run, _unit_sum(run, capital_loss)), even + ) + alimony = run["alimony_expense"] * (run["divorce_year"] < 2019) + # Tax-unit deductions with no person amounts are divided equally. + shared = _per_person(run, run["shared"]) * even + return np.where( + filer, + run["own_direct"] + alimony + losses + shared, + run["own_direct"] + alimony, + ) + + +def _close(actual, desired, **kwargs): + np.testing.assert_allclose(actual, desired, atol=TOLERANCE, rtol=RTOL, **kwargs) + + +def _at_most(lower, upper): + return (lower <= upper + TOLERANCE + RTOL * np.abs(upper)).all() + + +def _check(units, year): + run = _run(units, year, zero_spouse=False) + zeroed = _run(units, year, zero_spouse=True) + filer = ~run["is_dependent"] + dependent = run["is_dependent"] + + # 1. Accounting identities. + pairs = [ + ("above_the_line_deductions_person", "above_the_line_deductions"), + ("loss_ald_person", "loss_ald"), + ("limited_capital_loss_person", "limited_capital_loss"), + ("alimony_expense_ald_person", "alimony_expense_ald"), + ] + for person_name, unit_name in pairs: + _close( + _unit_sum(run, filer * run[person_name]), + run[unit_name], + err_msg=person_name, + ) + _close( + _unit_sum(run, run["adjusted_gross_income_person"]), + run["adjusted_gross_income"], + ) + _close( + _unit_sum(run, filer * run["medicaid_adjusted_gross_income_person"]), + run["adjusted_gross_income"], + ) + _close(_unit_sum(run, run["mt_capital_loss_reallocation"]), 0) + + # 2. Differential against the numpy reference. + _close(run["above_the_line_deductions_person"], _reference(run)) + + # 3. The spouse's deductions never add to the head's. A head with student + # loan interest is left out of the first check: the spouse's IRA + # deduction, for example, lowers the couple's MAGI and with it the + # phase-out of the head's student loan interest deduction. + head = run["is_head"] + spouse = run["is_spouse"] + has_spouse = _per_person(run, _unit_sum(run, spouse.astype(float))) > 0 + paired_head = head & has_spouse + # Each filer's deductions as their AGI shows them. + deductions = "deductions_in_agi" + for r in (run, zeroed): + r[deductions] = r["irs_gross_income"] - r["adjusted_gross_income_person"] + agi = "adjusted_gross_income_person" + no_student_loan = run["student_loan_interest"] == 0 + checked = paired_head & no_student_loan + assert _at_most(run[deductions][checked], zeroed[deductions][checked]) + # A Form 4797 loss, recorded for the tax unit, is a business loss of both + # the head and spouse. + has_loss = ( + (run["capital_losses"] > 0) + | (sum(np.maximum(0, -run[source]) for source in BUSINESS_SOURCES) > 0) + | (_per_person(run, run["other_net_gain"]) < 0) + ) + own_only = checked & ~has_loss + _close(run[deductions][own_only], zeroed[deductions][own_only]) + joint_taxed_income = (run["social_security"] > 0) | ( + run["unemployment_compensation"] > 0 + ) + unaffected = own_only & ~joint_taxed_income + _close(run[agi][unaffected], zeroed[agi][unaffected]) + assert _at_most(run[agi][spouse], zeroed[agi][spouse]) + assert _at_most(run["adjusted_gross_income"], zeroed["adjusted_gross_income"]) + + # 4. Dependents. + for name in ["adjusted_gross_income_person", "loss_ald_person"]: + assert (run[name][dependent] == 0).all(), name + assert (run["limited_capital_loss_person"][dependent] == 0).all() + + # 5. Bounds. + assert (run["above_the_line_deductions_person"] >= -TOLERANCE).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=4, + 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_own_deductions_2026(units): + _check(units, 2026) + + +@settings(**SETTINGS) +@given(st.lists(tax_units(), min_size=10, max_size=40)) +def test_own_deductions_2023(units): + # 2023 is the last year Montana lets spouses who file jointly for federal + # purposes file separately on the same form, the path that reads each + # spouse's AGI and the Montana capital loss reallocation. + _check(units, 2023) + + +@settings(**SETTINGS) +@given(st.lists(tax_units(), min_size=10, max_size=40)) +def test_own_deductions_2020(units): + # 2020 adds the tuition and fees deduction, divided equally, and the + # unemployment compensation exclusion, which reads the couple's AGI. + _check(units, 2020) + + +@pytest.mark.parametrize("year", [2020, 2023, 2026]) +def test_seeded_population(year): + _check(_seeded_units(), year) + + +def test_every_deduction_is_attributed_or_divided_equally_on_purpose(): + # A tax-unit deduction with no person amount falls back to an equal + # division between the head and spouse. Only those listed, with their + # reasons, in EQUALLY_DIVIDED_DEDUCTIONS may do so. + from policyengine_us import CountryTaxBenefitSystem + from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.above_the_line_deductions_person import ( + EQUALLY_DIVIDED_DEDUCTIONS, + ) + + system = CountryTaxBenefitSystem() + deductions = system.parameters.gov.irs.ald.deductions + seen = set() + for value in deductions.values_list: + seen.update(deductions(value.instant_str)) + for deduction in sorted(seen): + variable = system.variables[deduction] + attributed = ( + variable.entity.is_person or f"{deduction}_person" in system.variables + ) + assert attributed != (deduction in EQUALLY_DIVIDED_DEDUCTIONS), deduction diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.yaml new file mode 100644 index 00000000000..f9d7f00b039 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.yaml @@ -0,0 +1,126 @@ +- name: Under the Section 461(l) limit, the allowed business loss is divided by each spouse's own losses. + period: 2024 + absolute_error_margin: 0.1 + input: + people: + head: + age: 50 + self_employment_income: -1_000_000 + spouse: + age: 50 + employment_income: 50_000 + rental_income: -200_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # The 2024 joint threshold is $610,000 (Rev. Proc. 2023-34). + loss_ald: 610_000 + # 610,000 x 1,000,000 / 1,200,000 and 610,000 x 200,000 / 1,200,000. + loss_ald_person: [508_333.33, 101_666.67] + adjusted_gross_income_person: [-508_333.33, -51_666.67] + +- name: A Form 4797 loss, recorded only for the tax unit, counts equally for the head and spouse. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + employment_income: 30_000 + spouse: + age: 50 + employment_income: 20_000 + tax_units: + tax_unit: + members: [head, spouse] + other_net_gain: -4_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + loss_ald: 4_000 + loss_ald_person: [2_000, 2_000] + +- name: Business and capital losses are divided separately. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + employment_income: 80_000 + self_employment_income: -5_000 + long_term_capital_gains: -4_000 + spouse: + age: 50 + employment_income: 20_000 + short_term_capital_gains: -2_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # $5,000 business loss plus the $3,000 capital loss limit. + loss_ald: 8_000 + # The head has all the business loss and two-thirds of the capital loss. + loss_ald_person: [7_000, 1_000] + adjusted_gross_income_person: [73_000, 19_000] + +- name: A dependent's losses are on their own return. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + employment_income: 50_000 + long_term_capital_gains: -1_000 + dependent: + age: 20 + is_full_time_student: true + is_tax_unit_spouse: false + is_tax_unit_dependent: true + self_employment_income: -3_000 + long_term_capital_gains: -2_000 + tax_units: + tax_unit: + members: [head, dependent] + households: + household: + members: [head, dependent] + state_code: TX + output: + loss_ald: 1_000 + loss_ald_person: [1_000, 0] + +- name: A loss deduction set directly, with no person losses, is divided equally. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + employment_income: 30_000 + spouse: + age: 50 + employment_income: 20_000 + tax_units: + tax_unit: + members: [head, spouse] + loss_ald: 2_500 + households: + household: + members: [head, spouse] + state_code: TX + output: + loss_ald_person: [1_250, 1_250] + adjusted_gross_income_person: [28_750, 18_750] diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.yaml index dc8b705e5ac..937128b9b5f 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.yaml @@ -77,8 +77,8 @@ members: [person1, person2, person3] output: adjusted_gross_income: 90_000 - # self-employment loss is shared evenly by the head and spouse - adjusted_gross_income_person: [55_000, 35_000, 0] + # The head's self-employment loss lowers only the head's AGI. + adjusted_gross_income_person: [50_000, 40_000, 0] - name: adjusted_gross_income_person unit test 4 absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person_own_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person_own_deductions.yaml new file mode 100644 index 00000000000..1ecf7dc7d92 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person_own_deductions.yaml @@ -0,0 +1,316 @@ +# Each spouse's own above-the-line deductions lower only that spouse's AGI. +# Before this was fixed, every deduction other than the three self-employment +# ones was split 50/50 between the head and spouse on a joint return. + +- name: The spouse's IRA deduction lowers only the spouse's AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 45 + employment_income: 40_000 + traditional_ira_contributions: 6_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + filing_status: JOINT + adjusted_gross_income: 94_000 + above_the_line_deductions_person: [0, 6_000] + # Before: [57_000, 37_000]. + adjusted_gross_income_person: [60_000, 34_000] + +- name: Educator expenses and an early withdrawal penalty go to the spouse who has them. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + educator_expense: 300 + spouse: + age: 45 + employment_income: 40_000 + early_withdrawal_penalty: 200 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + adjusted_gross_income: 99_500 + adjusted_gross_income_person: [59_700, 39_800] + +- name: Alimony paid goes to the payer, and only under a pre-2019 instrument. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + alimony_expense: 12_000 + divorce_year: 2015 + spouse: + age: 45 + employment_income: 40_000 + # Not deductible: the instrument is after 2018 (26 USC 215, repealed + # by Pub. L. 115-97 sec. 11051). + alimony_expense: 5_000 + divorce_year: 2020 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + alimony_expense_ald_person: [12_000, 0] + alimony_expense_ald: 12_000 + adjusted_gross_income: 88_000 + # Before: [54_000, 34_000]. + adjusted_gross_income_person: [48_000, 40_000] + +- name: Student loan interest is divided in proportion to the interest each spouse paid. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 60_000 + student_loan_interest: 1_000 + spouse: + age: 35 + employment_income: 40_000 + student_loan_interest: 2_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # The return's $2,500 cap (26 USC 221(b)(1)) is shared 1:2. + student_loan_interest_ald: [833.33, 1_666.67] + adjusted_gross_income: 97_500 + # Before: [58_750, 38_750]. + adjusted_gross_income_person: [59_166.67, 38_333.33] + +- name: An HSA deduction, recorded only for the tax unit, is divided equally. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + # 26 USC 223(b)(5) divides a married couple's family-coverage limit + # equally unless they agree otherwise. + health_savings_account_ald: 4_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + adjusted_gross_income: 96_000 + adjusted_gross_income_person: [58_000, 38_000] + +- name: The head's capital loss, after the $3,000 limit, lowers only the head's AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -10_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + loss_ald: 3_000 + loss_ald_person: [3_000, 0] + adjusted_gross_income: 97_000 + # Before: [58_500, 38_500]. + adjusted_gross_income_person: [57_000, 40_000] + +- name: The limited capital loss is divided in proportion to each spouse's own loss. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + short_term_capital_gains: -2_000 + spouse: + age: 45 + employment_income: 40_000 + long_term_capital_gains: -6_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + loss_ald: 3_000 + loss_ald_person: [750, 2_250] + adjusted_gross_income: 97_000 + adjusted_gross_income_person: [59_250, 37_750] + +- name: The spouse's business loss lowers only the spouse's AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 45 + employment_income: 40_000 + self_employment_income: -8_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + loss_ald: 8_000 + loss_ald_person: [0, 8_000] + adjusted_gross_income: 92_000 + # Before: [56_000, 36_000]. + adjusted_gross_income_person: [60_000, 32_000] + +- name: A tax-unit deduction set directly, with no person amounts, is divided equally. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + alimony_expense_ald: 3_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + adjusted_gross_income: 97_000 + above_the_line_deductions_person: [1_500, 1_500] + adjusted_gross_income_person: [58_500, 38_500] + +- name: A tax-unit deduction set directly is divided by the spouses' own amounts. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + self_employment_tax_ald_person: 1_000 + spouse: + age: 45 + employment_income: 40_000 + self_employment_tax_ald_person: 3_000 + tax_units: + tax_unit: + members: [head, spouse] + # Set above the $4,000 the person amounts add up to. + self_employment_tax_ald: 6_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + adjusted_gross_income: 94_000 + above_the_line_deductions_person: [1_500, 4_500] + adjusted_gross_income_person: [58_500, 35_500] + +- name: A single filer keeps all of the return's deductions. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + traditional_ira_contributions: 6_000 + tax_units: + tax_unit: + members: [head] + health_savings_account_ald: 2_000 + households: + household: + members: [head] + state_code: TX + output: + adjusted_gross_income: 52_000 + adjusted_gross_income_person: 52_000 + +- name: A dependent's own deductions stay on the dependent's return. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 45 + employment_income: 40_000 + traditional_ira_contributions: 6_000 + dependent: + age: 20 + is_full_time_student: true + employment_income: 5_000 + traditional_ira_contributions: 1_000 + tax_units: + tax_unit: + members: [head, spouse, dependent] + households: + household: + members: [head, spouse, dependent] + state_code: TX + output: + adjusted_gross_income: 94_000 + # The dependent's amount is what they deduct on their own return. + above_the_line_deductions_person: [0, 6_000, 1_000] + adjusted_gross_income_person: [60_000, 34_000, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_agi_spouse_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_agi_spouse_deductions.yaml new file mode 100644 index 00000000000..7fd6336e203 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_agi_spouse_deductions.yaml @@ -0,0 +1,24 @@ +# Schedule S, Calculation J(a): each person's portion of federal AGI. + +- name: The head's IRA deduction lowers only the head's DC AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + traditional_ira_contributions: 6_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: DC + output: + # Before: [57_000, 37_000]. + dc_agi: [54_000, 40_000] diff --git a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml index 8ed948a57f6..a2b55ff8688 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml @@ -109,7 +109,8 @@ state_code: DC output: adjusted_gross_income: 80_000 - # total IRS self-employment losses are shared evenly by head and spouse - adjusted_gross_income_person: [45_000, 35_000, 0] + # Each spouse's self-employment loss lowers their own federal AGI, as each + # reports their own income and deductions in Calculation J. + adjusted_gross_income_person: [40_000, 40_000, 0] dc_self_employment_loss_addition: [12_000, 6_000, 0] - dc_agi: [57_000, 41_000, 0] + dc_agi: [52_000, 46_000, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_pre_exclusions_agi_spouse_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_pre_exclusions_agi_spouse_deductions.yaml new file mode 100644 index 00000000000..3c11ccfc845 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_pre_exclusions_agi_spouse_deductions.yaml @@ -0,0 +1,27 @@ +# Spouses filing combined separate returns each report their own income, +# losses and deductions: PIT-RES instructions (2025), page 5, line 1; +# 30 Del. C. 1162(b)(1). + +- name: The head's IRA deduction lowers only the head's Delaware AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + traditional_ira_contributions: 6_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: DE + output: + # PIT-RES line 1, columns B and A. Before: [57_000, 37_000]. + de_pre_exclusions_agi: [54_000, 40_000] + de_agi_indiv: [54_000, 40_000] diff --git a/policyengine_us/tests/policy/baseline/gov/states/il/hfs/fpp/il_fpp_income_level.yaml b/policyengine_us/tests/policy/baseline/gov/states/il/hfs/fpp/il_fpp_income_level.yaml index c05e65e94c2..629a8a38f1c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/il/hfs/fpp/il_fpp_income_level.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/il/hfs/fpp/il_fpp_income_level.yaml @@ -25,3 +25,27 @@ adjusted_gross_income_person: 0 output: il_fpp_income_level: 0 + +- name: Only the applicant's own deductions lower the applicant's income. + period: 2024 + absolute_error_margin: 0.001 + input: + people: + head: + age: 30 + employment_income: 30_000 + traditional_ira_contributions: 6_000 + spouse: + age: 30 + employment_income: 20_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: IL + output: + # Before: [27_000, 17_000]. + adjusted_gross_income_person: [24_000, 20_000] + il_fpp_income_level: [1.1742, 0.9785] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_agi_spouse_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_agi_spouse_deductions.yaml new file mode 100644 index 00000000000..5ccdaed15c6 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_agi_spouse_deductions.yaml @@ -0,0 +1,30 @@ +# Each spouse claims their own income and deductions on a combined return: +# Form 740 instructions (2024), page 13, filing status 2; KRS 141.180(3). + +- name: The head's IRA deduction lowers only the head's Kentucky AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + traditional_ira_contributions: 6_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: KY + output: + # Form 740, line 5, columns B and A. Before: [57_000, 37_000]. + adjusted_gross_income_person: [54_000, 40_000] + ky_agi: [54_000, 40_000] + # Line 11: line 9 less the $3,160 standard deduction. + ky_taxable_income_indiv: [50_840, 36_840] + # 4% of AGI less the standard deduction. + ky_withheld_income_tax: [2_033.60, 1_473.60] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/mn_renters_credit_household_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/mn_renters_credit_household_agi.yaml new file mode 100644 index 00000000000..fdd80c83110 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/mn_renters_credit_household_agi.yaml @@ -0,0 +1,25 @@ +- name: Household AGI counts the couple's AGI once, however it is divided between them. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 30_000 + traditional_ira_contributions: 6_000 + spouse: + age: 30 + tax_units: + tax_unit: + members: [head, spouse] + spm_units: + spm_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MN + output: + # Before, the spouse's AGI was -3,000 and the head's 27,000. + adjusted_gross_income_person: [24_000, 0] + mn_renters_credit_household_agi: 24_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_capital_loss_reallocation.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_capital_loss_reallocation.yaml new file mode 100644 index 00000000000..17838e59a84 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_capital_loss_reallocation.yaml @@ -0,0 +1,83 @@ +# Montana spouses who file jointly for federal purposes and separately on the +# same form (through 2023) each claim their own deductions; the IRA deduction +# goes to the spouse who made the contribution, and a capital loss clearly +# attributable to one spouse goes on that spouse's return, otherwise it is +# split equally. MCA 15-30-2110(6) and (8) (2021). + +- name: The head's IRA deduction lowers only the head's Montana AGI. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + traditional_ira_contributions: 6_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # Before: [57_000, 37_000]. + mt_agi_indiv: [54_000, 40_000] + mt_capital_loss_reallocation: [0, 0] + +- name: A capital loss of only one spouse goes on that spouse's return. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -4_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + limited_capital_loss_person: [3_000, 0] + mt_capital_loss_reallocation: [0, 0] + # Before: [58_500, 38_500]. + mt_agi_indiv: [57_000, 40_000] + +- name: When both spouses have capital losses, Montana splits the allowed loss equally. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -4_000 + spouse: + age: 45 + employment_income: 40_000 + short_term_capital_gains: -2_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # The federal part follows each spouse's own loss, 4,000 : 2,000. + limited_capital_loss_person: [2_000, 1_000] + adjusted_gross_income_person: [58_000, 39_000] + # Montana gives each spouse $1,500 of the $3,000. + mt_capital_loss_reallocation: [500, -500] + mt_agi_indiv: [58_500, 38_500] + mt_applicable_ald_deductions: [1_500, 1_500] diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/credits/joint_filing_credit/oh_joint_filing_credit_qualifying_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/credits/joint_filing_credit/oh_joint_filing_credit_qualifying_income.yaml index 158417b1341..38438c4490c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/credits/joint_filing_credit/oh_joint_filing_credit_qualifying_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/credits/joint_filing_credit/oh_joint_filing_credit_qualifying_income.yaml @@ -27,3 +27,29 @@ oh_joint_filing_credit_agi_subtractions: 10_000 output: oh_joint_filing_credit_qualifying_income: -6_000 + +- name: The spouse's own IRA deduction counts against the spouse's qualifying income. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 45 + employment_income: 3_000 + traditional_ira_contributions: 2_800 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: OH + output: + # The spouse's AGI is $200, under the $500 each spouse needs + # (R.C. 5747.05(E)). Before, half the IRA deduction went to the head: + # [58_600, 1_600], and the couple qualified. + oh_joint_filing_credit_qualifying_income: [60_000, 200] + oh_joint_filing_credit_eligible: false diff --git a/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/va_agi_person.yaml b/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/va_agi_person.yaml index b27b791a35c..785b76a0745 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/va_agi_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/va_agi_person.yaml @@ -107,3 +107,27 @@ state_code: VA output: va_agi_person: [-247, 55_728] + +- name: The head's IRA deduction lowers only the head's separate Virginia AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + traditional_ira_contributions: 6_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: VA + output: + # Worksheet for Determining Separate Virginia Adjusted Gross Income, + # line 10 (adjustments) and line 11. Before: [57_000, 37_000]. + va_agi_person: [54_000, 40_000] diff --git a/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.yaml b/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.yaml index 8d900252b8a..ff89b478593 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.yaml @@ -34,3 +34,27 @@ adjusted_gross_income_person: 20_000 output: wv_senior_citizen_disability_deduction_person: 0 + +- name: Each spouse's $8,000 limit uses their own AGI, after their own IRA deduction. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 67 + employment_income: 6_000 + traditional_ira_contributions: 2_000 + spouse: + age: 66 + employment_income: 20_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: WV + output: + # W. Va. Code 11-21-12(c): up to $8,000 of each eligible individual's + # federal AGI. Before: [5_000, 8_000]. + wv_senior_citizen_disability_deduction_person: [4_000, 8_000] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md index 8ee985038a9..1e299373e13 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md @@ -15,4 +15,18 @@ Above-the-line deductions are deductions made from gross income to arrive at adj | [223](https://www.law.cornell.edu/uscode/text/26/223) | Health savings accounts | | [469](https://www.law.cornell.edu/uscode/text/26/469) | Passive activity | | [911](https://www.law.cornell.edu/uscode/text/26/911) | Foreign earned income | -| [933](https://www.law.cornell.edu/uscode/text/26/933) | Income from Puerto Rico | \ No newline at end of file +| [933](https://www.law.cornell.edu/uscode/text/26/933) | Income from Puerto Rico | +## Each person's deductions + +Several state taxes and benefits need each spouse's own AGI on a joint return: Kentucky, Montana (through 2023), Delaware, Virginia and the District of Columbia, where spouses can file separately on one form; Ohio's joint filing credit; West Virginia's senior citizen and disability deduction; state withholding estimates; and Medicaid household income. `adjusted_gross_income_person` is each person's gross income less `above_the_line_deductions_person`. The head's and spouse's amounts add up to `above_the_line_deductions` and `adjusted_gross_income`. + +| Deduction | Who takes it | +| --- | --- | +| Person-level deductions: IRA, educator expenses, early withdrawal penalty, student loan interest, adoption assistance, U.S. savings bonds for education | The person who has it. Student loan interest is the return's limited deduction, divided by the interest each spouse paid. | +| Self-employment tax, self-employed health insurance and retirement plans, alimony paid | The person who has it, through `_person`. | +| Business and capital losses (`loss_ald`) | `loss_ald_person`: the return's business loss after Section 461(l), by each spouse's own business losses; the capital loss after the $3,000 limit (`limited_capital_loss_person`), by each spouse's own capital losses. A Form 4797 loss, recorded for the tax unit, counts equally for both. | +| HSA, tuition and fees, domestic production, possession income | Divided equally, since the input is the tax unit's. For an HSA, 26 USC 223(b)(5) divides a married couple's limit equally unless they agree otherwise. | + +A tax unit dependent's deductions are on their own return, so they never lower the filer's AGI; the dependent's `above_the_line_deductions_person` is what they deduct on that return, and Medicaid uses it. + +The states that read these amounts tell spouses to report their own income and deductions (Kentucky: KRS 141.180(3); Delaware: 30 Del. C. 1162(b)(1); the District: Schedule S, Calculation J; Virginia: the separate VAGI worksheet). Montana also has its own rule for capital losses: a loss clearly attributable to one spouse goes on that spouse's return, and otherwise it is split equally (MCA 15-30-2110(6), through 2023), which `mt_capital_loss_reallocation` applies. diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py new file mode 100644 index 00000000000..d529538e3d5 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py @@ -0,0 +1,104 @@ +from policyengine_us.model_api import * + +# Above-the-line deductions recorded only for the tax unit, with no person-level +# amount to attribute them by. Their amount is divided equally between the head +# and spouse. +EQUALLY_DIVIDED_DEDUCTIONS = ( + # An HSA belongs to one person, but the input is the return's total. On a + # joint return, 26 USC 223(b)(5) divides the family-coverage limit equally + # between spouses unless they agree on another division. + "health_savings_account_ald", + # Tuition and fees (26 USC 222, through 2020) the taxpayer paid for + # themself, their spouse or a dependent, under one limit for the return. + "capped_qualified_tuition_expenses_ald", + # Domestic production activities (26 USC 199, through 2017), an input for + # the tax unit. + "domestic_production_ald", + # Income of a bona fide resident of a possession (26 USC 931) or of Puerto + # Rico (26 USC 933), inputs for the tax unit. + "specified_possession_income", + "puerto_rico_income", +) + + +class above_the_line_deductions_person(Variable): + value_type = float + entity = Person + label = "Above-the-line deductions for each person" + unit = USD + documentation = ( + "Each person's above-the-line deductions. The head's and spouse's " + "amounts divide the tax unit's above_the_line_deductions between them. " + "Each has their own person-level deductions, such as their IRA " + "deduction, educator expenses and early withdrawal penalty, and their " + "own part of each tax-unit deduction that has a person-level amount " + "(_person), such as self-employment tax, alimony paid and " + "business and capital losses. A deduction recorded only for the tax " + "unit is divided equally between them. A tax unit dependent's amount " + "is the deductions they take on their own return." + ) + definition_period = YEAR + reference = "https://www.law.cornell.edu/uscode/text/26/62" + + def formula(person, period, parameters): + tax_unit = person.tax_unit + filer = ~person("is_tax_unit_dependent", period) + filers = tax_unit.sum(filer) + even_share = np.divide( + filer.astype(float), + filers, + out=np.zeros_like(filers, dtype=float), + where=filers > 0, + ) + total = 0 + # Sum in a fixed order so float32 results do not depend on the hash + # seed. + for deduction in sorted(set(parameters(period).gov.irs.ald.deductions)): + variable = person.entity.get_variable(deduction, check_existence=True) + if variable.entity.is_person: + # The person's own deduction. For the head and spouse it is + # their part of the return's sum over non-dependents. + total = total + person(deduction, period) + continue + return_amount = tax_unit(deduction, period) + person_variable = f"{deduction}_person" + if person.entity.get_variable(person_variable) is None: + total = total + return_amount * even_share + continue + own = person(person_variable, period) + filer_own = filer * own + filer_total = tax_unit.sum(filer_own) + # The return's amount is the sum of the head's and spouse's own + # amounts, unless it was set directly. Any difference is divided + # in proportion to their own amounts, or equally when neither has + # one, so the parts always add up to the return's amount. + weight = where( + filer_total > 0, + np.divide( + filer_own, + filer_total, + out=np.zeros_like(filer_total, dtype=float), + where=filer_total > 0, + ), + even_share, + ) + total = total + where( + filer, filer_own + (return_amount - filer_total) * weight, own + ) + # The head's and spouse's parts add up to above_the_line_deductions. + # If that is set directly, the difference is divided in proportion to + # their parts, or equally when neither has any. + filer_parts = filer * total + parts_total = tax_unit.sum(filer_parts) + weight = where( + parts_total > 0, + np.divide( + filer_parts, + parts_total, + out=np.zeros_like(parts_total, dtype=float), + where=parts_total > 0, + ), + even_share, + ) + residual = tax_unit("above_the_line_deductions", period) - parts_total + return where(filer, filer_parts + residual * weight, total) 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 index b454cbceff0..4d16942c2de 100644 --- 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 @@ -7,15 +7,18 @@ class alimony_expense_ald_person(Variable): 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." + "The alimony each person pays under a divorce or separation instrument " + "executed before 2019, which they deduct on their own return. " + "alimony_expense_ald adds the head's and spouse's amounts for the tax " + "unit's return." ) definition_period = YEAR - reference = "https://www.irs.gov/taxtopics/tc452" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/215", + "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) + eligible = p.divorce_year_threshold.calc(person("divorce_year", period)) + return person("alimony_expense", period) * eligible diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss_person.py new file mode 100644 index 00000000000..5710a12059b --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss_person.py @@ -0,0 +1,41 @@ +from policyengine_us.model_api import * + + +class limited_capital_loss_person(Variable): + value_type = float + entity = Person + label = "Limited capital loss deduction for each person" + unit = USD + documentation = ( + "Each head's or spouse's part of the tax unit's capital loss " + "deduction after the Section 1211(b) limit (limited_capital_loss), in " + "proportion to their own net capital losses. A tax unit dependent's " + "capital losses are on their own return, so their part is zero." + ) + definition_period = YEAR + reference = "https://www.law.cornell.edu/uscode/text/26/1211#b" + + def formula(person, period, parameters): + tax_unit = person.tax_unit + filer = ~person("is_tax_unit_dependent", period) + filers = tax_unit.sum(filer) + capital_loss = filer * person("capital_losses", period) + total_capital_loss = tax_unit.sum(capital_loss) + # When no member has a capital loss but the deduction was set + # directly, the head and spouse share it equally. + share = where( + total_capital_loss > 0, + np.divide( + capital_loss, + total_capital_loss, + out=np.zeros_like(total_capital_loss, dtype=float), + where=total_capital_loss > 0, + ), + np.divide( + filer.astype(float), + filers, + out=np.zeros_like(filers, dtype=float), + where=filers > 0, + ), + ) + return tax_unit("limited_capital_loss", period) * share diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py new file mode 100644 index 00000000000..edc14023f5a --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py @@ -0,0 +1,71 @@ +from policyengine_us.model_api import * + +# The person-level business, rental and estate amounts whose losses loss_ald +# deducts, subject to the Section 461(l) limit. +BUSINESS_INCOME_SOURCES = [ + "total_self_employment_income", + "farm_operations_income", + "rental_income", + "farm_rent_income", + "estate_income", + "partnership_s_corp_income", +] + + +class loss_ald_person(Variable): + value_type = float + entity = Person + label = "Business and capital loss ALD for each person" + unit = USD + documentation = ( + "Each head's or spouse's part of the tax unit's business and capital " + "loss deduction (loss_ald). The return's business loss, after the " + "Section 461(l) limit, is divided in proportion to each spouse's own " + "business, farm, rental, estate and partnership losses. Form 4797 " + "losses, recorded only for the tax unit, count equally for the head " + "and spouse, as other_net_gain_gross_income counts the gains. The " + "capital loss part is limited_capital_loss_person. A tax unit " + "dependent's losses are on their own return, so their part is zero." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/165", + "https://www.law.cornell.edu/uscode/text/26/461#l", + "https://www.law.cornell.edu/uscode/text/26/1211#b", + ) + + def formula(person, period, parameters): + tax_unit = person.tax_unit + filer = ~person("is_tax_unit_dependent", period) + filers = tax_unit.sum(filer) + even_share = np.divide( + filer.astype(float), + filers, + out=np.zeros_like(filers, dtype=float), + where=filers > 0, + ) + business_loss = 0 + for source in BUSINESS_INCOME_SOURCES: + business_loss = business_loss + max_(0, -person(source, period)) + other_net_loss = max_(0, -tax_unit("other_net_gain", period)) + business_loss = filer * business_loss + other_net_loss * even_share + total_business_loss = tax_unit.sum(business_loss) + # When no member has a business loss but the deduction was set + # directly, the head and spouse share it equally. + business_share = where( + total_business_loss > 0, + np.divide( + business_loss, + total_business_loss, + out=np.zeros_like(total_business_loss, dtype=float), + where=total_business_loss > 0, + ), + even_share, + ) + limited_business_loss = max_( + 0, + tax_unit("loss_ald", period) - tax_unit("limited_capital_loss", period), + ) + return limited_business_loss * business_share + person( + "limited_capital_loss_person", period + ) 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 710424f0fdd..fe8ae118077 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 @@ -6,6 +6,15 @@ class adjusted_gross_income_person(Variable): entity = Person label = "Federal adjusted gross income for each person" unit = USD + documentation = ( + "Each person's part of the tax unit's federal AGI: their own gross " + "income less their own above-the-line deductions " + "(above_the_line_deductions_person). On a joint return a deduction " + "that belongs to one spouse, such as their IRA deduction, lowers only " + "that spouse's AGI. The head's and spouse's amounts add up to the tax " + "unit's adjusted_gross_income. A tax unit dependent's income and " + "deductions are on their own return, so their amount here is zero." + ) definition_period = YEAR reference = "https://www.law.cornell.edu/uscode/text/26/62" @@ -14,34 +23,15 @@ def formula(person, period, parameters): # 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", - "self_employed_health_insurance_ald", - "self_employed_pension_contribution_ald", - ] - 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 - 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, - 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) - frac = where(fstatus == fstatus.possible_values.JOINT, 0.5, 1.0) - ald_sum_taxunit_shared = (is_head | is_spouse) * ald_sum_taxunit * frac - # calculate AGI by person - agi = gross_income - ald_sum_person - ald_sum_taxunit_shared + deductions = person("above_the_line_deductions_person", period) + agi = gross_income - not_dependent * deductions if parameters(period).gov.contrib.ubi_center.basic_income.taxable: basic_income = person.tax_unit("basic_income", period) # split basic income evenly between head and spouse + is_head = person("is_tax_unit_head", period) + is_spouse = person("is_tax_unit_spouse", period) + fstatus = person.tax_unit("filing_status", period) + frac = where(fstatus == fstatus.possible_values.JOINT, 0.5, 1.0) basic_income_shared = (is_head | is_spouse) * basic_income * frac agi += basic_income_shared return agi diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py index 943c22aca8c..af405e4bfa8 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py @@ -10,7 +10,11 @@ class mt_agi_indiv(Variable): defined_for = "mt_married_filing_separately_on_same_return_eligible" def formula(person, period, parameters): - agi = person("adjusted_gross_income_person", period) + # Each spouse's federal AGI, with the capital loss deduction divided + # under Montana's rule for spouses who file separately. + agi = person("adjusted_gross_income_person", period) + person( + "mt_capital_loss_reallocation", period + ) additions = person("mt_additions", period) subtractions = person("mt_subtractions", period) reduced_agi = max_(agi + additions - subtractions, 0) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py index 42fc2d02191..5ceae691b98 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py @@ -6,11 +6,30 @@ class mt_applicable_ald_deductions(Variable): entity = Person label = "Montana applicable above-the-line deductions " unit = USD + documentation = ( + "Each spouse's own federal adjustments to income, for their column of " + "the Taxable Social Security Benefits Schedule (Form 2, line 7, before " + "removing student loan interest). Items attributable to one spouse " + "are claimed by that spouse, and the capital loss deduction is " + "divided under Montana's rule (mt_capital_loss_reallocation)." + ) definition_period = YEAR defined_for = StateCode.MT + reference = ( + # 2023 Form 2 instructions, page 5: deductions attributable to only one + # spouse must be claimed by that spouse. + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=13", + # MCA 15-30-2110(6) and (8) (2021): capital losses and the IRA + # deduction of spouses who file separately. + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0100/0150-0300-0210-0100.html", + # Taxable Social Security Benefits Schedule, line 7. + "https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2023_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=6", + ) def formula(person, period, parameters): - ald_deductions = person.tax_unit("above_the_line_deductions", period) - total_deductions = ald_deductions / person.tax_unit("head_spouse_count", period) head_or_spouse = person("is_tax_unit_head_or_spouse", period) - return head_or_spouse * total_deductions + # The capital loss deduction is divided under Montana's rule. + deductions = person("above_the_line_deductions_person", period) - person( + "mt_capital_loss_reallocation", period + ) + return head_or_spouse * deductions diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_capital_loss_reallocation.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_capital_loss_reallocation.py new file mode 100644 index 00000000000..0b6c26f03ab --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_capital_loss_reallocation.py @@ -0,0 +1,41 @@ +from policyengine_us.model_api import * + + +class mt_capital_loss_reallocation(Variable): + value_type = float + entity = Person + label = "Montana reallocation of the capital loss deduction between spouses" + unit = USD + documentation = ( + "Each spouse's federal part of the capital loss deduction " + "(limited_capital_loss_person) less their part under Montana's rule " + "for spouses who file separately: a loss clearly attributable to one " + "spouse goes on that spouse's return, and otherwise the loss is split " + "equally. Adding this to a spouse's federal AGI gives the AGI Montana " + "uses for that spouse. The amounts add up to zero over the couple." + ) + definition_period = YEAR + defined_for = StateCode.MT + reference = ( + # MCA 15-30-2110(6) (2021), in force through tax year 2023. + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0100/0150-0300-0210-0100.html", + ) + + def formula(person, period, parameters): + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + has_loss = head_or_spouse & (person("capital_losses", period) > 0) + spouses_with_loss = person.tax_unit.sum(has_loss) + head_spouse_count = person.tax_unit("head_spouse_count", period) + limited_capital_loss = person.tax_unit("limited_capital_loss", period) + equal_part = np.divide( + head_or_spouse * limited_capital_loss, + head_spouse_count, + out=np.zeros_like(limited_capital_loss, dtype=float), + where=head_spouse_count > 0, + ) + montana_part = where( + spouses_with_loss == 1, + has_loss * limited_capital_loss, + equal_part, + ) + return person("limited_capital_loss_person", period) - montana_part From 3b0c04712ae7a399a727358317119f6a7001de60 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 22:52:53 -0400 Subject: [PATCH 02/28] Split loss_ald into limited_business_loss and the capital loss deduction loss_ald_person now divides the Section 461(l)-limited business loss by each spouse's business losses and the rest of loss_ald by their capital losses, so it stays right when loss_ald gains capital components (#9788). Adds the filer_share helper used by the per-person deduction variables. Co-Authored-By: Claude Opus 5.5 --- ...se_above_the_line_deductions_invariants.py | 11 ++- .../loss_ald_person.yaml | 1 + policyengine_us/tools/general.py | 28 ++++++ .../above_the_line_deductions_person.py | 43 ++------- .../limited_business_loss.py | 95 +++++++++++++++++++ .../limited_capital_loss_person.py | 27 +----- .../above_the_line_deductions/loss_ald.py | 74 +-------------- .../loss_ald_person.py | 54 ++++------- 8 files changed, 161 insertions(+), 172 deletions(-) create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py diff --git a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py index c5b1586f699..21f1b3f9976 100644 --- a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py @@ -90,6 +90,7 @@ "adjusted_gross_income", "above_the_line_deductions", "loss_ald", + "limited_business_loss", "limited_capital_loss", "alimony_expense_ald", "health_savings_account_ald", @@ -364,13 +365,13 @@ def _reference(run): 0, -_per_person(run, run["other_net_gain"]) ) capital_loss = filer * run["capital_losses"] - limited_capital = _per_person(run, run["limited_capital_loss"]) - limited_business = np.maximum( - 0, _per_person(run, run["loss_ald"]) - limited_capital - ) + # loss_ald is the business loss after the Section 461(l) limit plus the + # capital loss deduction. + limited_business = _per_person(run, run["limited_business_loss"]) + capital_deduction = _per_person(run, run["loss_ald"]) - limited_business losses = limited_business * _share( business_loss, _per_person(run, _unit_sum(run, business_loss)), even - ) + limited_capital * _share( + ) + capital_deduction * _share( capital_loss, _per_person(run, _unit_sum(run, capital_loss)), even ) alimony = run["alimony_expense"] * (run["divorce_year"] < 2019) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.yaml index f9d7f00b039..07844df18ac 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.yaml @@ -19,6 +19,7 @@ state_code: TX output: # The 2024 joint threshold is $610,000 (Rev. Proc. 2023-34). + limited_business_loss: 610_000 loss_ald: 610_000 # 610,000 x 1,000,000 / 1,200,000 and 610,000 x 200,000 / 1,200,000. loss_ald_person: [508_333.33, 101_666.67] diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index b730a0a30ca..3497a55b275 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -79,6 +79,34 @@ def person_share_of_tax_unit_amount(person, period, tax_unit_variable, person_va return where(filer, filer_share, own) +def filer_share(person, period, amounts): + """ + Each head's or spouse's share of the tax unit's total of `amounts`. + + `amounts` is a person-level array. A tax unit dependent's share is zero, + as their items belong on their own returns. When the head's and spouse's + amounts add up to zero or less, they share equally. + """ + tax_unit = person.tax_unit + filer = ~person("is_tax_unit_dependent", period) + filer_amounts = filer * amounts + total = tax_unit.sum(filer_amounts) + filers = tax_unit.sum(filer) + equal = np.divide( + filer.astype(float), + filers, + out=np.zeros_like(filers, dtype=float), + where=filers > 0, + ) + proportional = np.divide( + filer_amounts, + total, + out=np.zeros_like(total, dtype=float), + where=total > 0, + ) + return where(total > 0, proportional, equal) + + 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/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py index d529538e3d5..0199714747a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py @@ -43,13 +43,6 @@ class above_the_line_deductions_person(Variable): def formula(person, period, parameters): tax_unit = person.tax_unit filer = ~person("is_tax_unit_dependent", period) - filers = tax_unit.sum(filer) - even_share = np.divide( - filer.astype(float), - filers, - out=np.zeros_like(filers, dtype=float), - where=filers > 0, - ) total = 0 # Sum in a fixed order so float32 results do not depend on the hash # seed. @@ -63,42 +56,24 @@ def formula(person, period, parameters): return_amount = tax_unit(deduction, period) person_variable = f"{deduction}_person" if person.entity.get_variable(person_variable) is None: - total = total + return_amount * even_share + equal_share = filer_share(person, period, 0 * return_amount) + total = total + return_amount * equal_share continue own = person(person_variable, period) filer_own = filer * own - filer_total = tax_unit.sum(filer_own) # The return's amount is the sum of the head's and spouse's own # amounts, unless it was set directly. Any difference is divided # in proportion to their own amounts, or equally when neither has # one, so the parts always add up to the return's amount. - weight = where( - filer_total > 0, - np.divide( - filer_own, - filer_total, - out=np.zeros_like(filer_total, dtype=float), - where=filer_total > 0, - ), - even_share, - ) - total = total + where( - filer, filer_own + (return_amount - filer_total) * weight, own - ) + difference = return_amount - tax_unit.sum(filer_own) + share = filer_share(person, period, own) + total = total + where(filer, filer_own + difference * share, own) # The head's and spouse's parts add up to above_the_line_deductions. # If that is set directly, the difference is divided in proportion to # their parts, or equally when neither has any. filer_parts = filer * total - parts_total = tax_unit.sum(filer_parts) - weight = where( - parts_total > 0, - np.divide( - filer_parts, - parts_total, - out=np.zeros_like(parts_total, dtype=float), - where=parts_total > 0, - ), - even_share, + difference = tax_unit("above_the_line_deductions", period) - tax_unit.sum( + filer_parts ) - residual = tax_unit("above_the_line_deductions", period) - parts_total - return where(filer, filer_parts + residual * weight, total) + share = filer_share(person, period, total) + return where(filer, filer_parts + difference * share, total) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py new file mode 100644 index 00000000000..1fa394776f6 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py @@ -0,0 +1,95 @@ +from policyengine_us.model_api import * + + +class limited_business_loss(Variable): + value_type = float + entity = TaxUnit + label = "Limited business loss deduction" + unit = USD + documentation = ( + "The business, farm, rental, estate and partnership losses of the " + "head and spouse deductible from gross income, after the Section " + "461(l) excess business loss limit. loss_ald adds the limited " + "capital loss." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/165", + "https://www.law.cornell.edu/uscode/text/26/461#l", + ) + + def formula(tax_unit, period, parameters): + filing_status = tax_unit("filing_status", period) + # Section 461(l) excess business loss limitation + threshold_amount = parameters(period).gov.irs.ald.loss.max[filing_status] + person = tax_unit.members + # A tax-unit dependent's business, rental and estate items belong on + # the dependent's own return, as in irs_gross_income. They neither + # add to this return's losses nor raise its Section 461(l) limit. + not_dependent = ~person("is_tax_unit_dependent", period) + + # Section 461(l) compares business deductions to business gross + # income/gains plus the threshold amount. In this model, positive + # business amounts flow into gross income and losses are deducted here, + # so allowable business losses are capped at business income plus + # the threshold. + indiv_se_income = max_(0, person("total_self_employment_income", period)) + indiv_se_loss = max_(0, -person("total_self_employment_income", period)) + self_employment_income = tax_unit.sum(not_dependent * indiv_se_income) + self_employment_loss = tax_unit.sum(not_dependent * indiv_se_loss) + + # Schedule F farm business income/losses. + indiv_farm_operations_income = max_(0, person("farm_operations_income", period)) + indiv_farm_loss = max_(0, -person("farm_operations_income", period)) + farm_operations_income = tax_unit.sum( + not_dependent * indiv_farm_operations_income + ) + farm_loss = tax_unit.sum(not_dependent * indiv_farm_loss) + + # Schedule E rental, farm-rent, and estate/trust items. + indiv_rental_income = max_(0, person("rental_income", period)) + indiv_rental_loss = max_(0, -person("rental_income", period)) + rental_income = tax_unit.sum(not_dependent * indiv_rental_income) + rental_loss = tax_unit.sum(not_dependent * indiv_rental_loss) + + indiv_farm_rent_income = max_(0, person("farm_rent_income", period)) + indiv_farm_rent_loss = max_(0, -person("farm_rent_income", period)) + farm_rent_income = tax_unit.sum(not_dependent * indiv_farm_rent_income) + farm_rent_loss = tax_unit.sum(not_dependent * indiv_farm_rent_loss) + + indiv_estate_income = max_(0, person("estate_income", period)) + indiv_estate_loss = max_(0, -person("estate_income", period)) + estate_income = tax_unit.sum(not_dependent * indiv_estate_income) + estate_loss = tax_unit.sum(not_dependent * indiv_estate_loss) + + # Partnership/S-corp losses (Schedule E) + indiv_scorp_income = max_(0, person("partnership_s_corp_income", period)) + indiv_scorp_loss = max_(0, -person("partnership_s_corp_income", period)) + partnership_s_corp_income = tax_unit.sum(not_dependent * indiv_scorp_income) + partnership_s_corp_loss = tax_unit.sum(not_dependent * indiv_scorp_loss) + + other_net_gain = tax_unit("other_net_gain", period) + other_business_gain = max_(0, other_net_gain) + other_business_loss = max_(0, -other_net_gain) + + # Total business losses subject to Section 461(l) limitation + total_business_income = ( + self_employment_income + + farm_operations_income + + rental_income + + farm_rent_income + + estate_income + + partnership_s_corp_income + + other_business_gain + ) + total_business_loss = ( + self_employment_loss + + farm_loss + + rental_loss + + farm_rent_loss + + estate_loss + + partnership_s_corp_loss + + other_business_loss + ) + max_deductible_business_loss = total_business_income + threshold_amount + return min_(total_business_loss, max_deductible_business_loss) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss_person.py index 5710a12059b..49a019df77d 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss_person.py @@ -7,7 +7,7 @@ class limited_capital_loss_person(Variable): label = "Limited capital loss deduction for each person" unit = USD documentation = ( - "Each head's or spouse's part of the tax unit's capital loss " + "Each head's or spouse's part of the tax unit's net capital loss " "deduction after the Section 1211(b) limit (limited_capital_loss), in " "proportion to their own net capital losses. A tax unit dependent's " "capital losses are on their own return, so their part is zero." @@ -16,26 +16,5 @@ class limited_capital_loss_person(Variable): reference = "https://www.law.cornell.edu/uscode/text/26/1211#b" def formula(person, period, parameters): - tax_unit = person.tax_unit - filer = ~person("is_tax_unit_dependent", period) - filers = tax_unit.sum(filer) - capital_loss = filer * person("capital_losses", period) - total_capital_loss = tax_unit.sum(capital_loss) - # When no member has a capital loss but the deduction was set - # directly, the head and spouse share it equally. - share = where( - total_capital_loss > 0, - np.divide( - capital_loss, - total_capital_loss, - out=np.zeros_like(total_capital_loss, dtype=float), - where=total_capital_loss > 0, - ), - np.divide( - filer.astype(float), - filers, - out=np.zeros_like(filers, dtype=float), - where=filers > 0, - ), - ) - return tax_unit("limited_capital_loss", period) * share + capital_share = filer_share(person, period, person("capital_losses", period)) + return person.tax_unit("limited_capital_loss", period) * capital_share diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py index d51b44af919..9b9cb4e386a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py @@ -17,80 +17,8 @@ class loss_ald(Variable): ) def formula(tax_unit, period, parameters): - filing_status = tax_unit("filing_status", period) # Section 461(l) excess business loss limitation - threshold_amount = parameters(period).gov.irs.ald.loss.max[filing_status] - person = tax_unit.members - # A tax-unit dependent's business, rental and estate items belong on - # the dependent's own return, as in irs_gross_income. They neither - # add to this return's losses nor raise its Section 461(l) limit. - not_dependent = ~person("is_tax_unit_dependent", period) - - # Section 461(l) compares business deductions to business gross - # income/gains plus the threshold amount. In this model, positive - # business amounts flow into gross income and losses are deducted here, - # so allowable business losses are capped at business income plus - # the threshold. - indiv_se_income = max_(0, person("total_self_employment_income", period)) - indiv_se_loss = max_(0, -person("total_self_employment_income", period)) - self_employment_income = tax_unit.sum(not_dependent * indiv_se_income) - self_employment_loss = tax_unit.sum(not_dependent * indiv_se_loss) - - # Schedule F farm business income/losses. - indiv_farm_operations_income = max_(0, person("farm_operations_income", period)) - indiv_farm_loss = max_(0, -person("farm_operations_income", period)) - farm_operations_income = tax_unit.sum( - not_dependent * indiv_farm_operations_income - ) - farm_loss = tax_unit.sum(not_dependent * indiv_farm_loss) - - # Schedule E rental, farm-rent, and estate/trust items. - indiv_rental_income = max_(0, person("rental_income", period)) - indiv_rental_loss = max_(0, -person("rental_income", period)) - rental_income = tax_unit.sum(not_dependent * indiv_rental_income) - rental_loss = tax_unit.sum(not_dependent * indiv_rental_loss) - - indiv_farm_rent_income = max_(0, person("farm_rent_income", period)) - indiv_farm_rent_loss = max_(0, -person("farm_rent_income", period)) - farm_rent_income = tax_unit.sum(not_dependent * indiv_farm_rent_income) - farm_rent_loss = tax_unit.sum(not_dependent * indiv_farm_rent_loss) - - indiv_estate_income = max_(0, person("estate_income", period)) - indiv_estate_loss = max_(0, -person("estate_income", period)) - estate_income = tax_unit.sum(not_dependent * indiv_estate_income) - estate_loss = tax_unit.sum(not_dependent * indiv_estate_loss) - - # Partnership/S-corp losses (Schedule E) - indiv_scorp_income = max_(0, person("partnership_s_corp_income", period)) - indiv_scorp_loss = max_(0, -person("partnership_s_corp_income", period)) - partnership_s_corp_income = tax_unit.sum(not_dependent * indiv_scorp_income) - partnership_s_corp_loss = tax_unit.sum(not_dependent * indiv_scorp_loss) - - other_net_gain = tax_unit("other_net_gain", period) - other_business_gain = max_(0, other_net_gain) - other_business_loss = max_(0, -other_net_gain) - - # Total business losses subject to Section 461(l) limitation - total_business_income = ( - self_employment_income - + farm_operations_income - + rental_income - + farm_rent_income - + estate_income - + partnership_s_corp_income - + other_business_gain - ) - total_business_loss = ( - self_employment_loss - + farm_loss - + rental_loss - + farm_rent_loss - + estate_loss - + partnership_s_corp_loss - + other_business_loss - ) - max_deductible_business_loss = total_business_income + threshold_amount - limited_business_loss = min_(total_business_loss, max_deductible_business_loss) + limited_business_loss = tax_unit("limited_business_loss", period) # Section 1211(b) allows capital losses to the extent of capital # gains, plus a net loss of up to $3,000. diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py index edc14023f5a..2c2eec29fe0 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py @@ -1,7 +1,7 @@ from policyengine_us.model_api import * -# The person-level business, rental and estate amounts whose losses loss_ald -# deducts, subject to the Section 461(l) limit. +# The person-level business, rental and estate amounts whose losses +# limited_business_loss deducts. BUSINESS_INCOME_SOURCES = [ "total_self_employment_income", "farm_operations_income", @@ -19,12 +19,13 @@ class loss_ald_person(Variable): unit = USD documentation = ( "Each head's or spouse's part of the tax unit's business and capital " - "loss deduction (loss_ald). The return's business loss, after the " - "Section 461(l) limit, is divided in proportion to each spouse's own " - "business, farm, rental, estate and partnership losses. Form 4797 " - "losses, recorded only for the tax unit, count equally for the head " - "and spouse, as other_net_gain_gross_income counts the gains. The " - "capital loss part is limited_capital_loss_person. A tax unit " + "loss deduction (loss_ald). The business loss after the Section 461(l) " + "limit (limited_business_loss) is divided in proportion to each " + "spouse's own business, farm, rental, estate and partnership losses; " + "a Form 4797 loss, recorded only for the tax unit, counts equally for " + "the head and spouse, as other_net_gain_gross_income counts the gain. " + "The rest of loss_ald, the capital loss deduction, is divided in " + "proportion to each spouse's own net capital losses. A tax unit " "dependent's losses are on their own return, so their part is zero." ) definition_period = YEAR @@ -36,36 +37,17 @@ class loss_ald_person(Variable): def formula(person, period, parameters): tax_unit = person.tax_unit - filer = ~person("is_tax_unit_dependent", period) - filers = tax_unit.sum(filer) - even_share = np.divide( - filer.astype(float), - filers, - out=np.zeros_like(filers, dtype=float), - where=filers > 0, - ) business_loss = 0 for source in BUSINESS_INCOME_SOURCES: business_loss = business_loss + max_(0, -person(source, period)) other_net_loss = max_(0, -tax_unit("other_net_gain", period)) - business_loss = filer * business_loss + other_net_loss * even_share - total_business_loss = tax_unit.sum(business_loss) - # When no member has a business loss but the deduction was set - # directly, the head and spouse share it equally. - business_share = where( - total_business_loss > 0, - np.divide( - business_loss, - total_business_loss, - out=np.zeros_like(total_business_loss, dtype=float), - where=total_business_loss > 0, - ), - even_share, - ) - limited_business_loss = max_( - 0, - tax_unit("loss_ald", period) - tax_unit("limited_capital_loss", period), - ) - return limited_business_loss * business_share + person( - "limited_capital_loss_person", period + equal_share = filer_share(person, period, 0 * other_net_loss) + business_loss = business_loss + other_net_loss * equal_share + business_share = filer_share(person, period, business_loss) + capital_share = filer_share(person, period, person("capital_losses", period)) + limited_business_loss = tax_unit("limited_business_loss", period) + capital_loss_deduction = tax_unit("loss_ald", period) - limited_business_loss + return ( + limited_business_loss * business_share + + capital_loss_deduction * capital_share ) From fe25923765857fef1c808488daed58f552bd15d5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 22:55:23 -0400 Subject: [PATCH 03/28] Leave Medicaid AGI to a separate fix Attributing each spouse's deductions in Medicaid AGI without also netting the couple's MAGI before medicaid_magi_person's zero floor moved Medicaid eligibility in both directions; the two belong in one Medicaid change. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-agi-person-ald-split.fixed.md | 2 +- .../test_dependent_above_the_line_deductions_invariants.py | 6 ++---- .../income/medicaid_adjusted_gross_income_person.yaml | 3 +++ .../above_the_line_deductions/README.md | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/changelog.d/fix-agi-person-ald-split.fixed.md b/changelog.d/fix-agi-person-ald-split.fixed.md index dce561393e9..6ef5cb69f18 100644 --- a/changelog.d/fix-agi-person-ald-split.fixed.md +++ b/changelog.d/fix-agi-person-ald-split.fixed.md @@ -1 +1 @@ -Give each spouse their own above-the-line deductions in per-person AGI on a joint return (IRA, educator expenses, early withdrawal penalty, student loan interest, alimony paid, business and capital losses, and the rest), instead of splitting every deduction other than the self-employment ones 50/50; divide only tax-unit-level inputs (HSA, tuition and fees, domestic production, possession income) equally. This changes the state taxes and benefits that read each spouse's AGI (Kentucky, Montana, Delaware, Virginia and DC separate filing, Ohio's joint filing credit, West Virginia's senior deduction, state withholding, Medicaid household income, Illinois Family Planning). Montana's per-spouse AGI now follows its own capital loss rule (MCA 15-30-2110(6)). +Give each spouse their own above-the-line deductions in per-person AGI on a joint return (IRA, educator expenses, early withdrawal penalty, student loan interest, alimony paid, business and capital losses, and the rest), instead of splitting every deduction other than the self-employment ones 50/50; divide only tax-unit-level inputs (HSA, tuition and fees, domestic production, possession income) equally. This changes the state taxes and benefits that read each spouse's AGI (Kentucky, Montana, Delaware, Virginia and DC separate filing, Ohio's joint filing credit, West Virginia's senior deduction, state withholding, Illinois Family Planning). Montana's per-spouse AGI now follows its own capital loss rule (MCA 15-30-2110(6)). 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 170ad0fefa5..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 @@ -36,7 +36,7 @@ 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 deductions, alimony paid included. + their own gross income less their own person-level deductions. """ import numpy as np @@ -377,9 +377,7 @@ def _check(units, year): atol=TOLERANCE, ) dependent = run["is_dependent"] - # Alimony a dependent pays under a pre-2019 instrument is one of their own - # deductions too. - own_person_deductions = person_total - run["student_loan_interest_ald"] + alimony + 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], 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 b35bcc9e710..680a7393021 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,6 +71,7 @@ # 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] +<<<<<<< HEAD - name: Case 4, a dependent's pre-2019 alimony reduces the dependent's own AGI. period: 2025 @@ -123,3 +124,5 @@ output: adjusted_gross_income: 47_000 medicaid_adjusted_gross_income_person: 47_000 +======= +>>>>>>> 9bf3df0e73 (Leave Medicaid AGI to a separate fix) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md index 1e299373e13..652342f3039 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md @@ -18,7 +18,7 @@ Above-the-line deductions are deductions made from gross income to arrive at adj | [933](https://www.law.cornell.edu/uscode/text/26/933) | Income from Puerto Rico | ## Each person's deductions -Several state taxes and benefits need each spouse's own AGI on a joint return: Kentucky, Montana (through 2023), Delaware, Virginia and the District of Columbia, where spouses can file separately on one form; Ohio's joint filing credit; West Virginia's senior citizen and disability deduction; state withholding estimates; and Medicaid household income. `adjusted_gross_income_person` is each person's gross income less `above_the_line_deductions_person`. The head's and spouse's amounts add up to `above_the_line_deductions` and `adjusted_gross_income`. +Several state taxes and benefits need each spouse's own AGI on a joint return: Kentucky, Montana (through 2023), Delaware, Virginia and the District of Columbia, where spouses can file separately on one form; Ohio's joint filing credit; West Virginia's senior citizen and disability deduction; and state withholding estimates. `adjusted_gross_income_person` is each person's gross income less `above_the_line_deductions_person`. The head's and spouse's amounts add up to `above_the_line_deductions` and `adjusted_gross_income`. | Deduction | Who takes it | | --- | --- | @@ -27,6 +27,6 @@ Several state taxes and benefits need each spouse's own AGI on a joint return: K | Business and capital losses (`loss_ald`) | `loss_ald_person`: the return's business loss after Section 461(l), by each spouse's own business losses; the capital loss after the $3,000 limit (`limited_capital_loss_person`), by each spouse's own capital losses. A Form 4797 loss, recorded for the tax unit, counts equally for both. | | HSA, tuition and fees, domestic production, possession income | Divided equally, since the input is the tax unit's. For an HSA, 26 USC 223(b)(5) divides a married couple's limit equally unless they agree otherwise. | -A tax unit dependent's deductions are on their own return, so they never lower the filer's AGI; the dependent's `above_the_line_deductions_person` is what they deduct on that return, and Medicaid uses it. +A tax unit dependent's deductions are on their own return, so they never lower the filer's AGI; the dependent's `above_the_line_deductions_person` is what they deduct on that return. The states that read these amounts tell spouses to report their own income and deductions (Kentucky: KRS 141.180(3); Delaware: 30 Del. C. 1162(b)(1); the District: Schedule S, Calculation J; Virginia: the separate VAGI worksheet). Montana also has its own rule for capital losses: a loss clearly attributable to one spouse goes on that spouse's return, and otherwise it is split equally (MCA 15-30-2110(6), through 2023), which `mt_capital_loss_reallocation` applies. From 1d3470fb6159ba660de4779f34dee6a91ee17e2a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 23:26:38 -0400 Subject: [PATCH 04/28] WIP: drop the Montana capital loss override (ARM 42.15.206(3)(a)); review r1 fixes; strengthen property test The property test's new independent 461(l) check still has a test-side indexing bug: the model gives the right limited_business_loss (256,000 for a 2026 single filer with a 1.5M loss). Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-agi-person-ald-split.fixed.md | 2 +- ...se_above_the_line_deductions_invariants.py | 115 ++++++++++++++---- .../loss_ald_person.yaml | 24 ++++ ...ml => mt_agi_indiv_spouse_deductions.yaml} | 51 +++++--- .../above_the_line_deductions/README.md | 4 +- .../above_the_line_deductions_person.py | 3 +- .../limited_business_loss.py | 4 +- .../loss_ald_person.py | 12 +- .../states/mt/tax/income/base/mt_agi_indiv.py | 6 +- .../base/mt_applicable_ald_deductions.py | 24 ++-- .../base/mt_capital_loss_reallocation.py | 41 ------- 11 files changed, 175 insertions(+), 111 deletions(-) rename policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/{mt_capital_loss_reallocation.yaml => mt_agi_indiv_spouse_deductions.yaml} (53%) delete mode 100644 policyengine_us/variables/gov/states/mt/tax/income/base/mt_capital_loss_reallocation.py diff --git a/changelog.d/fix-agi-person-ald-split.fixed.md b/changelog.d/fix-agi-person-ald-split.fixed.md index 6ef5cb69f18..dd499ba1dd9 100644 --- a/changelog.d/fix-agi-person-ald-split.fixed.md +++ b/changelog.d/fix-agi-person-ald-split.fixed.md @@ -1 +1 @@ -Give each spouse their own above-the-line deductions in per-person AGI on a joint return (IRA, educator expenses, early withdrawal penalty, student loan interest, alimony paid, business and capital losses, and the rest), instead of splitting every deduction other than the self-employment ones 50/50; divide only tax-unit-level inputs (HSA, tuition and fees, domestic production, possession income) equally. This changes the state taxes and benefits that read each spouse's AGI (Kentucky, Montana, Delaware, Virginia and DC separate filing, Ohio's joint filing credit, West Virginia's senior deduction, state withholding, Illinois Family Planning). Montana's per-spouse AGI now follows its own capital loss rule (MCA 15-30-2110(6)). +Give each spouse their own above-the-line deductions in per-person AGI on a joint return (IRA, educator expenses, early withdrawal penalty, student loan interest, alimony paid, business and capital losses, and the rest), instead of splitting every deduction other than the self-employment ones 50/50; divide only tax-unit-level inputs (HSA, tuition and fees, domestic production, possession income) equally. This changes the state taxes and benefits that read each spouse's AGI (Kentucky, Montana, Delaware, Virginia and DC separate filing, Ohio's joint filing credit, West Virginia's senior deduction, state withholding, Illinois Family Planning). diff --git a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py index 21f1b3f9976..0d593bd1d92 100644 --- a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py @@ -12,10 +12,12 @@ amounts. Hypothesis draws batches of tax units (single, joint, joint with dependents) -in those states, with every kind of deduction input and occasional business -losses over the Section 461(l) limit; a seeded population of 200 units adds -breadth. Each batch runs as one vectorized simulation, once as drawn and once -with the spouse's own deduction inputs set to zero. For every tax unit: +in those states, with every kind of deduction input, occasional business +losses over the Section 461(l) limit, and some tax-unit deductions +(loss_ald, alimony_expense_ald, self_employment_tax_ald) set directly; a +seeded population of 200 units adds breadth. Each batch runs as one vectorized +simulation, once as drawn and once with the spouse's own deduction inputs set +to zero. For every tax unit: 1. Accounting identities. The head's and spouse's parts add up to the tax unit's amount: above_the_line_deductions_person to @@ -23,14 +25,17 @@ limited_capital_loss_person to limited_capital_loss and alimony_expense_ald_person to alimony_expense_ald. The members' adjusted_gross_income_person add up to adjusted_gross_income, and so do - the head's and spouse's Medicaid AGIs. Montana's capital loss - reallocation adds up to zero. -2. Differential. above_the_line_deductions_person equals an independent numpy - reference built from the inputs: each person's own person-level - deductions, their alimony under a pre-2019 instrument, their share of the - limited business loss by their own business losses and of the limited - capital loss by their own capital losses, and an equal share of the HSA - and tuition and fees deductions. + the head's and spouse's Medicaid AGIs. This holds when a tax-unit + deduction is set directly too. +2. Differential. limited_business_loss and limited_capital_loss equal numpy + computations of the Section 461(l) and 1211(b) limits from the inputs and + parameters. For units with no deduction set directly, + above_the_line_deductions_person equals a numpy reference built from the + inputs: each person's own person-level deductions, their alimony under a + pre-2019 instrument, their share of the numpy-limited business loss by + their own business losses and of the numpy-limited capital loss by their + own capital losses, and an equal share of the HSA and tuition and fees + deductions. 3. Own deductions. The spouse's deductions never add to the head's: the head's above_the_line_deductions_person is never higher with the spouse's deduction inputs than without them. It is the same, and so is the head's @@ -39,10 +44,12 @@ unemployment compensation) taxed by reference to the couple's AGI. The spouse's own AGI and the couple's AGI never fall when the spouse's deduction inputs are removed. + This is checked for units with no deduction set directly. 4. Dependents. A dependent's adjusted_gross_income_person, loss_ald_person and limited_capital_loss_person are zero, and their above_the_line_deductions_person is their own person-level deductions. -5. Bounds. Every above_the_line_deductions_person is non-negative. +5. Bounds. Every above_the_line_deductions_person and loss_ald_person is + non-negative. """ import numpy as np @@ -86,6 +93,8 @@ "estate_income", "partnership_s_corp_income", ] +# Tax-unit deductions that a unit may set directly. +DIRECT_INPUTS = ["loss_ald", "alimony_expense_ald", "self_employment_tax_ald"] TAX_UNIT_OUTPUTS = [ "adjusted_gross_income", "above_the_line_deductions", @@ -103,7 +112,6 @@ "loss_ald_person", "limited_capital_loss_person", "alimony_expense_ald_person", - "mt_capital_loss_reallocation", "irs_gross_income", "capital_losses", "alimony_expense", @@ -160,6 +168,15 @@ def tax_units(draw): "state": draw(st.sampled_from(STATES)), "health_savings_account_ald": _maybe(draw, st.integers(1, 8_000).map(float)), "other_net_gain": _maybe(draw, st.integers(-5_000, 5_000).map(float)), + "direct": draw( + st.one_of( + st.none(), + st.tuples( + st.sampled_from(DIRECT_INPUTS), + st.integers(0, 8_000).map(float), + ), + ) + ), "head": draw(person_amounts(dependent=False)), "spouse": (draw(person_amounts(dependent=False)) if kind != "single" else None), "dependents": [ @@ -216,6 +233,14 @@ def _seeded_units(n=200): if rng.random() < 0.2 else 0.0 ), + "direct": ( + ( + str(rng.choice(DIRECT_INPUTS)), + float(round(rng.uniform(0, 8_000))), + ) + if rng.random() < 0.15 + else None + ), "head": _seeded_amounts(rng, dependent=False), "spouse": ( _seeded_amounts(rng, dependent=False) if kind != "single" else None @@ -285,6 +310,9 @@ def _situation(units, year, *, zero_spouse): "health_savings_account_ald": {year: u["health_savings_account_ald"]}, "other_net_gain": {year: u["other_net_gain"]}, } + if u["direct"] is not None: + name, value = u["direct"] + groups["tax_units"][f"tax_unit_{i}"][name] = {year: value} groups["households"][f"household_{i}"] = { "members": members, "state_code": {year: u["state"]}, @@ -330,6 +358,11 @@ def _run(units, year, *, zero_spouse): out["is_head"] = np.asarray(sim.calculate("is_tax_unit_head", year), dtype=bool) out["is_spouse"] = np.asarray(sim.calculate("is_tax_unit_spouse", year), dtype=bool) out["unit"] = sim.populations["tax_unit"].members_entity_id + # The Section 461(l) and 1211(b) limits for each unit's filing status. + filing_status = sim.calculate("filing_status", year).decode_to_str() + loss = tbs.parameters(f"{year}-01-01").gov.irs.ald.loss + out["business_loss_limit"] = np.asarray(loss.max[filing_status], dtype=float) + out["capital_loss_limit"] = np.asarray(loss.capital.max[filing_status], dtype=float) return out @@ -351,6 +384,27 @@ def _share(amounts, totals, fallback): ) +def _limits(run): + """The limited business and capital losses of each unit, with numpy.""" + filer = ~run["is_dependent"] + business = sum(run[source] for source in BUSINESS_SOURCES) + other_net_gain = run["other_net_gain"] + income = _unit_sum(run, filer * np.maximum(0, business)) + np.maximum( + 0, other_net_gain + ) + loss = _unit_sum(run, filer * np.maximum(0, -business)) + np.maximum( + 0, -other_net_gain + ) + # Business sources are summed per person before flooring here, while the + # model floors each source; the inputs drawn give each person at most one + # business source with a loss, so the two agree. + limited_business = np.minimum(loss, income + run["business_loss_limit"]) + limited_capital = np.minimum( + run["capital_loss_limit"], _unit_sum(run, filer * run["capital_losses"]) + ) + return limited_business, limited_capital + + def _reference(run): """above_the_line_deductions_person, built from the inputs with numpy.""" filer = ~run["is_dependent"] @@ -365,10 +419,9 @@ def _reference(run): 0, -_per_person(run, run["other_net_gain"]) ) capital_loss = filer * run["capital_losses"] - # loss_ald is the business loss after the Section 461(l) limit plus the - # capital loss deduction. - limited_business = _per_person(run, run["limited_business_loss"]) - capital_deduction = _per_person(run, run["loss_ald"]) - limited_business + limited_business, limited_capital = _limits(run) + limited_business = _per_person(run, limited_business) + capital_deduction = _per_person(run, limited_capital) losses = limited_business * _share( business_loss, _per_person(run, _unit_sum(run, business_loss)), even ) + capital_deduction * _share( @@ -397,6 +450,8 @@ def _check(units, year): zeroed = _run(units, year, zero_spouse=True) filer = ~run["is_dependent"] dependent = run["is_dependent"] + direct_unit = np.array([u["direct"] is not None for u in units]) + direct = _per_person(run, direct_unit) # 1. Accounting identities. pairs = [ @@ -419,10 +474,12 @@ def _check(units, year): _unit_sum(run, filer * run["medicaid_adjusted_gross_income_person"]), run["adjusted_gross_income"], ) - _close(_unit_sum(run, run["mt_capital_loss_reallocation"]), 0) - # 2. Differential against the numpy reference. - _close(run["above_the_line_deductions_person"], _reference(run)) + # 2. Differential against numpy. + limited_business, limited_capital = _limits(run) + _close(run["limited_business_loss"], limited_business) + _close(run["limited_capital_loss"], limited_capital) + _close(run["above_the_line_deductions_person"][~direct], _reference(run)[~direct]) # 3. The spouse's deductions never add to the head's. A head with student # loan interest is left out of the first check: the spouse's IRA @@ -431,7 +488,7 @@ def _check(units, year): head = run["is_head"] spouse = run["is_spouse"] has_spouse = _per_person(run, _unit_sum(run, spouse.astype(float))) > 0 - paired_head = head & has_spouse + paired_head = head & has_spouse & ~direct # Each filer's deductions as their AGI shows them. deductions = "deductions_in_agi" for r in (run, zeroed): @@ -454,8 +511,11 @@ def _check(units, year): ) unaffected = own_only & ~joint_taxed_income _close(run[agi][unaffected], zeroed[agi][unaffected]) - assert _at_most(run[agi][spouse], zeroed[agi][spouse]) - assert _at_most(run["adjusted_gross_income"], zeroed["adjusted_gross_income"]) + assert _at_most(run[agi][spouse & ~direct], zeroed[agi][spouse & ~direct]) + assert _at_most( + run["adjusted_gross_income"][~direct_unit], + zeroed["adjusted_gross_income"][~direct_unit], + ) # 4. Dependents. for name in ["adjusted_gross_income_person", "loss_ald_person"]: @@ -463,7 +523,8 @@ def _check(units, year): assert (run["limited_capital_loss_person"][dependent] == 0).all() # 5. Bounds. - assert (run["above_the_line_deductions_person"] >= -TOLERANCE).all() + for name in ["above_the_line_deductions_person", "loss_ald_person"]: + assert (run[name] >= -TOLERANCE).all(), name # A batch's cost is mostly per-variable overhead, so each example is a large @@ -486,8 +547,8 @@ def test_own_deductions_2026(units): @given(st.lists(tax_units(), min_size=10, max_size=40)) def test_own_deductions_2023(units): # 2023 is the last year Montana lets spouses who file jointly for federal - # purposes file separately on the same form, the path that reads each - # spouse's AGI and the Montana capital loss reallocation. + # purposes file separately on the same form, a path that reads each + # spouse's AGI. _check(units, 2023) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.yaml index 07844df18ac..925efbcb5ca 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.yaml @@ -125,3 +125,27 @@ output: loss_ald_person: [1_250, 1_250] adjusted_gross_income_person: [28_750, 18_750] + +- name: A loss deduction set directly below the business loss goes to the business loss. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + employment_income: 30_000 + self_employment_income: -5_000 + spouse: + age: 50 + employment_income: 20_000 + tax_units: + tax_unit: + members: [head, spouse] + loss_ald: 2_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + limited_business_loss: 5_000 + loss_ald_person: [2_000, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_capital_loss_reallocation.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml similarity index 53% rename from policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_capital_loss_reallocation.yaml rename to policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml index 17838e59a84..d3ad20c440e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_capital_loss_reallocation.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml @@ -1,8 +1,9 @@ # Montana spouses who file jointly for federal purposes and separately on the -# same form (through 2023) each claim their own deductions; the IRA deduction -# goes to the spouse who made the contribution, and a capital loss clearly -# attributable to one spouse goes on that spouse's return, otherwise it is -# split equally. MCA 15-30-2110(6) and (8) (2021). +# same form (through 2023) each claim the items attributable to them; an item +# not clearly attributable to one spouse is divided equally (ARM 42.15.206(1); +# MCA 15-30-2110(6) to (8), 2021). The IRA deduction goes to the spouse who made +# the contribution, and a joint net capital loss may be divided by each +# spouse's ownership (ARM 42.15.206(3)(a), 2010 MAR p. 621). - name: The head's IRA deduction lowers only the head's Montana AGI. period: 2023 @@ -26,7 +27,6 @@ output: # Before: [57_000, 37_000]. mt_agi_indiv: [54_000, 40_000] - mt_capital_loss_reallocation: [0, 0] - name: A capital loss of only one spouse goes on that spouse's return. period: 2023 @@ -48,12 +48,12 @@ members: [head, spouse] state_code: MT output: + # ARM 42.15.206(3)(a): "If only one spouse owned the asset, that spouse + # shall report the $3,000". Before: [58_500, 38_500]. limited_capital_loss_person: [3_000, 0] - mt_capital_loss_reallocation: [0, 0] - # Before: [58_500, 38_500]. mt_agi_indiv: [57_000, 40_000] -- name: When both spouses have capital losses, Montana splits the allowed loss equally. +- name: A joint net capital loss is divided by each spouse's own losses. period: 2023 absolute_error_margin: 0.01 input: @@ -74,10 +74,33 @@ members: [head, spouse] state_code: MT output: - # The federal part follows each spouse's own loss, 4,000 : 2,000. + # The $3,000 allowed loss divided 4,000 : 2,000. limited_capital_loss_person: [2_000, 1_000] - adjusted_gross_income_person: [58_000, 39_000] - # Montana gives each spouse $1,500 of the $3,000. - mt_capital_loss_reallocation: [500, -500] - mt_agi_indiv: [58_500, 38_500] - mt_applicable_ald_deductions: [1_500, 1_500] + mt_agi_indiv: [58_000, 39_000] + mt_applicable_ald_deductions: [2_000, 1_000] + +- name: Each spouse's rental loss goes on their own return. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + rental_income: -8_000 + spouse: + age: 45 + employment_income: 40_000 + rental_income: -2_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # MCA 15-30-2110(7): a passive or rental loss clearly attributable to one + # spouse is shown on that spouse's return. Before: [55_000, 35_000]. + loss_ald_person: [8_000, 2_000] + mt_agi_indiv: [52_000, 38_000] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md index 652342f3039..57a1f33c6de 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md @@ -27,6 +27,6 @@ Several state taxes and benefits need each spouse's own AGI on a joint return: K | Business and capital losses (`loss_ald`) | `loss_ald_person`: the return's business loss after Section 461(l), by each spouse's own business losses; the capital loss after the $3,000 limit (`limited_capital_loss_person`), by each spouse's own capital losses. A Form 4797 loss, recorded for the tax unit, counts equally for both. | | HSA, tuition and fees, domestic production, possession income | Divided equally, since the input is the tax unit's. For an HSA, 26 USC 223(b)(5) divides a married couple's limit equally unless they agree otherwise. | -A tax unit dependent's deductions are on their own return, so they never lower the filer's AGI; the dependent's `above_the_line_deductions_person` is what they deduct on that return. +A tax unit dependent's deductions are on their own return, so they never lower the filer's AGI. The dependent's `above_the_line_deductions_person` is their own person-level deductions; their losses and tax-unit-only deductions are not modeled there. -The states that read these amounts tell spouses to report their own income and deductions (Kentucky: KRS 141.180(3); Delaware: 30 Del. C. 1162(b)(1); the District: Schedule S, Calculation J; Virginia: the separate VAGI worksheet). Montana also has its own rule for capital losses: a loss clearly attributable to one spouse goes on that spouse's return, and otherwise it is split equally (MCA 15-30-2110(6), through 2023), which `mt_capital_loss_reallocation` applies. +The states that read these amounts tell spouses to report their own income and deductions: Kentucky (KRS 141.180(3)), Delaware (30 Del. C. 1162(b)(1) and the PIT-RES line 1 instructions), the District (Schedule S, Calculation J), Virginia (Va. Code 58.1-324(C)(2) and the separate VAGI worksheet) and Montana through 2023 (ARM 42.15.206(1), which divides equally only an item not clearly attributable to one spouse, and 42.15.206(3)(a), which divides a joint net capital loss by ownership). diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py index 0199714747a..f2ba5696497 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py @@ -35,7 +35,8 @@ class above_the_line_deductions_person(Variable): "(_person), such as self-employment tax, alimony paid and " "business and capital losses. A deduction recorded only for the tax " "unit is divided equally between them. A tax unit dependent's amount " - "is the deductions they take on their own return." + "is their own person-level deductions, for their own return; their " + "losses and tax-unit-only deductions are not modeled here." ) definition_period = YEAR reference = "https://www.law.cornell.edu/uscode/text/26/62" diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py index 1fa394776f6..1a19a967590 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py @@ -9,8 +9,8 @@ class limited_business_loss(Variable): documentation = ( "The business, farm, rental, estate and partnership losses of the " "head and spouse deductible from gross income, after the Section " - "461(l) excess business loss limit. loss_ald adds the limited " - "capital loss." + "461(l) excess business loss limit. loss_ald adds the capital loss " + "deduction." ) definition_period = YEAR reference = ( diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py index 2c2eec29fe0..e375bd7902a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py @@ -45,9 +45,9 @@ def formula(person, period, parameters): business_loss = business_loss + other_net_loss * equal_share business_share = filer_share(person, period, business_loss) capital_share = filer_share(person, period, person("capital_losses", period)) - limited_business_loss = tax_unit("limited_business_loss", period) - capital_loss_deduction = tax_unit("loss_ald", period) - limited_business_loss - return ( - limited_business_loss * business_share - + capital_loss_deduction * capital_share - ) + # The business part comes first, so a loss_ald set directly below the + # business loss leaves no negative capital part. + loss_ald = tax_unit("loss_ald", period) + business_part = min_(tax_unit("limited_business_loss", period), loss_ald) + capital_part = max_(0, loss_ald - business_part) + return business_part * business_share + capital_part * capital_share diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py index af405e4bfa8..943c22aca8c 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py @@ -10,11 +10,7 @@ class mt_agi_indiv(Variable): defined_for = "mt_married_filing_separately_on_same_return_eligible" def formula(person, period, parameters): - # Each spouse's federal AGI, with the capital loss deduction divided - # under Montana's rule for spouses who file separately. - agi = person("adjusted_gross_income_person", period) + person( - "mt_capital_loss_reallocation", period - ) + agi = person("adjusted_gross_income_person", period) additions = person("mt_additions", period) subtractions = person("mt_subtractions", period) reduced_agi = max_(agi + additions - subtractions, 0) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py index 5ceae691b98..c7e40ce1413 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py @@ -9,27 +9,27 @@ class mt_applicable_ald_deductions(Variable): documentation = ( "Each spouse's own federal adjustments to income, for their column of " "the Taxable Social Security Benefits Schedule (Form 2, line 7, before " - "removing student loan interest). Items attributable to one spouse " - "are claimed by that spouse, and the capital loss deduction is " - "divided under Montana's rule (mt_capital_loss_reallocation)." + "removing student loan interest)." ) definition_period = YEAR defined_for = StateCode.MT reference = ( - # 2023 Form 2 instructions, page 5: deductions attributable to only one - # spouse must be claimed by that spouse. + # 2023 Form 2 instructions, page 6: deductions attributable to only one + # spouse, the student loan interest deduction included, must be claimed + # by that spouse. "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=13", - # MCA 15-30-2110(6) and (8) (2021): capital losses and the IRA - # deduction of spouses who file separately. + # MCA 15-30-2110(6) to (9) (2021), in force through tax year 2023. "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0100/0150-0300-0210-0100.html", # Taxable Social Security Benefits Schedule, line 7. "https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2023_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=6", ) def formula(person, period, parameters): + # Items attributable to one spouse are claimed by that spouse (ARM + # 42.15.206(1)); a joint net capital loss may be divided by ownership + # (ARM 42.15.206(3)(a)), as above_the_line_deductions_person divides + # it. MCA 15-30-2110(9)(a) also lets spouses split student loan + # interest equally or by AGI; the instructions have each spouse claim + # their own, which is what this uses. head_or_spouse = person("is_tax_unit_head_or_spouse", period) - # The capital loss deduction is divided under Montana's rule. - deductions = person("above_the_line_deductions_person", period) - person( - "mt_capital_loss_reallocation", period - ) - return head_or_spouse * deductions + return head_or_spouse * person("above_the_line_deductions_person", period) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_capital_loss_reallocation.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_capital_loss_reallocation.py deleted file mode 100644 index 0b6c26f03ab..00000000000 --- a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_capital_loss_reallocation.py +++ /dev/null @@ -1,41 +0,0 @@ -from policyengine_us.model_api import * - - -class mt_capital_loss_reallocation(Variable): - value_type = float - entity = Person - label = "Montana reallocation of the capital loss deduction between spouses" - unit = USD - documentation = ( - "Each spouse's federal part of the capital loss deduction " - "(limited_capital_loss_person) less their part under Montana's rule " - "for spouses who file separately: a loss clearly attributable to one " - "spouse goes on that spouse's return, and otherwise the loss is split " - "equally. Adding this to a spouse's federal AGI gives the AGI Montana " - "uses for that spouse. The amounts add up to zero over the couple." - ) - definition_period = YEAR - defined_for = StateCode.MT - reference = ( - # MCA 15-30-2110(6) (2021), in force through tax year 2023. - "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0100/0150-0300-0210-0100.html", - ) - - def formula(person, period, parameters): - head_or_spouse = person("is_tax_unit_head_or_spouse", period) - has_loss = head_or_spouse & (person("capital_losses", period) > 0) - spouses_with_loss = person.tax_unit.sum(has_loss) - head_spouse_count = person.tax_unit("head_spouse_count", period) - limited_capital_loss = person.tax_unit("limited_capital_loss", period) - equal_part = np.divide( - head_or_spouse * limited_capital_loss, - head_spouse_count, - out=np.zeros_like(limited_capital_loss, dtype=float), - where=head_spouse_count > 0, - ) - montana_part = where( - spouses_with_loss == 1, - has_loss * limited_capital_loss, - equal_part, - ) - return person("limited_capital_loss_person", period) - montana_part From 7165e4da585f815b43df214a2d9dce0bcd4a0bbc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 06:29:59 -0400 Subject: [PATCH 05/28] Restore main's Medicaid AGI tests after the rebase --- .../medicaid/income/medicaid_adjusted_gross_income_person.yaml | 3 --- 1 file changed, 3 deletions(-) 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 680a7393021..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 @@ -71,7 +71,6 @@ # 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] -<<<<<<< HEAD - name: Case 4, a dependent's pre-2019 alimony reduces the dependent's own AGI. period: 2025 @@ -124,5 +123,3 @@ output: adjusted_gross_income: 47_000 medicaid_adjusted_gross_income_person: 47_000 -======= ->>>>>>> 9bf3df0e73 (Leave Medicaid AGI to a separate fix) From 14e87d4f61fec84240ece2ae93a3e648c365c826 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 07:07:33 -0400 Subject: [PATCH 06/28] Leave filer amounts recorded on a dependent off the dependent's own deductions above_the_line_deductions_person gave a tax unit dependent every person-level deduction recorded on them, including employer adoption assistance and education savings bond interest, which gov.irs.ald.filer_amounts_recorded_on_dependents marks as the filer's. Those now count only for the head and spouse, whose parts already take them up through above_the_line_deductions. The dependent's part matters once Medicaid AGI reads it for a dependent's own return. Co-Authored-By: Claude Opus 5.5 --- .../above_the_line_deductions_person.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py index f2ba5696497..a3373bdf234 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py @@ -35,8 +35,11 @@ class above_the_line_deductions_person(Variable): "(_person), such as self-employment tax, alimony paid and " "business and capital losses. A deduction recorded only for the tax " "unit is divided equally between them. A tax unit dependent's amount " - "is their own person-level deductions, for their own return; their " - "losses and tax-unit-only deductions are not modeled here." + "is their own person-level deductions, for their own return, other " + "than amounts that are the filer's even when recorded on the " + "dependent (gov.irs.ald.filer_amounts_recorded_on_dependents), which " + "go to the head and spouse; the dependent's losses and tax-unit-only " + "deductions are not modeled here." ) definition_period = YEAR reference = "https://www.law.cornell.edu/uscode/text/26/62" @@ -44,15 +47,23 @@ class above_the_line_deductions_person(Variable): def formula(person, period, parameters): tax_unit = person.tax_unit filer = ~person("is_tax_unit_dependent", period) + p = parameters(period).gov.irs.ald total = 0 # Sum in a fixed order so float32 results do not depend on the hash # seed. - for deduction in sorted(set(parameters(period).gov.irs.ald.deductions)): + for deduction in sorted(set(p.deductions)): variable = person.entity.get_variable(deduction, check_existence=True) if variable.entity.is_person: # The person's own deduction. For the head and spouse it is # their part of the return's sum over non-dependents. - total = total + person(deduction, period) + amount = person(deduction, period) + if deduction in p.filer_amounts_recorded_on_dependents: + # The filer's amount even when recorded on a dependent, so + # it is not on the dependent's own return. The head's and + # spouse's parts take it up below, as + # above_the_line_deductions includes it. + amount = filer * amount + total = total + amount continue return_amount = tax_unit(deduction, period) person_variable = f"{deduction}_person" From 7eb2e399e2a1ac2990dd214c20f8b1b321a8783c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 09:17:04 -0400 Subject: [PATCH 07/28] Divide a filer amount recorded on a dependent equally between head and spouse Education savings bond interest and employer adoption assistance recorded on a dependent are the filer's (gov.irs.ald.filer_amounts_recorded_on_dependents), but which spouse's is not recorded. They reached the head's and spouse's parts through the reconciliation to above_the_line_deductions, in proportion to each spouse's other deductions, so the spouse's IRA deduction changed the head's share. They are now divided equally, as the other amounts recorded only for the return are. Co-Authored-By: Claude Opus 5.5 --- .../above_the_line_deductions/README.md | 2 +- .../above_the_line_deductions_person.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md index 57a1f33c6de..7126c577c16 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md @@ -27,6 +27,6 @@ Several state taxes and benefits need each spouse's own AGI on a joint return: K | Business and capital losses (`loss_ald`) | `loss_ald_person`: the return's business loss after Section 461(l), by each spouse's own business losses; the capital loss after the $3,000 limit (`limited_capital_loss_person`), by each spouse's own capital losses. A Form 4797 loss, recorded for the tax unit, counts equally for both. | | HSA, tuition and fees, domestic production, possession income | Divided equally, since the input is the tax unit's. For an HSA, 26 USC 223(b)(5) divides a married couple's limit equally unless they agree otherwise. | -A tax unit dependent's deductions are on their own return, so they never lower the filer's AGI. The dependent's `above_the_line_deductions_person` is their own person-level deductions; their losses and tax-unit-only deductions are not modeled there. +A tax unit dependent's deductions are on their own return, so they never lower the filer's AGI. The dependent's `above_the_line_deductions_person` is their own person-level deductions, other than amounts that are the filer's even when recorded on the dependent (`gov.irs.ald.filer_amounts_recorded_on_dependents`), which the head and spouse divide equally; the dependent's losses and tax-unit-only deductions are not modeled there. The states that read these amounts tell spouses to report their own income and deductions: Kentucky (KRS 141.180(3)), Delaware (30 Del. C. 1162(b)(1) and the PIT-RES line 1 instructions), the District (Schedule S, Calculation J), Virginia (Va. Code 58.1-324(C)(2) and the separate VAGI worksheet) and Montana through 2023 (ARM 42.15.206(1), which divides equally only an item not clearly attributable to one spouse, and 42.15.206(3)(a), which divides a joint net capital loss by ownership). diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py index a3373bdf234..c1e598cd87a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py @@ -38,8 +38,8 @@ class above_the_line_deductions_person(Variable): "is their own person-level deductions, for their own return, other " "than amounts that are the filer's even when recorded on the " "dependent (gov.irs.ald.filer_amounts_recorded_on_dependents), which " - "go to the head and spouse; the dependent's losses and tax-unit-only " - "deductions are not modeled here." + "the head and spouse divide equally; the dependent's losses and " + "tax-unit-only deductions are not modeled here." ) definition_period = YEAR reference = "https://www.law.cornell.edu/uscode/text/26/62" @@ -59,10 +59,12 @@ def formula(person, period, parameters): amount = person(deduction, period) if deduction in p.filer_amounts_recorded_on_dependents: # The filer's amount even when recorded on a dependent, so - # it is not on the dependent's own return. The head's and - # spouse's parts take it up below, as - # above_the_line_deductions includes it. - amount = filer * amount + # it is not on the dependent's own return. Which of the + # head and spouse it belongs to is not recorded, so a + # dependent's amount is divided equally between them. + on_dependents = tax_unit.sum(~filer * amount) + equal_share = filer_share(person, period, 0 * amount) + amount = filer * amount + on_dependents * equal_share total = total + amount continue return_amount = tax_unit(deduction, period) From f7915066bc2f1e06823fee5285e73b3e58c77887 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 09:17:04 -0400 Subject: [PATCH 08/28] Fix the spouse deduction invariant test's harness and numpy references - Units that set a tax-unit deduction directly run in their own simulation. An input given for one tax unit is an input for every unit in a simulation, so one unit's loss_ald set to 0 zeroed every other unit's. - The Section 461(l) reference keeps each business source's gain and loss separate (aggregate income against aggregate deductions), as the model does, rather than netting a person's sources first. - The capital loss reference nets losses against capital gains and distributions before the 1211(b) limit, as main does since #9788. - The alimony identity skips units that set alimony_expense_ald directly: alimony_expense_ald_person stays each payer's own alimony there. - The reference gives a dependent's filer amounts to the head and spouse equally. Co-Authored-By: Claude Opus 5.5 --- ...se_above_the_line_deductions_invariants.py | 122 ++++++++++++++---- 1 file changed, 96 insertions(+), 26 deletions(-) diff --git a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py index 0d593bd1d92..09eb702ef01 100644 --- a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py @@ -15,9 +15,11 @@ in those states, with every kind of deduction input, occasional business losses over the Section 461(l) limit, and some tax-unit deductions (loss_ald, alimony_expense_ald, self_employment_tax_ald) set directly; a -seeded population of 200 units adds breadth. Each batch runs as one vectorized -simulation, once as drawn and once with the spouse's own deduction inputs set -to zero. For every tax unit: +seeded population of 200 units adds breadth. Each batch runs as vectorized +simulations, once as drawn and once with the spouse's own deduction inputs set +to zero. Units that set the same tax-unit deduction directly share a +simulation, and units that set none share another: an input given for one +tax unit is an input for every unit in its simulation. For every tax unit: 1. Accounting identities. The head's and spouse's parts add up to the tax unit's amount: above_the_line_deductions_person to @@ -26,16 +28,19 @@ alimony_expense_ald_person to alimony_expense_ald. The members' adjusted_gross_income_person add up to adjusted_gross_income, and so do the head's and spouse's Medicaid AGIs. This holds when a tax-unit - deduction is set directly too. + deduction is set directly too, except that alimony_expense_ald_person + stays each payer's own alimony when alimony_expense_ald is set directly + (above_the_line_deductions_person divides the difference). 2. Differential. limited_business_loss and limited_capital_loss equal numpy computations of the Section 461(l) and 1211(b) limits from the inputs and parameters. For units with no deduction set directly, above_the_line_deductions_person equals a numpy reference built from the inputs: each person's own person-level deductions, their alimony under a pre-2019 instrument, their share of the numpy-limited business loss by - their own business losses and of the numpy-limited capital loss by their - own capital losses, and an equal share of the HSA and tuition and fees - deductions. + their own business losses and of the capital loss deduction (losses up + to the filers' capital gains and distributions, plus the net loss up to + the 1211(b) limit) by their own capital losses, and an equal share of the + HSA and tuition and fees deductions. 3. Own deductions. The spouse's deductions never add to the head's: the head's above_the_line_deductions_person is never higher with the spouse's deduction inputs than without them. It is the same, and so is the head's @@ -114,6 +119,8 @@ "alimony_expense_ald_person", "irs_gross_income", "capital_losses", + "capital_gains", + "non_sch_d_capital_gains", "alimony_expense", "divorce_year", "student_loan_interest", @@ -324,7 +331,35 @@ def _situation(units, year, *, zero_spouse): return {"people": people, **groups} +def _direct_name(unit): + return unit["direct"][0] if unit["direct"] is not None else "" + + +def _grouped(units): + """The units ordered so those setting the same variable directly are together. + + A variable given as an input for one tax unit becomes an input for every + tax unit in the simulation, at its default value for the others, so a + loss_ald set directly for one unit would set every other unit's loss_ald + to zero. Each group therefore runs as its own simulation. + """ + names = sorted({_direct_name(u) for u in units}) + return [[u for u in units if _direct_name(u) == name] for name in names] + + def _run(units, year, *, zero_spouse): + """Run each group of _grouped(units) and join the results in that order.""" + runs = [ + _run_group(group, year, zero_spouse=zero_spouse) for group in _grouped(units) + ] + offset = 0 + for run in runs: + run["unit"] = run["unit"] + offset + offset += len(run["adjusted_gross_income"]) + return {key: np.concatenate([run[key] for run in runs]) for key in runs[0]} + + +def _run_group(units, year, *, zero_spouse): sim = Simulation(situation=_situation(units, year, zero_spouse=zero_spouse)) tbs = sim.tax_benefit_system deductions = tbs.parameters(f"{year}-01-01").gov.irs.ald.deductions @@ -345,6 +380,16 @@ def _run(units, year, *, zero_spouse): own_names.append(f"{deduction}_person") else: shared_names.append(deduction) + # Amounts that are the filer's even when recorded on a dependent. + out["filer_amounts"] = sum( + ( + np.asarray(sim.calculate(name, year), dtype=float) + for name in tbs.parameters( + f"{year}-01-01" + ).gov.irs.ald.filer_amounts_recorded_on_dependents + ), + np.zeros(len(out["adjusted_gross_income_person"])), + ) out["own_direct"] = sum( np.asarray(sim.calculate(name, year), dtype=float) for name in own_names ) @@ -387,22 +432,32 @@ def _share(amounts, totals, fallback): def _limits(run): """The limited business and capital losses of each unit, with numpy.""" filer = ~run["is_dependent"] - business = sum(run[source] for source in BUSINESS_SOURCES) + # Section 461(l)(3)(A) compares the aggregate deductions of the trades or + # businesses with their aggregate gross income or gain, so each source's + # gain and loss count separately, not netted for the person first. + gains = sum(np.maximum(0, run[source]) for source in BUSINESS_SOURCES) + losses = sum(np.maximum(0, -run[source]) for source in BUSINESS_SOURCES) other_net_gain = run["other_net_gain"] - income = _unit_sum(run, filer * np.maximum(0, business)) + np.maximum( - 0, other_net_gain - ) - loss = _unit_sum(run, filer * np.maximum(0, -business)) + np.maximum( - 0, -other_net_gain - ) - # Business sources are summed per person before flooring here, while the - # model floors each source; the inputs drawn give each person at most one - # business source with a loss, so the two agree. + income = _unit_sum(run, filer * gains) + np.maximum(0, other_net_gain) + loss = _unit_sum(run, filer * losses) + np.maximum(0, -other_net_gain) limited_business = np.minimum(loss, income + run["business_loss_limit"]) + # 26 USC 1211(b): capital losses are allowed to the extent of capital + # gains, each filer's net gain and capital gain distributions (Schedule D + # lines 13 and 16), plus a net loss up to the limit (line 21). + capital_losses = _unit_sum(run, filer * run["capital_losses"]) + capital_gains = _unit_sum( + run, + filer + * ( + np.maximum(0, run["capital_gains"]) + + np.maximum(0, run["non_sch_d_capital_gains"]) + ), + ) + allowed_against_gains = np.minimum(capital_losses, capital_gains) limited_capital = np.minimum( - run["capital_loss_limit"], _unit_sum(run, filer * run["capital_losses"]) + run["capital_loss_limit"], capital_losses - allowed_against_gains ) - return limited_business, limited_capital + return limited_business, limited_capital, allowed_against_gains def _reference(run): @@ -419,9 +474,9 @@ def _reference(run): 0, -_per_person(run, run["other_net_gain"]) ) capital_loss = filer * run["capital_losses"] - limited_business, limited_capital = _limits(run) + limited_business, limited_capital, allowed_against_gains = _limits(run) limited_business = _per_person(run, limited_business) - capital_deduction = _per_person(run, limited_capital) + capital_deduction = _per_person(run, allowed_against_gains + limited_capital) losses = limited_business * _share( business_loss, _per_person(run, _unit_sum(run, business_loss)), even ) + capital_deduction * _share( @@ -430,10 +485,18 @@ def _reference(run): alimony = run["alimony_expense"] * (run["divorce_year"] < 2019) # Tax-unit deductions with no person amounts are divided equally. shared = _per_person(run, run["shared"]) * even + # A filer amount recorded on a dependent (gov.irs.ald. + # filer_amounts_recorded_on_dependents) is not on the dependent's own + # return; the head and spouse divide it equally. + dependent_filer_amounts = _per_person( + run, _unit_sum(run, run["is_dependent"] * run["filer_amounts"]) + ) + filer_part = run["own_direct"] + alimony + losses + shared + filer_part = filer_part + dependent_filer_amounts * even return np.where( filer, - run["own_direct"] + alimony + losses + shared, - run["own_direct"] + alimony, + filer_part, + run["own_direct"] + alimony - run["filer_amounts"], ) @@ -446,6 +509,8 @@ def _at_most(lower, upper): def _check(units, year): + # The order in which _run returns the units. + units = [u for group in _grouped(units) for u in group] run = _run(units, year, zero_spouse=False) zeroed = _run(units, year, zero_spouse=True) filer = ~run["is_dependent"] @@ -460,10 +525,15 @@ def _check(units, year): ("limited_capital_loss_person", "limited_capital_loss"), ("alimony_expense_ald_person", "alimony_expense_ald"), ] + set_directly = np.array([_direct_name(u) for u in units]) for person_name, unit_name in pairs: + # alimony_expense_ald_person is each payer's own alimony, so it adds up + # to alimony_expense_ald unless that is set directly; + # above_the_line_deductions_person reconciles the difference. + kept = set_directly != unit_name _close( - _unit_sum(run, filer * run[person_name]), - run[unit_name], + _unit_sum(run, filer * run[person_name])[kept], + run[unit_name][kept], err_msg=person_name, ) _close( @@ -476,7 +546,7 @@ def _check(units, year): ) # 2. Differential against numpy. - limited_business, limited_capital = _limits(run) + limited_business, limited_capital, _ = _limits(run) _close(run["limited_business_loss"], limited_business) _close(run["limited_capital_loss"], limited_capital) _close(run["above_the_line_deductions_person"][~direct], _reference(run)[~direct]) From 7cc6e3177ac62717d9d512fa09173f0e454ee9bb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 09:21:00 -0400 Subject: [PATCH 09/28] Give the determinism test's stand-in ALD parameters the filer amount list above_the_line_deductions_person now reads gov.irs.ald.filer_amounts_recorded_on_dependents. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tests/core/test_ald_determinism.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/core/test_ald_determinism.py b/policyengine_us/tests/core/test_ald_determinism.py index 89e4f10381f..da74763fc4c 100644 --- a/policyengine_us/tests/core/test_ald_determinism.py +++ b/policyengine_us/tests/core/test_ald_determinism.py @@ -124,7 +124,12 @@ def test_person_deductions_are_summed_in_sorted_unique_order(reverse, empty): deductions = _deductions(reverse, empty) parameters = SimpleNamespace( gov=SimpleNamespace( - irs=SimpleNamespace(ald=SimpleNamespace(deductions=deductions)) + irs=SimpleNamespace( + ald=SimpleNamespace( + deductions=deductions, + filer_amounts_recorded_on_dependents=[], + ) + ) ) ) order = [] From e080bda3ab4e47d8535242eb6b9dd7d806642591 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 10:35:51 -0400 Subject: [PATCH 10/28] Floor Michigan and Pennsylvania withholding at zero for a negative spouse AGI With each spouse's own deductions, a spouse with a business loss or an IRA deduction above their income has negative AGI, and these two withholding estimates multiplied it by the rate. Every other state's withholding formula already floors its base at zero. Co-Authored-By: Claude Opus 5.5 --- .../gov/states/mi/tax/income/mi_withheld_income_tax.yaml | 9 +++++++++ .../gov/states/pa/tax/income/pa_withheld_income_tax.yaml | 9 +++++++++ .../gov/states/mi/tax/income/mi_withheld_income_tax.py | 4 +++- .../gov/states/pa/tax/income/pa_withheld_income_tax.py | 4 +++- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_withheld_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_withheld_income_tax.yaml index 2928eff14e0..3b461c771ef 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_withheld_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_withheld_income_tax.yaml @@ -27,3 +27,12 @@ state_code: MI output: mi_withheld_income_tax: 0 + +- name: A negative AGI, as from a spouse's business loss, withholds nothing. + period: 2024 + absolute_error_margin: 0.01 + input: + adjusted_gross_income_person: -1_000 + state_code: MI + output: + mi_withheld_income_tax: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/pa/tax/income/pa_withheld_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/pa/tax/income/pa_withheld_income_tax.yaml index dfc9747cbe5..f16dcae99f4 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/pa/tax/income/pa_withheld_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/pa/tax/income/pa_withheld_income_tax.yaml @@ -27,3 +27,12 @@ state_code: PA output: pa_withheld_income_tax: 0 + +- name: A negative AGI, as from a spouse's business loss, withholds nothing. + period: 2024 + absolute_error_margin: 0.01 + input: + adjusted_gross_income_person: -1_000 + state_code: PA + output: + pa_withheld_income_tax: 0 diff --git a/policyengine_us/variables/gov/states/mi/tax/income/mi_withheld_income_tax.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_withheld_income_tax.py index 340ca08b7ea..7d3b59eeb26 100644 --- a/policyengine_us/variables/gov/states/mi/tax/income/mi_withheld_income_tax.py +++ b/policyengine_us/variables/gov/states/mi/tax/income/mi_withheld_income_tax.py @@ -14,4 +14,6 @@ def formula(person, period, parameters): p = parameters(period).gov.states.mi.tax.income # The MI standard deduction only applys for elderly # Wo do not apply deductions here - return p.rate * agi + # A spouse's AGI can be negative, as with a business loss; withholding + # is never negative. + return p.rate * max_(agi, 0) diff --git a/policyengine_us/variables/gov/states/pa/tax/income/pa_withheld_income_tax.py b/policyengine_us/variables/gov/states/pa/tax/income/pa_withheld_income_tax.py index 2081a74d973..fda42e111ac 100644 --- a/policyengine_us/variables/gov/states/pa/tax/income/pa_withheld_income_tax.py +++ b/policyengine_us/variables/gov/states/pa/tax/income/pa_withheld_income_tax.py @@ -14,4 +14,6 @@ def formula(person, period, parameters): # Pennsylvania does not have standard deductions, personal exemptions, or itemized deductions. # Also, They do not use the federal standard deduction amounts. p = parameters(period).gov.states.pa.tax.income - return agi * p.rate + # A spouse's AGI can be negative, as with a business loss; withholding + # is never negative. + return max_(agi, 0) * p.rate From b979d87009aa67434ffe78eb729ed7224f0f8a5a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 10:35:52 -0400 Subject: [PATCH 11/28] Prorate Virginia additions by each spouse's positive federal AGI va_agi_person divides tax-unit additions by each spouse's share of federal AGI. A spouse with negative AGI got a negative share and the other spouse more than all of it. A spouse with negative AGI now gets none, as PolicyEngine/policyengine-us#9878 does for Delaware's itemized deductions. Co-Authored-By: Claude Opus 5.5 --- .../states/va/tax/income/va_agi_person.yaml | 24 +++++++++++++++++++ .../gov/states/va/tax/income/va_agi_person.py | 17 ++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/va_agi_person.yaml b/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/va_agi_person.yaml index 785b76a0745..e3e1cd35aa8 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/va_agi_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/va_agi_person.yaml @@ -131,3 +131,27 @@ # Worksheet for Determining Separate Virginia Adjusted Gross Income, # line 10 (adjustments) and line 11. Before: [57_000, 37_000]. va_agi_person: [54_000, 40_000] + +- name: A spouse with negative federal AGI gets no share of the additions. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + adjusted_gross_income_person: 10_000 + va_subtractions_person: 0 + spouse: + adjusted_gross_income_person: -1_000 + va_subtractions_person: 0 + tax_units: + tax_unit: + members: [head, spouse] + va_additions: 1_000 + households: + household: + members: [head, spouse] + state_code: VA + output: + # Prorating by signed AGI would give the head 1,111.11 and the spouse + # -111.11 of the additions. + va_agi_person: [11_000, -1_000] diff --git a/policyengine_us/variables/gov/states/va/tax/income/va_agi_person.py b/policyengine_us/variables/gov/states/va/tax/income/va_agi_person.py index bbf929fa323..a8e6439e271 100644 --- a/policyengine_us/variables/gov/states/va/tax/income/va_agi_person.py +++ b/policyengine_us/variables/gov/states/va/tax/income/va_agi_person.py @@ -22,9 +22,20 @@ def formula(person, period, parameters): # taxable income, so the couple should not qualify for the adjustment. person_subtractions = person("va_subtractions_person", period) # Additions are only defined at the tax-unit level, so prorate them by - # federal AGI share. + # federal AGI share. A spouse with negative federal AGI gets no share, + # so neither share is negative or more than the whole. additions = person.tax_unit("va_additions", period) - total_federal_agi = person.tax_unit.sum(person_fagi) - prorate = where(total_federal_agi > 0, person_fagi / total_federal_agi, 0) + positive_fagi = max_(person_fagi, 0) + total_federal_agi = person.tax_unit.sum(positive_fagi) + prorate = where( + total_federal_agi > 0, + np.divide( + positive_fagi, + total_federal_agi, + out=np.zeros_like(total_federal_agi, dtype=float), + where=total_federal_agi > 0, + ), + 0, + ) person_additions = additions * prorate return person_fagi + person_additions - person_subtractions From f0be6169231568552a95a343e5bc68f6640c5de2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 10:35:52 -0400 Subject: [PATCH 12/28] Skip only directly set alimony in the spouse identity check Directly set loss_ald still reconciles through loss_ald_person, so its identity is checked. Co-Authored-By: Claude Opus 5.5 --- .../core/test_spouse_above_the_line_deductions_invariants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py index 09eb702ef01..b5c5fbd9cb1 100644 --- a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py @@ -530,7 +530,7 @@ def _check(units, year): # alimony_expense_ald_person is each payer's own alimony, so it adds up # to alimony_expense_ald unless that is set directly; # above_the_line_deductions_person reconciles the difference. - kept = set_directly != unit_name + kept = (unit_name != "alimony_expense_ald") | (set_directly != unit_name) _close( _unit_sum(run, filer * run[person_name])[kept], run[unit_name][kept], From 986d04658aa9e3ed38ebe6c10c518f0ba4af2498 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 12:26:37 -0400 Subject: [PATCH 13/28] Keep Montana's losses divided equally between spouses until its own rules are modeled Montana spouses who filed separately on one form through 2023 allocate capital losses by one of two elections (ARM 42.15.206(3)) and recompute the Section 461(l) limit with the single filer's threshold. Neither is modeled. Giving each spouse their own federal losses instead of half moved Montana away from both. With one spouse's $1,000 gain against the other's $3,000 loss, the loss spouse's AGI was $7,000, though separate tracking caps the loss at $1,500 ($8,500). A $600,000 business loss on one spouse used the joint limit, lowering tax by $45.65 against main. mt_loss_ald_reallocation adds back to each spouse's federal AGI the difference between their own loss_ald_person and an equal share of the return's loss_ald, so Montana divides losses as before. The IRA and other deductions still go to the spouse who has them (former MCA 15-30-2110(8); ARM 42.15.206(1)). Co-Authored-By: Claude Opus 5.5 --- .../mt_agi_indiv_spouse_deductions.yaml | 83 +++++++++++++++---- .../above_the_line_deductions/README.md | 2 +- .../states/mt/tax/income/base/mt_agi_indiv.py | 6 +- .../base/mt_applicable_ald_deductions.py | 16 ++-- .../income/base/mt_loss_ald_reallocation.py | 32 +++++++ 5 files changed, 117 insertions(+), 22 deletions(-) create mode 100644 policyengine_us/variables/gov/states/mt/tax/income/base/mt_loss_ald_reallocation.py diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml index d3ad20c440e..703ee32f781 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml @@ -2,8 +2,14 @@ # same form (through 2023) each claim the items attributable to them; an item # not clearly attributable to one spouse is divided equally (ARM 42.15.206(1); # MCA 15-30-2110(6) to (8), 2021). The IRA deduction goes to the spouse who made -# the contribution, and a joint net capital loss may be divided by each -# spouse's ownership (ARM 42.15.206(3)(a), 2010 MAR p. 621). +# the contribution (MCA 15-30-2110(8)). +# +# Montana's own allocation of business and capital losses is not modeled yet: +# the capital loss elections of ARM 42.15.206(3) (2010 MAR p. 621) and the +# Section 461(l) limit recomputed with the single filer's threshold. Until it +# is, the return's losses are divided equally between the spouses +# (mt_loss_ald_reallocation), as before each spouse's own deductions were +# attributed to them. - name: The head's IRA deduction lowers only the head's Montana AGI. period: 2023 @@ -28,7 +34,7 @@ # Before: [57_000, 37_000]. mt_agi_indiv: [54_000, 40_000] -- name: A capital loss of only one spouse goes on that spouse's return. +- name: Losses are divided equally, as before, whichever spouse has them. period: 2023 absolute_error_margin: 0.01 input: @@ -48,12 +54,14 @@ members: [head, spouse] state_code: MT output: - # ARM 42.15.206(3)(a): "If only one spouse owned the asset, that spouse - # shall report the $3,000". Before: [58_500, 38_500]. + # The federal per-spouse amounts give the $3,000 allowed loss to the head; + # Montana divides it equally: 60,000 - 1,500 and 40,000 - 1,500. limited_capital_loss_person: [3_000, 0] - mt_agi_indiv: [57_000, 40_000] + mt_loss_ald_reallocation: [1_500, -1_500] + mt_agi_indiv: [58_500, 38_500] + mt_applicable_ald_deductions: [1_500, 1_500] -- name: A joint net capital loss is divided by each spouse's own losses. +- name: Both spouses' capital losses are divided equally. period: 2023 absolute_error_margin: 0.01 input: @@ -74,12 +82,11 @@ members: [head, spouse] state_code: MT output: - # The $3,000 allowed loss divided 4,000 : 2,000. limited_capital_loss_person: [2_000, 1_000] - mt_agi_indiv: [58_000, 39_000] - mt_applicable_ald_deductions: [2_000, 1_000] + mt_agi_indiv: [58_500, 38_500] + mt_applicable_ald_deductions: [1_500, 1_500] -- name: Each spouse's rental loss goes on their own return. +- name: Rental losses are divided equally. period: 2023 absolute_error_margin: 0.01 input: @@ -100,7 +107,55 @@ members: [head, spouse] state_code: MT output: - # MCA 15-30-2110(7): a passive or rental loss clearly attributable to one - # spouse is shown on that spouse's return. Before: [55_000, 35_000]. + # The $10,000 of losses, 5,000 each. loss_ald_person: [8_000, 2_000] - mt_agi_indiv: [52_000, 38_000] + mt_agi_indiv: [55_000, 35_000] + +- name: One spouse's capital gain and the other's loss are divided as before. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 10_000 + long_term_capital_gains: 1_000 + spouse: + age: 40 + employment_income: 10_000 + long_term_capital_gains: -3_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # The $3,000 loss deduction ($1,000 against the head's gain and the $2,000 + # net loss) is divided equally: 11,000 - 1,500 and 10,000 - 1,500. + mt_agi_indiv: [9_500, 8_500] + +- name: A business loss over the joint Section 461(l) limit is divided equally. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 650_000 + self_employment_income: -600_000 + spouse: + age: 40 + employment_income: 10_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # The joint 2023 limit allows $578,000 of the loss; each spouse takes + # $289,000. Head: 650,000 - 289,000. Spouse: 10,000 - 289,000, floored. + mt_agi_indiv: [361_000, 0] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md index 7126c577c16..465693a45f9 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md @@ -29,4 +29,4 @@ Several state taxes and benefits need each spouse's own AGI on a joint return: K A tax unit dependent's deductions are on their own return, so they never lower the filer's AGI. The dependent's `above_the_line_deductions_person` is their own person-level deductions, other than amounts that are the filer's even when recorded on the dependent (`gov.irs.ald.filer_amounts_recorded_on_dependents`), which the head and spouse divide equally; the dependent's losses and tax-unit-only deductions are not modeled there. -The states that read these amounts tell spouses to report their own income and deductions: Kentucky (KRS 141.180(3)), Delaware (30 Del. C. 1162(b)(1) and the PIT-RES line 1 instructions), the District (Schedule S, Calculation J), Virginia (Va. Code 58.1-324(C)(2) and the separate VAGI worksheet) and Montana through 2023 (ARM 42.15.206(1), which divides equally only an item not clearly attributable to one spouse, and 42.15.206(3)(a), which divides a joint net capital loss by ownership). +The states that read these amounts tell spouses to report their own income and deductions: Kentucky (KRS 141.180(3)), Delaware (30 Del. C. 1162(b)(1) and the PIT-RES line 1 instructions), the District (Schedule S, Calculation J), Virginia (Va. Code 58.1-324(C)(2) and the separate VAGI worksheet) and Montana through 2023 (ARM 42.15.206(1), which divides equally only an item not clearly attributable to one spouse). Montana's own allocation of business and capital losses (the elections of ARM 42.15.206(3) and a separate Section 461(l) limit) is not modeled yet, so `mt_loss_ald_reallocation` keeps the return's losses divided equally there. diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py index 943c22aca8c..ff6e632e58b 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py @@ -10,7 +10,11 @@ class mt_agi_indiv(Variable): defined_for = "mt_married_filing_separately_on_same_return_eligible" def formula(person, period, parameters): - agi = person("adjusted_gross_income_person", period) + # Each spouse's federal AGI, with the return's business and capital + # losses divided equally between them (mt_loss_ald_reallocation). + agi = person("adjusted_gross_income_person", period) + person( + "mt_loss_ald_reallocation", period + ) additions = person("mt_additions", period) subtractions = person("mt_subtractions", period) reduced_agi = max_(agi + additions - subtractions, 0) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py index c7e40ce1413..85b1544c908 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py @@ -26,10 +26,14 @@ class mt_applicable_ald_deductions(Variable): def formula(person, period, parameters): # Items attributable to one spouse are claimed by that spouse (ARM - # 42.15.206(1)); a joint net capital loss may be divided by ownership - # (ARM 42.15.206(3)(a)), as above_the_line_deductions_person divides - # it. MCA 15-30-2110(9)(a) also lets spouses split student loan - # interest equally or by AGI; the instructions have each spouse claim - # their own, which is what this uses. + # 42.15.206(1)), and an IRA deduction by the spouse who made the + # contribution (former MCA 15-30-2110(8)). MCA 15-30-2110(9)(a) also + # lets spouses split student loan interest equally or by AGI; the + # instructions have each spouse claim their own, which is what this + # uses. The return's business and capital losses are divided equally + # (mt_loss_ald_reallocation), pending Montana's own loss allocation. head_or_spouse = person("is_tax_unit_head_or_spouse", period) - return head_or_spouse * person("above_the_line_deductions_person", period) + deductions = person("above_the_line_deductions_person", period) - person( + "mt_loss_ald_reallocation", period + ) + return head_or_spouse * deductions diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_loss_ald_reallocation.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_loss_ald_reallocation.py new file mode 100644 index 00000000000..1d3cd65a6c1 --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_loss_ald_reallocation.py @@ -0,0 +1,32 @@ +from policyengine_us.model_api import * + + +class mt_loss_ald_reallocation(Variable): + value_type = float + entity = Person + label = "Montana reallocation of business and capital losses between spouses" + unit = USD + documentation = ( + "Each spouse's federal business and capital loss deduction " + "(loss_ald_person) less an equal share of the return's loss_ald. " + "Montana's own allocation of losses between spouses who file " + "separately on the same form is not modeled here: the capital loss " + "elections of ARM 42.15.206(3), and the Section 461(l) limit " + "recomputed with the single filer's threshold. Until it is, Montana " + "divides the return's losses equally between the spouses, as " + "PolicyEngine did before each spouse's own deductions were " + "attributed to them. Adding this amount to a spouse's federal AGI " + "gives that equal division. Other deductions stay with the spouse " + "who has them." + ) + definition_period = YEAR + defined_for = StateCode.MT + reference = ( + "https://sosmt.gov/wp-content/uploads/attachments/MAR10-05.pdf#page=35", + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=23", + ) + + def formula(person, period, parameters): + loss_ald = person.tax_unit("loss_ald", period) + equal_share = filer_share(person, period, 0 * loss_ald) + return person("loss_ald_person", period) - loss_ald * equal_share From bbb64ab4b2ae57ff1ef9dc1c7d55345c556242f0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 16:43:32 -0400 Subject: [PATCH 14/28] Base Montana's joint standard deduction and medical floor on the joint return's AGI mt_standard_deduction_joint and mt_medical_expense_deduction_joint added each spouse's mt_agi_indiv, which is floored at zero. With each spouse's own deductions, a spousal IRA above that spouse's income was lost to the floor: head wages $30,000 and a $6,000 spousal IRA gave a joint deduction of $6,000 (20% of $30,000) instead of $4,920 (the floor, 20% of the joint $24,000 being less). Both now read mt_agi_joint, the joint column's Montana AGI through 2023. Their tests take mt_agi_joint as the input. Adds the spousal IRA case, a single filer's loss and a case with student loan interest and an IRA in each spouse's own column. Co-Authored-By: Claude Opus 5.5 --- .../mt_medical_expense_deduction_joint.yaml | 18 +++-- .../standard/mt_standard_deduction_joint.yaml | 20 ++--- .../mt_agi_indiv_spouse_deductions.yaml | 74 +++++++++++++++++++ .../mt_medical_expense_deduction_joint.py | 3 +- .../standard/mt_standard_deduction_joint.py | 5 +- 5 files changed, 100 insertions(+), 20 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml index df9e02675bc..4597a07e887 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml @@ -6,11 +6,13 @@ person1: is_tax_unit_head: true other_medical_expenses: 1_000 - mt_agi_indiv: 400 person2: is_tax_unit_head: false other_medical_expenses: 1_000 - mt_agi_indiv: 600 + tax_units: + tax_unit: + members: [person1, person2] + mt_agi_joint: 1_000 households: household: members: [person1, person2] @@ -26,11 +28,13 @@ person1: is_tax_unit_head: true other_medical_expenses: 1_000 - mt_agi_indiv: 0 person2: is_tax_unit_head: false other_medical_expenses: 1_000 - mt_agi_indiv: 0 + tax_units: + tax_unit: + members: [person1, person2] + mt_agi_joint: 0 households: household: members: [person1, person2] @@ -46,11 +50,13 @@ person1: is_tax_unit_head: true other_medical_expenses: 0 - mt_agi_indiv: 1_000 person2: is_tax_unit_head: false other_medical_expenses: 0 - mt_agi_indiv: 0 + tax_units: + tax_unit: + members: [person1, person2] + mt_agi_joint: 1_000 households: household: members: [person1, person2] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.yaml index fc733856faf..ce924aa87d8 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.yaml @@ -2,7 +2,7 @@ period: 2021 input: state_code: MT - mt_agi_indiv: 12_950 + mt_agi_joint: 12_950 filing_status: SINGLE output: mt_standard_deduction_joint: 2_590 @@ -11,7 +11,7 @@ period: 2021 input: state_code: MT - mt_agi_indiv: 8_000 + mt_agi_joint: 8_000 filing_status: SURVIVING_SPOUSE output: mt_standard_deduction_joint: 2_140 @@ -20,7 +20,7 @@ period: 2021 input: state_code: MT - mt_agi_indiv: 30_000 + mt_agi_joint: 30_000 filing_status: SEPARATE output: mt_standard_deduction_joint: 4_830 @@ -29,7 +29,7 @@ period: 2022 input: state_code: MT - mt_agi_indiv: 25_180 + mt_agi_joint: 25_180 filing_status: HEAD_OF_HOUSEHOLD output: mt_standard_deduction_joint: 5_036 @@ -38,7 +38,7 @@ period: 2022 input: state_code: MT - mt_agi_indiv: 15_000 + mt_agi_joint: 15_000 filing_status: JOINT output: mt_standard_deduction_joint: 4_520 @@ -50,17 +50,15 @@ people: person1: is_tax_unit_head: true - mt_agi_indiv: 5_000 person2: is_tax_unit_head: false - mt_agi_indiv: 100_000 person3: is_tax_unit_head: false - mt_agi_indiv: 0 tax_units: tax_unit: members: [person1, person2, person3] filing_status: JOINT + mt_agi_joint: 105_000 households: household: members: [person1, person2, person3] @@ -75,17 +73,15 @@ people: person1: is_tax_unit_head: true - mt_agi_indiv: 20_000 person2: is_tax_unit_head: false - mt_agi_indiv: 10_000 person3: is_tax_unit_head: false - mt_agi_indiv: 0 tax_units: tax_unit: members: [person1, person2, person3] filing_status: JOINT + mt_agi_joint: 30_000 households: household: members: [person1, person2, person3] @@ -97,7 +93,7 @@ period: 2024 input: state_code: MT - mt_agi_indiv: 12_950 + mt_agi_joint: 12_950 filing_status: JOINT output: mt_standard_deduction_joint: 29_200 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml index 703ee32f781..d880cd9fa83 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml @@ -159,3 +159,77 @@ # The joint 2023 limit allows $578,000 of the loss; each spouse takes # $289,000. Head: 650,000 - 289,000. Spouse: 10,000 - 289,000, floored. mt_agi_indiv: [361_000, 0] + +- name: A spousal IRA above the spouse's income still lowers the joint deductions' AGI. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 30_000 + spouse: + age: 45 + traditional_ira_contributions: 6_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # The spouse's own Montana AGI, -6,000, is floored at zero, but the joint + # return's Montana AGI is 30,000 - 6,000 = 24,000. The joint standard + # deduction is 20% of it, 4,800, raised to the 4,920 floor. + mt_agi_indiv: [30_000, 0] + mt_agi_joint: 24_000 + mt_standard_deduction_joint: [4_920, 0] + +- name: A single filer's loss is their own. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + filer: + age: 45 + employment_income: 40_000 + long_term_capital_gains: -5_000 + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: MT + output: + # The $3,000 allowed capital loss: 40,000 - 3,000. + mt_loss_ald_reallocation: 0 + mt_agi_indiv: 37_000 + +- name: Each spouse's student loan interest and IRA deduction stay in their own column. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + student_loan_interest: 2_000 + spouse: + age: 45 + employment_income: 40_000 + traditional_ira_contributions: 5_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # Joint MAGI for the student loan interest phase-out is 95,000, under the + # 2023 joint start of 155,000, so the full 2,000 is deducted. Splitting + # all deductions equally would give [3_500, 3_500] and [56_500, 36_500]. + mt_applicable_ald_deductions: [2_000, 5_000] + mt_agi_indiv: [58_000, 35_000] diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py index 28d8b020611..8216a9afc96 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py @@ -19,6 +19,7 @@ def formula(person, period, parameters): p = parameters(period).gov.irs.deductions.itemized.medical # Law does not define Montana AGI as the cap. # Tax form points to page 1, line 14, which is Montana AGI. - medical_floor = p.floor * add(person.tax_unit, period, ["mt_agi_indiv"]) + # The joint return's Montana AGI, not each spouse's floored amount. + medical_floor = p.floor * person.tax_unit("mt_agi_joint", period) is_head = person("is_tax_unit_head", period) return is_head * max_(0, expense - medical_floor) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.py index 9f5279e5caa..94413332027 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.py @@ -17,7 +17,10 @@ def formula(person, period, parameters): if p.state_specific_deduction_applies: # ── Pre-2024 MT-specific calculation ─────────────────────────────── - agi = add(person.tax_unit, period, ["mt_agi_indiv"]) + # The joint return's Montana AGI. Adding each spouse's + # mt_agi_indiv would lose a spouse's negative amount, such as an + # IRA deduction above their own income, to its zero floor. + agi = person.tax_unit("mt_agi_joint", period) floor = p.floor[filing_status] cap = p.cap[filing_status] uncapped = p.rate * agi From 5f010fb77af88dc782bd4fbbcb368c4ecca9d7e1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 17:30:40 -0400 Subject: [PATCH 15/28] Use the joint Montana AGI for the medical floor only through 2023 Review r5: the medical floor had no year gate, so reading mt_agi_joint also changed 2024 and later, when mt_agi_indiv is zero for everyone and main's floor was zero. From 2024 Montana starts from federal itemized deductions, whose floor is a share of federal AGI, a separate fix. The joint AGI now applies while married filing separately on the same return is allowed (through 2023); 2024 on is unchanged from main. Co-Authored-By: Claude Opus 5.5 --- .../medical/mt_medical_expense_deduction_joint.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py index 8216a9afc96..7377e0efdb9 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py @@ -19,7 +19,16 @@ def formula(person, period, parameters): p = parameters(period).gov.irs.deductions.itemized.medical # Law does not define Montana AGI as the cap. # Tax form points to page 1, line 14, which is Montana AGI. - # The joint return's Montana AGI, not each spouse's floored amount. - medical_floor = p.floor * person.tax_unit("mt_agi_joint", period) + if parameters( + period + ).gov.states.mt.tax.income.married_filing_separately_on_same_return_allowed: + # Through 2023: the joint return's Montana AGI (Form 2, line 14), + # not each spouse's amount floored at zero. + agi = person.tax_unit("mt_agi_joint", period) + else: + # From 2024 Montana starts from federal itemized deductions, whose + # floor is a share of federal AGI; that is not modeled here yet. + agi = add(person.tax_unit, period, ["mt_agi_indiv"]) + medical_floor = p.floor * agi is_head = person("is_tax_unit_head", period) return is_head * max_(0, expense - medical_floor) From 64bbf5eaadd7c9d03ea0052dfe2385a263ad1329 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 17:36:59 -0400 Subject: [PATCH 16/28] Note that Montana's 2024+ medical floor is zero, as on main Comment only (review r6). Co-Authored-By: Claude Opus 5.5 --- .../itemized/medical/mt_medical_expense_deduction_joint.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py index 7377e0efdb9..a91d562330b 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py @@ -28,6 +28,8 @@ def formula(person, period, parameters): else: # From 2024 Montana starts from federal itemized deductions, whose # floor is a share of federal AGI; that is not modeled here yet. + # mt_agi_indiv is zero for everyone from 2024, so this floor is + # zero, as on main. agi = add(person.tax_unit, period, ["mt_agi_indiv"]) medical_floor = p.floor * agi is_head = person("is_tax_unit_head", period) From 36058bac8dac0ae0a96d4f32aac98b7753cf21ed Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 06:36:07 -0400 Subject: [PATCH 17/28] Limit DC separate-filing capital losses per spouse; stop Montana spouse allocation from 2024 Per Max's d1014 ruling: - DC: spouses filing separately on the same return each deduct their own capital losses up to their own capital gains plus the $1,500 married filing separately limit (D-40 instructions, line c), via dc_separate_capital_loss_adjustment. It applies only to the separate column; dc_taxable_income_joint keeps the federal joint deduction. - Montana: mt_loss_ald_reallocation and mt_applicable_ald_deductions apply only while spouses may file separately on the same form (through 2023). Adds YAML cases and DC/Montana invariants to the spouse property test. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-agi-person-ald-split.fixed.md | 2 +- ...se_above_the_line_deductions_invariants.py | 69 +++++++- .../dc_separate_capital_loss_adjustment.yaml | 162 ++++++++++++++++++ .../mt_agi_indiv_spouse_deductions.yaml | 56 ++++++ .../dc_separate_capital_loss_adjustment.py | 62 +++++++ .../dc/tax/income/dc_taxable_income_indiv.py | 8 +- .../dc/tax/income/dc_taxable_income_joint.py | 5 +- .../income/deductions/dc_deduction_indiv.py | 6 +- .../base/mt_applicable_ald_deductions.py | 5 +- .../income/base/mt_loss_ald_reallocation.py | 9 +- 10 files changed, 367 insertions(+), 17 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.yaml create mode 100644 policyengine_us/variables/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.py diff --git a/changelog.d/fix-agi-person-ald-split.fixed.md b/changelog.d/fix-agi-person-ald-split.fixed.md index dd499ba1dd9..12ed3b76419 100644 --- a/changelog.d/fix-agi-person-ald-split.fixed.md +++ b/changelog.d/fix-agi-person-ald-split.fixed.md @@ -1 +1 @@ -Give each spouse their own above-the-line deductions in per-person AGI on a joint return (IRA, educator expenses, early withdrawal penalty, student loan interest, alimony paid, business and capital losses, and the rest), instead of splitting every deduction other than the self-employment ones 50/50; divide only tax-unit-level inputs (HSA, tuition and fees, domestic production, possession income) equally. This changes the state taxes and benefits that read each spouse's AGI (Kentucky, Montana, Delaware, Virginia and DC separate filing, Ohio's joint filing credit, West Virginia's senior deduction, state withholding, Illinois Family Planning). +Give each spouse their own above-the-line deductions in per-person AGI on a joint return (IRA, educator expenses, early withdrawal penalty, student loan interest, alimony paid, business and capital losses, and the rest), instead of splitting every deduction other than the self-employment ones 50/50; divide only tax-unit-level inputs (HSA, tuition and fees, domestic production, possession income) equally. This changes the state taxes and benefits that read each spouse's AGI (Kentucky, Montana through 2023, Delaware, Virginia and DC separate filing, Ohio's joint filing credit, West Virginia's senior deduction, state withholding, Illinois Family Planning). DC spouses who file separately on the same return each deduct their own capital losses up to their own capital gains plus $1,500. Montana divides nothing between spouses from 2024, when filing separately on the same form ended. diff --git a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py index b5c5fbd9cb1..59ef5a077db 100644 --- a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py @@ -55,6 +55,16 @@ above_the_line_deductions_person is their own person-level deductions. 5. Bounds. Every above_the_line_deductions_person and loss_ald_person is non-negative. +6. DC separate filing on the same return. For the head and spouse of a joint + DC return with no deduction set directly, + dc_separate_capital_loss_adjustment equals their share of the federal + capital loss deduction less their own capital losses up to their own + capital gains and distributions plus the $1,500 separate limit; it is zero + for everyone else. Each couple's adjustments add up to zero or more: + filing separately never deducts more capital loss than the joint return. + (The YAML tests check that the joint DC computation is unaffected.) +7. Montana from 2024. Spouses no longer file separately on the same form, so + mt_loss_ald_reallocation and mt_applicable_ald_deductions are zero. """ import numpy as np @@ -126,6 +136,9 @@ "student_loan_interest", "social_security", "unemployment_compensation", + "dc_separate_capital_loss_adjustment", + "mt_loss_ald_reallocation", + "mt_applicable_ald_deductions", *BUSINESS_SOURCES, ] @@ -408,6 +421,11 @@ def _run_group(units, year, *, zero_spouse): loss = tbs.parameters(f"{year}-01-01").gov.irs.ald.loss out["business_loss_limit"] = np.asarray(loss.max[filing_status], dtype=float) out["capital_loss_limit"] = np.asarray(loss.capital.max[filing_status], dtype=float) + out["separate_capital_loss_limit"] = np.full( + len(filing_status), float(loss.capital.max["SEPARATE"]) + ) + out["joint"] = filing_status == "JOINT" + out["state"] = np.array([u["state"] for u in units]) return out @@ -460,28 +478,40 @@ def _limits(run): return limited_business, limited_capital, allowed_against_gains -def _reference(run): - """above_the_line_deductions_person, built from the inputs with numpy.""" +def _even(run): + """An equal share for each filer of a unit.""" filer = ~run["is_dependent"] filers = _per_person(run, _unit_sum(run, filer.astype(float))) - even = np.divide( + return np.divide( filer.astype(float), filers, out=np.zeros_like(filers), where=filers > 0 ) + + +def _capital_deduction_person(run): + """Each filer's share of the capital loss deduction, by their own losses.""" + capital_loss = ~run["is_dependent"] * run["capital_losses"] + _, limited_capital, allowed_against_gains = _limits(run) + capital_deduction = _per_person(run, allowed_against_gains + limited_capital) + return capital_deduction * _share( + capital_loss, _per_person(run, _unit_sum(run, capital_loss)), _even(run) + ) + + +def _reference(run): + """above_the_line_deductions_person, built from the inputs with numpy.""" + filer = ~run["is_dependent"] + even = _even(run) business_loss = filer * sum( np.maximum(0, -run[source]) for source in BUSINESS_SOURCES ) business_loss = business_loss + even * np.maximum( 0, -_per_person(run, run["other_net_gain"]) ) - capital_loss = filer * run["capital_losses"] - limited_business, limited_capital, allowed_against_gains = _limits(run) + limited_business, _, _ = _limits(run) limited_business = _per_person(run, limited_business) - capital_deduction = _per_person(run, allowed_against_gains + limited_capital) losses = limited_business * _share( business_loss, _per_person(run, _unit_sum(run, business_loss)), even - ) + capital_deduction * _share( - capital_loss, _per_person(run, _unit_sum(run, capital_loss)), even - ) + ) + _capital_deduction_person(run) alimony = run["alimony_expense"] * (run["divorce_year"] < 2019) # Tax-unit deductions with no person amounts are divided equally. shared = _per_person(run, run["shared"]) * even @@ -596,6 +626,27 @@ def _check(units, year): for name in ["above_the_line_deductions_person", "loss_ald_person"]: assert (run[name] >= -TOLERANCE).all(), name + # 6. DC separate filing on the same return. + adjustment = run["dc_separate_capital_loss_adjustment"] + dc_unit = run["state"] == "DC" + dc_joint = _per_person(run, dc_unit & run["joint"]) & (head | spouse) + own_gains = np.maximum(0, run["capital_gains"]) + np.maximum( + 0, run["non_sch_d_capital_gains"] + ) + separate = np.minimum( + run["capital_losses"], + own_gains + _per_person(run, run["separate_capital_loss_limit"]), + ) + expected = dc_joint * (_capital_deduction_person(run) - separate) + _close(adjustment[~direct], expected[~direct]) + assert (adjustment[~dc_joint] == 0).all() + assert (_unit_sum(run, adjustment) >= -TOLERANCE).all() + + # 7. Montana from 2024. + if year >= 2024: + for name in ["mt_loss_ald_reallocation", "mt_applicable_ald_deductions"]: + assert (run[name] == 0).all(), name + # A batch's cost is mostly per-variable overhead, so each example is a large # batch and there are few examples. diff --git a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.yaml new file mode 100644 index 00000000000..efddf0f5342 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.yaml @@ -0,0 +1,162 @@ +# Spouses who file separately on the same DC return each deduct their own +# capital losses up to their own capital gains plus $1,500, the limit for +# married people filing separately (2024 D-40 instructions, p. 16, line c). +# The joint DC computation keeps the federal joint deduction, up to $3,000. + +- name: A head's $5,000 capital loss is limited to $1,500 when filing separately. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -5_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: DC + output: + # The federal joint return deducts $3,000, all of it the head's. + adjusted_gross_income_person: [57_000, 40_000] + dc_agi: [57_000, 40_000] + # Separately the head deducts $1,500: 3,000 - 1,500. + dc_separate_capital_loss_adjustment: [1_500, 0] + # 57,000 + 1,500 - 29,200 and 40,000. + dc_taxable_income_indiv: [29_300, 40_000] + # Joint: 97,000 - 29,200, unchanged. + dc_taxable_income_joint: 67_800 + # 3,668 before, plus 6% of 1,500. Still less than the joint 4,163. + dc_income_tax_before_credits_indiv: 3_758 + dc_income_tax_before_credits_joint: 4_163 + dc_income_tax_before_credits: 3_758 + +- name: Each spouse deducts up to $1,500 of their own losses. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -4_000 + spouse: + age: 45 + employment_income: 40_000 + short_term_capital_gains: -2_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: DC + output: + # The federal $3,000 is divided in proportion to the losses: 2,000 and + # 1,000. Separately each spouse deducts 1,500. + adjusted_gross_income_person: [58_000, 39_000] + dc_separate_capital_loss_adjustment: [500, -500] + dc_taxable_income_indiv: [29_300, 38_500] + dc_taxable_income_joint: 67_800 + +- name: One spouse's capital gain does not absorb the other's loss when filing separately. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -5_000 + spouse: + age: 45 + employment_income: 40_000 + long_term_capital_gains: 4_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: DC + output: + # Jointly the head's $5,000 loss offsets the spouse's $4,000 gain plus + # $1,000 more. Separately the head deducts $1,500 and the spouse reports + # the whole gain. + adjusted_gross_income_person: [55_000, 44_000] + dc_separate_capital_loss_adjustment: [3_500, 0] + # The deduction is reallocated to the spouses' separate AGIs (58,500 and + # 44,000), still adding up to 29,200. + dc_deduction_indiv: [18_500, 10_700] + dc_taxable_income_indiv: [40_000, 33_300] + dc_taxable_income_joint: 69_800 + dc_income_tax_before_credits_indiv: 3_998 + dc_income_tax_before_credits_joint: 4_333 + +- name: A spouse's own capital gain distributions absorb their own loss first. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -2_000 + non_sch_d_capital_gains: 1_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: DC + output: + # The whole $2,000 loss is deductible either way: $1,000 against the + # distributions and a $1,000 net loss. + dc_separate_capital_loss_adjustment: [0, 0] + dc_taxable_income_indiv: [29_800, 40_000] + +- name: No adjustment for a single filer or a dependent. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -5_000 + spouse: + age: 45 + employment_income: 40_000 + child: + age: 10 + long_term_capital_gains: -1_000 + single: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -5_000 + tax_units: + couple_unit: + members: [head, spouse, child] + single_unit: + members: [single] + households: + couple_household: + members: [head, spouse, child] + state_code: DC + single_household: + members: [single] + state_code: DC + output: + dc_separate_capital_loss_adjustment: [1_500, 0, 0, 0] + dc_taxable_income_indiv: [29_300, 40_000, 0, 42_400] + dc_taxable_income_joint: [67_800, 42_400] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml index d880cd9fa83..7e0125ab39e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml @@ -233,3 +233,59 @@ # all deductions equally would give [3_500, 3_500] and [56_500, 36_500]. mt_applicable_ald_deductions: [2_000, 5_000] mt_agi_indiv: [58_000, 35_000] + +- name: From 2024 Montana no longer divides anything between spouses. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -4_000 + traditional_ira_contributions: 6_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # Married filing separately on the same form ended after 2023, and the + # return starts from the couple's federal taxable income. + mt_married_filing_separately_on_same_return_eligible: [false, false] + mt_loss_ald_reallocation: [0, 0] + mt_applicable_ald_deductions: [0, 0] + mt_agi_indiv: [0, 0] + # The same as main, and the same when the spouse has the loss and the IRA + # deduction (next case). + mt_income_tax: 3_154.20 + +- name: From 2024 Montana's tax does not depend on which spouse has the deductions. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 45 + employment_income: 40_000 + long_term_capital_gains: -4_000 + traditional_ira_contributions: 6_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + mt_loss_ald_reallocation: [0, 0] + mt_applicable_ald_deductions: [0, 0] + mt_income_tax: 3_154.20 diff --git a/policyengine_us/variables/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.py b/policyengine_us/variables/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.py new file mode 100644 index 00000000000..22c74ea1adc --- /dev/null +++ b/policyengine_us/variables/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.py @@ -0,0 +1,62 @@ +from policyengine_us.model_api import * + + +class dc_separate_capital_loss_adjustment(Variable): + value_type = float + entity = Person + label = "DC capital loss adjustment when spouses file separately on the same return" + unit = USD + documentation = ( + "For the head and spouse of a joint federal return, the amount added " + "to their DC AGI when they file separately on the same DC return. " + "Each spouse's federal AGI includes their share of the couple's " + "capital loss deduction, in proportion to their own capital losses. " + "Filing separately, each spouse instead deducts their own capital " + "losses up to their own capital gains plus $1,500, the limit for " + "married people filing separately. The adjustment is the first amount " + "less the second. It does not apply to the joint DC computation, " + "which keeps the federal joint deduction." + ) + definition_period = YEAR + defined_for = StateCode.DC + reference = ( + # Line c: "The maximum allowable annual capital loss claim is $3000 + # ($1500 if married or registered domestic partner filing + # separately)." + "https://otr.cfo.dc.gov/sites/default/files/dc/sites/otr/publication/attachments/2024_D40_Booklet_062325.pdf#page=16", + "https://otr.cfo.dc.gov/sites/default/files/dc/sites/otr/publication/attachments/2025_D40_Book_082026_v1.pdf#page=19", + # Schedule S, Calculation J: each spouse's portion of federal AGI. + "https://otr.cfo.dc.gov/sites/default/files/dc/sites/otr/publication/attachments/2025_D40_Book_082026_v1.pdf#page=51", + "https://www.law.cornell.edu/uscode/text/26/1211#b", + ) + + def formula(person, period, parameters): + tax_unit = person.tax_unit + filing_status = tax_unit("filing_status", period) + joint = filing_status == filing_status.possible_values.JOINT + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + p = parameters(period).gov.irs + capital_losses = person("capital_losses", period) + # The spouse's part of the federal capital loss deduction, as in + # loss_ald_person: losses against gains plus the net loss up to + # $3,000, in proportion to each spouse's own capital losses. + capital_share = filer_share(person, period, capital_losses) + federal = ( + person("limited_capital_loss_person", period) + + tax_unit("capital_losses_allowed_against_gains", period) * capital_share + ) + # As in capital_losses_allowed_against_gains: no capital loss + # deduction when a reform takes capital gains out of gross income. + sources = p.gross_income.sources + if "capital_gains" not in sources: + return 0 * federal + # Filing separately, the spouse's own capital gains, including capital + # gain distributions, absorb their own losses first, and the net loss + # deductible is at most $1,500. + gains = 0 + for source in ["capital_gains", "non_sch_d_capital_gains"]: + if source in sources: + gains = gains + max_(0, person(source, period)) + separate_limit = p.ald.loss.capital.max["SEPARATE"] + separate = min_(capital_losses, gains + separate_limit) + return joint * head_or_spouse * (federal - separate) diff --git a/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_indiv.py b/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_indiv.py index dba44f51fc1..e9663abc7e2 100644 --- a/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_indiv.py +++ b/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_indiv.py @@ -12,5 +12,11 @@ class dc_taxable_income_indiv(Variable): "https://otr.cfo.dc.gov/sites/default/files/dc/sites/otr/publication/attachments/2022_D-40_Booklet_Final_blk_01_23_23_Ordc.pdf#page=34", ) defined_for = StateCode.DC - adds = ["dc_agi"] + documentation = ( + "Each spouse's DC taxable income when they file separately on the " + "same return: their DC AGI, with their own capital losses limited as " + "on a separate return (dc_separate_capital_loss_adjustment), less " + "their part of the DC deduction." + ) + adds = ["dc_agi", "dc_separate_capital_loss_adjustment"] subtracts = ["dc_deduction_indiv"] diff --git a/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_joint.py b/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_joint.py index f44e770471a..d2350341577 100644 --- a/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_joint.py +++ b/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_joint.py @@ -12,4 +12,7 @@ class dc_taxable_income_joint(Variable): "https://otr.cfo.dc.gov/sites/default/files/dc/sites/otr/publication/attachments/2022_D-40_Booklet_Final_blk_01_23_23_Ordc.pdf#page=34", ) defined_for = StateCode.DC - adds = ["dc_taxable_income_indiv"] + # The couple's DC AGI less their DC deduction. The separate filing + # capital loss limit (dc_separate_capital_loss_adjustment) does not apply. + adds = ["dc_agi"] + subtracts = ["dc_deduction_indiv"] diff --git a/policyengine_us/variables/gov/states/dc/tax/income/deductions/dc_deduction_indiv.py b/policyengine_us/variables/gov/states/dc/tax/income/deductions/dc_deduction_indiv.py index d3bd77568cd..614ff509c74 100644 --- a/policyengine_us/variables/gov/states/dc/tax/income/deductions/dc_deduction_indiv.py +++ b/policyengine_us/variables/gov/states/dc/tax/income/deductions/dc_deduction_indiv.py @@ -15,7 +15,11 @@ class dc_deduction_indiv(Variable): def formula(person, period, parameters): tax_unit_deduction = person.tax_unit("dc_deduction_joint", period) - person_agi = person("dc_agi", period) + # Each spouse's DC AGI when filing separately. The parts add up to the + # joint deduction, so the joint computation is unaffected. + person_agi = person("dc_agi", period) + person( + "dc_separate_capital_loss_adjustment", period + ) head = person("is_tax_unit_head", period) spouse = person("is_tax_unit_spouse", period) tax = parameters(period).gov.states.dc.tax.income diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py index 85b1544c908..6e172bccefa 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py @@ -9,10 +9,11 @@ class mt_applicable_ald_deductions(Variable): documentation = ( "Each spouse's own federal adjustments to income, for their column of " "the Taxable Social Security Benefits Schedule (Form 2, line 7, before " - "removing student loan interest)." + "removing student loan interest). Through 2023, while spouses could " + "file separately on the same form." ) definition_period = YEAR - defined_for = StateCode.MT + defined_for = "mt_married_filing_separately_on_same_return_eligible" reference = ( # 2023 Form 2 instructions, page 6: deductions attributable to only one # spouse, the student loan interest deduction included, must be claimed diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_loss_ald_reallocation.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_loss_ald_reallocation.py index 1d3cd65a6c1..4d3856b88ab 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_loss_ald_reallocation.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_loss_ald_reallocation.py @@ -17,13 +17,18 @@ class mt_loss_ald_reallocation(Variable): "PolicyEngine did before each spouse's own deductions were " "attributed to them. Adding this amount to a spouse's federal AGI " "gives that equal division. Other deductions stay with the spouse " - "who has them." + "who has them. It applies through 2023: from 2024 spouses no longer " + "file separately on the same form, and Montana no longer allocates " + "losses between them." ) definition_period = YEAR - defined_for = StateCode.MT + defined_for = "mt_married_filing_separately_on_same_return_eligible" reference = ( "https://sosmt.gov/wp-content/uploads/attachments/MAR10-05.pdf#page=35", "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=23", + # 2024 Form 2 instructions, page i: "Capital losses, passive losses, + # and excess business losses will no longer be allocated by spouse". + "https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=5", ) def formula(person, period, parameters): From 0676d5ca200814b44c6261feed758400bce0e028 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 07:07:33 -0400 Subject: [PATCH 18/28] Net household members' Medicaid MAGI before the zero floor, and attribute each spouse's own deductions medicaid_magi_person floored each person's MAGI at zero, and medicaid_household_income added the floored amounts, so a spouse's business loss never offset the other spouse's wages on their joint return. Household income is the sum of every member's MAGI-based income (42 CFR 435.603(d)(1)), and the 36B methodology the regulation adopts (435.603(e)) combines the taxpayer's and dependents' modified AGIs "even if one or both of them are negative" before entering a negative total as zero (Form 8962, line 3). medicaid_magi_person is now unfloored and medicaid_household_income floors only the household's total. medicaid_adjusted_gross_income_person now subtracts above_the_line_deductions_person, so each spouse's own IRA deduction, student loan interest, alimony and business and capital losses lower only their own amount, and a dependent's own deductions, alimony paid included, lower the dependent's own AGI. Tax-unit-only MAGI additions are divided equally between the head and spouse with filer_share. Co-Authored-By: Claude Opus 5.5 --- .../medicaid-magi-joint-floor.fixed.md | 1 + ...medicaid_adjusted_gross_income_person.yaml | 81 +++++ .../income/medicaid_household_income.yaml | 305 ++++++++++++++++++ .../medicaid/income/medicaid_magi_person.yaml | 57 ++++ .../medicaid_adjusted_gross_income_person.py | 81 ++--- .../income/medicaid_household_income.py | 21 +- .../medicaid_household_income_member.py | 6 + .../medicaid/income/medicaid_magi_person.py | 46 ++- 8 files changed, 510 insertions(+), 88 deletions(-) create mode 100644 changelog.d/medicaid-magi-joint-floor.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_magi_person.yaml diff --git a/changelog.d/medicaid-magi-joint-floor.fixed.md b/changelog.d/medicaid-magi-joint-floor.fixed.md new file mode 100644 index 00000000000..4cc013f3c77 --- /dev/null +++ b/changelog.d/medicaid-magi-joint-floor.fixed.md @@ -0,0 +1 @@ +Medicaid MAGI household income now nets one household member's negative MAGI-based income, such as a spouse's business loss on a joint return, against the others' income and floors only the household's total at zero (42 CFR 435.603(d)(1); Form 8962, line 3). Each person's Medicaid AGI now subtracts their own above-the-line deductions, and a self-employment loss no longer counts as unearned income in the Medicaid filing-requirement test. 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 b35bcc9e710..b86fe850086 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 @@ -123,3 +123,84 @@ output: adjusted_gross_income: 47_000 medicaid_adjusted_gross_income_person: 47_000 + +- name: Case 6, a spouse's own deductions lower only that spouse's Medicaid AGI. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 50_000 + is_tax_unit_head: true + spouse: + age: 40 + employment_income: 20_000 + traditional_ira_contributions: 5_000 + early_withdrawal_penalty: 200 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # Head: 50,000. Spouse: 20,000 - 5,000 - 200 = 14,800. They add up to the + # joint AGI, 64,800. + adjusted_gross_income: 64_800 + medicaid_adjusted_gross_income_person: [50_000, 14_800] + +- name: Case 7, a spouse's business loss lowers only that spouse's Medicaid AGI, below zero. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 30_000 + is_tax_unit_head: true + spouse: + age: 40 + self_employment_income: -20_000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + adjusted_gross_income: 10_000 + medicaid_adjusted_gross_income_person: [30_000, -20_000] + +- name: Case 8, savings bond interest recorded on a dependent is the filer's exclusion. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + employment_income: 50_000 + is_tax_unit_head: true + student: + age: 20 + is_full_time_student: true + employment_income: 20_000 + us_bonds_for_higher_ed: 1_000 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + # gov.irs.ald.filer_amounts_recorded_on_dependents: the exclusion is the + # parent's (Form 8815), so it lowers the parent's AGI, 50,000 - 1,000, + # and not the student's own, 20,000. + adjusted_gross_income: 49_000 + medicaid_adjusted_gross_income_person: [49_000, 20_000] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml new file mode 100644 index 00000000000..a4bef983002 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml @@ -0,0 +1,305 @@ +# Medicaid MAGI household income is the sum of the MAGI-based income of every +# member of the household (42 CFR 435.603(d)(1)). A member's MAGI-based income +# can be negative, and it offsets the others' income before the total is +# floored at zero, as Form 8962 line 3 combines the taxpayer's and dependents' +# modified AGIs "even if one or both of them are negative" +# (https://www.irs.gov/pub/irs-prior/i8962--2025.pdf#page=8). + +# The first three cases are CMS's worked examples in the MAGI-Based Household +# Income Eligibility Training Manual (December 2020), +# https://www.medicaid.gov/state-resource-center/mac-learning-collaboratives/downloads/household-composition-and-income-training.pdf +# Monthly amounts are annualized. + +- name: CMS Sam scenario, a joint couple's income is added (training manual, p. 83). + period: 2025 + absolute_error_margin: 0.01 + input: + people: + sam: + age: 35 + employment_income: 12_000 # $1,000 a month + is_tax_unit_head: true + susan: + age: 35 + employment_income: 6_000 # $500 a month + is_tax_unit_spouse: true + sarah: + age: 6 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [sam, susan, sarah] + marital_units: + couple: + members: [sam, susan] + sarah_unit: + members: [sarah] + households: + household: + members: [sam, susan, sarah] + state_code: TX + output: + # $1,500 a month for a household of 3. + medicaid_household_income: [18_000, 18_000, 18_000] + +- name: CMS Marty scenario, a dependent adult child not required to file adds no income (training manual, p. 86). + period: 2025 + absolute_error_margin: 0.01 + input: + people: + marty: + age: 50 + employment_income: 30_000 # $2,500 a month + is_tax_unit_head: true + mark: + age: 22 + is_full_time_student: true + employment_income: 9_600 # $800 a month, below the filing threshold + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [marty, mark] + households: + household: + members: [marty, mark] + state_code: TX + output: + medicaid_person_is_required_to_file: [true, false] + medicaid_household_income: [30_000, 30_000] + +- name: CMS Sadie and Stephanie scenarios, a dependent child's income below the filing threshold is excluded (training manual, pp. 87-88). + period: 2025 + absolute_error_margin: 0.01 + input: + people: + sadie: + age: 55 + employment_income: 36_000 # $3,000 a month + is_tax_unit_head: true + stephanie: + age: 17 + employment_income: 6_000 # $500 a month + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [sadie, stephanie] + households: + household: + members: [sadie, stephanie] + state_code: TX + output: + # $3,000 a month for Sadie and for Stephanie. The manual's household also + # has Stephanie's daughter Sarah, whom Sadie claims; she has no income and + # uses the non-filer rules, so she is left out here. + medicaid_person_is_required_to_file: [true, false] + medicaid_household_income: [36_000, 36_000] + +- name: A spouse's business loss offsets the other spouse's wages on their joint return. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 30_000 + is_tax_unit_head: true + spouse: + age: 40 + self_employment_income: -20_000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # Joint AGI: wages 30,000 less the 20,000 Schedule C loss (Schedule 1, + # line 3) = 10,000. Each spouse's MAGI-based income is their own part, + # 30,000 and -20,000, and the household's total is 10,000. Flooring each + # spouse at zero would give 30,000; splitting the loss equally and then + # flooring would give 20,000. + adjusted_gross_income: 10_000 + medicaid_magi_person: [30_000, -20_000] + medicaid_household_income: [10_000, 10_000] + +- name: One spouse's IRA deduction lowers the couple's income once. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 30_000 + traditional_ira_contributions: 6_000 + is_tax_unit_head: true + spouse: + age: 40 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # 30,000 - 6,000 = 24,000. The deduction is the head's; splitting it + # equally would leave the spouse at -3,000, floored to 0, and the head at + # 27,000. + adjusted_gross_income: 24_000 + medicaid_magi_person: [24_000, 0] + medicaid_household_income: [24_000, 24_000] + +- name: A negative joint MAGI offsets a dependent's income when the dependent is required to file. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + self_employment_income: -30_000 + is_tax_unit_head: true + spouse: + age: 45 + employment_income: 10_000 + is_tax_unit_spouse: true + child: + age: 17 + employment_income: 25_000 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child] + households: + household: + members: [head, spouse, child] + state_code: TX + output: + # Joint AGI: 10,000 - 30,000 = -20,000 (Form 8962, line 2a). The child's + # wages exceed the 15,000 standard deduction, so the child is required to + # file and the child's MAGI, 25,000, is added (line 2b; 42 CFR + # 435.603(d)(2)). Line 3 combines them even though line 2a is negative: + # -20,000 + 25,000 = 5,000. + adjusted_gross_income: -20_000 + medicaid_person_is_required_to_file: [false, false, true] + medicaid_magi_person: [-30_000, 10_000, 25_000] + medicaid_household_income: [5_000, 5_000, 5_000] + +- name: A dependent's negative MAGI offsets the filer's income when the dependent is required to file. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + filer: + age: 45 + employment_income: 50_000 + is_tax_unit_head: true + mother: + age: 72 + taxable_private_pension_income: 4_000 + alimony_expense: 6_000 + divorce_year: 2010 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [filer, mother] + households: + household: + members: [filer, mother] + state_code: TX + output: + # The mother's unearned income, 4,000, exceeds 1,350 plus the 2,000 + # additional amount for age, so she is required to file. Her own AGI is + # 4,000 less alimony paid under a 2010 instrument, 6,000: -2,000. The + # filer's household counts her MAGI (42 CFR 435.603(d)(2)(ii)), which + # offsets the filer's: 50,000 - 2,000 = 48,000. As a parent claimed by her + # child she uses the non-filer rules (435.603(f)(2)(i)); her own + # household is herself, whose negative total is floored at zero. + medicaid_person_is_required_to_file: [true, true] + medicaid_magi_person: [50_000, -2_000] + medicaid_uses_non_filer_rules: [false, true] + medicaid_household_income: [48_000, 0] + +- name: Cohabiting spouses filing separately offset each other's income in their shared household. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 30_000 + person2: + age: 40 + self_employment_income: -20_000 + tax_units: + tax_unit1: + members: [person1] + cohabitating_spouses: true + tax_unit_is_filer: true + tax_unit2: + members: [person2] + cohabitating_spouses: true + tax_unit_is_filer: true + marital_units: + couple: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # Each spouse is in the other's household whether or not they file + # jointly (42 CFR 435.603(f)(4)), so each household's income is the sum + # of the two spouses' MAGI-based income: 30,000 - 20,000 = 10,000. + medicaid_magi_person: [30_000, -20_000] + medicaid_household_income: [10_000, 10_000] + +- name: A non-filer spouse's business loss offsets the other spouse's wages. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 12_000 + is_tax_unit_head: true + spouse: + age: 40 + self_employment_income: -5_000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + tax_unit_is_filer: false + households: + household: + members: [head, spouse] + state_code: TX + output: + # Neither spouse files, so each household is the individual and their + # spouse (42 CFR 435.603(f)(3)(i)): 12,000 - 5,000 = 7,000. + medicaid_uses_non_filer_rules: [true, true] + medicaid_magi_person: [12_000, -5_000] + medicaid_household_income: [7_000, 7_000] + +- name: A household whose total MAGI-based income is negative has zero household income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + self_employment_income: -10_000 + taxable_interest_income: 1_000 + tax_units: + tax_unit: + members: [person] + households: + household: + members: [person] + state_code: TX + output: + medicaid_magi_person: -9_000 + medicaid_household_income: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_magi_person.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_magi_person.yaml new file mode 100644 index 00000000000..9de5eababee --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_magi_person.yaml @@ -0,0 +1,57 @@ +- name: Case 1, each spouse's MAGI is their own AGI plus their own additions, unfloored. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 30_000 + tax_exempt_interest_income: 1_000 + is_tax_unit_head: true + spouse: + age: 40 + self_employment_income: -20_000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + foreign_earned_income_exclusion: 4_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + # The head's tax-exempt interest is the head's own. The foreign earned + # income exclusion is recorded only for the tax unit, so it is divided + # equally. Head: 30,000 + 1,000 + 2,000 = 33,000. Spouse: -20,000 + 2,000 + # = -18,000. They add up to the couple's MAGI, 10,000 + 1,000 + 4,000. + medicaid_magi: 15_000 + medicaid_magi_person: [33_000, -18_000] + medicaid_household_income: [15_000, 15_000] + +- name: Case 2, a dependent's own tax-exempt interest is in the dependent's MAGI. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + employment_income: 40_000 + is_tax_unit_head: true + child: + age: 16 + employment_income: 16_000 + tax_exempt_interest_income: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + # The child's wages exceed the 15,000 standard deduction, so the child is + # required to file and counts: 40,000 + 16,000 + 500. + medicaid_magi_person: [40_000, 16_500] + medicaid_household_income: [56_500, 56_500] 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 b1176492612..44aea280ede 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,77 +1,38 @@ from policyengine_us.model_api import * -# 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): value_type = float entity = Person label = "Federal adjusted gross income for Medicaid MAGI household rules" unit = USD + documentation = ( + "Each person's AGI for Medicaid MAGI-based income: their own gross " + "income less their own above-the-line deductions " + "(above_the_line_deductions_person). On a joint return a deduction " + "that belongs to one spouse, such as their IRA deduction or business " + "loss, lowers only that spouse's amount, and the head's and spouse's " + "amounts add up to the return's AGI. A tax unit dependent's amount is " + "the AGI of the dependent's own return: their own income less their " + "own deductions, alimony paid included. It can be negative; " + "medicaid_household_income floors the household's total, not each " + "person's amount." + ) definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/62" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/62", + "https://www.law.cornell.edu/cfr/text/42/435.603#e", + ) def formula(person, period, parameters): gross_income = person("medicaid_irs_gross_income", period) - 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 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: - 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 - if ald not in TAX_UNIT_AGI_ALDS_WITH_PERSON_LEVEL_EQUIVALENTS - ] - # 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. - # 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) - filing_status = person.tax_unit("filing_status", period) - frac = where( - filing_status == filing_status.possible_values.JOINT, - 0.5, - 1.0, - ) - 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 - dependent_own_ald - shared_ald + deductions = person("above_the_line_deductions_person", period) + agi = gross_income - deductions if parameters(period).gov.contrib.ubi_center.basic_income.taxable: basic_income = person.tax_unit("basic_income", period) - agi += head_or_spouse * basic_income * frac + # Divided equally between the head and spouse, as in + # adjusted_gross_income_person. + agi += basic_income * filer_share(person, period, 0 * basic_income) return agi diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py index 0dbd9d927eb..480eea443fa 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py @@ -10,8 +10,22 @@ class medicaid_household_income(Variable): entity = Person label = "Medicaid MAGI household income" unit = USD + documentation = ( + "The sum of the MAGI-based income of every member of the person's " + "Medicaid household (42 CFR 435.603(d)(1)). Members' amounts are " + "added before any floor, so a loss of one member, such as a spouse's " + "business loss on a joint return, offsets the others' income, as it " + "does in the couple's joint AGI. Only the total is floored at zero, " + "as Form 8962 combines the taxpayer's and dependents' modified AGIs " + "'even if one or both of them are negative' before entering a " + "negative total as zero." + ) definition_period = YEAR - reference = "https://www.law.cornell.edu/cfr/text/42/435.603#d" + reference = ( + "https://www.law.cornell.edu/cfr/text/42/435.603#d", + "https://www.law.cornell.edu/uscode/text/26/36B#d_2_A", + "https://www.irs.gov/pub/irs-prior/i8962--2025.pdf#page=8", + ) def formula(person, period, parameters): child_age_eligible = person("medicaid_non_filer_child_age_eligible", period) @@ -67,7 +81,7 @@ def formula(person, period, parameters): person, period, tax_household_income ) - return where( + household_income = where( non_filer_rules, non_filer_household_income, where( @@ -76,3 +90,6 @@ def formula(person, period, parameters): tax_household_income, ), ) + # Each member's MAGI-based income can be negative; only the + # household's total is floored. + return max_(0, household_income) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income_member.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income_member.py index 732f49d9083..5cf5729b0c1 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income_member.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income_member.py @@ -6,6 +6,12 @@ class medicaid_household_income_member(Variable): entity = Person label = "Countable person income for Medicaid MAGI household income" unit = USD + documentation = ( + "The person's MAGI-based income (medicaid_magi_person) as counted in " + "another member's non-filer household, or zero for a child excluded " + "under 42 CFR 435.603(d)(2)(i). It can be negative; " + "medicaid_household_income floors the household's total." + ) definition_period = YEAR reference = "https://www.law.cornell.edu/cfr/text/42/435.603#d" diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_magi_person.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_magi_person.py index 901c708482e..ef23a5e0db1 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_magi_person.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_magi_person.py @@ -1,43 +1,37 @@ from policyengine_us.model_api import * -PERSON_LEVEL_MEDICAID_MAGI_ADDITIONS = ["tax_exempt_interest_income"] - - class medicaid_magi_person(Variable): value_type = float entity = Person label = "Person-level Medicaid MAGI" unit = USD + documentation = ( + "Each person's MAGI-based income: their Medicaid AGI plus the MAGI " + "additions. A person-level addition, such as tax-exempt interest, is " + "the person's own; an addition recorded only for the tax unit is " + "divided equally between the head and spouse. The head's and " + "spouse's amounts add up to their return's MAGI before any floor. " + "This amount is not floored at zero, so one household member's loss " + "offsets the others' income in medicaid_household_income, which " + "floors the household's total." + ) definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/42/1396a#e_14_G", "https://www.law.cornell.edu/uscode/text/26/36B#d_2", + "https://www.law.cornell.edu/cfr/text/42/435.603#e", ) def formula(person, period, parameters): agi = person("medicaid_adjusted_gross_income_person", period) additions = parameters(period).gov.hhs.medicaid.income.modification - person_level_additions = [ - addition - for addition in additions - if addition in PERSON_LEVEL_MEDICAID_MAGI_ADDITIONS - ] - tax_unit_level_additions = [ - addition for addition in additions if addition not in person_level_additions - ] - filing_status = person.tax_unit("filing_status", period) - frac = where( - filing_status == filing_status.possible_values.JOINT, - 0.5, - 1.0, - ) - shared_additions = ( - person("is_tax_unit_head_or_spouse", period) - * add(person.tax_unit, period, tax_unit_level_additions) - * frac - ) - return max_( - 0, - agi + add(person, period, person_level_additions) + shared_additions, - ) + total = agi + for addition in additions: + variable = person.entity.get_variable(addition, check_existence=True) + if variable.entity.is_person: + total = total + person(addition, period) + else: + amount = person.tax_unit(addition, period) + total = total + amount * filer_share(person, period, 0 * amount) + return total From 60a1889bd1ed9b9fd11f83067445e4329c2b17d2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 07:07:33 -0400 Subject: [PATCH 19/28] Count only gross income other than wages and self-employment as unearned in the Medicaid filing test medicaid_person_is_required_to_file took unearned income as gross income less earned_income. Gross income counts each source's positive amount, so a self-employment loss made other income look unearned: a dependent with a 5,000 loss and 100 of interest had 5,100 of unearned income and was required to file. With household members' MAGI no longer floored, that would count the dependent's negative income. Unearned income is now gross income less the positive wage and self-employment sources it includes (Pub. 501, Table 2), which also stops 401(k) deferrals, left out of box 1 wages, from hiding unearned income. Co-Authored-By: Claude Opus 5.5 --- .../medicaid_person_is_required_to_file.yaml | 38 +++++++++++++++++++ .../medicaid_person_is_required_to_file.py | 17 ++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.yaml index 045350b9415..94f2f9c5ef7 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.yaml @@ -107,3 +107,41 @@ members: [person1] output: medicaid_person_is_required_to_file: false + +- name: Case 7, a self-employment loss does not count as unearned income. + # IRS Pub. 501 (2025), p. 4, Table 2: unearned income is interest, + # dividends, capital gain distributions, pensions and the like; earned + # income is wages and pay for work. Here unearned income is the 100 of + # interest, not 100 less the 5,000 loss, so it is not more than 1,350, and + # gross income, 100, is not more than 1,350. The dependent is not required + # to file. + period: 2025 + input: + people: + person1: + age: 17 + self_employment_income: -5_000 + taxable_interest_income: 100 + tax_units: + tax_unit: + members: [person1] + output: + medicaid_person_is_required_to_file: false + +- name: Case 8, wages deferred to a 401(k) plan do not hide unearned income. + # Box 1 wages leave out the 2,000 deferral, so gross income is 8,000 of + # wages and 1,500 of interest. Unearned income, 1,500, is more than 1,350 + # (Pub. 501 (2025), Table 2), so the dependent must file. + period: 2025 + input: + people: + person1: + age: 17 + employment_income: 10_000 + traditional_401k_contributions: 2_000 + taxable_interest_income: 1_500 + tax_units: + tax_unit: + members: [person1] + output: + medicaid_person_is_required_to_file: true diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.py index d03c904be47..c5fec3109ad 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.py @@ -3,6 +3,13 @@ FilingStatus, ) +# The wage and self-employment sources in gov.irs.gross_income.sources. +EARNED_GROSS_INCOME_SOURCES = [ + "irs_employment_income", + "self_employment_income", + "sstb_self_employment_income", +] + class medicaid_person_is_required_to_file(Variable): value_type = bool @@ -12,6 +19,7 @@ class medicaid_person_is_required_to_file(Variable): reference = ( "https://www.law.cornell.edu/cfr/text/42/435.603#d_2", "https://www.irs.gov/publications/p501", + "https://www.irs.gov/pub/irs-prior/p501--2025.pdf#page=4", ) def formula(person, period, parameters): @@ -19,7 +27,14 @@ def formula(person, period, parameters): filing_requirement = parameters(period).gov.irs.income.filing_requirement gross_income = person("medicaid_irs_gross_income", period) earned_income = person("earned_income", period) - unearned_income = gross_income - earned_income + # Unearned income is gross income other than wages and self-employment + # earnings (IRS Pub. 501, Table 2). Gross income counts each source's + # positive amount, so subtract those sources' positive amounts: a + # business loss does not turn other income into unearned income. + earned_gross_income = 0 + for source in EARNED_GROSS_INCOME_SOURCES: + earned_gross_income += max_(0, person(source, period)) + unearned_income = max_(0, gross_income - earned_gross_income) married = person.marital_unit.nb_persons() == 2 filing_status = where(married, FilingStatus.SEPARATE, FilingStatus.SINGLE) From c96ef4214cc5df57179a85b9c4f8a41e2500827f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 07:11:11 -0400 Subject: [PATCH 20/28] Add property tests for Medicaid household income with members' losses Hypothesis draws batches of joint filers with dependent children, non-filing couples, cohabiting spouses filing separately and single filers, with losses, own deductions and children who may be required to file. For every household: joint filers' household income is max(0, J + D), with J the couple's MAGI figured from the tax unit's AGI; the spouses' MAGIs add up to J; spouses outside a joint return are netted before the floor; income is never negative and is zero exactly when the unfloored sum is not positive; and three metamorphic relations hold (the spouse's own deductions never change the head's, swapping spouses changes nothing, and lowering the spouse's self-employment income never raises household income). 20 examples plus a seeded population of 200 households: 44 s wall and 1.17 GB peak locally (macOS, loaded host). Co-Authored-By: Claude Opus 5.5 --- ...st_medicaid_household_income_invariants.py | 437 ++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 policyengine_us/tests/core/test_medicaid_household_income_invariants.py diff --git a/policyengine_us/tests/core/test_medicaid_household_income_invariants.py b/policyengine_us/tests/core/test_medicaid_household_income_invariants.py new file mode 100644 index 00000000000..90603f4763c --- /dev/null +++ b/policyengine_us/tests/core/test_medicaid_household_income_invariants.py @@ -0,0 +1,437 @@ +"""Invariants for Medicaid MAGI household income when members have losses. + +Household income is the sum of the MAGI-based income of every member of the +household (42 CFR 435.603(d)(1)), figured with the 36B methodology +(435.603(e)). On a joint return the couple has one AGI, so one spouse's loss +offsets the other's income, and Form 8962 line 3 combines the couple's and +their dependents' modified AGIs "even if one or both of them are negative" +before entering a negative total as zero. So no member's MAGI-based income is +floored; only the household's total is. + +Hypothesis draws batches of households: joint filers with up to two +dependent children, a married couple who do not file, cohabiting spouses who +file separately, and single filers. Spouses draw wages, self-employment and +rental income that can be losses, capital gains or losses, interest, Social +Security, and their own IRA, early withdrawal penalty, student loan interest +and alimony deductions. Children draw wages, interest, tax-exempt interest and +IRA contributions, so some are required to file. A seeded population of 200 +households adds breadth. Each batch runs as one vectorized simulation, and +again with metamorphic changes. For every household: + +1. Joint filers: each member's household income is + max(0, J + D), where J is the couple's MAGI, figured independently from + the tax unit's adjusted_gross_income plus the MAGI additions, and D is the + sum of the MAGI of the children required to file. When J >= 0 and every + counted child's MAGI is >= 0 this is max(0, J) + D. +2. Accounting: the head's and spouse's medicaid_magi_person add up to J, and + a dependent's Medicaid AGI is their own gross income less their own + above-the-line deductions. +3. Non-filing couples and cohabiting spouses filing separately: each + spouse's household income is max(0, the two spouses' MAGI added). + Single filers: max(0, their MAGI). +4. Household income is never negative, and is zero exactly when the + unfloored sum is not positive. +5. Metamorphic: removing the spouse's own IRA deduction, early withdrawal + penalty and alimony leaves the head's own deductions unchanged; swapping + the head's and spouse's incomes and deductions leaves every member's + household income unchanged; and lowering the spouse's self-employment + income never raises household income. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +YEAR = 2025 +TOLERANCE = 0.01 # dollars + +# Each spouse's own deductions that no other member's inputs limit. Student +# loan interest and capital losses are limited for the return as a whole, so +# one spouse's amount can change the other's share. +SPOUSE_ONLY_DEDUCTION_INPUTS = [ + "traditional_ira_contributions", + "early_withdrawal_penalty", + "alimony_expense", +] +OUTPUTS = [ + "medicaid_household_income", + "medicaid_magi_person", + "medicaid_adjusted_gross_income_person", + "medicaid_irs_gross_income", + "medicaid_person_is_required_to_file", + "medicaid_uses_non_filer_rules", + "above_the_line_deductions_person", + "student_loan_interest_ald", + "tax_exempt_interest_income", +] +TAX_UNIT_OUTPUTS = [ + "adjusted_gross_income", + "tax_exempt_social_security", + "foreign_earned_income_exclusion", +] + +amount = st.integers(1, 60_000).map(float) +maybe = lambda strategy: st.one_of(st.just(0.0), strategy) # noqa: E731 + + +@st.composite +def spouse_amounts(draw): + return { + "employment_income": draw(maybe(amount)), + "self_employment_income": draw(maybe(st.integers(-40_000, 60_000).map(float))), + "rental_income": draw(maybe(st.integers(-15_000, 20_000).map(float))), + "long_term_capital_gains": draw(maybe(st.integers(-10_000, 10_000).map(float))), + "taxable_interest_income": draw(maybe(st.integers(1, 3_000).map(float))), + "tax_exempt_interest_income": draw(maybe(st.integers(1, 2_000).map(float))), + "social_security_retirement": draw(maybe(st.integers(1, 30_000).map(float))), + "traditional_ira_contributions": draw(maybe(st.integers(1, 7_000).map(float))), + "early_withdrawal_penalty": draw(maybe(st.integers(1, 500).map(float))), + "student_loan_interest": draw(maybe(st.integers(1, 3_000).map(float))), + "alimony_expense": draw(maybe(st.integers(1, 12_000).map(float))), + "divorce_year": draw(st.sampled_from([2010, 2020])), + } + + +@st.composite +def child_amounts(draw): + return { + "age": draw(st.integers(5, 17)), + "employment_income": draw(maybe(st.integers(1, 30_000).map(float))), + "taxable_interest_income": draw(maybe(st.integers(1, 3_000).map(float))), + "tax_exempt_interest_income": draw(maybe(st.integers(1, 1_000).map(float))), + "traditional_ira_contributions": draw(maybe(st.integers(1, 3_000).map(float))), + } + + +@st.composite +def households(draw): + kind = draw(st.sampled_from(["joint", "joint", "non_filer", "separate", "single"])) + n_children = draw(st.integers(0, 2)) if kind == "joint" else 0 + return { + "kind": kind, + "head": draw(spouse_amounts()), + "spouse": None if kind == "single" else draw(spouse_amounts()), + "children": [draw(child_amounts()) for _ in range(n_children)], + } + + +SEED = 20261006 + + +def _seeded_households(n=200): + rng = np.random.default_rng(SEED) + + def some(low, high, p=0.5): + return float(round(rng.uniform(low, high))) if rng.random() < p else 0.0 + + def spouse(): + return { + "employment_income": some(1, 60_000, 0.6), + "self_employment_income": some(-40_000, 60_000, 0.4), + "rental_income": some(-15_000, 20_000, 0.2), + "long_term_capital_gains": some(-10_000, 10_000, 0.2), + "taxable_interest_income": some(1, 3_000), + "tax_exempt_interest_income": some(1, 2_000, 0.2), + "social_security_retirement": some(1, 30_000, 0.2), + "traditional_ira_contributions": some(1, 7_000, 0.3), + "early_withdrawal_penalty": some(1, 500, 0.2), + "student_loan_interest": some(1, 3_000, 0.3), + "alimony_expense": some(1, 12_000, 0.2), + "divorce_year": int(rng.choice([2010, 2020])), + } + + units = [] + for _ in range(n): + kind = str(rng.choice(["joint", "joint", "non_filer", "separate", "single"])) + n_children = int(rng.integers(0, 3)) if kind == "joint" else 0 + units.append( + { + "kind": kind, + "head": spouse(), + "spouse": None if kind == "single" else spouse(), + "children": [ + { + "age": int(rng.integers(5, 18)), + "employment_income": some(1, 30_000), + "taxable_interest_income": some(1, 3_000), + "tax_exempt_interest_income": some(1, 1_000, 0.3), + "traditional_ira_contributions": some(1, 3_000, 0.3), + } + for _ in range(n_children) + ], + } + ) + return units + + +def _situation(units): + people = {} + groups = { + "tax_units": {}, + "households": {}, + "marital_units": {}, + "families": {}, + "spm_units": {}, + } + kinds, roles = [], [] + for i, u in enumerate(units): + head, spouse = f"head_{i}", f"spouse_{i}" + people[head] = {"age": 45, **u["head"]} + members, roles_i = [head], ["head"] + if u["spouse"] is not None: + people[spouse] = {"age": 45, **u["spouse"]} + members.append(spouse) + roles_i.append("spouse") + groups["marital_units"][f"couple_{i}"] = {"members": [head, spouse]} + else: + groups["marital_units"][f"couple_{i}"] = {"members": [head]} + if u["kind"] == "separate": + people[head]["is_tax_unit_head"] = True + people[spouse]["is_tax_unit_head"] = True + for name, member in (("a", head), ("b", spouse)): + groups["tax_units"][f"tax_unit_{i}{name}"] = { + "members": [member], + "cohabitating_spouses": {YEAR: True}, + "tax_unit_is_filer": {YEAR: True}, + } + else: + people[head]["is_tax_unit_head"] = True + if u["spouse"] is not None: + people[spouse]["is_tax_unit_spouse"] = True + for j, child in enumerate(u["children"]): + name = f"child_{i}_{j}" + people[name] = {"is_tax_unit_dependent": True, **child} + members.append(name) + roles_i.append("child") + groups["marital_units"][f"child_{i}_{j}"] = {"members": [name]} + if u["kind"] != "separate": + groups["tax_units"][f"tax_unit_{i}"] = { + "members": members, + "tax_unit_is_filer": {YEAR: u["kind"] != "non_filer"}, + } + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "TX"}, + } + # Each household is its own family, so the non-filer rules' parents + # and children come from the household only. + groups["families"][f"family_{i}"] = {"members": members} + groups["spm_units"][f"spm_unit_{i}"] = {"members": members} + kinds += [u["kind"]] * len(members) + roles += roles_i + people = { + name: {k: {YEAR: v} for k, v in values.items()} + for name, values in people.items() + } + return {"people": people, **groups}, np.array(kinds), np.array(roles) + + +def _run(units): + situation, kinds, roles = _situation(units) + sim = Simulation(situation=situation) + out = {name: np.asarray(sim.calculate(name, YEAR), dtype=float) for name in OUTPUTS} + for name in TAX_UNIT_OUTPUTS: + out[name] = np.asarray(sim.calculate(name, YEAR, map_to="person"), dtype=float) + out["kind"], out["role"] = kinds, roles + # People are listed household by household, so the household index + # groups each household's members. + out["household"] = sim.populations["household"].members_entity_id + return out + + +def _household_sum(run, values): + return np.bincount(run["household"], weights=values)[run["household"]] + + +def _swap_spouses(units): + swapped = [] + for u in units: + if u["spouse"] is None: + swapped.append(u) + else: + swapped.append({**u, "head": u["spouse"], "spouse": u["head"]}) + return swapped + + +def _without_spouse_deductions(units): + changed = [] + for u in units: + if u["spouse"] is None: + changed.append(u) + continue + spouse = {**u["spouse"], **{k: 0.0 for k in SPOUSE_ONLY_DEDUCTION_INPUTS}} + changed.append({**u, "spouse": spouse}) + return changed + + +def _lower_spouse_self_employment(units, by=7_500.0): + changed = [] + for u in units: + if u["spouse"] is None: + changed.append(u) + continue + spouse = { + **u["spouse"], + "self_employment_income": u["spouse"]["self_employment_income"] - by, + } + changed.append({**u, "spouse": spouse}) + return changed + + +def _check(units): + run = _run(units) + income = run["medicaid_household_income"] + magi = run["medicaid_magi_person"] + kind, role = run["kind"], run["role"] + adult = role != "child" + child = role == "child" + counted_child = child & (run["medicaid_person_is_required_to_file"] > 0) + + # 4. Never negative. + assert (income >= 0).all() + + # 1 and 2. Joint filers. The couple's MAGI, from the tax unit's AGI and + # the MAGI additions (36B(d)(2)(B)): the head's and spouse's tax-exempt + # interest, the foreign earned income exclusion and nontaxable Social + # Security, which the children here do not have. + joint = kind == "joint" + couple_magi = ( + run["adjusted_gross_income"] + + _household_sum(run, adult * run["tax_exempt_interest_income"]) + + run["foreign_earned_income_exclusion"] + + run["tax_exempt_social_security"] + ) + np.testing.assert_allclose( + _household_sum(run, adult * magi)[joint], + couple_magi[joint], + atol=TOLERANCE, + err_msg="spouses' MAGI do not add up to the couple's MAGI", + ) + children_magi = _household_sum(run, counted_child * magi) + assert not run["medicaid_uses_non_filer_rules"][joint].any() + np.testing.assert_allclose( + income[joint], + np.maximum(0, couple_magi + children_magi)[joint], + atol=TOLERANCE, + err_msg="joint household income is not max(0, J + D)", + ) + nonnegative = (couple_magi >= 0) & ( + _household_sum(run, counted_child * (magi < 0)) == 0 + ) + np.testing.assert_allclose( + income[joint & nonnegative], + (np.maximum(0, couple_magi) + children_magi)[joint & nonnegative], + atol=TOLERANCE, + ) + # A dependent's Medicaid AGI is their own gross income less their own + # deductions. + np.testing.assert_allclose( + run["medicaid_adjusted_gross_income_person"][child], + (run["medicaid_irs_gross_income"] - run["above_the_line_deductions_person"])[ + child + ], + atol=TOLERANCE, + ) + np.testing.assert_allclose( + magi[child], + ( + run["medicaid_adjusted_gross_income_person"] + + run["tax_exempt_interest_income"] + )[child], + atol=TOLERANCE, + ) + + # 3. Couples outside a joint return, and single filers. + pair = (kind == "non_filer") | (kind == "separate") + np.testing.assert_allclose( + income[pair], + np.maximum(0, _household_sum(run, magi))[pair], + atol=TOLERANCE, + err_msg="spouses outside a joint return are not netted", + ) + single = kind == "single" + np.testing.assert_allclose( + income[single], np.maximum(0, magi)[single], atol=TOLERANCE + ) + + # 4. Zero exactly when the unfloored sum is not positive. + unfloored = np.where( + joint, + couple_magi + children_magi, + np.where(pair, _household_sum(run, magi), magi), + ) + np.testing.assert_allclose(income, np.maximum(0, unfloored), atol=TOLERANCE) + assert (income[unfloored <= 0] == 0).all() + + # 5. Metamorphic relations. The spouse's own deductions never change the + # head's deductions, other than the student loan interest deduction, + # whose phase-out reads the couple's joint MAGI. + head = role == "head" + without = _run(_without_spouse_deductions(units)) + head_deductions = ( + run["above_the_line_deductions_person"] - run["student_loan_interest_ald"] + ) + np.testing.assert_allclose( + ( + without["above_the_line_deductions_person"] + - without["student_loan_interest_ald"] + )[head], + head_deductions[head], + atol=TOLERANCE, + err_msg="the spouse's own deductions changed the head's deductions", + ) + swapped = _run(_swap_spouses(units)) + np.testing.assert_allclose( + swapped["medicaid_household_income"], + income, + atol=TOLERANCE, + err_msg="swapping the spouses changed household income", + ) + lowered = _run(_lower_spouse_self_employment(units)) + assert (lowered["medicaid_household_income"] <= income + TOLERANCE).all() + + +SETTINGS = dict( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(households(), min_size=10, max_size=40)) +def test_medicaid_household_income_nets_members_before_the_floor(units): + _check(units) + + +def test_seeded_population(): + _check(_seeded_households()) + + +@pytest.mark.parametrize( + "head, spouse, expected", + [ + # The brief's example: 30,000 of wages and a 20,000 Schedule C loss. + ( + {"employment_income": 30_000.0}, + {"self_employment_income": -20_000.0}, + 10_000, + ), + # One spouse's 6,000 IRA deduction, the other spouse with no income. + ( + {"employment_income": 30_000.0, "traditional_ira_contributions": 6_000.0}, + {}, + 24_000, + ), + ], +) +def test_joint_examples(head, spouse, expected): + units = [{"kind": "joint", "head": head, "spouse": spouse, "children": []}] + run = _run(units) + np.testing.assert_allclose( + run["medicaid_household_income"], [expected, expected], atol=TOLERANCE + ) From b41007690f27de199745e44649c585df46e20983 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 12:43:03 -0400 Subject: [PATCH 21/28] Keep family-wide relatives' losses out of non-filer households, and give dependents the claimant's separate spouse Two findings from review r1: - A non-filer household's parents and siblings are read from the whole family, which can include people outside the household, such as a grandparent who is also a parent there. With signed amounts, a grandmother's $5,000 loss cut her granddaughter's household income from $12,000 to $7,000, though 42 CFR 435.603(f)(3)(iii) leaves her out. Those relatives' amounts are now added only when positive: each child's, and each couple's parents' netted together, so married parents' losses still offset each other. The individual's and spouse's amounts stay signed. PolicyEngine/policyengine-us#9406's co-resident parent IDs fix the membership itself. - A dependent's tax household is the claimant's (435.603(f)(2)), which includes the claimant's cohabiting spouse who files separately (f)(4). Household size already counted that spouse; household income now does too. Also corrects two comments to the 2025 standard deduction, $15,750. Co-Authored-By: Claude Opus 5.5 --- .../income/medicaid_household_income.yaml | 98 ++++++++++++++++++- .../medicaid/income/medicaid_magi_person.yaml | 2 +- .../income/medicaid_household_income.py | 41 +++++++- 3 files changed, 135 insertions(+), 6 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml index a4bef983002..74e02c85cab 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml @@ -178,7 +178,7 @@ state_code: TX output: # Joint AGI: 10,000 - 30,000 = -20,000 (Form 8962, line 2a). The child's - # wages exceed the 15,000 standard deduction, so the child is required to + # wages exceed the 15,750 standard deduction, so the child is required to # file and the child's MAGI, 25,000, is added (line 2b; 42 CFR # 435.603(d)(2)). Line 3 combines them even though line 2a is negative: # -20,000 + 25,000 = 5,000. @@ -303,3 +303,99 @@ output: medicaid_magi_person: -9_000 medicaid_household_income: 0 + +- name: A grandparent outside a child's non-filer household does not lower its income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 50 + self_employment_income: -5_000 + own_children_in_household: 1 + is_tax_unit_head: true + mother: + age: 30 + employment_income: 12_000 + own_children_in_household: 1 + is_tax_unit_head: true + child: + age: 8 + is_tax_unit_head: true + tax_units: + tax_unit1: + members: [grandmother] + tax_unit_is_filer: false + tax_unit2: + members: [mother] + tax_unit_is_filer: false + tax_unit3: + members: [child] + tax_unit_is_filer: false + families: + family: + members: [grandmother, mother, child] + marital_units: + grandmother_unit: + members: [grandmother] + mother_unit: + members: [mother] + child_unit: + members: [child] + households: + household: + members: [grandmother, mother, child] + state_code: TX + output: + # The child's household is the child and her mother (42 CFR + # 435.603(f)(3)(iii)); her grandmother is not in it, so the grandmother's + # loss does not offset the mother's 12,000. The grandmother's own + # household's total, -5,000, is floored at zero. + medicaid_uses_non_filer_rules: [true, true, true] + medicaid_household_income: [0, 12_000, 12_000] + +- name: A dependent's household includes the claimant's cohabiting spouse who files separately. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent_a: + age: 50 + employment_income: 30_000 + child: + age: 21 + is_full_time_student: true + taxable_interest_income: 2_000 + early_withdrawal_penalty: 5_000 + is_tax_unit_dependent: true + parent_b: + age: 50 + self_employment_income: -20_000 + tax_units: + tax_unit_a: + members: [parent_a, child] + cohabitating_spouses: true + tax_unit_is_filer: true + tax_unit_b: + members: [parent_b] + cohabitating_spouses: true + tax_unit_is_filer: true + marital_units: + couple: + members: [parent_a, parent_b] + child_unit: + members: [child] + households: + household: + members: [parent_a, child, parent_b] + state_code: TX + output: + # The child's unearned income, 2,000, exceeds 1,350, so the child is + # required to file, and the child's MAGI is 2,000 - 5,000 = -3,000. Parent + # A's household includes the child and parent B (435.603(f)(4)): 30,000 + # - 3,000 - 20,000 = 7,000. The child is in parent A's household + # (435.603(f)(2)), so the child's is the same. Parent B's household is + # parent B and parent A: -20,000 + 30,000 = 10,000. + medicaid_person_is_required_to_file: [true, true, false] + medicaid_magi_person: [30_000, -3_000, -20_000] + medicaid_household_income: [7_000, 7_000, 10_000] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_magi_person.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_magi_person.yaml index 9de5eababee..cb6769142fe 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_magi_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_magi_person.yaml @@ -51,7 +51,7 @@ members: [parent, child] state_code: TX output: - # The child's wages exceed the 15,000 standard deduction, so the child is + # The child's wages exceed the 15,750 standard deduction, so the child is # required to file and counts: 40,000 + 16,000 + 500. medicaid_magi_person: [40_000, 16_500] medicaid_household_income: [56_500, 56_500] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py index 480eea443fa..3e1542ce4ac 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py @@ -18,7 +18,12 @@ class medicaid_household_income(Variable): "does in the couple's joint AGI. Only the total is floored at zero, " "as Form 8962 combines the taxpayer's and dependents' modified AGIs " "'even if one or both of them are negative' before entering a " - "negative total as zero." + "negative total as zero. In the non-filer household, whose parents " + "and siblings are read from the whole family, those relatives' " + "amounts are added only when positive (each child's, and each " + "couple's parents' netted), so a relative outside the household " + "cannot lower its income; the individual's and their spouse's are " + "always added signed." ) definition_period = YEAR reference = ( @@ -58,17 +63,45 @@ def formula(person, period, parameters): 0, ) spouse_income = same_unit_spouse_income + separate_spouse_income - family_child_income = person.family.sum(child_age_eligible * member_income) + # The non-filer household's parents and siblings are read from the + # whole family, which can include people outside the household, such + # as a grandparent who is also a parent there. So their amounts are + # added only when positive: each child's, and each couple's parents' + # netted together, so married parents' losses still offset each + # other's income. The individual's own and their spouse's amounts are + # always added signed. + family_child_income = person.family.sum( + child_age_eligible * max_(0, member_income) + ) + is_parent = person("is_parent", period) + couple_parent_income = person.marital_unit.sum(is_parent * member_income) + couple_parents = person.marital_unit.sum(is_parent) + parent_share = np.divide( + is_parent.astype(float), + couple_parents, + out=np.zeros_like(couple_parents, dtype=float), + where=couple_parents > 0, + ) family_parent_income = person.family.sum( - person("is_parent", period) * member_income + parent_share * max_(0, couple_parent_income) ) non_filer_household_income = where( child_age_eligible, spouse_income + family_parent_income + family_child_income, member_income + spouse_income + family_child_income, ) + # A tax household includes the cohabiting spouse of a filer who files + # separately (42 CFR 435.603(f)(4)), and so do the households of that + # filer's dependents, which are the filer's (435.603(f)(2)). + filer_separate_spouse_income = where( + head_or_spouse | claimed_by_another_return, + separate_spouse_income, + person.tax_unit.sum( + person("is_tax_unit_head", period) * separate_spouse_income + ), + ) tax_household_income = ( - person.tax_unit.sum(tax_member_income) + separate_spouse_income + person.tax_unit.sum(tax_member_income) + filer_separate_spouse_income ) tax_household_income = tax_household_income + medicaid_external_claimed_sum( person, From d4b561e7a1b430a19f5e47105c00cc48d7e6e768 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 16:47:51 -0400 Subject: [PATCH 22/28] Document the non-filer limits and test married non-filer parents Review r2 notes: - A non-filer child's own amount and their siblings' enter with the family's children, only when positive, and unmarried co-resident parents are each counted only when positive. The documentation now says so, rather than "always added signed". - Medicaid AGI documentation says a dependent's own business and capital losses are not modeled. - New case: married non-filer parents, $12,000 of wages and a $5,000 business loss, give their child's household $7,000. Co-Authored-By: Claude Opus 5.5 --- .../income/medicaid_household_income.yaml | 44 +++++++++++++++++++ .../medicaid_adjusted_gross_income_person.py | 5 ++- .../income/medicaid_household_income.py | 10 +++-- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml index 74e02c85cab..50ac0ef050b 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml @@ -399,3 +399,47 @@ medicaid_person_is_required_to_file: [true, true, false] medicaid_magi_person: [30_000, -3_000, -20_000] medicaid_household_income: [7_000, 7_000, 10_000] + +- name: Married non-filer parents' loss offsets their wages in their child's household. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 35 + employment_income: 12_000 + own_children_in_household: 1 + is_tax_unit_head: true + father: + age: 35 + self_employment_income: -5_000 + own_children_in_household: 1 + is_tax_unit_spouse: true + child: + age: 8 + is_tax_unit_head: true + tax_units: + parents: + members: [mother, father] + tax_unit_is_filer: false + child_unit: + members: [child] + tax_unit_is_filer: false + families: + family: + members: [mother, father, child] + marital_units: + couple: + members: [mother, father] + child_marital_unit: + members: [child] + households: + household: + members: [mother, father, child] + state_code: TX + output: + # Each parent's household is the parent, their spouse and their child + # (42 CFR 435.603(f)(3)(i)-(ii)), and the child's is the child and both + # parents ((f)(3)(iii)): 12,000 - 5,000 = 7,000 for all three. + medicaid_uses_non_filer_rules: [true, true, true] + medicaid_household_income: [7_000, 7_000, 7_000] 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 44aea280ede..f56a789a255 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 @@ -13,8 +13,9 @@ class medicaid_adjusted_gross_income_person(Variable): "that belongs to one spouse, such as their IRA deduction or business " "loss, lowers only that spouse's amount, and the head's and spouse's " "amounts add up to the return's AGI. A tax unit dependent's amount is " - "the AGI of the dependent's own return: their own income less their " - "own deductions, alimony paid included. It can be negative; " + "their own income less their own person-level deductions, alimony " + "paid included; the dependent's own business and capital losses are " + "not modeled. It can be negative; " "medicaid_household_income floors the household's total, not each " "person's amount." ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py index 3e1542ce4ac..9c386021d83 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py @@ -22,8 +22,10 @@ class medicaid_household_income(Variable): "and siblings are read from the whole family, those relatives' " "amounts are added only when positive (each child's, and each " "couple's parents' netted), so a relative outside the household " - "cannot lower its income; the individual's and their spouse's are " - "always added signed." + "cannot lower its income. An adult's own amount and their spouse's " + "are added signed; a child's own amount and their siblings' enter " + "with the family's children, only when positive. Unmarried " + "co-resident parents are each counted only when positive." ) definition_period = YEAR reference = ( @@ -68,8 +70,8 @@ def formula(person, period, parameters): # as a grandparent who is also a parent there. So their amounts are # added only when positive: each child's, and each couple's parents' # netted together, so married parents' losses still offset each - # other's income. The individual's own and their spouse's amounts are - # always added signed. + # other's income. An adult's own and their spouse's amounts are added + # signed; a child's own amount is among the family's children. family_child_income = person.family.sum( child_age_eligible * max_(0, member_income) ) From 12282314c46cd79b7b720c897112ca655eabd489 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 08:12:14 -0400 Subject: [PATCH 23/28] Base DC's separate-filing capital adjustment on the capital part of loss_ald Review r7: dc_separate_capital_loss_adjustment removed the computed capital loss deduction even when loss_ald was set directly, so it took out an amount federal AGI did not contain. It now removes the capital part of each spouse's loss_ald_person (loss_ald less the business loss, by own capital losses). Adds YAML cases with loss_ald set to $0 and $1,000, and the property test checks every unit against that capital part, keeping the nonnegative couple sum for computed units only. Co-Authored-By: Claude Opus 5.5 --- ...se_above_the_line_deductions_invariants.py | 37 ++++++++++--- .../dc_separate_capital_loss_adjustment.yaml | 55 +++++++++++++++++++ .../dc_separate_capital_loss_adjustment.py | 44 ++++++++------- 3 files changed, 106 insertions(+), 30 deletions(-) diff --git a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py index 59ef5a077db..fa2c472cf0d 100644 --- a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py @@ -56,12 +56,15 @@ 5. Bounds. Every above_the_line_deductions_person and loss_ald_person is non-negative. 6. DC separate filing on the same return. For the head and spouse of a joint - DC return with no deduction set directly, - dc_separate_capital_loss_adjustment equals their share of the federal - capital loss deduction less their own capital losses up to their own - capital gains and distributions plus the $1,500 separate limit; it is zero - for everyone else. Each couple's adjustments add up to zero or more: - filing separately never deducts more capital loss than the joint return. + DC return, dc_separate_capital_loss_adjustment equals the capital part of + their loss_ald_person (loss_ald less the business loss, by their own + capital losses) less their own capital losses up to their own capital + gains and distributions plus the $1,500 separate limit; it is zero for + everyone else. For units with no deduction set directly, that capital part + equals the numpy capital loss deduction, and each couple's adjustments add + up to zero or more: filing separately never deducts more capital loss than + the joint return. (A loss_ald set directly below the losses can make the + sum negative.) (The YAML tests check that the joint DC computation is unaffected.) 7. Montana from 2024. Spouses no longer file separately on the same form, so mt_loss_ald_reallocation and mt_applicable_ald_deductions are zero. @@ -497,6 +500,16 @@ def _capital_deduction_person(run): ) +def _capital_part_of_loss_ald(run): + """Each filer's share of loss_ald less the business loss, by own losses.""" + business_part = np.minimum(run["limited_business_loss"], run["loss_ald"]) + capital_part = np.maximum(0, run["loss_ald"] - business_part) + capital_loss = ~run["is_dependent"] * run["capital_losses"] + return _per_person(run, capital_part) * _share( + capital_loss, _per_person(run, _unit_sum(run, capital_loss)), _even(run) + ) + + def _reference(run): """above_the_line_deductions_person, built from the inputs with numpy.""" filer = ~run["is_dependent"] @@ -637,10 +650,16 @@ def _check(units, year): run["capital_losses"], own_gains + _per_person(run, run["separate_capital_loss_limit"]), ) - expected = dc_joint * (_capital_deduction_person(run) - separate) - _close(adjustment[~direct], expected[~direct]) + # The capital part of each filer's loss_ald_person, which their federal + # AGI includes. For units with no deduction set directly it is the numpy + # capital loss deduction. + federal = _capital_part_of_loss_ald(run) + _close(federal[~direct], _capital_deduction_person(run)[~direct]) + _close(adjustment, dc_joint * (federal - separate)) assert (adjustment[~dc_joint] == 0).all() - assert (_unit_sum(run, adjustment) >= -TOLERANCE).all() + # A loss_ald set directly below the capital losses can leave the separate + # columns deducting more than federal AGI does. + assert (_unit_sum(run, adjustment)[~direct_unit] >= -TOLERANCE).all() # 7. Montana from 2024. if year >= 2024: diff --git a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.yaml index efddf0f5342..29b2cbd951d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.yaml @@ -160,3 +160,58 @@ dc_separate_capital_loss_adjustment: [1_500, 0, 0, 0] dc_taxable_income_indiv: [29_300, 40_000, 0, 42_400] dc_taxable_income_joint: [67_800, 42_400] + +- name: A loss_ald set directly to zero leaves no capital deduction in federal AGI to remove. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -5_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + loss_ald: 0 + households: + household: + members: [head, spouse] + state_code: DC + output: + adjusted_gross_income_person: [60_000, 40_000] + # Federal AGI deducts nothing; separately the head deducts 1,500. + dc_separate_capital_loss_adjustment: [-1_500, 0] + dc_taxable_income_indiv: [29_300, 40_000] + dc_taxable_income_joint: 70_800 + dc_income_tax_before_credits_indiv: 3_758 + +- name: A loss_ald set directly below the separate limit. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -5_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + loss_ald: 1_000 + households: + household: + members: [head, spouse] + state_code: DC + output: + # The head's federal AGI deducts the whole 1,000; separately 1,500. + adjusted_gross_income_person: [59_000, 40_000] + dc_separate_capital_loss_adjustment: [-500, 0] + dc_taxable_income_indiv: [29_300, 40_000] + dc_taxable_income_joint: 69_800 diff --git a/policyengine_us/variables/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.py b/policyengine_us/variables/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.py index 22c74ea1adc..b1424610055 100644 --- a/policyengine_us/variables/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.py +++ b/policyengine_us/variables/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.py @@ -10,7 +10,8 @@ class dc_separate_capital_loss_adjustment(Variable): "For the head and spouse of a joint federal return, the amount added " "to their DC AGI when they file separately on the same DC return. " "Each spouse's federal AGI includes their share of the couple's " - "capital loss deduction, in proportion to their own capital losses. " + "capital loss deduction (the capital part of loss_ald_person), in " + "proportion to their own capital losses. " "Filing separately, each spouse instead deducts their own capital " "losses up to their own capital gains plus $1,500, the limit for " "married people filing separately. The adjustment is the first amount " @@ -37,26 +38,27 @@ def formula(person, period, parameters): head_or_spouse = person("is_tax_unit_head_or_spouse", period) p = parameters(period).gov.irs capital_losses = person("capital_losses", period) - # The spouse's part of the federal capital loss deduction, as in - # loss_ald_person: losses against gains plus the net loss up to - # $3,000, in proportion to each spouse's own capital losses. - capital_share = filer_share(person, period, capital_losses) - federal = ( - person("limited_capital_loss_person", period) - + tax_unit("capital_losses_allowed_against_gains", period) * capital_share - ) - # As in capital_losses_allowed_against_gains: no capital loss - # deduction when a reform takes capital gains out of gross income. - sources = p.gross_income.sources - if "capital_gains" not in sources: - return 0 * federal + # The capital part of the spouse's loss_ald_person, which is what + # their federal AGI includes: loss_ald less the business loss, in + # proportion to each spouse's own capital losses. When computed, that + # is the losses against gains plus the net loss up to $3,000; it also + # follows a loss_ald set directly. + loss_ald = tax_unit("loss_ald", period) + business_part = min_(tax_unit("limited_business_loss", period), loss_ald) + capital_part = max_(0, loss_ald - business_part) + federal = capital_part * filer_share(person, period, capital_losses) # Filing separately, the spouse's own capital gains, including capital # gain distributions, absorb their own losses first, and the net loss - # deductible is at most $1,500. - gains = 0 - for source in ["capital_gains", "non_sch_d_capital_gains"]: - if source in sources: - gains = gains + max_(0, person(source, period)) - separate_limit = p.ald.loss.capital.max["SEPARATE"] - separate = min_(capital_losses, gains + separate_limit) + # deductible is at most $1,500. As in + # capital_losses_allowed_against_gains, there is no capital loss + # deduction when a reform takes capital gains out of gross income. + sources = p.gross_income.sources + separate = 0 * federal + if "capital_gains" in sources: + gains = 0 + for source in ["capital_gains", "non_sch_d_capital_gains"]: + if source in sources: + gains = gains + max_(0, person(source, period)) + separate_limit = p.ald.loss.capital.max["SEPARATE"] + separate = min_(capital_losses, gains + separate_limit) return joint * head_or_spouse * (federal - separate) From eaabd725476ecb797eab2df217efaaf2552f79ac Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 10:57:37 -0400 Subject: [PATCH 24/28] Take DC's self-employment loss add-back from the business part of loss_ald Review r8: with loss_ald set directly and both business and capital losses, dc_self_employment_loss_addition isolated the business loss by subtracting the computed capital deduction, while loss_ald_person (and so federal AGI and the DC capital adjustment) takes the business part first. Use min(limited_business_loss, loss_ald), as loss_ald_person does; the amount is unchanged when loss_ald is computed. Adds computed and direct mixed-loss YAML cases. Co-Authored-By: Claude Opus 5.5 --- .../dc_self_employment_loss_addition.yaml | 62 +++++++++++++++++++ .../dc_self_employment_loss_addition.py | 11 ++-- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml index a2b55ff8688..d39aa346f9c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml @@ -114,3 +114,65 @@ adjusted_gross_income_person: [40_000, 40_000, 0] dc_self_employment_loss_addition: [12_000, 6_000, 0] dc_agi: [52_000, 46_000, 0] + +- name: Business and capital losses, computed. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + self_employment_income: -20_000 + long_term_capital_gains: -5_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: DC + output: + # loss_ald: 20,000 business and 3,000 capital. + loss_ald: 23_000 + adjusted_gross_income_person: [37_000, 40_000] + # 20,000 - 12,000. + dc_self_employment_loss_addition: [8_000, 0] + # Separately the head deducts 1,500 of capital loss: 3,000 - 1,500. + dc_separate_capital_loss_adjustment: [1_500, 0] + # 37,000 + 8,000 + 1,500 - 29,200. + dc_taxable_income_indiv: [17_300, 40_000] + +- name: Business and capital losses with loss_ald set directly. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + self_employment_income: -20_000 + long_term_capital_gains: -5_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + loss_ald: 22_000 + households: + household: + members: [head, spouse] + state_code: DC + output: + # The business loss comes first: 20,000 business and 2,000 capital, as in + # loss_ald_person. + adjusted_gross_income_person: [38_000, 40_000] + dc_self_employment_loss_addition: [8_000, 0] + # 2,000 - 1,500. + dc_separate_capital_loss_adjustment: [500, 0] + # 38,000 + 8,000 + 500 - 29,200. + dc_taxable_income_indiv: [17_300, 40_000] diff --git a/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py b/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py index 9348a9f1b1e..f9ce2ef41b7 100644 --- a/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py +++ b/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py @@ -17,14 +17,13 @@ def formula(person, period, parameters): loss_person = max_(0, -person("total_self_employment_income", period)) loss_taxunit = person.tax_unit.sum(loss_person) # Cap at SE loss actually deducted in federal AGI via loss_ald. - # loss_ald includes both SE and capital losses; isolate SE portion. + # loss_ald includes both business and capital losses; isolate the + # business part as loss_ald_person does, so a loss_ald set directly + # is divided the same way. loss_ald = person.tax_unit("loss_ald", period) - capital_loss_in_ald = add( - person.tax_unit, - period, - ["capital_losses_allowed_against_gains", "limited_capital_loss"], + se_loss_in_ald = min_( + person.tax_unit("limited_business_loss", period), loss_ald ) - se_loss_in_ald = max_(0, loss_ald - capital_loss_in_ald) effective_loss = min_(loss_taxunit, se_loss_in_ald) p = parameters(period).gov.states.dc.tax.income.additions addition_taxunit = max_(0, effective_loss - p.self_employment_loss.threshold) From e76165e374aedc303b200ab95d01b9b45bc186a5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 12:18:02 -0400 Subject: [PATCH 25/28] Date linked Medicaid household property IDs to the tested year --- .../tests/core/test_medicaid_household_income_invariants.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/core/test_medicaid_household_income_invariants.py b/policyengine_us/tests/core/test_medicaid_household_income_invariants.py index 16c1129c434..a71ae120f51 100644 --- a/policyengine_us/tests/core/test_medicaid_household_income_invariants.py +++ b/policyengine_us/tests/core/test_medicaid_household_income_invariants.py @@ -453,7 +453,7 @@ def test_linked_non_filer_income_matches_signed_legal_membership(magi): ages = [65, 35, 35, 10, 12, 30] people = { name: { - "person_id": i + 1, + "person_id": {YEAR: i + 1}, "age": {YEAR: ages[i]}, "medicaid_magi_person": {YEAR: magi[i]}, # Count the children's MAGI under 435.603(d)(2)(i), including @@ -462,9 +462,9 @@ def test_linked_non_filer_income_matches_signed_legal_membership(magi): } for i, name in enumerate(names) } - people["mother"]["parent_1_id"] = 1 + people["mother"]["parent_1_id"] = {YEAR: 1} for child in ("child", "sibling"): - people[child].update(parent_1_id=2, parent_2_id=3) + people[child].update(parent_1_id={YEAR: 2}, parent_2_id={YEAR: 3}) situation = { "people": people, "tax_units": { From ad2d76c1b0de997a748be051d8c801ef618d5eb9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 12:54:21 -0400 Subject: [PATCH 26/28] Reconcile HSA attribution coverage with main's person input --- ...se_above_the_line_deductions_invariants.py | 13 +++- ...medicaid_adjusted_gross_income_person.yaml | 60 +++++++++++++++++++ .../above_the_line_deductions/README.md | 3 +- .../above_the_line_deductions_person.py | 4 -- 4 files changed, 72 insertions(+), 8 deletions(-) diff --git a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py index fa2c472cf0d..77304f2c0ea 100644 --- a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py @@ -5,8 +5,10 @@ On a joint return a deduction that belongs to one spouse, such as their IRA deduction, educator expenses, early withdrawal penalty, alimony paid, student loan interest or business and capital losses, lowers only that spouse's AGI. -Only deductions recorded for the tax unit alone (here the HSA deduction and -the tuition and fees deduction) are divided equally. States that tax spouses +Only deductions recorded for the tax unit alone are divided equally. This +generator supplies HSA amounts only for the tax unit, so it exercises that +equal fallback; supplied person HSA amounts are attributed to their owners. +States that tax spouses separately (Kentucky, Montana, Delaware, Virginia, Ohio's joint filing credit, the District of Columbia, West Virginia's senior deduction) read these amounts. @@ -392,6 +394,11 @@ def _run_group(units, year, *, zero_spouse): own_names.append(deduction) elif deduction in ("loss_ald", "alimony_expense_ald"): continue + elif deduction == "health_savings_account_ald": + # These generators supply only the return's HSA amount. With + # no person amounts, the existing equal fallback still applies + # even though main now provides an optional *_person input. + shared_names.append(deduction) elif f"{deduction}_person" in tbs.variables: own_names.append(f"{deduction}_person") else: @@ -706,7 +713,7 @@ def test_seeded_population(year): def test_every_deduction_is_attributed_or_divided_equally_on_purpose(): - # A tax-unit deduction with no person amount falls back to an equal + # A tax-unit deduction with no person-level variable falls back to an equal # division between the head and spouse. Only those listed, with their # reasons, in EQUALLY_DIVIDED_DEDUCTIONS may do so. from policyengine_us import CountryTaxBenefitSystem 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 b86fe850086..904d7092a73 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 @@ -204,3 +204,63 @@ # and not the student's own, 20,000. adjusted_gross_income: 49_000 medicaid_adjusted_gross_income_person: [49_000, 20_000] + +- name: Case 9, a supplied spouse HSA deduction lowers only that spouse's AGI. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 60_000 + spouse: + age: 40 + is_tax_unit_spouse: true + employment_income: 40_000 + health_savings_account_ald_person: 4_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + health_savings_account_ald: 4_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + # Each spouse figures their own Form 8889 deduction; the return combines + # the two line 13 amounts (2025 instructions, page 4). + # https://www.irs.gov/pub/irs-pdf/i8889.pdf#page=4 + adjusted_gross_income: 96_000 + above_the_line_deductions_person: [0, 4_000] + medicaid_adjusted_gross_income_person: [60_000, 36_000] + +- name: Case 10, unrecorded person HSA amounts preserve the equal filer fallback. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 60_000 + spouse: + age: 40 + is_tax_unit_spouse: true + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + health_savings_account_ald: 4_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + # The return's 4,000 deduction is supplied without either person's + # amount, so the existing filer_share fallback allocates 2,000 each. + adjusted_gross_income: 96_000 + above_the_line_deductions_person: [2_000, 2_000] + medicaid_adjusted_gross_income_person: [58_000, 38_000] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md index c470e35823a..f0f60e8a423 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md @@ -26,7 +26,8 @@ Several state taxes and benefits need each spouse's own AGI on a joint return: K | Person-level deductions: IRA, educator expenses, early withdrawal penalty, student loan interest, adoption assistance, U.S. savings bonds for education | The person who has it. Student loan interest is the return's limited deduction, divided by the interest each spouse paid. | | Self-employment tax, self-employed health insurance and retirement plans, alimony paid | The person who has it, through `_person`. | | Business and capital losses (`loss_ald`) | `loss_ald_person`: the return's business loss after Section 461(l), by each spouse's own business losses; the capital loss after the $3,000 limit (`limited_capital_loss_person`), by each spouse's own capital losses. A Form 4797 loss, recorded for the tax unit, counts equally for both. | -| HSA, tuition and fees, domestic production, possession income | Divided equally, since the input is the tax unit's. For an HSA, 26 USC 223(b)(5) divides a married couple's limit equally unless they agree otherwise. | +| HSA | Attributed by supplied `health_savings_account_ald_person` amounts, which each spouse figures on their own [Form 8889](https://www.irs.gov/pub/irs-pdf/i8889.pdf#page=4). When no person amounts are recorded, this allocator retains its equal fallback for the tax-unit input. | +| Tuition and fees, domestic production, possession income | Divided equally, since the input is the tax unit's. | A tax unit dependent's deductions are on their own return, so they never lower the filer's AGI. The dependent's `above_the_line_deductions_person` is their own person-level deductions, other than amounts that are the filer's even when recorded on the dependent (`gov.irs.ald.filer_amounts_recorded_on_dependents`), which the head and spouse divide equally; the dependent's losses and tax-unit-only deductions are not modeled there. diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py index c1e598cd87a..7d692c90a73 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py @@ -4,10 +4,6 @@ # amount to attribute them by. Their amount is divided equally between the head # and spouse. EQUALLY_DIVIDED_DEDUCTIONS = ( - # An HSA belongs to one person, but the input is the return's total. On a - # joint return, 26 USC 223(b)(5) divides the family-coverage limit equally - # between spouses unless they agree on another division. - "health_savings_account_ald", # Tuition and fees (26 USC 222, through 2020) the taxpayer paid for # themself, their spouse or a dependent, under one limit for the return. "capped_qualified_tuition_expenses_ald", From f5c83ea9cce90add97e95fc545f653039ccc7d3d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 01:47:20 -0400 Subject: [PATCH 27/28] Describe how a joint business loss is divided in Medicaid person AGI The documentation said a spouse's business loss lowers only that spouse's amount. loss_ald_person divides it in proportion to each spouse's own net business losses, and equally when neither has one. Documentation only. Co-Authored-By: Claude Opus 5.5 --- .../income/medicaid_adjusted_gross_income_person.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 f56a789a255..f06d3a945b2 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 @@ -10,9 +10,13 @@ class medicaid_adjusted_gross_income_person(Variable): "Each person's AGI for Medicaid MAGI-based income: their own gross " "income less their own above-the-line deductions " "(above_the_line_deductions_person). On a joint return a deduction " - "that belongs to one spouse, such as their IRA deduction or business " - "loss, lowers only that spouse's amount, and the head's and spouse's " - "amounts add up to the return's AGI. A tax unit dependent's amount is " + "that belongs to one spouse, such as their IRA deduction, lowers only " + "that spouse's amount. A business loss is divided as loss_ald_person " + "divides it: in proportion to each spouse's own net business losses, " + "and equally when neither spouse has one, for example when a " + "spouse's SSTB loss is smaller than their other self-employment " + "income. The head's and spouse's amounts add up to the return's AGI. " + "A tax unit dependent's amount is " "their own income less their own person-level deductions, alimony " "paid included; the dependent's own business and capital losses are " "not modeled. It can be negative; " From 987402badf5970094434183ea1424c425b06d436 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 02:00:08 -0400 Subject: [PATCH 28/28] Point the Medicaid person AGI documentation to loss_ald_person Restating the allocation left out the equal share of a Form 4797 loss. Refer to loss_ald_person instead. Documentation only. Co-Authored-By: Claude Opus 5.5 --- .../income/medicaid_adjusted_gross_income_person.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 f06d3a945b2..f1c1a243d50 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 @@ -11,11 +11,10 @@ class medicaid_adjusted_gross_income_person(Variable): "income less their own above-the-line deductions " "(above_the_line_deductions_person). On a joint return a deduction " "that belongs to one spouse, such as their IRA deduction, lowers only " - "that spouse's amount. A business loss is divided as loss_ald_person " - "divides it: in proportion to each spouse's own net business losses, " - "and equally when neither spouse has one, for example when a " - "spouse's SSTB loss is smaller than their other self-employment " - "income. The head's and spouse's amounts add up to the return's AGI. " + "that spouse's amount. A business loss is divided between the spouses " + "as loss_ald_person divides it (see that variable), so it can lower " + "both spouses' amounts. The head's and spouse's amounts add up to the " + "return's AGI. " "A tax unit dependent's amount is " "their own income less their own person-level deductions, alimony " "paid included; the dependent's own business and capital losses are "