From 9346e9e4fcd04d8d16fa520e89b2e970084eb873 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 22:42:53 -0400 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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 12282314c46cd79b7b720c897112ca655eabd489 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 08:12:14 -0400 Subject: [PATCH 18/23] 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 19/23] 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 dd6b2543a69c1251837fde3860a6b7713001700f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 10:36:23 -0400 Subject: [PATCH 20/23] Align spouse invariants with shared IRA compensation limits --- ...se_above_the_line_deductions_invariants.py | 17 ++++--- ...ed_gross_income_person_own_deductions.yaml | 51 +++++++++++++++++++ 2 files changed, 62 insertions(+), 6 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 2c24e64d921..19e9b8948e4 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 @@ -47,9 +47,9 @@ 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 + AGI, when the head has no loss, IRA contribution 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. This is checked for units with no deduction set directly. @@ -144,6 +144,7 @@ "alimony_expense", "divorce_year", "student_loan_interest", + "traditional_ira_contributions", "social_security", "unemployment_compensation", "dc_separate_capital_loss_adjustment", @@ -659,7 +660,10 @@ def _check(units, year): | (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 + # Section 219(c) lets a lower earner use compensation left after the + # higher earner's IRA contributions. Removing those contributions can + # therefore increase the head's own deduction without changing ownership. + own_only = checked & ~has_loss & (run["traditional_ira_contributions"] == 0) _close(run[deductions][own_only], zeroed[deductions][own_only]) joint_taxed_income = (run["social_security"] > 0) | ( run["unemployment_compensation"] > 0 @@ -751,12 +755,13 @@ 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() + # This audit only reads the baseline registry and parameter histories. + # Reuse the already initialized system rather than uprating it again. + system = Simulation.default_tax_benefit_system_instance deductions = system.parameters.gov.irs.ald.deductions seen = set() for value in deductions.values_list: 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 index b97a22a4524..9b297f83a4a 100644 --- 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 @@ -344,3 +344,54 @@ # 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] + +- name: Spousal IRA compensation limits preserve each contributor's deduction ownership. + period: 2020 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 1_000 + traditional_ira_contributions: 3_000 + spouse: + age: 45 + employment_income: 3_000 + traditional_ira_contributions: 3_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + traditional_ira_deduction: [1_000, 3_000] + above_the_line_deductions_person: [1_000, 3_000] + adjusted_gross_income_person: [0, 0] + adjusted_gross_income: 0 + +- name: Removing the higher earner's IRA contributions frees compensation for the lower earner. + period: 2020 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 1_000 + traditional_ira_contributions: 3_000 + spouse: + age: 45 + employment_income: 3_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + traditional_ira_deduction: [3_000, 0] + above_the_line_deductions_person: [3_000, 0] + adjusted_gross_income_person: [-2_000, 3_000] + adjusted_gross_income: 1_000 From a1a10fc21ff1969e2bae9a929368907f84a5122a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 10:43:17 -0400 Subject: [PATCH 21/23] Account for IRA phaseouts in deduction invariants --- ...se_above_the_line_deductions_invariants.py | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 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 19e9b8948e4..0f42a20e099 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 @@ -44,14 +44,14 @@ 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 tuition and fees deduction. -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 +3. Own deductions. Except for IRA and student-loan shared limits and MAGI + interactions, 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, IRA contribution 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. + deduction inputs are removed and the head has no IRA contribution. 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 @@ -637,10 +637,10 @@ def _check(units, year): _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 - # deduction, for example, lowers the couple's MAGI and with it the - # phase-out of the head's student loan interest deduction. + # 3. Shared deduction limits and MAGI interactions can change the head's + # own deduction. Spouse deductions can lower joint MAGI and increase the + # head's IRA or student-loan deduction; removing the spouse's IRA + # contributions can also free compensation under Section 219(c). head = run["is_head"] spouse = run["is_spouse"] has_spouse = _per_person(run, _unit_sum(run, spouse.astype(float))) > 0 @@ -651,7 +651,8 @@ def _check(units, year): 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 + no_ira = run["traditional_ira_contributions"] == 0 + checked = paired_head & no_student_loan & no_ira 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. @@ -660,20 +661,23 @@ def _check(units, year): | (sum(np.maximum(0, -run[source]) for source in BUSINESS_SOURCES) > 0) | (_per_person(run, run["other_net_gain"]) < 0) ) - # Section 219(c) lets a lower earner use compensation left after the - # higher earner's IRA contributions. Removing those contributions can - # therefore increase the head's own deduction without changing ownership. - own_only = checked & ~has_loss & (run["traditional_ira_contributions"] == 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 & ~direct], zeroed[agi][spouse & ~direct]) + # Removing a spouse's pension contributions can also change retirement + # plan coverage and release the head's IRA phase-out. That discontinuity + # can increase the head's deduction by more than the amount removed. + head_has_ira = _unit_sum(run, head & ~no_ira) > 0 + independent_units = ~direct_unit & ~head_has_ira + checked_spouse = spouse & _per_person(run, independent_units) + assert _at_most(run[agi][checked_spouse], zeroed[agi][checked_spouse]) assert _at_most( - run["adjusted_gross_income"][~direct_unit], - zeroed["adjusted_gross_income"][~direct_unit], + run["adjusted_gross_income"][independent_units], + zeroed["adjusted_gross_income"][independent_units], ) # 4. Dependents. From a52b3440ef30df5928a66cd7c21dec106d867833 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 11:45:14 -0400 Subject: [PATCH 22/23] Preserve hash-seed coverage without repeated country startup --- .../tests/core/test_ald_determinism.py | 308 +++++++++++++++--- 1 file changed, 267 insertions(+), 41 deletions(-) diff --git a/policyengine_us/tests/core/test_ald_determinism.py b/policyengine_us/tests/core/test_ald_determinism.py index 0691569169c..bd7bc997c92 100644 --- a/policyengine_us/tests/core/test_ald_determinism.py +++ b/policyengine_us/tests/core/test_ald_determinism.py @@ -1,12 +1,13 @@ """Above-the-line deductions must have stable float32 accumulation order.""" import importlib +import importlib.machinery import json import os from pathlib import Path import subprocess import sys -from types import SimpleNamespace +from types import ModuleType, SimpleNamespace import numpy as np import pytest @@ -29,6 +30,31 @@ ] +ROUNDING_PERIOD = 2026 +ROUNDING_INPUTS = { + "irs_employment_income": 2**25, + "irs_gross_income": 2**25, + "loss_ald": 2**24, + "alimony_expense_ald": 1, + "health_savings_account_ald": 1, + "student_loan_interest_ald_eligible": True, + "is_tax_unit_dependent": False, + "is_tax_unit_head": True, + "is_tax_unit_spouse": False, + "filing_status": "SINGLE", +} +PROBE_FORMULAS = { + "above_the_line_deductions": ( + f"{AGI_MODULE}.above_the_line_deductions.above_the_line_deductions" + ), + "above_the_line_deductions_person": ( + f"{AGI_MODULE}.above_the_line_deductions.above_the_line_deductions_person" + ), + "loss_ald_person": f"{AGI_MODULE}.above_the_line_deductions.loss_ald_person", + **FORMULAS, +} + + def _deductions(reverse, empty): deductions = ( [] @@ -176,60 +202,254 @@ def __call__(self, name, period): assert order == expected -def _household_results(): +def _set_rounding_inputs(simulation, irs): + # Share the explicit boundary inputs between the full and small systems. + for name in irs.gross_income.sources: + simulation.set_input(name, ROUNDING_PERIOD, [0]) + for name in irs.ald.deductions: + simulation.set_input(name, ROUNDING_PERIOD, [0]) + for name in PERSON_ALDS: + simulation.set_input(f"{name}_person", ROUNDING_PERIOD, [0]) + for name, value in ROUNDING_INPUTS.items(): + simulation.set_input(name, ROUNDING_PERIOD, [value]) + + +def _serialized_results(simulation): + results = {} + for name in FORMULAS: + values = simulation.calculate(name, ROUNDING_PERIOD) + results[name] = { + "values": values.tolist(), + "dtype": str(values.dtype), + "bytes": values.tobytes().hex(), + } + return results + + +def _probe_fixture(simulation): + """Capture the real parameter lists and explicit dependency inputs.""" + from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.loss_ald_person import ( + BUSINESS_INCOME_SOURCES, + ) + + system = simulation.tax_benefit_system + parameters = system.parameters(f"{ROUNDING_PERIOD}-01-01") + irs = parameters.gov.irs + magi = irs.ald.student_loan_interest.magi + lists = { + "deductions": list(irs.ald.deductions), + "gross_income_sources": list(irs.gross_income.sources), + "filer_amounts_recorded_on_dependents": list( + irs.ald.filer_amounts_recorded_on_dependents + ), + "person_alds": list(magi.person_alds), + "excluded_alds": list(magi.excluded_alds), + } + names = set(lists["deductions"]) | set(lists["gross_income_sources"]) + for deduction in lists["deductions"]: + candidate = f"{deduction}_person" + if candidate in system.variables: + names.add(candidate) + names.update(f"{name}_person" for name in lists["person_alds"]) + names.update(BUSINESS_INCOME_SOURCES) + names.update(ROUNDING_INPUTS) + names.update( + ( + "section_911_excluded_income", + "other_net_gain", + "limited_business_loss", + "capital_losses", + # MAGI reads all unemployment compensation, even when only its + # taxable portion is in the gross-income parameter list. + "unemployment_compensation", + "basic_income", + ) + ) + # These classes remain real production formulas in every child. + names.difference_update(PROBE_FORMULAS) + variables = {} + for name in sorted(names): + variable = system.variables[name] + values = simulation.calculate(name, ROUNDING_PERIOD) + if name == "filing_status": + value_type = "filing_status" + default_value = variable.default_value.name + input_values = values.decode_to_str().tolist() + elif variable.value_type in (float, bool, int): + value_type = variable.value_type.__name__ + default_value = variable.value_type(variable.default_value) + input_values = values.tolist() + else: + raise TypeError(f"Unsupported probe input {name}: {variable.value_type}") + variables[name] = { + "entity": variable.entity.key, + "value_type": value_type, + "default_value": default_value, + "definition_period": variable.definition_period, + "input_values": input_values, + } + return { + "parameters": lists, + "basic_income_taxable": bool( + parameters.gov.contrib.ubi_center.basic_income.taxable + ), + "variables": variables, + } + + +def _household_results(fixture_path=None): """Run real formulas at a float32 rounding boundary, with no dataset.""" from policyengine_us import Simulation simulation = Simulation( situation={ - "people": {"person": {"age": {2026: 40}}}, + "people": {"person": {"age": {ROUNDING_PERIOD: 40}}}, "households": { "household": { "members": ["person"], - "state_code": {2026: "MT"}, - "county_fips": {2026: "30031"}, + "state_code": {ROUNDING_PERIOD: "MT"}, + "county_fips": {ROUNDING_PERIOD: "30031"}, } }, } ) - irs = simulation.tax_benefit_system.parameters("2026-01-01").gov.irs + irs = simulation.tax_benefit_system.parameters(f"{ROUNDING_PERIOD}-01-01").gov.irs # Explicit inputs isolate summation from deduction eligibility and phaseouts. - for name in irs.gross_income.sources: - simulation.set_input(name, 2026, [0]) - for name in irs.ald.deductions: - simulation.set_input(name, 2026, [0]) - for name in PERSON_ALDS: - simulation.set_input(f"{name}_person", 2026, [0]) - inputs = { - "irs_employment_income": 2**25, - "irs_gross_income": 2**25, - "loss_ald": 2**24, - "alimony_expense_ald": 1, - "health_savings_account_ald": 1, - "student_loan_interest_ald_eligible": True, - "is_tax_unit_dependent": False, - "is_tax_unit_head": True, - "is_tax_unit_spouse": False, - "filing_status": "SINGLE", - } - for name, value in inputs.items(): - simulation.set_input(name, 2026, [value]) - results = {} - for name in FORMULAS: - values = simulation.calculate(name, 2026) - results[name] = { - "values": values.tolist(), - "dtype": str(values.dtype), - "bytes": values.tobytes().hex(), - } + _set_rounding_inputs(simulation, irs) + results = _serialized_results(simulation) + if fixture_path is not None: + fixture_path.write_text(json.dumps(_probe_fixture(simulation))) return results -def test_household_float32_deductions_are_identical_across_hash_seeds(): +def _install_probe_package(): + """Avoid country startup only in a fresh hash-seed child process.""" + if "policyengine_us" in sys.modules: + raise RuntimeError("The hash-seed probe must precede country imports") + package_dir = Path(__file__).resolve().parents[2] + package = ModuleType("policyengine_us") + package.__path__ = [str(package_dir)] + package.__package__ = "policyengine_us" + package.__file__ = str(package_dir / "__init__.py") + package.__spec__ = importlib.machinery.ModuleSpec( + "policyengine_us", loader=None, is_package=True + ) + package.__spec__.submodule_search_locations = package.__path__ + sys.modules["policyengine_us"] = package + + +def _hash_seed_results(fixture_path): + """Run production formulas and actual core float32 holders under a seed.""" + _install_probe_package() + from policyengine_core.model_api import Enum, Variable + from policyengine_core.parameters import ParameterNode + from policyengine_core.simulations import Simulation as CoreSimulation + from policyengine_core.taxbenefitsystems import TaxBenefitSystem + from policyengine_us.entities import entities + from policyengine_us.variables.household.demographic.tax_unit.filing_status import ( + FilingStatus, + ) + + fixture = json.loads(fixture_path.read_text()) + system = TaxBenefitSystem(entities) + entity_by_key = {entity.key: entity for entity in system.entities} + for name, attributes in fixture["variables"].items(): + assert name not in PROBE_FORMULAS + if attributes["value_type"] == "filing_status": + value_type = Enum + default_value = FilingStatus[attributes["default_value"]] + extra = {"possible_values": FilingStatus} + else: + value_type = {"float": float, "bool": bool, "int": int}[ + attributes["value_type"] + ] + default_value = attributes["default_value"] + extra = {} + system.add_variable( + type( + name, + (Variable,), + { + "entity": entity_by_key[attributes["entity"]], + "value_type": value_type, + "default_value": default_value, + "definition_period": attributes["definition_period"], + "label": f"Explicit rounding-boundary fixture input: {name}", + **extra, + }, + ) + ) + for name, module_path in PROBE_FORMULAS.items(): + system.add_variable(getattr(importlib.import_module(module_path), name)) + + p = fixture["parameters"] + + def leaf(value): + return {"values": {f"{ROUNDING_PERIOD}-01-01": value}} + + system.parameters = ParameterNode( + "", + data={ + "gov": { + "irs": { + "gross_income": {"sources": leaf(p["gross_income_sources"])}, + "ald": { + "deductions": leaf(p["deductions"]), + "filer_amounts_recorded_on_dependents": leaf( + p["filer_amounts_recorded_on_dependents"] + ), + "student_loan_interest": { + "magi": { + "person_alds": leaf(p["person_alds"]), + "excluded_alds": leaf(p["excluded_alds"]), + } + }, + }, + }, + "contrib": { + "ubi_center": { + "basic_income": { + "taxable": leaf(fixture["basic_income_taxable"]) + } + } + }, + } + }, + ) + simulation = CoreSimulation( + tax_benefit_system=system, + situation={ + "people": {"person": {}}, + "tax_units": {"unit": {"members": ["person"]}}, + }, + default_input_period=ROUNDING_PERIOD, + default_calculation_period=ROUNDING_PERIOD, + ) + for name, attributes in fixture["variables"].items(): + simulation.set_input(name, ROUNDING_PERIOD, attributes["input_values"]) + _set_rounding_inputs( + simulation, system.parameters(f"{ROUNDING_PERIOD}-01-01").gov.irs + ) + results = _serialized_results(simulation) + if "policyengine_us.system" in sys.modules: + raise RuntimeError("The hash-seed probe imported the full country system") + return results + + +def test_household_float32_deductions_are_identical_across_hash_seeds(tmp_path): + fixture_path = tmp_path / "rounding-boundary.json" + # Keep one complete US integration as the oracle. Only the child processes + # replace unrelated formula dependencies with its captured boundary inputs. + reference = _household_results(fixture_path=fixture_path) results = {} for seed in (0, 20, 32, 34, 42, 123): completed = subprocess.run( - [sys.executable, str(Path(__file__).resolve()), "--household-probe"], + [ + sys.executable, + str(Path(__file__).resolve()), + "--hash-seed-probe", + str(fixture_path), + ], env={**os.environ, "PYTHONHASHSEED": str(seed)}, check=True, capture_output=True, @@ -238,12 +458,18 @@ def test_household_float32_deductions_are_identical_across_hash_seeds(): ) results[seed] = json.loads(completed.stdout) assert all(result == results[0] for result in results.values()), json.dumps(results) - # Both $1 deductions precede the large deduction in the canonical order; - # their sum and the resulting income are exactly representable in float32. - for result in results[0].values(): + assert results[0] == reference, json.dumps( + {"full_household": reference, "hash_seeds": results} + ) + # The boundary combines 2**25 of income with 2**24 and two $1 deductions. + # Keep its expected float32 result alongside the exact byte comparisons. + for result in reference.values(): assert result["dtype"] == "float32" assert result["values"] == [16_777_214.0] -if __name__ == "__main__" and sys.argv[1:] == ["--household-probe"]: - print(json.dumps(_household_results())) +if __name__ == "__main__": + if sys.argv[1:] == ["--household-probe"]: + print(json.dumps(_household_results())) + elif len(sys.argv) == 3 and sys.argv[1] == "--hash-seed-probe": + print(json.dumps(_hash_seed_results(Path(sys.argv[2])))) From f65aab3e3d803a3eaf5c8134e76e66808db143ca Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 08:48:33 -0400 Subject: [PATCH 23/23] Avoid reallocating owned federal losses twice for Kentucky --- ...ix-ky-loss-ald-person-integration.fixed.md | 1 + .../states/ky/tax/income/ky_federal_agi.yaml | 65 ++++++++++++++++++- .../ky/tax/income/ky_files_separately.yaml | 2 +- .../states/ky/tax/income/ky_federal_agi.py | 14 +++- 4 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fix-ky-loss-ald-person-integration.fixed.md diff --git a/changelog.d/fix-ky-loss-ald-person-integration.fixed.md b/changelog.d/fix-ky-loss-ald-person-integration.fixed.md new file mode 100644 index 00000000000..ac399f3bda0 --- /dev/null +++ b/changelog.d/fix-ky-loss-ald-person-integration.fixed.md @@ -0,0 +1 @@ +Avoid reallocating spouses' business and capital losses twice in Kentucky AGI after federal person AGI assigns their own deductions. diff --git a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml index 4d3e4e2e4a4..db205d26cf0 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml @@ -19,7 +19,7 @@ members: [head, spouse] state_code: KY output: - adjusted_gross_income_person: [25_000, -15_000] + adjusted_gross_income_person: [40_000, -30_000] adjusted_gross_income: 10_000 ky_federal_agi: [40_000, -30_000] ky_agi: [40_000, -30_000] @@ -155,6 +155,69 @@ ky_agi: [40_000, 10_000, 0] ky_modified_agi_if_separate: 50_000 +- name: Kentucky reverses reconciled federal losses when supplied person amounts are zero + period: 2024 + input: + people: + head: + age: 45 + employment_income: 40_000 + loss_ald_person: 0 + spouse: + age: 43 + self_employment_income: -30_000 + loss_ald_person: 0 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: KY + output: + # Federal ALD reconciles the supplied zero person amounts to the allowed + # 30,000 loss with its equal fallback. Kentucky still assigns the loss + # to its owner and preserves the joint federal AGI. + loss_ald: 30_000 + adjusted_gross_income_person: [25_000, -15_000] + adjusted_gross_income: 10_000 + ky_federal_agi: [40_000, -30_000] + ky_agi: [40_000, -30_000] + ky_modified_agi_if_joint: 10_000 + ky_modified_agi_if_separate: 40_000 + +- name: Supplied dependent loss amounts do not enter Kentucky's federal loss reversal + period: 2024 + input: + people: + head: + age: 45 + employment_income: 40_000 + loss_ald_person: 0 + spouse: + age: 43 + self_employment_income: -30_000 + loss_ald_person: 30_000 + child: + age: 10 + is_tax_unit_dependent: true + loss_ald_person: 50_000 + tax_units: + tax_unit: + members: [head, spouse, child] + households: + household: + members: [head, spouse, child] + state_code: KY + output: + loss_ald: 30_000 + adjusted_gross_income_person: [40_000, -30_000, 0] + adjusted_gross_income: 10_000 + ky_federal_agi: [40_000, -30_000, 0] + ky_agi: [40_000, -30_000, 0] + ky_modified_agi_if_joint: 10_000 + ky_modified_agi_if_separate: 40_000 + - name: A reform removing loss deductions does not restore them through allocation period: 2024 input: diff --git a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_files_separately.yaml b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_files_separately.yaml index e0860148c1b..9bf7fc33ee1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_files_separately.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_files_separately.yaml @@ -224,7 +224,7 @@ # (120,000 - 3,160) x 4% = 4,673.60. Both credit rates are zero. ky_income_tax: 3_873.60 ky_files_separately: false - adjusted_gross_income_person: [95_000, 5_000] + adjusted_gross_income_person: [120_000, -20_000] ky_agi: [120_000, -20_000] ky_modified_agi_if_joint: 100_000 ky_modified_agi_if_separate: 120_000 diff --git a/policyengine_us/variables/gov/states/ky/tax/income/ky_federal_agi.py b/policyengine_us/variables/gov/states/ky/tax/income/ky_federal_agi.py index 5286ea249d3..05e87080788 100644 --- a/policyengine_us/variables/gov/states/ky/tax/income/ky_federal_agi.py +++ b/policyengine_us/variables/gov/states/ky/tax/income/ky_federal_agi.py @@ -24,8 +24,9 @@ def formula(person, period, parameters): return federal_agi # KRS 141.016(3): income and business deductions belong to the - # spouse to whom they are attributable. Federal person AGI instead - # shares the joint loss deduction; undo that sharing for Kentucky. + # spouse to whom they are attributable. Undo the loss allocation + # actually included in federal person AGI before applying Kentucky's + # allocation, so an already-owned loss is not reassigned twice. filing_status = person.tax_unit("filing_status", period) is_filer = person("is_tax_unit_head", period) | person( "is_tax_unit_spouse", period @@ -73,4 +74,11 @@ def allocate(owned, allowed): owned_loss = allocate(owned_business_loss, business_loss) + allocate( owned_capital_loss, capital_loss ) - return federal_agi + share * loss - owned_loss + # Match above_the_line_deductions_person's reconciliation when supplied + # person loss amounts differ from the return's loss deduction. Keep + # dependent amounts off the parents' return. + filer_loss = not_dependent * person("loss_ald_person", period) + federal_loss = filer_loss + ( + loss - person.tax_unit.sum(filer_loss) + ) * filer_share(person, period, filer_loss) + return federal_agi + federal_loss - owned_loss