From 0bcebf6127bfb3bd1e6f824413b85130cd8c1887 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 15:13:29 -0400 Subject: [PATCH 01/22] Net capital gain distributions inside the capital loss limit A filer with capital losses or other capital gains enters capital gain distributions on Schedule D line 13 (2025 Instructions for Schedule D), so they net against the losses before the 26 U.S.C. 1211(b) limit on line 21. The model added distributions (non_sch_d_capital_gains) to gross income and deducted min(3,000, losses) separately: a 5,000 long-term loss with 3,000 of distributions gave 0 in AGI instead of -2,000. - capital_losses_allowed_against_gains (new): losses allowed up to the gains in gross income, distributions included (1211(b), first part). - limited_capital_loss: the net loss up to the limit (1211(b)(1)-(2), Schedule D line 21). loss_ald deducts both. This also nets spouses' gains and losses on a joint return. - loss_limited_net_capital_gains (+ person version): Form 1040 line 7a, max(-limit, LT + ST + distributions). Net investment income no longer adds the distributions separately (Form 8960 line 5a). - has_qdiv_or_ltcg: qualified dividends, or Schedule D lines 15 and 16 both above zero (Form 1040 instructions, line 16). - dependent_taxable_ss_magi: the same netting on the dependent's return. - dc_self_employment_loss_addition: subtracts both capital parts of loss_ald. Tests: YAML cases with form line comments; grid, Hypothesis property and monotonicity tests. Co-Authored-By: Claude Opus 5.5 --- .../sch-d-cg-distributions-agi.fixed.md | 1 + .../gov/irs/gross_income/sources.yaml | 4 +- .../gov/irs/investment/income/sources.yaml | 4 +- ...schedule_d_capital_gain_distributions.yaml | 335 ++++++++++++++++++ ...t_capital_gain_distributions_loss_limit.py | 311 ++++++++++++++++ .../capital_losses_allowed_against_gains.py | 56 +++ .../limited_capital_loss.py | 25 +- .../above_the_line_deductions/loss_ald.py | 13 +- .../irs_gross_income/has_qdiv_or_ltcg.py | 43 ++- .../loss_limited_net_capital_gains.py | 31 +- .../loss_limited_net_capital_gains_person.py | 14 +- .../dependent_taxable_ss_magi.py | 15 +- .../dc_self_employment_loss_addition.py | 8 +- .../capital_gains/non_sch_d_capital_gains.py | 12 + pyproject.toml | 1 + uv.lock | 90 +++++ 16 files changed, 933 insertions(+), 30 deletions(-) create mode 100644 changelog.d/sch-d-cg-distributions-agi.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml create mode 100644 policyengine_us/tests/test_capital_gain_distributions_loss_limit.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py diff --git a/changelog.d/sch-d-cg-distributions-agi.fixed.md b/changelog.d/sch-d-cg-distributions-agi.fixed.md new file mode 100644 index 00000000000..3d0811e886e --- /dev/null +++ b/changelog.d/sch-d-cg-distributions-agi.fixed.md @@ -0,0 +1 @@ +Net capital gain distributions against Schedule D capital losses before the 26 U.S.C. 1211(b) $3,000 loss limit in adjusted gross income, net investment income, loss-limited capital gains (Michigan, Mississippi, Delaware and Georgia), a dependent's Social Security modified AGI and the capital gain tax worksheet test, and net spouses' gains and losses on a joint return. diff --git a/policyengine_us/parameters/gov/irs/gross_income/sources.yaml b/policyengine_us/parameters/gov/irs/gross_income/sources.yaml index 0d5ec44971a..d5ed2911232 100644 --- a/policyengine_us/parameters/gov/irs/gross_income/sources.yaml +++ b/policyengine_us/parameters/gov/irs/gross_income/sources.yaml @@ -10,7 +10,9 @@ values: - other_net_gain_gross_income - capital_gains - non_sch_d_capital_gains - # Capital gain distributions received without filing Schedule D (Form 1040 line 7). + # Capital gain distributions reported on Form 1040 line 7a. With capital + # losses they go on Schedule D line 13; loss_ald nets the losses against + # them (capital_losses_allowed_against_gains). - taxable_interest_income - rental_income # Royalties included in rental income diff --git a/policyengine_us/parameters/gov/irs/investment/income/sources.yaml b/policyengine_us/parameters/gov/irs/investment/income/sources.yaml index 8bc8ed878e7..75c2d0158a3 100644 --- a/policyengine_us/parameters/gov/irs/investment/income/sources.yaml +++ b/policyengine_us/parameters/gov/irs/investment/income/sources.yaml @@ -14,9 +14,9 @@ values: # code H input says otherwise, and a dependent's amounts stay out of the # filer's net investment income. - estate_net_investment_income + # Form 8960 line 5a: the Form 1040 line 7 capital gain or loss, which + # includes capital gain distributions whether or not Schedule D is filed. - loss_limited_net_capital_gains - - non_sch_d_capital_gains - # Capital gain distributions received without filing Schedule D (Form 1040 line 7). metadata: unit: currency-USD label: net investment income sources diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml new file mode 100644 index 00000000000..6925c15a5e5 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml @@ -0,0 +1,335 @@ +# Capital gain distributions with Schedule D gains and losses (2025). +# +# Instructions for Schedule D, "Capital Gain Distributions": "Enter on +# Schedule D, line 13, the total capital gain distributions paid to you during +# the year". Form 1040 instructions, line 7a, Exception 1: a filer skips +# Schedule D only if "You have no capital losses, and your only capital gains +# are capital gain distributions". 26 U.S.C. 1211(b) allows capital losses +# "only to the extent of the gains from such sales or exchanges, plus (if such +# losses exceed such gains) the lower of" $3,000 ($1,500 married filing +# separately) or the excess. +# +# Schedule D: line 7 is the net short-term gain or loss, line 15 the net +# long-term gain or loss including line 13, line 16 = lines 7 + 15, and line 21 +# is the smaller loss of line 16 or ($3,000). Form 1040 line 7a is line 16, or +# line 21 if line 16 is a loss. + +- name: Distributions net against a long-term loss before the loss limit + period: 2025 + input: + people: + person1: + age: 45 + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 13 = 3,000; line 15 = -5,000 + 3,000 = -2,000; line 7 = 0; + # line 16 = -2,000; line 21 = -2,000 (smaller loss than -3,000). + loss_limited_net_capital_gains: -2_000 + # 1211(b): 3,000 of the loss offsets the distributions, and the 2,000 + # excess is below the 3,000 limit. + capital_losses_allowed_against_gains: 3_000 + limited_capital_loss: 2_000 + # Gross income 3,000 (the distributions) less 5,000 of allowed losses. + adjusted_gross_income: -2_000 + # Form 8960 line 5a is Form 1040 line 7a. + net_investment_income: -2_000 + # Lines 15 and 16 are losses and there are no qualified dividends. + has_qdiv_or_ltcg: false + +- name: Distributions above the loss leave a net gain + period: 2025 + input: + people: + person1: + age: 45 + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 6_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 15 = -5,000 + 6,000 = 1,000 = line 16, a gain on Form 1040 line 7a. + loss_limited_net_capital_gains: 1_000 + capital_losses_allowed_against_gains: 5_000 + limited_capital_loss: 0 + adjusted_gross_income: 1_000 + net_investment_income: 1_000 + # Lines 15 and 16 are both gains. + has_qdiv_or_ltcg: true + +- name: A loss beyond the distributions is limited to 3,000 + period: 2025 + input: + people: + person1: + age: 45 + long_term_capital_gains: -10_000 + non_sch_d_capital_gains: 4_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 16 = -10,000 + 4,000 = -6,000; line 21 = -3,000. + loss_limited_net_capital_gains: -3_000 + capital_losses_allowed_against_gains: 4_000 + limited_capital_loss: 3_000 + adjusted_gross_income: -3_000 + +- name: Distributions net against a short-term loss + period: 2025 + input: + people: + person1: + age: 45 + short_term_capital_gains: -5_000 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 7 = -5,000; line 15 = 3,000; line 16 = -2,000; line 21 = -2,000. + loss_limited_net_capital_gains: -2_000 + adjusted_gross_income: -2_000 + # Line 15 is a gain but line 16 is a loss, so neither tax worksheet + # applies (Form 1040 instructions, line 16). + has_qdiv_or_ltcg: false + +- name: Married filing separately, the limit is 1,500 + period: 2025 + input: + people: + person1: + age: 45 + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 1_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SEPARATE + households: + household: + members: [person1] + state_code: TX + output: + # Line 16 = -4,000; line 21 = -1,500. + loss_limited_net_capital_gains: -1_500 + capital_losses_allowed_against_gains: 1_000 + limited_capital_loss: 1_500 + adjusted_gross_income: -1_500 + +- name: Distributions without Schedule D are unchanged + period: 2025 + input: + people: + person1: + age: 45 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Exception 1: no capital losses and only capital gain distributions, so + # the 3,000 goes directly on Form 1040 line 7a. + loss_limited_net_capital_gains: 3_000 + capital_losses_allowed_against_gains: 0 + limited_capital_loss: 0 + adjusted_gross_income: 3_000 + net_investment_income: 3_000 + # Form 1040 instructions, line 16: no Schedule D and distributions on + # line 7a. + has_qdiv_or_ltcg: true + +- name: A short-term gain alone does not use a capital gain tax worksheet + period: 2025 + input: + people: + person1: + age: 45 + short_term_capital_gains: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 15 = 0, so lines 15 and 16 are not both more than zero. + has_qdiv_or_ltcg: false + adjusted_gross_income: 5_000 + +- name: Spouses' gains and losses net on one joint Schedule D + period: 2025 + input: + people: + person1: + age: 45 + long_term_capital_gains: 10_000 + person2: + age: 45 + long_term_capital_gains: -5_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + filing_status: JOINT + # Line 15 = 10,000 - 5,000 = 5,000 = line 16. Before, gross income took + # person1's 10,000 and only 3,000 of person2's loss was deducted (7,000). + loss_limited_net_capital_gains: 5_000 + capital_losses_allowed_against_gains: 5_000 + limited_capital_loss: 0 + adjusted_gross_income: 5_000 + +- name: One spouse's distributions net against the other's short-term loss + period: 2025 + input: + people: + person1: + age: 45 + non_sch_d_capital_gains: 2_000 + person2: + age: 45 + short_term_capital_gains: -6_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # Line 7 = -6,000; line 15 = 2,000; line 16 = -4,000; line 21 = -3,000. + loss_limited_net_capital_gains: -3_000 + capital_losses_allowed_against_gains: 2_000 + limited_capital_loss: 3_000 + adjusted_gross_income: -3_000 + +- name: Wages with distributions and a long-term loss + period: 2025 + input: + people: + person1: + age: 45 + employment_income: 50_000 + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Form 1040 line 9 = 50,000 + (-2,000) = 48,000 = line 11. + adjusted_gross_income: 48_000 + # Line 15 = 48,000 - 15,750 standard deduction. + taxable_income: 32_250 + +- name: A dependent's distributions net against their capital loss on their own return + period: 2022 + input: + people: + head: + age: 45 + is_tax_unit_head: true + employment_income: 50_000 + parent: + age: 70 + is_tax_unit_dependent: true + is_tax_unit_spouse: false + social_security_retirement: 20_000 + taxable_interest_income: 18_000 + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [head, parent] + households: + household: + members: [head, parent] + output: + # The dependent's own Schedule D: line 16 = -5,000 + 3,000 = -2,000, so + # modified AGI (IRC 86(b)(2)) = 18,000 - 2,000 = 16,000. Before, the + # distributions were added on top of a 3,000 loss (18,000). + dependent_taxable_ss_magi: [0, 16_000] + # The head's return has none of the dependent's capital gains or losses. + capital_losses_allowed_against_gains: 0 + limited_capital_loss: 0 + adjusted_gross_income: 50_000 + +- name: Each person's share includes the distributions + period: 2025 + input: + people: + person1: + age: 45 + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + loss_limited_net_capital_gains_person: -2_000 + +- name: DC still isolates the self-employment loss when capital losses offset gains + absolute_error_margin: 0.01 + period: 2024 + input: + people: + person1: + age: 45 + self_employment_income: -50_000 + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: DC + output: + # loss_ald = 50,000 self-employment + 3,000 capital loss against the + # distributions + 2,000 net capital loss. + loss_ald: 55_000 + # Only the self-employment loss counts: max(0, 50,000 - 12,000). + dc_self_employment_loss_addition: 38_000 diff --git a/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py b/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py new file mode 100644 index 00000000000..af0c7dc51e1 --- /dev/null +++ b/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py @@ -0,0 +1,311 @@ +"""Property tests: capital gain distributions net inside the capital loss limit. + +A filer with capital losses or other capital gains files Schedule D and enters +capital gain distributions on line 13 (2025 Instructions for Schedule D, +"Capital Gain Distributions"). Line 16 combines the short-term and long-term +totals, and if it is a loss, line 21 limits it to $3,000 ($1,500 married +filing separately), as 26 U.S.C. 1211(b) does. Form 1040 line 7a is line 16, +or line 21. A filer whose only capital gains are distributions skips +Schedule D and reports them on line 7a directly (Form 1040 instructions, line +7a, Exception 1); the amount is the same. + +So for every combination of long-term (LT) and short-term (ST) gains and +losses and distributions (D) of the filers on a return: + +1. The capital gain or loss in adjusted gross income, and + loss_limited_net_capital_gains, equal max(-limit, LT + ST + D). +2. capital_losses_allowed_against_gains and limited_capital_loss split the + 1211(b) allowance: the losses allowed up to the gains, and the net loss, + which is between 0 and the limit. +3. Net investment income (Form 8960 line 5a) is the same Form 1040 line 7a + amount. +4. has_qdiv_or_ltcg is true exactly when Schedule D lines 15 (LT + D) and 16 + (LT + D + ST) are both more than zero. +5. The people's shares in loss_limited_net_capital_gains_person add up to the + tax unit amount (to within single-precision rounding of the proportional + split of a net loss). +6. Without Schedule D gains or losses the results are the distributions, as + before the change. +7. A dependent's modified AGI for Social Security (IRC 86(b)(2)), figured on + the dependent's own return, nets the same way. + +Amounts are whole dollars of at most $2,000,000, so every sum is exact in +single precision and the comparisons are exact, apart from the person shares +in 5. +""" + +import itertools + +import numpy as np +import pytest + +from policyengine_us import Simulation + +YEAR = 2025 +LIMIT = {"SINGLE": 3_000, "JOINT": 3_000, "SEPARATE": 1_500} + +OUTPUTS = [ + "adjusted_gross_income", + "loss_limited_net_capital_gains", + "capital_losses_allowed_against_gains", + "limited_capital_loss", + "net_investment_income", + "has_qdiv_or_ltcg", +] + + +def line_7a(h): + """Form 1040 line 7a from the return's totals.""" + total = sum( + p["long_term"] + p["short_term"] + p["distributions"] for p in h["people"] + ) + return max(-LIMIT[h["filing_status"]], total) + + +def schedule_d_lines_15_16(h): + line_15 = sum(p["long_term"] + p["distributions"] for p in h["people"]) + line_16 = line_15 + sum(p["short_term"] for p in h["people"]) + return line_15, line_16 + + +def build_situation(households): + """One tax unit per household, with only capital gains, losses and + distributions, so adjusted gross income is the capital gain or loss.""" + people, tax_units, marital_units, households_out = {}, {}, {}, {} + for i, h in enumerate(households): + members = [] + for j, p in enumerate(h["people"]): + name = f"person_{i}_{j}" + people[name] = { + "age": {YEAR: 45}, + "long_term_capital_gains": {YEAR: p["long_term"]}, + "short_term_capital_gains": {YEAR: p["short_term"]}, + "non_sch_d_capital_gains": {YEAR: p["distributions"]}, + } + members.append(name) + # Set on every unit: a variable input for some units gives the + # others its default value, not its formula. + tax_units[f"tax_unit_{i}"] = { + "members": members, + "filing_status": {YEAR: h["filing_status"]}, + } + marital_units[f"marital_unit_{i}"] = {"members": members} + households_out[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "TX"}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "households": households_out, + } + + +def calculate(households): + simulation = Simulation(situation=build_situation(households)) + results = {v: np.asarray(simulation.calculate(v, YEAR)) for v in OUTPUTS} + results["filing_status"] = np.asarray( + simulation.calculate("filing_status", YEAR).decode_to_str() + ) + results["person_share"] = np.asarray( + simulation.calculate("loss_limited_net_capital_gains_person", YEAR) + ) + return results + + +def assert_properties(households): + model = calculate(households) + person = 0 + for i, h in enumerate(households): + fs = h["filing_status"] + assert model["filing_status"][i] == fs, h + limit = LIMIT[fs] + expected = line_7a(h) + # 1. AGI and Form 1040 line 7a. + assert model["adjusted_gross_income"][i] == expected, h + assert model["loss_limited_net_capital_gains"][i] == expected, h + # 2. The 1211(b) split, and the identity it gives: gross income + # includes the gains, and the two parts deduct the losses. + allowed_against_gains = model["capital_losses_allowed_against_gains"][i] + net_loss = model["limited_capital_loss"][i] + assert 0 <= net_loss <= limit, h + assert net_loss == max(0, -expected), h + gains = sum( + max(0, p["long_term"] + p["short_term"]) + p["distributions"] + for p in h["people"] + ) + losses = sum(max(0, -(p["long_term"] + p["short_term"])) for p in h["people"]) + assert allowed_against_gains == min(losses, gains), h + assert allowed_against_gains + net_loss == min(losses, gains + limit), h + assert gains - allowed_against_gains - net_loss == expected, h + # 3. Form 8960 line 5a. + assert model["net_investment_income"][i] == expected, h + # 4. Form 1040 line 16 routing (no qualified dividends here). + line_15, line_16 = schedule_d_lines_15_16(h) + assert bool(model["has_qdiv_or_ltcg"][i]) == (line_15 > 0 and line_16 > 0), h + # 5. Person shares. A net loss is allocated in proportion to each + # person's amount, a division that single precision rounds (for + # example -3,000 split as 12/17 and 5/17 sums to -3,000.0002). + n = len(h["people"]) + shares = model["person_share"][person : person + n].astype(float) + assert abs(shares.sum() - expected) <= 0.01, h + person += n + # 6. The path without Schedule D. + if all(p["long_term"] == 0 and p["short_term"] == 0 for p in h["people"]): + distributions = sum(p["distributions"] for p in h["people"]) + assert expected == distributions + assert allowed_against_gains == 0 and net_loss == 0, h + + +# --------------------------------------------------------------------------- +# A deterministic grid (runs without Hypothesis). +# --------------------------------------------------------------------------- + +GRID_AMOUNTS = [-12_000, -5_000, -2_500, 0, 1_000, 4_000] +GRID_DISTRIBUTIONS = [0, 1_000, 3_000, 9_000] + + +def grid_households(): + households = [] + for lt, st_, d in itertools.product(GRID_AMOUNTS, GRID_AMOUNTS, GRID_DISTRIBUTIONS): + person = {"long_term": lt, "short_term": st_, "distributions": d} + households.append({"filing_status": "SINGLE", "people": [person]}) + households.append({"filing_status": "SEPARATE", "people": [person]}) + # The same totals split between spouses: the long-term amount and the + # distributions with one spouse, the short-term amount with the other. + households.append( + { + "filing_status": "JOINT", + "people": [ + {"long_term": lt, "short_term": 0, "distributions": d}, + {"long_term": 0, "short_term": st_, "distributions": 0}, + ], + } + ) + return households + + +def test_grid(): + assert_properties(grid_households()) + + +def test_example_from_the_schedule_d_instructions(): + """Long-term loss 5,000 and distributions 3,000: line 16 is -2,000, so + the deductible loss is 2,000 (not 3,000 against gross income that still + includes the distributions, which gave 0).""" + h = { + "filing_status": "SINGLE", + "people": [{"long_term": -5_000, "short_term": 0, "distributions": 3_000}], + } + model = calculate([h]) + assert model["adjusted_gross_income"][0] == -2_000 + assert model["limited_capital_loss"][0] == 2_000 + + +# --------------------------------------------------------------------------- +# Hypothesis. +# --------------------------------------------------------------------------- + +# Hypothesis is a dev extra; skip rather than fail collection without it. +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +AMOUNT = st.integers(min_value=-2_000_000, max_value=2_000_000) +SMALL = st.integers(min_value=-20_000, max_value=20_000) +DISTRIBUTIONS = st.one_of( + st.just(0), + st.integers(min_value=0, max_value=20_000), + st.integers(min_value=0, max_value=2_000_000), +) + + +@st.composite +def person(draw): + amounts = draw(st.sampled_from([AMOUNT, SMALL])) + return { + "long_term": draw(st.one_of(st.just(0), amounts)), + "short_term": draw(st.one_of(st.just(0), amounts)), + "distributions": draw(DISTRIBUTIONS), + } + + +@st.composite +def household(draw): + filing_status = draw(st.sampled_from(["SINGLE", "SEPARATE", "JOINT"])) + n = 2 if filing_status == "JOINT" else 1 + return { + "filing_status": filing_status, + "people": [draw(person()) for _ in range(n)], + } + + +@hypothesis.settings(max_examples=60, deadline=None) +@hypothesis.given(st.lists(household(), min_size=1, max_size=12)) +def test_properties(households): + assert_properties(households) + + +@hypothesis.settings(max_examples=40, deadline=None) +@hypothesis.given( + st.lists(household(), min_size=1, max_size=8), + st.integers(min_value=1, max_value=50_000), +) +def test_more_distributions_never_lower_agi(households, extra): + """Adjusted gross income does not fall when distributions rise.""" + more = [ + { + "filing_status": h["filing_status"], + "people": [ + { + **h["people"][0], + "distributions": h["people"][0]["distributions"] + extra, + } + ] + + h["people"][1:], + } + for h in households + ] + base = calculate(households)["adjusted_gross_income"] + raised = calculate(more)["adjusted_gross_income"] + assert (raised >= base).all() + # And by no more than the extra distributions. + assert (raised - base <= extra).all() + + +@hypothesis.settings(max_examples=40, deadline=None) +@hypothesis.given(st.lists(person(), min_size=1, max_size=10)) +def test_dependent_modified_agi(dependents): + """A dependent's own-return modified AGI (IRC 86(b)(2)) nets the + distributions inside the single-filer limit.""" + people, tax_units, households_out = {}, {}, {} + for i, p in enumerate(dependents): + head, dep = f"head_{i}", f"dependent_{i}" + people[head] = {"age": {YEAR: 45}, "is_tax_unit_head": {YEAR: True}} + people[dep] = { + "age": {YEAR: 70}, + "is_tax_unit_dependent": {YEAR: True}, + "is_tax_unit_spouse": {YEAR: False}, + "long_term_capital_gains": {YEAR: p["long_term"]}, + "short_term_capital_gains": {YEAR: p["short_term"]}, + "non_sch_d_capital_gains": {YEAR: p["distributions"]}, + } + tax_units[f"tax_unit_{i}"] = {"members": [head, dep]} + households_out[f"household_{i}"] = { + "members": [head, dep], + "state_code": {YEAR: "TX"}, + } + simulation = Simulation( + situation={ + "people": people, + "tax_units": tax_units, + "households": households_out, + } + ) + magi = np.asarray(simulation.calculate("dependent_taxable_ss_magi", YEAR)) + agi = np.asarray(simulation.calculate("adjusted_gross_income", YEAR)) + for i, p in enumerate(dependents): + total = p["long_term"] + p["short_term"] + p["distributions"] + assert magi[2 * i + 1] == max(-3_000, total), p + # The dependent's capital gains and losses stay off the head's return. + assert agi[i] == 0, p diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py new file mode 100644 index 00000000000..a44fb1635b0 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py @@ -0,0 +1,56 @@ +from policyengine_us.model_api import * + + +class capital_losses_allowed_against_gains(Variable): + value_type = float + entity = TaxUnit + label = "Capital losses allowed against capital gains" + unit = USD + documentation = ( + "Capital losses deducted to the extent of capital gains, the first " + "part of the allowance in 26 U.S.C. 1211(b). Gross income includes " + "each filer's net capital gain and capital gain distributions, but " + "not losses; this deduction nets the losses against those gains, as " + "Schedule D does with capital gain distributions on line 13. " + "limited_capital_loss is the second part, the net loss up to the " + "$3,000 limit." + ) + definition_period = YEAR + reference = ( + dict( + title="26 U.S. Code § 1211(b) - Limitation on capital losses", + href="https://www.law.cornell.edu/uscode/text/26/1211#b", + ), + dict( + title="26 U.S. Code § 62(a)(3) - Losses from sale or exchange of property", + href="https://www.law.cornell.edu/uscode/text/26/62#a_3", + ), + dict( + title="26 U.S. Code § 852(b)(3)(B) - Capital gain dividends", + href="https://www.law.cornell.edu/uscode/text/26/852#b_3_B", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), Capital Gain Distributions", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=2", + ), + ) + + def formula(tax_unit, period, parameters): + sources = parameters(period).gov.irs.gross_income.sources + person = tax_unit.members + # A tax-unit dependent's capital gains and losses belong on the + # dependent's own return, as in irs_gross_income. + not_dependent = ~person("is_tax_unit_dependent", period) + # The capital gains irs_gross_income counts: each filer's net gain + # from sales and exchanges, and capital gain distributions reported + # without Schedule D. Capital gain distributions are long-term capital + # gains (26 U.S.C. 852(b)(3)(B)), and a filer with capital losses + # enters them on Schedule D line 13, where they net against the + # losses. + gains = 0 + for source in ["capital_gains", "non_sch_d_capital_gains"]: + if source in sources: + gains += max_(0, person(source, period)) + gains_in_gross_income = tax_unit.sum(not_dependent * gains) + capital_losses = tax_unit_non_dep_sum("capital_losses", tax_unit, period) + return min_(capital_losses, gains_in_gross_income) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py index 0206b428c2f..858a0a11da0 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py @@ -6,9 +6,24 @@ class limited_capital_loss(Variable): entity = TaxUnit label = "Limited capital loss deduction" unit = USD - documentation = "The capital loss deductible from gross income." + documentation = ( + "The net capital loss deductible from gross income: the excess of " + "capital losses over capital gains, including capital gain " + "distributions, up to $3,000 ($1,500 if married filing separately). " + "Schedule D line 21, as a positive number. Losses up to the gains " + "are in capital_losses_allowed_against_gains." + ) definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/1211" + reference = ( + dict( + title="26 U.S. Code § 1211(b) - Limitation on capital losses", + href="https://www.law.cornell.edu/uscode/text/26/1211#b", + ), + dict( + title="2025 Schedule D (Form 1040), lines 16 and 21", + href="https://www.irs.gov/pub/irs-prior/f1040sd--2025.pdf#page=2", + ), + ) def formula(tax_unit, period, parameters): p = parameters(period).gov.irs @@ -17,4 +32,8 @@ def formula(tax_unit, period, parameters): # A tax-unit dependent's capital losses belong on the dependent's own # return, as in irs_gross_income. capital_losses = tax_unit_non_dep_sum("capital_losses", tax_unit, period) - return min_(max_loss, capital_losses) + # 26 U.S.C. 1211(b)(2): the excess of the losses over the gains. + excess = capital_losses - tax_unit( + "capital_losses_allowed_against_gains", period + ) + return min_(max_loss, excess) 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 8eedf2bcb62..5e199a624bc 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 @@ -13,6 +13,7 @@ class loss_ald(Variable): 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(tax_unit, period, parameters): @@ -91,7 +92,15 @@ def formula(tax_unit, period, parameters): max_deductible_business_loss = total_business_income + threshold_amount limited_business_loss = min_(total_business_loss, max_deductible_business_loss) - # Capital losses have separate limit under Section 1211 ($3,000) + # Section 1211(b) allows capital losses to the extent of capital + # gains, plus a net loss of up to $3,000. + capital_losses_allowed_against_gains = tax_unit( + "capital_losses_allowed_against_gains", period + ) limited_capital_loss = tax_unit("limited_capital_loss", period) - return limited_business_loss + limited_capital_loss + return ( + limited_business_loss + + capital_losses_allowed_against_gains + + limited_capital_loss + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py index 2db8bf45bfc..07761ee07bc 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py @@ -5,22 +5,35 @@ class has_qdiv_or_ltcg(Variable): value_type = bool entity = TaxUnit label = "Has qualified dividends or long-term capital gains" - documentation = "Whether this tax unit has qualified dividend income or long-term capital gains income" + documentation = ( + "Whether this tax unit figures its tax with the Qualified Dividends " + "and Capital Gain Tax Worksheet or the Schedule D Tax Worksheet: it " + "has qualified dividends, or Schedule D lines 15 and 16 are both more " + "than zero. Capital gain distributions are on Schedule D line 13, so " + "a filer who reports them without Schedule D has lines 15 and 16 " + "equal to them." + ) definition_period = YEAR + reference = ( + dict( + title="2025 Instructions for Form 1040, line 16", + href="https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=36", + ), + dict( + title="2025 Schedule D (Form 1040), lines 15 to 22", + href="https://www.irs.gov/pub/irs-prior/f1040sd--2025.pdf#page=2", + ), + ) def formula(tax_unit, period, parameters): - # negatives amounts cannot offset other income sources - INCOME_SOURCES = [ - "loss_limited_net_capital_gains", - "net_capital_gains", - "long_term_capital_gains", - "non_sch_d_capital_gains", - "qualified_dividend_income", - ] - return np.any( - [ - add(tax_unit, period, [income_source]) > 0 - for income_source in INCOME_SOURCES - ], - axis=0, + qualified_dividends = add(tax_unit, period, ["qualified_dividend_income"]) + # Schedule D line 15: net long-term capital gain or loss, including + # capital gain distributions (line 13). + line_15 = add( + tax_unit, + period, + ["long_term_capital_gains", "non_sch_d_capital_gains"], ) + # Schedule D line 16: line 15 plus the net short-term gain or loss. + line_16 = line_15 + add(tax_unit, period, ["short_term_capital_gains"]) + return (qualified_dividends > 0) | ((line_15 > 0) & (line_16 > 0)) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py index 6aabd824a34..8306853e817 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py @@ -6,11 +6,40 @@ class loss_limited_net_capital_gains(Variable): entity = TaxUnit definition_period = YEAR label = "Loss-limited net capital gains" + documentation = ( + "The capital gain or loss on Form 1040 line 7a: Schedule D line 16 " + "(long-term and short-term gains and losses, with capital gain " + "distributions on line 13), or line 21 if line 16 is a loss. A filer " + "with no other capital gains or losses reports the distributions " + "without Schedule D, and the amount is the same." + ) unit = USD + reference = ( + dict( + title="26 U.S. Code § 1211(b) - Limitation on capital losses", + href="https://www.law.cornell.edu/uscode/text/26/1211#b", + ), + dict( + title="2025 Schedule D (Form 1040), lines 13, 16 and 21", + href="https://www.irs.gov/pub/irs-prior/f1040sd--2025.pdf#page=2", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), Capital Gain Distributions", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=2", + ), + dict( + title="2025 Instructions for Form 1040, line 7a, Exception 1", + href="https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=31", + ), + ) def formula(tax_unit, period, parameters): p = parameters(period).gov.irs filing_status = tax_unit("filing_status", period) loss_limit = p.capital_gains.loss_limit[filing_status] net_capital_gains = tax_unit("net_capital_gains", period) - return max_(-loss_limit, net_capital_gains) + # Capital gain distributions reported without Schedule D. A filer with + # capital losses or other capital gains files Schedule D and enters + # them on line 13, so they net against losses before the limit. + distributions = add(tax_unit, period, ["non_sch_d_capital_gains"]) + return max_(-loss_limit, net_capital_gains + distributions) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py index 4354058a947..2e4632639bf 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py @@ -7,11 +7,19 @@ class loss_limited_net_capital_gains_person(Variable): label = "Loss-limited capital gains (person)" unit = USD definition_period = YEAR - reference = "https://www.irs.gov/pub/irs-pdf/f1040sd.pdf" + documentation = ( + "Each person's share of loss_limited_net_capital_gains: the person's " + "capital gains and losses plus capital gain distributions, with a " + "tax-unit net loss limited and allocated in proportion." + ) + reference = "https://www.irs.gov/pub/irs-prior/f1040sd--2025.pdf#page=2" def formula(person, period, parameters): - # Get person's raw capital gains - person_capital_gains = person("capital_gains", period) + # The person's capital gains and losses, plus capital gain + # distributions, which go on Schedule D line 13 with them. + person_capital_gains = add( + person, period, ["capital_gains", "non_sch_d_capital_gains"] + ) # Get tax unit totals tax_unit = person.tax_unit diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py index 932e2011bb4..64d607bff9e 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py @@ -58,10 +58,18 @@ def formula(person, period, parameters): ald = [deduction for deduction in p.ald.deductions if deduction not in revoked] deduct_losses = "loss_ald" in ald + # Capital gain distributions net with capital gains and losses on + # Schedule D (line 13), inside the IRC 1211(b) limit, below. + net_distributions = ( + "capital_gains" in sources and "non_sch_d_capital_gains" in sources + ) + income = 0 for source in sources: components = DEPENDENT_GROSS_INCOME_SOURCE_OVERRIDES.get(source, [source]) for component in components: + if net_distributions and component == "non_sch_d_capital_gains": + continue amount = add(person, period, [component]) if deduct_losses and component in BUSINESS_LOSS_SOURCES: income += amount @@ -78,7 +86,12 @@ def formula(person, period, parameters): capital_loss_limit["SEPARATE"], capital_loss_limit["SINGLE"], ) - income += max_(person("capital_gains", period), -loss_limit) + capital_gains = person("capital_gains", period) + if net_distributions: + capital_gains = capital_gains + max_( + 0, person("non_sch_d_capital_gains", period) + ) + income += max_(capital_gains, -loss_limit) deductions = [ PERSON_ABOVE_THE_LINE_DEDUCTIONS[deduction] 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 998e9695a38..45ae2cb2b7d 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 @@ -19,8 +19,12 @@ def formula(person, period, parameters): # 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 = person.tax_unit("loss_ald", period) - limited_capital_loss = person.tax_unit("limited_capital_loss", period) - se_loss_in_ald = max_(0, loss_ald - limited_capital_loss) + capital_loss_in_ald = add( + person.tax_unit, + period, + ["capital_losses_allowed_against_gains", "limited_capital_loss"], + ) + 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) diff --git a/policyengine_us/variables/household/income/person/capital_gains/non_sch_d_capital_gains.py b/policyengine_us/variables/household/income/person/capital_gains/non_sch_d_capital_gains.py index 3d1649588fb..ad4d944c6b7 100644 --- a/policyengine_us/variables/household/income/person/capital_gains/non_sch_d_capital_gains.py +++ b/policyengine_us/variables/household/income/person/capital_gains/non_sch_d_capital_gains.py @@ -6,5 +6,17 @@ class non_sch_d_capital_gains(Variable): entity = Person label = "Capital gains not reported on Schedule D" unit = USD + documentation = ( + "Capital gain distributions (Form 1099-DIV box 2a) reported on Form " + "1040 line 7a without Schedule D. A filer who also has capital losses " + "or other capital gains enters them on Schedule D line 13, so the " + "model nets them with those gains and losses before the capital loss " + "limit (loss_limited_net_capital_gains, " + "capital_losses_allowed_against_gains and limited_capital_loss)." + ) + reference = dict( + title="2025 Instructions for Schedule D (Form 1040), Capital Gain Distributions", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=2", + ) definition_period = YEAR uprating = "calibration.gov.irs.soi.long_term_capital_gains" diff --git a/pyproject.toml b/pyproject.toml index 3c6db03d87f..1234463cd98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dev = [ "setuptools>=80.9.0", "build>=1.2.2.post1", "towncrier>=24.8.0", + "hypothesis>=6.100.0", ] [tool.hatch.build.targets.wheel] diff --git a/uv.lock b/uv.lock index de61653569a..61b455fea5d 100644 --- a/uv.lock +++ b/uv.lock @@ -766,6 +766,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/07/3d0c34c345043c6a398a5882e196b2220dc5861adfa18322448b90908f26/huggingface_hub-1.3.4-py3-none-any.whl", hash = "sha256:a0c526e76eb316e96a91e8a1a7a93cf66b0dd210be1a17bd5fc5ae53cba76bfd", size = 536611, upload-time = "2026-01-26T14:05:08.549Z" }, ] +[[package]] +name = "hypothesis" +version = "6.168.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/b7/13118bbc45d6d8b9d04e2de779e2a4ff23145ea39efa692b994fb874ca72/hypothesis-6.168.3.tar.gz", hash = "sha256:a43388f9067678fef6e13bdff325b6cfa6961a590498bb37f7ff31589c83bc75", size = 511022, upload-time = "2026-09-28T05:20:58.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/cd/746a2fc1e5e5ba34f07ea6ee1ad07525a3dde5ff4836db3a7980af49a891/hypothesis-6.168.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d20972ca134e652e9928ecd200966a8a2857adc2812c1d74b12a872e5cd503eb", size = 791536, upload-time = "2026-09-28T05:18:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7a158e377b69556c8e12c25c8fd811d0103e54c24a12dab1f3b9819f202e/hypothesis-6.168.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eafbec09d3e87d13d8242411d1f5868b5e879f1bd6d95e233e9ff80c527bea1c", size = 787313, upload-time = "2026-09-28T05:20:18.122Z" }, + { url = "https://files.pythonhosted.org/packages/45/c6/7df9104ee359e8fcd77791781c47e70794964ca69673665e0de2a6590f4c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73c5627497968cc62d140e9fde1ea21e12cac7b64dc419fea8286cd34ff1ad4e", size = 1124036, upload-time = "2026-09-28T05:20:04.372Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/8a61715404a73b9a82cb1f976803428d603cdea976a9c30266c72ad6a7ce/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dc56e6dc6eeb0aeb08ac463f847279bde2336c4786faf7ee0af4b93cd031a7", size = 1147875, upload-time = "2026-09-28T05:19:15.495Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3f/2b16e95cc3b9a069afa23830a012aefe377ce4a457a227e5a5026eb827ff/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:753bb501f8560d2e321ed3b62596b4496c56f15668b0a0669231ccc3e6c4e80d", size = 1149489, upload-time = "2026-09-28T05:19:18.886Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e7/d7cf6dc068bb02732b2a6e38a35a0dcb7f2137608198fc79d740f69d197c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71ab606c472449cb872ef2a7acaec679ee4f651b7f0a477a04ebc85d8933ef8c", size = 1191992, upload-time = "2026-09-28T05:19:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/5f/26/f1e5b25dec15998e8375d722825ec3dae1075cbe7cb1f74221b2312a2e33/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26928956c54748e4dfa587333741ab750246123b93484955646ff316cca27eef", size = 1169911, upload-time = "2026-09-28T05:19:27.996Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/901234a49147e6bf8a5b9e54b775706a223c2b52befea1106ea2aaf15d48/hypothesis-6.168.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:0bdfc53c041b61c854fa3761735736b991bc2bddafee45a361cfe4d43027c1ef", size = 1129406, upload-time = "2026-09-28T05:18:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/55/c1/5fc9ff91ec9fba6bb527833386c66812524b5cc75fe7d4a4f9122ee1c420/hypothesis-6.168.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:650528e2b1e2a45e95c2df624d4b9364b8ac5a027ba84e1eea2bc5398dd01dcd", size = 1160399, upload-time = "2026-09-28T05:20:23.911Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d2/49ad1ef5c55547c8abfd0fc571b3493806dda530fd5cb3d8263dbad86a72/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:209dc54cdb1b4d6d7020ad8d09e44c49756361b7183adacea4d6f5705085b595", size = 1299906, upload-time = "2026-09-28T05:18:58.417Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/f98a26094c4f462c4215807bed0f9a6b5508b27a3c329bcba1bc8adc7ac7/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:af8ca98cd11dc7f9427bb90483abcd9688d4df2e8e63893a30a2423b027ebb11", size = 1425538, upload-time = "2026-09-28T05:20:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b8/c5b4406e3a6591e51a42f85469351d1d824e0a54b84167aec9b2353759fe/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f8122cfdd0bc0ba843063effa22ac310bdebdb3a1319bf1e63f14baa119c72ab", size = 1377084, upload-time = "2026-09-28T05:20:06.618Z" }, + { url = "https://files.pythonhosted.org/packages/e0/01/0d84a8ea469d024c15f602799c0b8410fbe03d99fdb0370449b33c3c916f/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4bedbb379eab34f792af7ee9a05aae04e9c08bbb52e0f90f5a2110d4fe4b2fbd", size = 1281162, upload-time = "2026-09-28T05:19:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/22/b5/ea6435038de1a795f005cb603531e8ccc053febc50180f3ab55583c9c60e/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:5a953115b9f5c95133ab2d04efffeec96e5658c3207923dca7285f7db3e6bbef", size = 1300433, upload-time = "2026-09-28T05:18:55.634Z" }, + { url = "https://files.pythonhosted.org/packages/6c/78/fa6c77d9f64b6d592e69e582d83cbce7d7e56897faa4762885a2123b0166/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6173558e676ad25ed1e20507fa4024a0d816dd90f715f77006e5a28f19109026", size = 1336273, upload-time = "2026-09-28T05:19:08.273Z" }, + { url = "https://files.pythonhosted.org/packages/f1/56/4acd0d778bab818c9ea336fcba768bf43bc70db7f84cc373579298df4641/hypothesis-6.168.3-cp310-abi3-win32.whl", hash = "sha256:dc66390fb12d80585aa9222bf538ce8b7aa22cf5d118250647355a1c9e8f62f4", size = 678211, upload-time = "2026-09-28T05:19:23.355Z" }, + { url = "https://files.pythonhosted.org/packages/b0/db/0a02b146ad1c30f9716362f2e1a379dfd6b0f7be03ac1e22c9da24c9e2a9/hypothesis-6.168.3-cp310-abi3-win_amd64.whl", hash = "sha256:92325b276360fe86c5bf71a568c0d53a6d140b0de36dcf029f9164a17803bb24", size = 684906, upload-time = "2026-09-28T05:18:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c0/958deaf726848f96f52250740bf39f13f476b068e22f73e2b358eaa07532/hypothesis-6.168.3-cp310-abi3-win_arm64.whl", hash = "sha256:3cf6f1eeaf41cd8d60cf1f88fde905ca1dd77c906929a507d6ac7f66f2ccba2a", size = 683292, upload-time = "2026-09-28T05:19:57.404Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ab/443208c2988a24eaafbfbd6791b897ad9a8a1dd7f870d5b455d7d15a28ad/hypothesis-6.168.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9029775dc2e25e3e8e596c4306926f0079158353631c7c52c285bbc5c8073c2b", size = 792239, upload-time = "2026-09-28T05:19:59.144Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7b/619c1a76c52de2d3cfd68a2f9876c09464d63097db5bf86a28b62dc37bb1/hypothesis-6.168.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e1d91530cefdb9e0b6467c203eb509c61a11d70480a37db59cb53a2f9913102", size = 788113, upload-time = "2026-09-28T05:19:17.145Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/33841e8833105f850214a486d83740ba52041892ca94e687a05829c0ce83/hypothesis-6.168.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0df00cbe8133aa220308d11fa28e3add996cf5102a39b279c1d711015fd9114b", size = 1124241, upload-time = "2026-09-28T05:18:37.589Z" }, + { url = "https://files.pythonhosted.org/packages/4b/43/c84a7900feced6a420e824ab61d316e4f59a6887d991bc143ecc294a09ed/hypothesis-6.168.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7f75a2e23288ee82ee965a952473d9c5447c2cbc1afc94be09d2402201774e", size = 1170406, upload-time = "2026-09-28T05:19:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/47/5d/6e2a32b9c3020af7a06b00fe446a2c5155ae76ac468c0b68d7188e524002/hypothesis-6.168.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc2b1c37631f2231dd0d077225aa5908fc2bd34a4c3faf475053b0bc2eb24c95", size = 1300339, upload-time = "2026-09-28T05:20:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f9/78/19f3c9eb9a5c215b9fbde57be18ef7c1c78afa509b460d4e8f86026d741a/hypothesis-6.168.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:73960a58f6efc8cbc57d8ad0647955f7c5b25f3d562e3eaa57afa1c69894f7aa", size = 1336484, upload-time = "2026-09-28T05:19:02.841Z" }, + { url = "https://files.pythonhosted.org/packages/88/bb/d46c4519e989e022667705d8a56e83a09ad4ea50eae07ca4bb0f4708dd23/hypothesis-6.168.3-cp311-cp311-win_amd64.whl", hash = "sha256:3f1122223759acc0fe5301c1ae505d762ee61af91db7b191a791ccdbcd965462", size = 684646, upload-time = "2026-09-28T05:20:00.842Z" }, + { url = "https://files.pythonhosted.org/packages/7a/56/44a17266bcf1add5fba7c3df5c3920396a5e924d917fb0534a6f9e2b9b9a/hypothesis-6.168.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bff12e036b67abc5ded682f724f4d7afaff5223190270a0763695f218c079068", size = 793313, upload-time = "2026-09-28T05:20:22.045Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/1a25b11e9133cb54c02ee6cf99a61296ad461aabc7aae3399e1d0ff1f095/hypothesis-6.168.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73c6188056e6dc110260e39d141c69c3d02f23e3ba1d8610c7a8b6f2510ec96", size = 784855, upload-time = "2026-09-28T05:19:34.496Z" }, + { url = "https://files.pythonhosted.org/packages/8b/da/728d3210f0b0089337f37206a100d31ef546d1f99839e709f0a9ca443f8e/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb1063cb794765097b8c0add598bd34a904f44dc15db81275dd1f0ed1ac567ed", size = 1123043, upload-time = "2026-09-28T05:19:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/53/86/bfb9ec344a8b3ef2fa6f59bb3d30c5b3a7d86d594107670539c34ee53d1b/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b2d47f5d9060b049039bef0b267e88480f6468f95ee394e514e84a81ccdc5f5", size = 1169121, upload-time = "2026-09-28T05:20:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e0/5a76dbafbcaf8fff075f09377cddefa17289ef05dbf51878dde88385b966/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8367f623a98cbf90f33fd2a43b100671b152b5975bc343a578290945f52f7018", size = 1298749, upload-time = "2026-09-28T05:19:32.883Z" }, + { url = "https://files.pythonhosted.org/packages/28/15/0e65af96b353f2c4bdc283c53ed0980e5ac5009afe97ef3b23c77b364664/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd81241f4cd76fb18d481e87b9688b30a0cb5c30841730513323ba1c7aba1837", size = 1335271, upload-time = "2026-09-28T05:20:54.005Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c5/37eab2155664aa544df321f6d3940b89c6702d79a72fdc98d7b5a2286f8c/hypothesis-6.168.3-cp312-cp312-win_amd64.whl", hash = "sha256:377438de53afb94347d9845b7d90db5905c6d2a8d612108e9938e0f80503bb90", size = 682211, upload-time = "2026-09-28T05:18:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a2/6787da846d929e52fc3344d803299c45782dbeac287528af08380e984bf8/hypothesis-6.168.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b230a850de63334c16654a34a2d547e0179d36b9071d4439b3e7237f6d077e7", size = 793254, upload-time = "2026-09-28T05:19:36.294Z" }, + { url = "https://files.pythonhosted.org/packages/89/ba/2893f5ca42501f4d562cba3229c8994c3a3e5fac66ef60c1e0e0b5220a5d/hypothesis-6.168.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63b0226cd3e0d8bdd97c3384b22a21934ed4d53246c1c93575dada616672499", size = 784699, upload-time = "2026-09-28T05:18:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/b7/aa/7d7349daf75b71f6f35876f8de115e974c5c04d600e86df1b2779b0883ab/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0369f5df055f96e117ab12e5f249668ff144731ab5280bc7a205fdf81f990b89", size = 1123016, upload-time = "2026-09-28T05:20:36.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/f0/7774e1ea072708ea46cb25c4aeee5f9978b6c271764809069e8a6789858e/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f076bcd0f77fdcdb7797826c879099573d03a02e228ebe649ea917081b962ac0", size = 1168989, upload-time = "2026-09-28T05:20:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7d/113992abed9efbd7944e3a496a382da58152dce126fddf6419ff31a0a57d/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b823ba1fcec8da730f29316d010b06d3f7e0c3828dcf91020e24c55e7d24652a", size = 1298626, upload-time = "2026-09-28T05:19:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/d5/65/3659fa5e733027e5b37a27486e57f4053535e40853d23501c422bb275043/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dd2849c269d674e4618590f3b48d443bd4c06b5aef3d8d869086c2e6d213d248", size = 1335184, upload-time = "2026-09-28T05:20:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6c/35aab2221b5ea65125340f236e1a765a9e9f3b28d89a389ea0033fed29f7/hypothesis-6.168.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ef7d26f5789e691401d5f87eafed9bd2763f0dbe47af6d6b66012a509404766", size = 682173, upload-time = "2026-09-28T05:19:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/27b0cb56ff5d2bc92458bf6fcebdb1f0dc01f57f043a178e9f9d74498169/hypothesis-6.168.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e2d4c68729a13df9af4998d2652cfb5d541c5609b88c306880a5dfb284938ec2", size = 793287, upload-time = "2026-09-28T05:20:38.522Z" }, + { url = "https://files.pythonhosted.org/packages/e8/bd/5342f95c3bc36586777ef8cb8eba87ac7acf0184210327fb0e6d8654e295/hypothesis-6.168.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b345f818083ec99966a43ca4f7b38feb62c6920ce28572bc2bde4948a8b7eaba", size = 784857, upload-time = "2026-09-28T05:20:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5f/fc774241518a0588d362680a3084275058aab86d6bc02a2c61e1073142d0/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0608c610fc002978fc5de0f471770d8817e9455cb8649a47983c9f8bccfc1897", size = 1123330, upload-time = "2026-09-28T05:19:29.735Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/131f16775a3dca5f4fe27f0d6ad9a9851600098a54a872d163f6ebf0b664/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dcf6448b1ecc37f2b23f2d1b3ddfc9ff6b6910614c15a642dc82f419b96037b", size = 1169178, upload-time = "2026-09-28T05:19:53.436Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/c9f5bff8b73321c9fd12f2d69666c6e4861b97b60f5f24fdd6ac293007fd/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f9f094041dcce5119ebd7bab71062743ab02b6e654d056b370beda78c19e2", size = 1299127, upload-time = "2026-09-28T05:20:14.486Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/d4618f8ab12dd76c4d58ea052252759aa2bf0c4e71f4b502ba5d577e0b9f/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d479985fe73af97badfb72dc6d20c6a353e486a36f6d065f600026e0cf954a86", size = 1335390, upload-time = "2026-09-28T05:20:10.326Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/727161e17cc297df1aeea945de05c532033b66751625bec192bc5b5ff707/hypothesis-6.168.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:785e2c45f8c08e274b4bf1ccae97f1a4e09407e9a68e80790cfab1d17a4a45fa", size = 624291, upload-time = "2026-09-28T05:18:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/77/23/2f9b506392a0b8712440bcb781abc5713be9ffac1303b6d10f3669050882/hypothesis-6.168.3-cp314-cp314-win_amd64.whl", hash = "sha256:320920b1e3dae8611eee8a03d063cf2187446f2a17c38cfb8a7fc1466f71eee2", size = 682064, upload-time = "2026-09-28T05:20:16.336Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/efabfd95eb2b69c0c9fa1e1c83c8290aa2715d46baeb5a7764faf09c133a/hypothesis-6.168.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:35380baa981108a7f60c4eab71e46acd8d8f58440520346a1a6aba06dca7e074", size = 791875, upload-time = "2026-09-28T05:19:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/2a0ada1623a9a33048bbf9f00a7c92513398c6b8865cba5efb854262ce30/hypothesis-6.168.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f0aaaed00438fa6673d856aaee12a4a8afbde82a9f3c5ff487cacfb064239afe", size = 783412, upload-time = "2026-09-28T05:18:48.266Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/73c615d37eeb12da0209555612c32ced628d860267c23cfd2691c6207aea/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27e6df1576bf838e7f484d4ae5cba92114497ca30afb917056bf1ea33e95675", size = 1121662, upload-time = "2026-09-28T05:19:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/92/54/1893344a3b8bbf83fa7c9bb7e96f6836580213854f46671cb749f7b4706c/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bc85014577982ec6d2e266edc5cd6e7a7d3674c648791ca983c67a52f89a0c4", size = 1167761, upload-time = "2026-09-28T05:18:49.85Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/a61d82968febd25a0f08ffbbfabfaeead66f68fc5fa73f3c9ece1c36fc8f/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ccfc29505aa1cdcc254cb9cd701fd0811fef5480addd97d4127a00df023410f7", size = 1297309, upload-time = "2026-09-28T05:19:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/fe8e4cf6d39d02f4efadaa351427fa13722086cfc54a2da3579741c5177a/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32d0699566aaa93f9e97a44705d7164386f1e91de78d277f53bada35e78bcc96", size = 1334260, upload-time = "2026-09-28T05:19:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/39/6a/09177ebc62f4778f94379ecade6a51299d3d8c4f4b75ba636bf3a0202364/hypothesis-6.168.3-cp314-cp314t-win_amd64.whl", hash = "sha256:28d88fa174ecbd4ecbd7bb290f06d0db084a3971c3f511ae2830b65b5e25500f", size = 681992, upload-time = "2026-09-28T05:19:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/890bf33d608cd63348d3146a3ca83362fbf09e589c5e5230a00c403070f4/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:61f5782d807b1e6aa5c9beef1054cb2037e7d1add48a64972cd6ee447778781f", size = 791231, upload-time = "2026-09-28T05:18:43.998Z" }, + { url = "https://files.pythonhosted.org/packages/03/dd/fc36b204f8aa7437c676576d9437f0422de46aa8a729e6cd5bbb0224e759/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:7aacf3cf40c7ce8f9e4924d5b57bc0b068beafdfed2347bcf160a28b4cfbba7b", size = 783171, upload-time = "2026-09-28T05:19:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d2/3cd087d577db67c404991d7723e64d7cd75570cdc82a0f7329aed6f2086c/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01768a03a30dc54df7fe457c0b34016c84d598fa00d5965ededab93ba4eb3408", size = 1121152, upload-time = "2026-09-28T05:18:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/42/6f/a05a68cc66e7a22701c50bf94fb9ddeb36f4f5d0de3cc130066709eda621/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8a4ffc6c6e6e577f2bfbcfebf7cffbb310283ba2532c729570a0a752cebaaa", size = 1144069, upload-time = "2026-09-28T05:20:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f4/fdd7a093fb2fb3a0ce24256c92ad5bf67936f4669bd06d5ceae4298150ec/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b987d73eba95183a7e59cca6d1925c588aa4307922d9852cc2b4d282a2ae4128", size = 1146692, upload-time = "2026-09-28T05:20:25.83Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/6dbd4377e935505ae4fc8ee4c7b18c69994c4bbaca015447c470218bdbee/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4569c39bd97d9573e946429ed676f3b55a7c8ed80920addd67d384a47d59b38", size = 1189480, upload-time = "2026-09-28T05:20:46.734Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bf/ff288b496b690000d2686dcfa7f67855e4c5a6dddb464ed8e4880be83bb2/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6042b8707a4b25bbbfe20b110258fb7549a68951e5525608a8ce06091039e9fc", size = 1167109, upload-time = "2026-09-28T05:19:24.758Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d1/3fee2bc29fc747fabbb27e174515592e390809ca465d3bbbabbcfa3235a8/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:f413629de94d38a7a2ad259697a6752526143cb49c2e2d7bdba19381c80693f6", size = 1126823, upload-time = "2026-09-28T05:18:52.636Z" }, + { url = "https://files.pythonhosted.org/packages/58/6a/585294392fa6a6d9719446a042a777ba98ecca265d9e48cbedc10c88df0f/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f071737e4e775bebba07e319eb645a880d1e0186b4d24bad23255e849d86d483", size = 1155773, upload-time = "2026-09-28T05:20:51.268Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/fa283ff79996debf1ff08593e01f8b773eb8211b19b3d6d5d491317a65bf/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f59a3912858d0609c26054aa1c474847c797937f5e0e1c77f0d3f08032e50f09", size = 1296671, upload-time = "2026-09-28T05:18:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/42/05/98f9c2f628da5afabb6c3e8df5d2d299d2d50bf306b14a6e26d3954c4951/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f09c05a23a8025dd5cad07c2ed299a46778e72d49ff0dd5bf353bcc0f7dc1580", size = 1422001, upload-time = "2026-09-28T05:19:04.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/f2109ade29a7ec284d55bca328d784743e8b13e321576fd34ee7e65f99af/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:44ace770bda3a0301739fc5a413c764de790c739df1f1a7218dd049f8594d9f5", size = 1374182, upload-time = "2026-09-28T05:20:27.764Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6d/e05d5f72441564a3bebc71fa155deafd0fd3b015d6014ec8a00edf6e42bb/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:03b131043608f94a2578a2a896a1a72092acb7079815eef5b8513b08447e0e62", size = 1278402, upload-time = "2026-09-28T05:20:56.332Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/6988a7f1f69c5c530687dce03a32c157e2d878e3a2e32af9269ce016b78a/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e9784aca26eddfe99b03a0292320db742cc8a74200ef864fdce949f526f973cd", size = 1297770, upload-time = "2026-09-28T05:19:11.387Z" }, + { url = "https://files.pythonhosted.org/packages/a2/28/5bb82b60b836bd2329e1fe01ad94efc2b14dfa806f8e50ed14b795d78770/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2b52ac363096232bebc2add117e9178d91f4248f4cdb919fd1026b5f86a4bb16", size = 1333977, upload-time = "2026-09-28T05:20:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7d/d419841b8f65481ea1a50c4ba36f2670d48b8c7a51e4e8586089564cf7f6/hypothesis-6.168.3-cp315-abi3.abi3t-win32.whl", hash = "sha256:d28e3a6b511a74ce37df5274b51f36c2b274fea7365e00b16f0c81e22acd5957", size = 675394, upload-time = "2026-09-28T05:19:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/1de6a2ad100e44817f2e9a8e8ba3eeaf4769621f4f87f2d4966c4971fcc4/hypothesis-6.168.3-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:7b9638789548361a57d984f56619ac694a914c328181d911271618409261ff4a", size = 681699, upload-time = "2026-09-28T05:19:09.957Z" }, + { url = "https://files.pythonhosted.org/packages/c0/79/be3370fd02734d6b1d950183ae9224580d19fa8d356b95348d60f1e3eeb7/hypothesis-6.168.3-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:65d78e4357ec48ed2c67825f06740ee3599be4cfe770a6092bed07108d679ac5", size = 679849, upload-time = "2026-09-28T05:19:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0e/27960db1d45a94497e1681fb9d7b9140b44da4e655ba20f0d82f95d10230/hypothesis-6.168.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:101531b4ccf7fa12965a6b295d10b64c2f8a8fab61e1f9b18c55335d8fb02575", size = 793135, upload-time = "2026-09-28T05:19:14.056Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cb/be08379286c1925fcd651b92ed835f470a83b859b5c484b3431da9d851f5/hypothesis-6.168.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:41e0120814de5c3a6b58d8cb27cb11b8873ab33fba130cd09855a27f5a12a1ca", size = 788995, upload-time = "2026-09-28T05:20:34.164Z" }, + { url = "https://files.pythonhosted.org/packages/38/7b/6d885d139b2ed76e2f4908cf10eae56acc73bf05cbef286357c4559c9a04/hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d120009d145697909f2f0c532f2d568558ddd6990707f8cba6576bfb9770696", size = 1124974, upload-time = "2026-09-28T05:19:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/05/cc/b302ae809b48254830cae57801d0af0b66783df515b5dd56b9f21c8dfa86/hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:818b3d09bba60ef90463e47a4bc87275dba1e8aeb184d3102888440ca7c7837d", size = 1171967, upload-time = "2026-09-28T05:20:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ab/51031080a6600f5b07adf5f9dfa117345c2adda07a5c24bda523e70bd1e9/hypothesis-6.168.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9e3c36eba80e089ecfa28ec08049723223cb41d3cfe4d9988169cf98440e402b", size = 685641, upload-time = "2026-09-28T05:19:44.609Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -1746,6 +1834,7 @@ dev = [ { name = "build" }, { name = "coverage" }, { name = "furo" }, + { name = "hypothesis" }, { name = "jupyter-book" }, { name = "ruff" }, { name = "setuptools" }, @@ -1757,6 +1846,7 @@ requires-dist = [ { name = "build", marker = "extra == 'dev'", specifier = ">=1.2.2.post1" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.9.2" }, { name = "furo", marker = "extra == 'dev'", specifier = ">=2024.8.6" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.100.0" }, { name = "jupyter-book", marker = "extra == 'dev'", specifier = ">=1.0.4.post1" }, { name = "microdf-python", specifier = ">=1.0.0" }, { name = "pandas", specifier = ">=3.0" }, From bb6eb56a6cd335527b53cd1291beccfea260501f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 15:25:37 -0400 Subject: [PATCH 02/22] Count distributions inside Schedule D in the NII invariant reference filer_loss_limited_net_capital_gains (from #9784) now nets the head and spouse's capital gain distributions with their Schedule D gains and losses before the 1211(b) limit, and net investment income no longer adds the distributions separately. The invariant test's reference computation follows: distributions move from the person-level Form 8960 amounts to the Schedule D inputs. Co-Authored-By: Claude Opus 5.5 --- ...est_dependent_net_investment_income_invariants.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py index 4be5e561e74..7c7798c1ec5 100644 --- a/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py +++ b/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py @@ -18,8 +18,8 @@ AGI, NIIT MAGI or `net_investment_income_tax`. 2. `net_investment_income` equals an independent numpy computation over the head and spouse: their interest, dividends, rents, passive pass-through, - estate and trust income and capital gain distributions, plus their netted - Schedule D gain or loss, limited under 26 USC 1211(b). + estate and trust income, plus their netted Schedule D gain or loss with + capital gain distributions on line 13, limited under 26 USC 1211(b). 3. -loss limit <= `filer_loss_limited_net_capital_gains`, and it equals `loss_limited_net_capital_gains` when the unit has no dependents. 4. For tax units without dependents, `net_investment_income` equals the @@ -56,7 +56,12 @@ + NONNEGATIVE_INPUTS + ["partnership_s_corp_income", "passive_partnership_s_corp_income"] ) -CAPITAL_INPUTS = ["short_term_capital_gains", "long_term_capital_gains"] +# Schedule D gains and losses, with capital gain distributions (line 13). +CAPITAL_INPUTS = [ + "short_term_capital_gains", + "long_term_capital_gains", + "non_sch_d_capital_gains", +] # Every person-level amount Form 8960 counts, other than Schedule D gains. NII_PERSON_INPUTS = [ "taxable_interest_income", @@ -65,7 +70,6 @@ "rental_income", "passive_partnership_s_corp_income", "estate_income", - "non_sch_d_capital_gains", ] OUTPUTS = [ "net_investment_income", From 6f0349149750f3a8ed28226112eaf65d95666429 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 15:50:51 -0400 Subject: [PATCH 03/22] Test the netting when a reform drops a gross income source capital_losses_allowed_against_gains counts only the capital gains that gov.irs.gross_income.sources includes, and dependent_taxable_ss_magi nets distributions only when both sources are listed. Cover both branches with reforms that drop non_sch_d_capital_gains or capital_gains. Co-Authored-By: Claude Opus 5.5 --- ...t_capital_gain_distributions_loss_limit.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py b/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py index af0c7dc51e1..fa81e20cb38 100644 --- a/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py +++ b/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py @@ -39,6 +39,9 @@ import numpy as np import pytest +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + from policyengine_us import Simulation YEAR = 2025 @@ -203,6 +206,83 @@ def test_example_from_the_schedule_d_instructions(): assert model["limited_capital_loss"][0] == 2_000 +# --------------------------------------------------------------------------- +# Reforms that drop a source from gross income. +# --------------------------------------------------------------------------- + + +def without_gross_income_source(name): + """A reform that removes one source from gov.irs.gross_income.sources.""" + + class reform(Reform): + def apply(self): + def modify(parameters): + sources = parameters.gov.irs.gross_income.sources + values = [s for s in sources(f"{YEAR}-01-01") if s != name] + sources.update( + start=instant(f"{YEAR}-01-01"), + stop=instant(f"{YEAR}-12-31"), + value=values, + ) + return parameters + + self.modify_parameters(modify) + + return reform + + +@pytest.mark.parametrize( + "dropped, agi, allowed_against_gains, net_loss, dependent_magi", + [ + # Baseline: Schedule D line 16 = -5,000 + 3,000 = -2,000. + (None, -2_000, 3_000, 2_000, -2_000), + # Distributions out of gross income: nothing to net the loss against, + # so the full 3,000 limit applies, on the dependent's return too. + ("non_sch_d_capital_gains", -3_000, 0, 3_000, -3_000), + # Capital gains out of gross income: the losses still net against the + # distributions on the head's return. The dependent's modified AGI + # then counts the distributions alone, as gross income does. + ("capital_gains", -2_000, 3_000, 2_000, 3_000), + ], +) +def test_netting_follows_the_gross_income_sources( + dropped, agi, allowed_against_gains, net_loss, dependent_magi +): + people = { + "head": { + "age": {YEAR: 45}, + "is_tax_unit_head": {YEAR: True}, + "long_term_capital_gains": {YEAR: -5_000}, + "non_sch_d_capital_gains": {YEAR: 3_000}, + }, + "dependent": { + "age": {YEAR: 70}, + "is_tax_unit_dependent": {YEAR: True}, + "is_tax_unit_spouse": {YEAR: False}, + "long_term_capital_gains": {YEAR: -5_000}, + "non_sch_d_capital_gains": {YEAR: 3_000}, + }, + } + members = list(people) + simulation = Simulation( + situation={ + "people": people, + "tax_units": {"tax_unit": {"members": members}}, + "households": { + "household": {"members": members, "state_code": {YEAR: "TX"}} + }, + }, + reform=without_gross_income_source(dropped) if dropped else None, + ) + assert simulation.calculate("adjusted_gross_income", YEAR)[0] == agi + assert ( + simulation.calculate("capital_losses_allowed_against_gains", YEAR)[0] + == allowed_against_gains + ) + assert simulation.calculate("limited_capital_loss", YEAR)[0] == net_loss + assert simulation.calculate("dependent_taxable_ss_magi", YEAR)[1] == dependent_magi + + # --------------------------------------------------------------------------- # Hypothesis. # --------------------------------------------------------------------------- From 9c2029429a489714774a4b84e0dbde5a1342ee33 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 19:05:19 -0400 Subject: [PATCH 04/22] Address review: Michigan adjustments, filer-only worksheet test, floors From the independent review of #9788: - mi_household_resources subtracted all above-the-line deductions, including the capital loss already netted in the capital gains line (MI-1040CR line 30 is U.S. Schedule 1 adjustments only). It now leaves out capital_losses_allowed_against_gains and limited_capital_loss. The two existing loss-limit cases double counted the loss: 44,000 -> 47,000 and 47,000 -> 48,500. - has_qdiv_or_ltcg figures Schedule D lines 15 and 16 over the head and spouse, so a dependent's loss cannot switch off the filer's worksheet. - Distributions are floored at zero per person in every line 7a variable (box 2a is not negative), as irs_gross_income does. - Capital losses are deductible only while capital_gains is a gross income source, matching dependent_taxable_ss_magi under such a reform. - Test that the two loss-limit parameters agree; rename the #9784 invariant test that no longer compares with the pre-change formula. - loss_ald label: "Business and capital loss ALD". Co-Authored-By: Claude Opus 5.5 --- ...endent_net_investment_income_invariants.py | 8 ++- ...schedule_d_capital_gain_distributions.yaml | 55 +++++++++++++++++ .../mi/tax/income/mi_household_resources.yaml | 60 +++++++++++++++++-- ...t_capital_gain_distributions_loss_limit.py | 30 ++++++++-- .../capital_losses_allowed_against_gains.py | 4 ++ .../limited_capital_loss.py | 4 ++ .../above_the_line_deductions/loss_ald.py | 2 +- .../filer_loss_limited_net_capital_gains.py | 20 +++---- .../irs_gross_income/has_qdiv_or_ltcg.py | 32 ++++++---- .../loss_limited_net_capital_gains.py | 6 +- .../loss_limited_net_capital_gains_person.py | 4 +- .../mi/tax/income/mi_household_resources.py | 14 ++++- 12 files changed, 199 insertions(+), 40 deletions(-) diff --git a/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py index 7c7798c1ec5..484e9d1ce60 100644 --- a/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py +++ b/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py @@ -23,7 +23,9 @@ 3. -loss limit <= `filer_loss_limited_net_capital_gains`, and it equals `loss_limited_net_capital_gains` when the unit has no dependents. 4. For tax units without dependents, `net_investment_income` equals the - pre-change all-member formula, so filers without dependents see no change. + all-member formula (person-level sources plus + `loss_limited_net_capital_gains`), so leaving dependents out changes + nothing for filers without dependents. 5. 0 <= NIIT = 3.8% x min(max(0, NII), max(0, MAGI - threshold)). """ @@ -303,8 +305,8 @@ def test_filer_capital_gains_within_loss_limit(runs, units): ) -def test_units_without_dependents_match_pre_change_formula(runs, units): - """Differential check against the all-member formula used before.""" +def test_units_without_dependents_match_all_member_formula(runs, units): + """Differential check against the all-member formula.""" person_nii = sum(_by_person(units, name) for name in NII_PERSON_INPUTS) old = _unit_sum(runs, person_nii) + runs["with"]["loss_limited_net_capital_gains"] no_dependents = np.array([not u["dependents"] for u in units]) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml index 6925c15a5e5..681e96b22f0 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml @@ -333,3 +333,58 @@ loss_ald: 55_000 # Only the self-employment loss counts: max(0, 50,000 - 12,000). dc_self_employment_loss_addition: 38_000 + +- name: A dependent's capital loss does not change the head's worksheet test + period: 2025 + input: + people: + head: + age: 45 + is_tax_unit_head: true + employment_income: 300_000 + non_sch_d_capital_gains: 5_000 + child: + age: 20 + is_tax_unit_dependent: true + is_tax_unit_spouse: false + long_term_capital_gains: -10_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # The head's own Form 1040 line 7a is the 5,000 of distributions (no + # Schedule D needed), so line 16 routes to the Qualified Dividends and + # Capital Gain Tax Worksheet. The dependent's loss is on the + # dependent's own return. + has_qdiv_or_ltcg: true + filer_loss_limited_net_capital_gains: 5_000 + adjusted_gross_income: 305_000 + +- name: A negative distributions input counts as zero + period: 2025 + input: + people: + person1: + age: 45 + non_sch_d_capital_gains: -2_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Form 1099-DIV box 2a is not negative; gross income and Form 1040 line + # 7a both floor the input at zero. + adjusted_gross_income: 0 + loss_limited_net_capital_gains: 0 + filer_loss_limited_net_capital_gains: 0 + loss_limited_net_capital_gains_person: 0 + net_investment_income: 0 + has_qdiv_or_ltcg: false + diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml index f04cbe65ec2..629963b3f8d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml @@ -87,9 +87,10 @@ output: # Per MI-1040CR-7 Line 24: losses cannot exceed $3,000 for MFJ # loss_limited_net_capital_gains: max(-150,000, -3,000) = -3,000 - # above_the_line_deductions includes loss_ald of 3,000 - # Total: 50,000 + (-3,000) - 3,000 = 44,000 - mi_household_resources: 44_000 + # The capital loss is not a U.S. Schedule 1 adjustment (MI-1040CR line + # 30), so it is not subtracted a second time. + # Total: 50,000 + (-3,000) = 47,000 + mi_household_resources: 47_000 - name: Capital gains loss limited to 1500 for MFS period: 2024 @@ -101,6 +102,53 @@ output: # Per MI-1040CR-7 Line 24: losses cannot exceed $1,500 for MFS # loss_limited_net_capital_gains: max(-100,000, -1,500) = -1,500 - # above_the_line_deductions includes loss_ald of 1,500 - # Total: 50,000 + (-1,500) - 1,500 = 47,000 - mi_household_resources: 47_000 + # Not subtracted again as a Schedule 1 adjustment (line 30). + # Total: 50,000 + (-1,500) = 48,500 + mi_household_resources: 48_500 + +- name: Capital gain distributions net against a capital loss + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + long_term_capital_gains: -20_000 + non_sch_d_capital_gains: 20_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # 2025 MI-1040 book, MI-1040CR line 19: "net capital gains and losses ... + # from your U.S. Schedule D". Schedule D line 16 = -20,000 + 20,000 + # (line 13) = 0. Line 30 (U.S. Schedule 1 adjustments) has none. + mi_household_resources: 50_000 + +- name: Spouses' capital gains and losses net on a joint Schedule D + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + long_term_capital_gains: 40_000 + person2: + age: 45 + long_term_capital_gains: -40_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 19: Schedule D line 16 = 40,000 - 40,000 = 0. + mi_household_resources: 50_000 diff --git a/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py b/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py index fa81e20cb38..398df7e19db 100644 --- a/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py +++ b/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py @@ -239,10 +239,10 @@ def modify(parameters): # Distributions out of gross income: nothing to net the loss against, # so the full 3,000 limit applies, on the dependent's return too. ("non_sch_d_capital_gains", -3_000, 0, 3_000, -3_000), - # Capital gains out of gross income: the losses still net against the - # distributions on the head's return. The dependent's modified AGI - # then counts the distributions alone, as gross income does. - ("capital_gains", -2_000, 3_000, 2_000, 3_000), + # Capital gains out of gross income: capital losses from sales are + # not deductible either, on the head's return or the dependent's; + # the distributions stay in gross income. + ("capital_gains", 3_000, 0, 0, 3_000), ], ) def test_netting_follows_the_gross_income_sources( @@ -283,6 +283,28 @@ def test_netting_follows_the_gross_income_sources( assert simulation.calculate("dependent_taxable_ss_magi", YEAR)[1] == dependent_magi +def test_the_two_loss_limit_parameters_agree(): + """limited_capital_loss and dependent_taxable_ss_magi read + gov.irs.ald.loss.capital.max; loss_limited_net_capital_gains and + filer_loss_limited_net_capital_gains read gov.irs.capital_gains.loss_limit. + AGI's capital amount equals Form 1040 line 7a only while they agree.""" + from policyengine_us import CountryTaxBenefitSystem + + parameters = CountryTaxBenefitSystem().parameters + for year in range(2013, 2036): + irs = parameters(f"{year}-01-01").gov.irs + for status in [ + "SINGLE", + "JOINT", + "SEPARATE", + "HEAD_OF_HOUSEHOLD", + "SURVIVING_SPOUSE", + ]: + assert ( + irs.ald.loss.capital.max[status] == irs.capital_gains.loss_limit[status] + ), (year, status) + + # --------------------------------------------------------------------------- # Hypothesis. # --------------------------------------------------------------------------- diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py index a44fb1635b0..745a37c4552 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py @@ -52,5 +52,9 @@ def formula(tax_unit, period, parameters): if source in sources: gains += max_(0, person(source, period)) gains_in_gross_income = tax_unit.sum(not_dependent * gains) + # Losses from sales and exchanges are deductible while their gains + # are in gross income (a reform can drop capital_gains from it). + if "capital_gains" not in sources: + return 0 * gains_in_gross_income capital_losses = tax_unit_non_dep_sum("capital_losses", tax_unit, period) return min_(capital_losses, gains_in_gross_income) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py index 858a0a11da0..2009c72a419 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py @@ -29,6 +29,10 @@ def formula(tax_unit, period, parameters): p = parameters(period).gov.irs filing_status = tax_unit("filing_status", period) max_loss = p.ald.loss.capital.max[filing_status] + # As in capital_losses_allowed_against_gains: no capital loss + # deduction when a reform takes capital gains out of gross income. + if "capital_gains" not in p.gross_income.sources: + return 0 * max_loss # A tax-unit dependent's capital losses belong on the dependent's own # return, as in irs_gross_income. capital_losses = tax_unit_non_dep_sum("capital_losses", tax_unit, period) 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 5e199a624bc..d51b44af919 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 @@ -4,7 +4,7 @@ class loss_ald(Variable): value_type = float entity = TaxUnit - label = "Business loss ALD" + label = "Business and capital loss ALD" unit = USD documentation = ( "Above-the-line deduction from gross income for business and capital losses." diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py index dcc7ae68f6b..120ca814d65 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py @@ -25,15 +25,15 @@ def formula(tax_unit, period, parameters): p = parameters(period).gov.irs filing_status = tax_unit("filing_status", period) loss_limit = p.capital_gains.loss_limit[filing_status] - # Capital gain distributions go on Schedule D line 13 with the other - # gains and losses, so they net before the limit. net_capital_gains = tax_unit_non_dep_add( - tax_unit, - period, - [ - "long_term_capital_gains", - "short_term_capital_gains", - "non_sch_d_capital_gains", - ], + tax_unit, period, ["long_term_capital_gains", "short_term_capital_gains"] + ) + # Capital gain distributions go on Schedule D line 13 with the other + # gains and losses, so they net before the limit. Each filer's input + # is floored at zero, as in irs_gross_income. + person = tax_unit.members + not_dependent = ~person("is_tax_unit_dependent", period) + distributions = tax_unit.sum( + not_dependent * max_(0, person("non_sch_d_capital_gains", period)) ) - return max_(-loss_limit, net_capital_gains) + return max_(-loss_limit, net_capital_gains + distributions) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py index 07761ee07bc..ac66f03c566 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py @@ -8,10 +8,10 @@ class has_qdiv_or_ltcg(Variable): documentation = ( "Whether this tax unit figures its tax with the Qualified Dividends " "and Capital Gain Tax Worksheet or the Schedule D Tax Worksheet: it " - "has qualified dividends, or Schedule D lines 15 and 16 are both more " - "than zero. Capital gain distributions are on Schedule D line 13, so " - "a filer who reports them without Schedule D has lines 15 and 16 " - "equal to them." + "has qualified dividends, or the head and spouse's Schedule D lines " + "15 and 16 are both more than zero. Capital gain distributions are on " + "Schedule D line 13, so a filer who reports them without Schedule D " + "has lines 15 and 16 equal to them." ) definition_period = YEAR reference = ( @@ -27,13 +27,21 @@ class has_qdiv_or_ltcg(Variable): def formula(tax_unit, period, parameters): qualified_dividends = add(tax_unit, period, ["qualified_dividend_income"]) - # Schedule D line 15: net long-term capital gain or loss, including - # capital gain distributions (line 13). - line_15 = add( - tax_unit, - period, - ["long_term_capital_gains", "non_sch_d_capital_gains"], + # Schedule D lines 15 and 16 of the head and spouse; a tax unit + # dependent's gains and losses are on the dependent's own return. + person = tax_unit.members + not_dependent = ~person("is_tax_unit_dependent", period) + distributions = tax_unit.sum( + not_dependent * max_(0, person("non_sch_d_capital_gains", period)) + ) + # Line 15: net long-term capital gain or loss, including capital gain + # distributions (line 13). + line_15 = ( + tax_unit_non_dep_add(tax_unit, period, ["long_term_capital_gains"]) + + distributions + ) + # Line 16: line 15 plus the net short-term gain or loss. + line_16 = line_15 + tax_unit_non_dep_add( + tax_unit, period, ["short_term_capital_gains"] ) - # Schedule D line 16: line 15 plus the net short-term gain or loss. - line_16 = line_15 + add(tax_unit, period, ["short_term_capital_gains"]) return (qualified_dividends > 0) | ((line_15 > 0) & (line_16 > 0)) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py index 8306853e817..f7693680121 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py @@ -41,5 +41,9 @@ def formula(tax_unit, period, parameters): # Capital gain distributions reported without Schedule D. A filer with # capital losses or other capital gains files Schedule D and enters # them on line 13, so they net against losses before the limit. - distributions = add(tax_unit, period, ["non_sch_d_capital_gains"]) + # Form 1099-DIV box 2a amounts are not negative; floor each person's + # input at zero, as irs_gross_income does. + distributions = tax_unit.sum( + max_(0, tax_unit.members("non_sch_d_capital_gains", period)) + ) return max_(-loss_limit, net_capital_gains + distributions) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py index 2e4632639bf..74c84afb65a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py @@ -17,8 +17,8 @@ class loss_limited_net_capital_gains_person(Variable): def formula(person, period, parameters): # The person's capital gains and losses, plus capital gain # distributions, which go on Schedule D line 13 with them. - person_capital_gains = add( - person, period, ["capital_gains", "non_sch_d_capital_gains"] + person_capital_gains = person("capital_gains", period) + max_( + 0, person("non_sch_d_capital_gains", period) ) # Get tax unit totals diff --git a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py index 5ea1d3c7c5f..7364f38af3f 100644 --- a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py +++ b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py @@ -14,6 +14,10 @@ class mi_household_resources(Variable): "section-206-508/", "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" "Forms/IIT/TY2024/BOOK_MI-1040CR-7.pdf", + # 2025 MI-1040 book, MI-1040CR lines 19 (capital gains) and 30 + # (adjustments from U.S. Schedule 1). + "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" + "Forms/IIT/TY2025/MI-1040-Book.pdf#page=32", ) def formula(tax_unit, period, parameters): @@ -45,7 +49,15 @@ def formula(tax_unit, period, parameters): total += add(tax_unit, period, [source]) health_insurance_premiums = add(tax_unit, period, ["health_insurance_premiums"]) - above_the_line_deductions = tax_unit("above_the_line_deductions", period) + # Line 30: "total adjustments from your U.S. Form 1040, Schedule 1". + # Capital losses are not Schedule 1 adjustments: they are already + # netted and limited in the capital gains line (Form 1040 line 7a), + # so the capital parts of loss_ald are not subtracted again. + above_the_line_deductions = tax_unit("above_the_line_deductions", period) - add( + tax_unit, + period, + ["capital_losses_allowed_against_gains", "limited_capital_loss"], + ) return max_( 0, total - health_insurance_premiums - above_the_line_deductions, From b047c783abc21bf5e5494c3e4296e8d68f1a1922 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 21:28:27 -0400 Subject: [PATCH 05/22] Follow 1211(b) in the #9647 invariant and a Maryland surtax test CI caught two tests written against the old capital loss formula: - test_dependent_losses_agi_invariants (#9647) compared limited_capital_loss with min(cap, sum of losses), which does not net one spouse's gains against the other's losses. Its reference now splits 1211(b) the same way: losses allowed up to the gains, plus the excess up to the cap. Renamed ..._match_all_member_formula. - md_capital_gains_surtax "partial offset" set capital_losses (a formula variable, max(0, -capital_gains)) to 50,000 next to 150,000 of net gains, a state no inputs can produce, and its comment applied the $3,000 cap to losses within gains. It now inputs the short-term loss directly (30,000 gains less 50,000 losses = -20,000): Schedule D line 16 and Form 502CG line 1 are 100,000, surtax 2,000 (same on main). Co-Authored-By: Claude Opus 5.5 --- .../test_dependent_losses_agi_invariants.py | 28 +++++++++++-------- .../md_capital_gains_surtax.yaml | 15 +++++----- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/policyengine_us/tests/core/test_dependent_losses_agi_invariants.py b/policyengine_us/tests/core/test_dependent_losses_agi_invariants.py index 467f3a2e155..53fc73fc7f1 100644 --- a/policyengine_us/tests/core/test_dependent_losses_agi_invariants.py +++ b/policyengine_us/tests/core/test_dependent_losses_agi_invariants.py @@ -20,9 +20,12 @@ 2. 0 <= limited_capital_loss <= the filing-status cap, and it never exceeds the non-dependents' own capital losses (26 USC 1211(b)). 3. loss_ald >= limited_capital_loss >= 0. -4. For tax units without dependents, both variables equal the pre-change - all-member formula (a reference implementation below), so filers without - dependents see no change. +4. For tax units without dependents, both variables equal the all-member + formula (a reference implementation below), so leaving dependents out + changes nothing for filers without dependents. The capital part follows + 26 USC 1211(b): losses are allowed up to the gains + (`capital_losses_allowed_against_gains`), plus the excess up to the cap + (`limited_capital_loss`). """ import numpy as np @@ -258,8 +261,8 @@ def test_loss_ald_at_least_limited_capital_loss(runs): ) -def test_units_without_dependents_match_pre_change_formula(runs, units): - """Differential check against the all-member formula used before.""" +def test_units_without_dependents_match_all_member_formula(runs, units): + """Differential check against the all-member formula.""" p = CountryTaxBenefitSystem().parameters(f"{runs['year']}-01-01").gov.irs filing_status = runs["with"]["filing_status"] se = _by_person(units, "self_employment_income") + _by_person( @@ -269,12 +272,13 @@ def test_units_without_dependents_match_pre_change_formula(runs, units): income = sum(_unit_sum(runs, np.maximum(x, 0)) for x in person_business) loss = sum(_unit_sum(runs, np.maximum(-x, 0)) for x in person_business) limited_business_loss = np.minimum(loss, income + p.ald.loss.max[filing_status]) - capital_losses = _unit_sum( - runs, - np.maximum(0, -sum(_by_person(units, name) for name in CAPITAL_INPUTS)), - ) + person_capital = sum(_by_person(units, name) for name in CAPITAL_INPUTS) + capital_losses = _unit_sum(runs, np.maximum(0, -person_capital)) + capital_gains = _unit_sum(runs, np.maximum(0, person_capital)) + allowed_against_gains = np.minimum(capital_losses, capital_gains) limited_capital_loss = np.minimum( - p.ald.loss.capital.max[filing_status], capital_losses + p.ald.loss.capital.max[filing_status], + capital_losses - allowed_against_gains, ) no_dependents = np.array([not u["dependents"] for u in units]) assert no_dependents.sum() > 100 @@ -285,6 +289,8 @@ def test_units_without_dependents_match_pre_change_formula(runs, units): ) np.testing.assert_allclose( runs["with"]["loss_ald"][no_dependents], - (limited_business_loss + limited_capital_loss)[no_dependents], + (limited_business_loss + allowed_against_gains + limited_capital_loss)[ + no_dependents + ], atol=TOLERANCE, ) diff --git a/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml b/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml index a42e7f34eb7..1e93b88aea1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml @@ -149,8 +149,8 @@ people: person1: long_term_capital_gains: 120_000 - short_term_capital_gains: 30_000 - capital_losses: 50_000 + # $30,000 of short-term gains less $50,000 of short-term losses. + short_term_capital_gains: -20_000 tax_units: tax_unit: members: [person1] @@ -160,11 +160,12 @@ members: [person1] state_code: MD output: - # Gross gains: $120,000 + $30,000 = $150,000 - # Capital losses limited to $3,000 federal cap (was $50,000) - # Net gains after losses: max($150,000 - $3,000, 0) = $147,000 - # Surtax: $147,000 * 2% = $2,940 - md_capital_gains_surtax: 2_940 + # Federal Schedule D line 16 = $120,000 - $20,000 = $100,000. Losses up + # to the gains are allowed in full (26 U.S.C. 1211(b)); the $3,000 cap + # applies only to losses in excess of gains. + # 2025 Form 502CG line 1 (Form 502 line 1c, federal line 7a): $100,000 + # Surtax: $100,000 * 2% = $2,000 + md_capital_gains_surtax: 2_000 - name: MD Capital gains surtax 2025 - High earner with large gains period: 2025 From 1a54e8999f652767660f02c17e9cb72bf34e07e2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 08:44:35 -0400 Subject: [PATCH 06/22] Net Michigan business and rental losses on MI-1040CR lines 16 and 17 MCL 206.508(4) adds back "any net business loss after netting all business income and loss" and "any net rental or royalty loss". The MI-1040CR nets line 16 (Schedule C, Form 4797 Part II, Schedule E Parts II and III, Schedule F) and line 17 (Schedule E Parts I and V) and floors each total at zero. Line 30 is the U.S. Schedule 1 adjustments, so loss_ald, which holds business, rental and capital losses, is no longer subtracted there. Estate and trust income and Form 4797 ordinary gains now count on line 16. Co-Authored-By: Claude Opus 5.5 --- ...usehold-resources-business-losses.fixed.md | 1 + .../mi/tax/income/household_resources.yaml | 9 + .../mi/tax/income/mi_household_resources.yaml | 304 +++++++++++++-- .../test_mi_household_resources_losses.py | 366 ++++++++++++++++++ .../mi/tax/income/mi_household_resources.py | 76 ++-- 5 files changed, 693 insertions(+), 63 deletions(-) create mode 100644 changelog.d/mi-household-resources-business-losses.fixed.md create mode 100644 policyengine_us/tests/test_mi_household_resources_losses.py diff --git a/changelog.d/mi-household-resources-business-losses.fixed.md b/changelog.d/mi-household-resources-business-losses.fixed.md new file mode 100644 index 00000000000..05062154438 --- /dev/null +++ b/changelog.d/mi-household-resources-business-losses.fixed.md @@ -0,0 +1 @@ +Michigan household resources net business and rental income within MI-1040CR lines 16 and 17 before flooring each total at zero, count estate and trust income and Form 4797 ordinary gains on line 16, and no longer subtract business, rental or capital losses again as Schedule 1 adjustments on line 30. diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml index d767f2da36c..2050bf95c30 100644 --- a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml @@ -5,9 +5,16 @@ values: - strike_benefits - dividend_income - interest_income + # MI-1040CR line 16, net business income: U.S. Schedule C, Form 4797 + # Part II, Schedule E Parts II and III, and Schedule F, netted and + # floored at zero as a total. - farm_operations_income - total_self_employment_income - partnership_s_corp_income + - estate_income + - other_net_gain + # MI-1040CR line 17, net royalty or rent income: U.S. Schedule E Parts I + # and V, netted and floored at zero as a total. - rental_income - farm_rent_income - pension_income @@ -45,5 +52,7 @@ metadata: href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/MI-1040-Instructions.pdf#page=31 - title: Michigan 2025 Individual Income Tax Instructions href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=31 + - title: Michigan 2025 Individual Income Tax Instructions, Total Household Resources + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=26 - title: Michigan Compiled Laws Chapter 9 Section 206.508 - Definitions. (4) href: https://law.justia.com/codes/michigan/2022/chapter-206/statute-act-281-of-1967/division-281-1967-1/division-281-1967-1-9/section-206-508/ diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml index 629963b3f8d..b07d3226c9e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml @@ -1,13 +1,25 @@ +# Line numbers are the MI-1040CR's (2024 and 2025 MI-1040 books, pages 31-32; +# the two years' household resources lines are the same). Total household +# resources are "AGI, excluding net business and farm losses, net rent and +# royalty losses, and any carryover of a net operating loss, plus all income +# exempt or excluded from AGI" (page 26), as in MCL 206.508(4). +# Line 16 nets U.S. Schedule C, Form 4797 Part II, Schedule E Parts II and III +# and Schedule F; line 17 nets Schedule E Parts I and V. "If the total is +# negative enter 0." Line 30 is "total adjustments from your U.S. Form 1040, +# Schedule 1", and line 31 is health insurance premiums. + - name: Cannot be negative period: 2024 input: net_capital_gains: -2_000 state_code: MI health_insurance_premiums: 0 - above_the_line_deductions: 1_000 + early_withdrawal_penalty: 1_000 output: - # Capital gains losses are limited to -2,000 (within $3,000 limit) - # but mi_household_resources cannot be negative, so 0 + # Line 19: -2,000 (within the $3,000 loss limit). + # Line 30: penalty on early withdrawal of savings (Schedule 1 line 18), + # 1,000. + # -2,000 - 1,000 = -3,000; household resources are not negative, so 0. mi_household_resources: 0 - name: Reduced to 0 @@ -16,9 +28,10 @@ net_capital_gains: 2_000 state_code: MI health_insurance_premiums: 3_000 - above_the_line_deductions: 1_000 + early_withdrawal_penalty: 1_000 output: - # 2,000 - 3,000 - 1,000 = -2,000, floored at 0 + # Line 19: 2,000. Line 30: 1,000. Line 31: 3,000. + # 2,000 - 1,000 - 3,000 = -2,000, floored at 0. mi_household_resources: 0 - name: Partially reduced @@ -27,9 +40,10 @@ net_capital_gains: 10_000 state_code: MI health_insurance_premiums: 3_000 - above_the_line_deductions: 1_000 + early_withdrawal_penalty: 1_000 output: - # 10,000 - 3,000 - 1,000 = 6,000 + # Line 19: 10,000. Line 30: 1,000. Line 31: 3,000. + # Line 33: 10,000 - (1,000 + 3,000) = 6,000. mi_household_resources: 6_000 - name: Negative business income floored at 0 @@ -39,11 +53,11 @@ irs_employment_income: 100_000 state_code: MI output: - # Per MI-1040CR-7 Line 21: "If negative, enter 0" - # Self-employment income floored at 0 = 0 - # But loss_ald = 50,000 (federal above-the-line deduction for business loss) - # Total: 100,000 - 50,000 = 50,000 - mi_household_resources: 50_000 + # Line 14: 100,000. Line 16: Schedule C -50,000; the total is negative, + # so 0. The business loss is not a Schedule 1 adjustment, so line 30 is + # 0 (no self-employment tax on a loss). + # Line 33: 100,000. + mi_household_resources: 100_000 - name: Negative SSTB business income floored at 0 period: 2024 @@ -52,16 +66,22 @@ irs_employment_income: 100_000 state_code: MI output: - mi_household_resources: 50_000 + # Line 14: 100,000. Line 16: Schedule C -50,000, so 0. Line 30: 0. + # Line 33: 100,000. + mi_household_resources: 100_000 - name: Farm operations income counted as household resources period: 2024 input: farm_operations_income: 50_000 - above_the_line_deductions: 0 state_code: MI output: - mi_household_resources: 50_000 + # Line 16: Schedule F 50,000. + # Line 30: deduction for self-employment tax (Schedule 1 line 15), half + # of 50,000 x 0.9235 x 15.3% = 3,532.39. + # Line 33: 50,000 - 3,532.39 = 46,467.61. + mi_household_resources: 46_467.61 + absolute_error_margin: 0.01 - name: Negative rental income floored at 0 period: 2024 @@ -70,11 +90,11 @@ employment_income_before_lsr: 50_000 state_code: MI output: - # Per MI-1040CR-7 Line 22: "If negative, enter 0" - # Rental income floored at 0 for the MI source line, - # but federal above_the_line_deductions includes loss_ald of 30,000. - # Total: 50,000 - 30,000 = 20,000 - mi_household_resources: 20_000 + # Line 14: 50,000. Line 17: Schedule E Part I -30,000; the total is + # negative, so 0. The rental loss is not a Schedule 1 adjustment, so + # line 30 is 0. + # Line 33: 50,000. + mi_household_resources: 50_000 - name: Large capital gains loss limited to 3000 period: 2024 @@ -85,11 +105,10 @@ state_code: MI filing_status: JOINT output: - # Per MI-1040CR-7 Line 24: losses cannot exceed $3,000 for MFJ - # loss_limited_net_capital_gains: max(-150,000, -3,000) = -3,000 - # The capital loss is not a U.S. Schedule 1 adjustment (MI-1040CR line - # 30), so it is not subtracted a second time. - # Total: 50,000 + (-3,000) = 47,000 + # Line 19: "losses cannot exceed $3,000 if single or married filing + # jointly": max(-150,000, -3,000) = -3,000. + # The capital loss is not a Schedule 1 adjustment, so line 30 is 0. + # Line 33: 50,000 - 3,000 = 47,000. mi_household_resources: 47_000 - name: Capital gains loss limited to 1500 for MFS @@ -100,10 +119,9 @@ state_code: MI filing_status: SEPARATE output: - # Per MI-1040CR-7 Line 24: losses cannot exceed $1,500 for MFS - # loss_limited_net_capital_gains: max(-100,000, -1,500) = -1,500 - # Not subtracted again as a Schedule 1 adjustment (line 30). - # Total: 50,000 + (-1,500) = 48,500 + # Line 19: "$1,500 if married filing separately": max(-100,000, -1,500) + # = -1,500. Line 30: 0. + # Line 33: 50,000 - 1,500 = 48,500. mi_household_resources: 48_500 - name: Capital gain distributions net against a capital loss @@ -123,9 +141,8 @@ members: [person1] state_code: MI output: - # 2025 MI-1040 book, MI-1040CR line 19: "net capital gains and losses ... - # from your U.S. Schedule D". Schedule D line 16 = -20,000 + 20,000 - # (line 13) = 0. Line 30 (U.S. Schedule 1 adjustments) has none. + # Line 19: "net capital gains and losses ... from your U.S. Schedule D". + # Schedule D line 16 = -20,000 + 20,000 (line 13) = 0. Line 30: 0. mi_household_resources: 50_000 - name: Spouses' capital gains and losses net on a joint Schedule D @@ -152,3 +169,226 @@ output: # Line 19: Schedule D line 16 = 40,000 - 40,000 = 0. mi_household_resources: 50_000 + +- name: A Schedule C loss nets against S corporation income on line 16 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + self_employment_income: -10_000 + s_corp_income: 30_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 14: 50,000. + # Line 16: Schedule C -10,000 + Schedule E Part II 30,000 = 20,000. + # Line 30: 0 (no self-employment tax: the Schedule C result is a loss, + # and S corporation income is not self-employment income). + # Line 33: 50,000 + 20,000 = 70,000. + mi_household_resources: 70_000 + +- name: A net business loss on line 16 is floored at 0 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + self_employment_income: -40_000 + s_corp_income: 30_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 16: -40,000 + 30,000 = -10,000; "If the total is negative enter + # 0." The net business loss is excluded (MCL 206.508(4)(a)), not + # subtracted on line 30. + # Line 33: 50,000. + mi_household_resources: 50_000 + +- name: A business loss does not reduce rental income + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + self_employment_income: -20_000 + rental_income: 15_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 16: Schedule C -20,000, so 0. Line 17: Schedule E Part I 15,000. + # The two lines are floored separately. Line 30: 0. + # Line 33: 50,000 + 0 + 15,000 = 65,000. + mi_household_resources: 65_000 + +- name: A rental loss nets against farm rental income on line 17 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + rental_income: -10_000 + farm_rent_income: 25_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 17: Schedule E Part I -10,000 + Part V (Form 4835) 25,000 = + # 15,000. Line 30: 0. + # Line 33: 50,000 + 15,000 = 65,000. + mi_household_resources: 65_000 + +- name: A rental loss does not reduce business income + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + rental_income: -30_000 + s_corp_income: 20_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 16: 20,000. Line 17: -30,000, so 0. Line 30: 0. + # Line 33: 50,000 + 20,000 = 70,000. + mi_household_resources: 70_000 + +- name: Estate and trust income is business income on line 16 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + estate_income: 12_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 16: Schedule E Part III 12,000. Line 30: 0. + # Line 33: 50,000 + 12,000 = 62,000. + mi_household_resources: 62_000 + +- name: An estate loss nets against S corporation income on line 16 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + estate_income: -5_000 + s_corp_income: 20_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 16: Schedule E Part III -5,000 + Part II 20,000 = 15,000. + # Line 30: 0. + # Line 33: 50,000 + 15,000 = 65,000. + mi_household_resources: 65_000 + +- name: Form 4797 ordinary gains are business income on line 16 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + tax_units: + tax_unit: + members: [person1] + other_net_gain: 6_000 + households: + household: + members: [person1] + state_code: MI + output: + # Line 16: Form 4797 Part II 6,000. Line 30: 0. + # Line 33: 50,000 + 6,000 = 56,000. + mi_household_resources: 56_000 + +- name: A Form 4797 ordinary loss nets against S corporation income on line 16 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + s_corp_income: 20_000 + tax_units: + tax_unit: + members: [person1] + other_net_gain: -8_000 + households: + household: + members: [person1] + state_code: MI + output: + # Line 16: Form 4797 Part II -8,000 + Schedule E Part II 20,000 = 12,000. + # Line 30: 0. + # Line 33: 50,000 + 12,000 = 62,000. + mi_household_resources: 62_000 + +- name: Schedule 1 adjustments still reduce household resources with a business loss + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 60_000 + self_employment_income: 10_000 + farm_operations_income: -4_000 + early_withdrawal_penalty: 500 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 14: 60,000. + # Line 16: Schedule C 10,000 + Schedule F -4,000 = 6,000. + # Line 30: deduction for self-employment tax (Schedule 1 line 15), half + # of 6,000 x 0.9235 x 15.3% = 423.89, plus the early withdrawal penalty + # (line 18), 500: 923.89. + # Line 33: 60,000 + 6,000 - 923.89 = 65,076.11. + mi_household_resources: 65_076.11 + absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/test_mi_household_resources_losses.py b/policyengine_us/tests/test_mi_household_resources_losses.py new file mode 100644 index 00000000000..a3dd61c2f63 --- /dev/null +++ b/policyengine_us/tests/test_mi_household_resources_losses.py @@ -0,0 +1,366 @@ +"""Property tests: business and rental losses in Michigan household resources. + +MCL 206.508(4) defines total household resources as income "increased by the +following deductions from federal gross income: (a) Any net business loss +after netting all business income and loss. (b) Any net rental or royalty +loss." The 2025 MI-1040 book (page 26) puts it as "AGI, excluding net business +and farm losses, net rent and royalty losses". On the MI-1040CR: + +- line 16 nets U.S. Schedule C, Form 4797 Part II, Schedule E Parts II and III + and Schedule F, and line 17 nets Schedule E Parts I and V; "If the total is + negative enter 0"; +- line 19 is net capital gains and losses from Schedule D, a loss limited to + $3,000; +- line 30 is "total adjustments from your U.S. Form 1040, Schedule 1" + (Part II). Business, rental and capital losses are income items, not + adjustments, so loss_ald does not enter it; +- line 31 is health insurance premiums. + +So for every combination of business, rental and capital gains and losses: + +1. mi_household_resources = max(0, wages + max(0, line 16 sources) + + max(0, line 17 sources) + max(-3,000, capital gains) - Schedule 1 + adjustments - premiums), where the adjustments here are the deduction for + self-employment tax and the early withdrawal penalty. +2. It does not depend on loss_ald: a reform that takes loss_ald out of + gov.irs.ald.deductions changes AGI but not household resources. +3. Losses stay in their own line. When every line 16 source is zero or + negative, the result equals the result with them all set to zero; the + same holds for line 17. +4. Household resources are between zero and the sum of the positive income + amounts. +5. Raising a source that carries no self-employment tax (S corporation, + estate, Form 4797, rental or farm rental income) by d raises household + resources by between 0 and d. Schedule C and Schedule F income are left + out on purpose: when line 16 is floored at zero, more Schedule C income + still raises the deduction for self-employment tax on line 30, so + household resources fall. The form gives that result. + +Amounts are whole dollars of at most $500,000, and the self-employment tax +deduction is read from the model. The model computes in single precision, so a +comparison allows one cent or eight float32 spacings at the household's total +absolute amount, whichever is larger (0.03125 at $500,000). +""" + +import itertools + +import numpy as np +import pytest + +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation + +YEAR = 2025 +CAPITAL_LOSS_LIMIT = 3_000 +TOLERANCE = 0.01 + +PERSON_LINE_16 = [ + "self_employment_income", + "farm_operations_income", + "s_corp_income", + "estate_income", +] +PERSON_LINE_17 = ["rental_income", "farm_rent_income"] +PERSON_INPUTS = [ + "employment_income_before_lsr", + *PERSON_LINE_16, + *PERSON_LINE_17, + "long_term_capital_gains", + "early_withdrawal_penalty", + "health_insurance_premiums", +] +# Form 4797 Part II, a tax unit input. +UNIT_LINE_16 = ["other_net_gain"] +# Sources without self-employment tax, for property 5. +NO_SE_TAX_SOURCES = ["s_corp_income", "estate_income", "rental_income"] +NO_SE_TAX_SOURCES += ["farm_rent_income", "other_net_gain"] + + +def zero_person(): + return {name: 0 for name in PERSON_INPUTS} + + +def build_situation(households): + """One Michigan tax unit per household; each household has one person + (single) or two (joint), plus the tax unit's Form 4797 amount.""" + people, tax_units, marital_units, households_out = {}, {}, {}, {} + for i, h in enumerate(households): + members = [] + for j, p in enumerate(h["people"]): + name = f"person_{i}_{j}" + people[name] = {"age": {YEAR: 45}} + for variable in PERSON_INPUTS: + people[name][variable] = {YEAR: p[variable]} + members.append(name) + # Set on every unit: a variable input for some units gives the + # others its default value, not its formula. + tax_units[f"tax_unit_{i}"] = { + "members": members, + "other_net_gain": {YEAR: h["other_net_gain"]}, + } + marital_units[f"marital_unit_{i}"] = {"members": members} + households_out[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "MI"}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "households": households_out, + } + + +def without_loss_ald(): + """A reform that removes loss_ald from gov.irs.ald.deductions.""" + + class reform(Reform): + def apply(self): + def modify(parameters): + deductions = parameters.gov.irs.ald.deductions + values = [d for d in deductions(f"{YEAR}-01-01") if d != "loss_ald"] + deductions.update( + start=instant(f"{YEAR}-01-01"), + stop=instant(f"{YEAR}-12-31"), + value=values, + ) + return parameters + + self.modify_parameters(modify) + + return reform + + +OUTPUTS = [ + "mi_household_resources", + "self_employment_tax_ald", + "above_the_line_deductions", + "loss_ald", + "adjusted_gross_income", +] + + +def calculate(households, reform=None): + simulation = Simulation(situation=build_situation(households), reform=reform) + results = {v: np.asarray(simulation.calculate(v, YEAR)) for v in OUTPUTS} + # Line 30 summed directly, as the formula does: subtracting a large + # loss_ald from above_the_line_deductions would lose cents in float32. + deductions = simulation.tax_benefit_system.parameters.gov.irs.ald.deductions + results["schedule_1_adjustments"] = sum( + np.asarray(simulation.calculate(d, YEAR, map_to="tax_unit")) + for d in deductions(f"{YEAR}-01-01") + if d != "loss_ald" + ) + return results + + +def tolerance(h): + """One cent, or eight float32 spacings at the household's total absolute + amount.""" + scale = sum(abs(v) for p in h["people"] for v in p.values()) + scale += abs(h["other_net_gain"]) + return max(TOLERANCE, 8 * float(np.spacing(np.float32(scale)))) + + +def total(h, names): + return sum(p[name] for p in h["people"] for name in names) + + +def reference(h, self_employment_tax_ald): + """MI-1040CR line 33, total household resources, from the form.""" + line_14 = total(h, ["employment_income_before_lsr"]) + line_16 = max(0, total(h, PERSON_LINE_16) + h["other_net_gain"]) + line_17 = max(0, total(h, PERSON_LINE_17)) + line_19 = max(-CAPITAL_LOSS_LIMIT, total(h, ["long_term_capital_gains"])) + line_30 = self_employment_tax_ald + total(h, ["early_withdrawal_penalty"]) + line_31 = total(h, ["health_insurance_premiums"]) + return max(0, line_14 + line_16 + line_17 + line_19 - line_30 - line_31) + + +def positive_income(h): + names = ["employment_income_before_lsr", *PERSON_LINE_16, *PERSON_LINE_17] + names.append("long_term_capital_gains") + return sum(max(0, p[name]) for p in h["people"] for name in names) + max( + 0, h["other_net_gain"] + ) + + +def with_sources_zeroed(h, person_sources, unit_sources): + out = { + "people": [dict(p) for p in h["people"]], + "other_net_gain": h["other_net_gain"], + } + for p in out["people"]: + for name in person_sources: + p[name] = 0 + if "other_net_gain" in unit_sources: + out["other_net_gain"] = 0 + return out + + +def assert_properties(households): + model = calculate(households) + no_loss_ald = calculate(households, reform=without_loss_ald()) + for i, h in enumerate(households): + result = model["mi_household_resources"][i] + tol = tolerance(h) + # 1. The form. + assert result == pytest.approx( + reference(h, model["self_employment_tax_ald"][i]), abs=tol + ), h + # The only Schedule 1 adjustments in these households are the two + # the reference uses. + expected_adjustments = model["self_employment_tax_ald"][i] + total( + h, ["early_withdrawal_penalty"] + ) + assert model["schedule_1_adjustments"][i] == pytest.approx( + expected_adjustments, abs=tol + ), h + # 2. Not loss_ald. + assert no_loss_ald["mi_household_resources"][i] == pytest.approx( + result, abs=tol + ), h + # 4. Bounds. + assert 0 <= result <= positive_income(h) + tol, h + + # 3. Losses stay in their own line. + for person_sources, unit_sources in ( + (PERSON_LINE_16, UNIT_LINE_16), + (PERSON_LINE_17, []), + ): + only_losses = [ + i + for i, h in enumerate(households) + if all(p[name] <= 0 for p in h["people"] for name in person_sources) + and all(h[name] <= 0 for name in unit_sources) + ] + if not only_losses: + continue + zeroed = calculate( + [ + with_sources_zeroed(households[i], person_sources, unit_sources) + for i in only_losses + ] + ) + for k, i in enumerate(only_losses): + assert model["mi_household_resources"][i] == pytest.approx( + zeroed["mi_household_resources"][k], abs=tolerance(households[i]) + ), households[i] + + +# --------------------------------------------------------------------------- +# Deterministic grid: every sign combination of the line 16 and 17 sources. +# --------------------------------------------------------------------------- + +GRID_VALUES = [-30_000, 0, 20_000] +GRID_SOURCES = [*PERSON_LINE_16, *PERSON_LINE_17, "other_net_gain"] + + +def grid_households(): + households = [] + for values in itertools.product(GRID_VALUES, repeat=len(GRID_SOURCES)): + person = zero_person() + person["employment_income_before_lsr"] = 50_000 + amounts = dict(zip(GRID_SOURCES, values)) + for name in [*PERSON_LINE_16, *PERSON_LINE_17]: + person[name] = amounts[name] + households.append( + {"people": [person], "other_net_gain": amounts["other_net_gain"]} + ) + return households + + +def test_grid(): + assert_properties(grid_households()) + + +def test_example_from_the_instructions(): + """A Schedule C loss of 10,000 and S corporation income of 30,000 give + line 16 = 20,000; a rental loss of 30,000 gives line 17 = 0; the business + loss is not subtracted again on line 30.""" + person = zero_person() + person.update( + employment_income_before_lsr=50_000, + self_employment_income=-10_000, + s_corp_income=30_000, + rental_income=-30_000, + ) + model = calculate([{"people": [person], "other_net_gain": 0}]) + assert model["mi_household_resources"][0] == 70_000 + # Federal AGI deducts both losses: 50,000 + 30,000 - 10,000 - 30,000. + assert model["adjusted_gross_income"][0] == 40_000 + + +# --------------------------------------------------------------------------- +# Hypothesis properties. +# --------------------------------------------------------------------------- + +# Hypothesis is a dev extra; skip rather than fail collection without it. +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +AMOUNT = st.integers(min_value=-500_000, max_value=500_000) +SMALL = st.integers(min_value=-20_000, max_value=20_000) +NONNEGATIVE = st.integers(min_value=0, max_value=200_000) +# Each example builds Simulations; on a loaded runner input generation can +# trip the too_slow health check, which says nothing about the model. +SLOW = [hypothesis.HealthCheck.too_slow] + + +@st.composite +def person(draw): + amounts = draw(st.sampled_from([AMOUNT, SMALL])) + p = zero_person() + p["employment_income_before_lsr"] = draw(st.one_of(st.just(0), NONNEGATIVE)) + for name in [*PERSON_LINE_16, *PERSON_LINE_17, "long_term_capital_gains"]: + p[name] = draw(st.one_of(st.just(0), amounts)) + p["early_withdrawal_penalty"] = draw( + st.one_of(st.just(0), st.integers(min_value=0, max_value=5_000)) + ) + p["health_insurance_premiums"] = draw( + st.one_of(st.just(0), st.integers(min_value=0, max_value=10_000)) + ) + return p + + +@st.composite +def household(draw): + n = draw(st.sampled_from([1, 2])) + return { + "people": [draw(person()) for _ in range(n)], + "other_net_gain": draw(st.one_of(st.just(0), AMOUNT, SMALL)), + } + + +@hypothesis.settings(max_examples=40, deadline=None, suppress_health_check=SLOW) +@hypothesis.given(st.lists(household(), min_size=1, max_size=12)) +def test_properties(households): + assert_properties(households) + + +@hypothesis.settings(max_examples=30, deadline=None, suppress_health_check=SLOW) +@hypothesis.given( + st.lists(household(), min_size=1, max_size=8), + st.sampled_from(NO_SE_TAX_SOURCES), + st.integers(min_value=1, max_value=100_000), +) +def test_more_income_without_self_employment_tax(households, source, extra): + """5. More S corporation, estate, Form 4797, rental or farm rental income + raises household resources by between 0 and the increase.""" + raised = [] + for h in households: + r = {"people": [dict(p) for p in h["people"]], "other_net_gain": 0} + r["other_net_gain"] = h["other_net_gain"] + if source == "other_net_gain": + r["other_net_gain"] += extra + else: + r["people"][0][source] += extra + raised.append(r) + before = calculate(households)["mi_household_resources"] + after = calculate(raised)["mi_household_resources"] + for i, h in enumerate(raised): + change = float(after[i]) - float(before[i]) + tol = tolerance(h) + assert -tol <= change <= extra + tol, (households[i], source, extra) diff --git a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py index 7364f38af3f..6072da76d40 100644 --- a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py +++ b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py @@ -12,53 +12,67 @@ class mi_household_resources(Variable): "https://law.justia.com/codes/michigan/2022/chapter-206/" "statute-act-281-of-1967/division-281-1967-1/division-281-1967-1-9/" "section-206-508/", + "https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-508", + "https://web.archive.org/web/20250202150154/https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/BOOK_MI-1040CR-7.pdf", + # 2025 MI-1040 book: "Total Household Resources" (page 26) and + # MI-1040CR lines 16 and 17 (page 31), 19 and 30 (page 32). "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" - "Forms/IIT/TY2024/BOOK_MI-1040CR-7.pdf", - # 2025 MI-1040 book, MI-1040CR lines 19 (capital gains) and 30 - # (adjustments from U.S. Schedule 1). + "Forms/IIT/TY2025/MI-1040-Book.pdf#page=26", + "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" + "Forms/IIT/TY2025/MI-1040-Book.pdf#page=31", "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" "Forms/IIT/TY2025/MI-1040-Book.pdf#page=32", ) def formula(tax_unit, period, parameters): p = parameters(period).gov.states.mi.tax.income - # Per form instructions, only certain income sources must be - # floored at 0 if negative. Capital gains have special loss limitation. - income_sources = p.household_resources - - # Sources that must be floored at 0 per form instructions: - # "Net business income (including net farm income). If negative, enter 0" - # "Net royalty or rent income. If negative, enter 0" - floored_sources = { - "farm_operations_income", + # MCL 206.508(4) increases income by "(a) Any net business loss after + # netting all business income and loss" and "(b) Any net rental or + # royalty loss". The MI-1040CR nets each group and floors the total + # at zero, so a loss offsets income only within its own line. + # Line 16: U.S. Schedule C, Form 4797 Part II, Schedule E Parts II + # and III, and Schedule F. "If the total is negative enter 0." + business_sources = { "total_self_employment_income", + "other_net_gain", "partnership_s_corp_income", + "estate_income", + "farm_operations_income", + } + # Line 17: U.S. Schedule E Parts I and V (rents, royalties and farm + # rental income). "If the total is negative enter 0." + rental_sources = { "rental_income", "farm_rent_income", } - # Iterate through each source, applying flooring only to - # sources that require it per form instructions - total = 0 - for source in income_sources: - if source in floored_sources: - # Per MI-1040CR instructions: "If negative, enter 0" - total += max_(add(tax_unit, period, [source]), 0) + business_income = 0 + rental_income = 0 + other_income = 0 + for source in p.household_resources: + amount = add(tax_unit, period, [source]) + if source in business_sources: + business_income += amount + elif source in rental_sources: + rental_income += amount else: - # Other sources are added as-is without flooring - total += add(tax_unit, period, [source]) + other_income += amount + total = other_income + max_(business_income, 0) + max_(rental_income, 0) - health_insurance_premiums = add(tax_unit, period, ["health_insurance_premiums"]) # Line 30: "total adjustments from your U.S. Form 1040, Schedule 1". - # Capital losses are not Schedule 1 adjustments: they are already - # netted and limited in the capital gains line (Form 1040 line 7a), - # so the capital parts of loss_ald are not subtracted again. - above_the_line_deductions = tax_unit("above_the_line_deductions", period) - add( + # Business, rental and capital losses are income items (Schedule 1 + # Part I and Schedule D), not Part II adjustments. They are netted + # and floored on lines 16, 17 and 19 above, so loss_ald, which holds + # them, is left out. + adjustments = add( tax_unit, period, - ["capital_losses_allowed_against_gains", "limited_capital_loss"], - ) - return max_( - 0, - total - health_insurance_premiums - above_the_line_deductions, + [ + deduction + for deduction in parameters(period).gov.irs.ald.deductions + if deduction != "loss_ald" + ], ) + # Line 31: health insurance premiums. + health_insurance_premiums = add(tax_unit, period, ["health_insurance_premiums"]) + return max_(0, total - adjustments - health_insurance_premiums) From b6c5f613ce5fabccb2ac060c6f85149821bdbd74 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 12:35:05 -0400 Subject: [PATCH 07/22] Keep dependents' business and rental items off Michigan lines 16 and 17 MCL 206.508(3): "'Household' means a claimant and spouse." Netting every tax unit member's sources let a dependent's business or rental loss offset the claimant's income (a 15-year-old's Schedule C loss of 20,000 took the claimant's household resources from 70,000 to 50,000). Lines 16 and 17 now sum only non-dependents, as loss_ald and irs_gross_income do. Tests: three dependent YAML cases and a deterministic dependent property test. The loss_ald reform check now runs once, on the grid, instead of on every Hypothesis example; test_properties drops from 931 s in CI to about 8 s. The deterministic tests no longer need hypothesis. Small inputs are drawn at $100 or more, the tolerance docstring is corrected, and comments list Schedule E Part IV (REMIC) on line 17. Co-Authored-By: Claude Opus 5.5 --- .../mi/tax/income/household_resources.yaml | 5 +- .../mi/tax/income/mi_household_resources.yaml | 79 ++++- .../test_mi_household_resources_losses.py | 303 +++++++++++------- .../mi/tax/income/mi_household_resources.py | 29 +- 4 files changed, 287 insertions(+), 129 deletions(-) diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml index 43c4201c741..d92aee43d73 100644 --- a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml @@ -13,8 +13,9 @@ values: - partnership_s_corp_income - estate_income - other_net_gain - # MI-1040CR line 17, net royalty or rent income: U.S. Schedule E Parts I - # and V, netted and floored at zero as a total. + # MI-1040CR line 17, net royalty or rent income: U.S. Schedule E Parts I, + # IV (REMIC income, which has no input) and V, netted and floored at zero + # as a total. - rental_income - farm_rent_income - pension_income diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml index b07d3226c9e..a0ed4fad654 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml @@ -4,9 +4,11 @@ # royalty losses, and any carryover of a net operating loss, plus all income # exempt or excluded from AGI" (page 26), as in MCL 206.508(4). # Line 16 nets U.S. Schedule C, Form 4797 Part II, Schedule E Parts II and III -# and Schedule F; line 17 nets Schedule E Parts I and V. "If the total is -# negative enter 0." Line 30 is "total adjustments from your U.S. Form 1040, -# Schedule 1", and line 31 is health insurance premiums. +# and Schedule F; line 17 nets Schedule E Parts I, IV (REMICs, no input) and V. +# "If the total is negative enter 0." Line 30 is "total adjustments from your +# U.S. Form 1040, Schedule 1", and line 31 is health insurance premiums. The +# household is "a claimant and spouse" (MCL 206.508(3)), so a dependent's +# business and rental items stay off lines 16 and 17. - name: Cannot be negative period: 2024 @@ -392,3 +394,74 @@ # Line 33: 60,000 + 6,000 - 923.89 = 65,076.11. mi_household_resources: 65_076.11 absolute_error_margin: 0.01 + +- name: A dependent's Schedule C loss does not net against the claimant's business income + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + s_corp_income: 20_000 + person2: + age: 15 + self_employment_income: -20_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # The household is the claimant (MCL 206.508(3)); the dependent's + # Schedule C belongs on the dependent's own return. + # Line 16: Schedule E Part II 20,000. Line 30: 0. + # Line 33: 50,000 + 20,000 = 70,000. + mi_household_resources: 70_000 + +- name: A dependent's rental loss does not net against the claimant's farm rental income + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + farm_rent_income: 10_000 + person2: + age: 15 + rental_income: -10_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 17: the claimant's Schedule E Part V 10,000. Line 30: 0. + # Line 33: 50,000 + 10,000 = 60,000. + mi_household_resources: 60_000 + +- name: A dependent's S corporation income is not household resources + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + person2: + age: 15 + s_corp_income: 8_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # The household is the claimant and spouse (MCL 206.508(3)); the + # dependent's Schedule E income is not on line 16. Line 30: 0. + # Line 33: 50,000. + mi_household_resources: 50_000 diff --git a/policyengine_us/tests/test_mi_household_resources_losses.py b/policyengine_us/tests/test_mi_household_resources_losses.py index a3dd61c2f63..d72a0d40574 100644 --- a/policyengine_us/tests/test_mi_household_resources_losses.py +++ b/policyengine_us/tests/test_mi_household_resources_losses.py @@ -7,8 +7,8 @@ and farm losses, net rent and royalty losses". On the MI-1040CR: - line 16 nets U.S. Schedule C, Form 4797 Part II, Schedule E Parts II and III - and Schedule F, and line 17 nets Schedule E Parts I and V; "If the total is - negative enter 0"; + and Schedule F, and line 17 nets Schedule E Parts I, IV (REMIC income, which + has no input) and V; "If the total is negative enter 0"; - line 19 is net capital gains and losses from Schedule D, a loss limited to $3,000; - line 30 is "total adjustments from your U.S. Form 1040, Schedule 1" @@ -16,6 +16,8 @@ adjustments, so loss_ald does not enter it; - line 31 is health insurance premiums. +The household is "a claimant and spouse" (MCL 206.508(3)). + So for every combination of business, rental and capital gains and losses: 1. mi_household_resources = max(0, wages + max(0, line 16 sources) + @@ -23,7 +25,8 @@ adjustments - premiums), where the adjustments here are the deduction for self-employment tax and the early withdrawal penalty. 2. It does not depend on loss_ald: a reform that takes loss_ald out of - gov.irs.ald.deductions changes AGI but not household resources. + gov.irs.ald.deductions changes AGI but not household resources (checked on + the grid, which builds the reform once). 3. Losses stay in their own line. When every line 16 source is zero or negative, the result equals the result with them all set to zero; the same holds for line 17. @@ -35,11 +38,16 @@ out on purpose: when line 16 is floored at zero, more Schedule C income still raises the deduction for self-employment tax on line 30, so household resources fall. The form gives that result. +6. A dependent's business and rental losses and S corporation, estate and + rental income leave household resources unchanged. Amounts are whole dollars of at most $500,000, and the self-employment tax deduction is read from the model. The model computes in single precision, so a -comparison allows one cent or eight float32 spacings at the household's total -absolute amount, whichever is larger (0.03125 at $500,000). +comparison allows one cent or eight float32 spacings at the sum of the +household's absolute amounts, whichever is larger: $0.25 for a household whose +amounts add up to $500,000, and up to about $4 for the largest two-person +households drawn. The early withdrawal penalty and premiums are drawn as zero +or at least $100, so leaving either out would exceed the tolerance. """ import itertools @@ -83,24 +91,31 @@ def zero_person(): def build_situation(households): - """One Michigan tax unit per household; each household has one person - (single) or two (joint), plus the tax unit's Form 4797 amount.""" + """One Michigan tax unit per household: one adult (single) or two + (joint), any dependents (aged 15), and the tax unit's Form 4797 amount.""" people, tax_units, marital_units, households_out = {}, {}, {}, {} for i, h in enumerate(households): - members = [] - for j, p in enumerate(h["people"]): + adults, members = [], [] + dependents = h.get("dependents", []) + for j, (p, age) in enumerate( + [(p, 45) for p in h["people"]] + [(p, 15) for p in dependents] + ): name = f"person_{i}_{j}" - people[name] = {"age": {YEAR: 45}} + people[name] = {"age": {YEAR: age}} for variable in PERSON_INPUTS: people[name][variable] = {YEAR: p[variable]} members.append(name) + if age == 45: + adults.append(name) + else: + marital_units[f"marital_unit_{i}_{j}"] = {"members": [name]} # Set on every unit: a variable input for some units gives the # others its default value, not its formula. tax_units[f"tax_unit_{i}"] = { "members": members, "other_net_gain": {YEAR: h["other_net_gain"]}, } - marital_units[f"marital_unit_{i}"] = {"members": members} + marital_units[f"marital_unit_{i}"] = {"members": adults} households_out[f"household_{i}"] = { "members": members, "state_code": {YEAR: "MI"}, @@ -136,8 +151,6 @@ def modify(parameters): OUTPUTS = [ "mi_household_resources", "self_employment_tax_ald", - "above_the_line_deductions", - "loss_ald", "adjusted_gross_income", ] @@ -157,9 +170,10 @@ def calculate(households, reform=None): def tolerance(h): - """One cent, or eight float32 spacings at the household's total absolute - amount.""" - scale = sum(abs(v) for p in h["people"] for v in p.values()) + """One cent, or eight float32 spacings at the sum of the household's + absolute amounts.""" + people = h["people"] + h.get("dependents", []) + scale = sum(abs(v) for p in people for v in p.values()) scale += abs(h["other_net_gain"]) return max(TOLERANCE, 8 * float(np.spacing(np.float32(scale)))) @@ -169,7 +183,8 @@ def total(h, names): def reference(h, self_employment_tax_ald): - """MI-1040CR line 33, total household resources, from the form.""" + """MI-1040CR line 33, total household resources, from the form, for a + household without dependents.""" line_14 = total(h, ["employment_income_before_lsr"]) line_16 = max(0, total(h, PERSON_LINE_16) + h["other_net_gain"]) line_17 = max(0, total(h, PERSON_LINE_17)) @@ -200,9 +215,26 @@ def with_sources_zeroed(h, person_sources, unit_sources): return out -def assert_properties(households): - model = calculate(households) - no_loss_ald = calculate(households, reform=without_loss_ald()) +def only_losses(h, person_sources, unit_sources): + return all(p[name] <= 0 for p in h["people"] for name in person_sources) and all( + h[name] <= 0 for name in unit_sources + ) + + +def assert_properties(households, check_loss_ald_reform=False): + """Properties 1, 3 and 4 (and 2 when asked) for households without + dependents. The zeroed copies for property 3 go in the same simulation.""" + zeroed, zeroed_of = [], [] + for i, h in enumerate(households): + for person_sources, unit_sources in ( + (PERSON_LINE_16, UNIT_LINE_16), + (PERSON_LINE_17, []), + ): + if only_losses(h, person_sources, unit_sources): + zeroed.append(with_sources_zeroed(h, person_sources, unit_sources)) + zeroed_of.append(i) + model = calculate(households + zeroed) + n = len(households) for i, h in enumerate(households): result = model["mi_household_resources"][i] tol = tolerance(h) @@ -218,40 +250,24 @@ def assert_properties(households): assert model["schedule_1_adjustments"][i] == pytest.approx( expected_adjustments, abs=tol ), h - # 2. Not loss_ald. - assert no_loss_ald["mi_household_resources"][i] == pytest.approx( - result, abs=tol - ), h # 4. Bounds. assert 0 <= result <= positive_income(h) + tol, h - # 3. Losses stay in their own line. - for person_sources, unit_sources in ( - (PERSON_LINE_16, UNIT_LINE_16), - (PERSON_LINE_17, []), - ): - only_losses = [ - i - for i, h in enumerate(households) - if all(p[name] <= 0 for p in h["people"] for name in person_sources) - and all(h[name] <= 0 for name in unit_sources) - ] - if not only_losses: - continue - zeroed = calculate( - [ - with_sources_zeroed(households[i], person_sources, unit_sources) - for i in only_losses - ] - ) - for k, i in enumerate(only_losses): - assert model["mi_household_resources"][i] == pytest.approx( - zeroed["mi_household_resources"][k], abs=tolerance(households[i]) - ), households[i] + for k, i in enumerate(zeroed_of): + assert model["mi_household_resources"][i] == pytest.approx( + model["mi_household_resources"][n + k], abs=tolerance(households[i]) + ), households[i] + # 2. Not loss_ald. + if check_loss_ald_reform: + no_loss_ald = calculate(households, reform=without_loss_ald()) + for i, h in enumerate(households): + assert no_loss_ald["mi_household_resources"][i] == pytest.approx( + model["mi_household_resources"][i], abs=tolerance(h) + ), h # --------------------------------------------------------------------------- -# Deterministic grid: every sign combination of the line 16 and 17 sources. +# Deterministic tests (no Hypothesis needed). # --------------------------------------------------------------------------- GRID_VALUES = [-30_000, 0, 20_000] @@ -259,6 +275,7 @@ def assert_properties(households): def grid_households(): + """Every sign combination of the seven line 16 and 17 sources.""" households = [] for values in itertools.product(GRID_VALUES, repeat=len(GRID_SOURCES)): person = zero_person() @@ -273,10 +290,10 @@ def grid_households(): def test_grid(): - assert_properties(grid_households()) + assert_properties(grid_households(), check_loss_ald_reform=True) -def test_example_from_the_instructions(): +def test_worked_example(): """A Schedule C loss of 10,000 and S corporation income of 30,000 give line 16 = 20,000; a rental loss of 30,000 gives line 17 = 0; the business loss is not subtracted again on line 30.""" @@ -293,74 +310,128 @@ def test_example_from_the_instructions(): assert model["adjusted_gross_income"][0] == 40_000 -# --------------------------------------------------------------------------- -# Hypothesis properties. -# --------------------------------------------------------------------------- +DEPENDENT_VALUES = { + # Losses only for Schedule C and F: a dependent's self-employment tax + # deduction is a separate question (it is not on this return either). + "self_employment_income": [-20_000, 0], + "farm_operations_income": [-10_000, 0], + "s_corp_income": [-20_000, 0, 15_000], + "estate_income": [-5_000, 0, 5_000], + "rental_income": [-20_000, 0, 15_000], + "farm_rent_income": [-5_000, 0, 5_000], +} + + +def test_dependents_business_and_rental_items(): + """6. Households whose claimant has business and rental income and + losses, with a dependent whose line 16 and 17 amounts take every + combination below, equal the same households without the dependent's + amounts.""" + claimants = [] + # Both lines positive, line 16 negative, line 17 negative. + for s_corp, rent in [(20_000, 15_000), (-30_000, 20_000), (20_000, -30_000)]: + claimant = zero_person() + claimant.update( + employment_income_before_lsr=50_000, + s_corp_income=s_corp, + rental_income=rent, + ) + claimants.append(claimant) + with_dependent, without = [], [] + names = list(DEPENDENT_VALUES) + for claimant in claimants: + for values in itertools.product(*DEPENDENT_VALUES.values()): + dependent = zero_person() + dependent.update(dict(zip(names, values))) + with_dependent.append( + {"people": [claimant], "dependents": [dependent], "other_net_gain": 0} + ) + without.append( + { + "people": [claimant], + "dependents": [zero_person()], + "other_net_gain": 0, + } + ) + model = calculate(with_dependent + without) + n = len(with_dependent) + result = model["mi_household_resources"] + np.testing.assert_allclose(result[:n], result[n:], atol=TOLERANCE) + # The claimant's own lines still net and floor. + for i, h in enumerate(without): + assert result[n + i] == pytest.approx( + reference(h, model["self_employment_tax_ald"][n + i]), abs=TOLERANCE + ), h -# Hypothesis is a dev extra; skip rather than fail collection without it. -hypothesis = pytest.importorskip("hypothesis") -st = pytest.importorskip("hypothesis.strategies") - -AMOUNT = st.integers(min_value=-500_000, max_value=500_000) -SMALL = st.integers(min_value=-20_000, max_value=20_000) -NONNEGATIVE = st.integers(min_value=0, max_value=200_000) -# Each example builds Simulations; on a loaded runner input generation can -# trip the too_slow health check, which says nothing about the model. -SLOW = [hypothesis.HealthCheck.too_slow] - - -@st.composite -def person(draw): - amounts = draw(st.sampled_from([AMOUNT, SMALL])) - p = zero_person() - p["employment_income_before_lsr"] = draw(st.one_of(st.just(0), NONNEGATIVE)) - for name in [*PERSON_LINE_16, *PERSON_LINE_17, "long_term_capital_gains"]: - p[name] = draw(st.one_of(st.just(0), amounts)) - p["early_withdrawal_penalty"] = draw( - st.one_of(st.just(0), st.integers(min_value=0, max_value=5_000)) - ) - p["health_insurance_premiums"] = draw( - st.one_of(st.just(0), st.integers(min_value=0, max_value=10_000)) - ) - return p +# --------------------------------------------------------------------------- +# Hypothesis properties. Hypothesis is a dev extra: without it these two +# tests are not collected and the deterministic tests above still run. +# --------------------------------------------------------------------------- -@st.composite -def household(draw): - n = draw(st.sampled_from([1, 2])) - return { - "people": [draw(person()) for _ in range(n)], - "other_net_gain": draw(st.one_of(st.just(0), AMOUNT, SMALL)), - } +try: + import hypothesis + import hypothesis.strategies as st +except ImportError: # pragma: no cover + hypothesis = None + +if hypothesis is not None: + AMOUNT = st.integers(min_value=-500_000, max_value=500_000) + SMALL = st.integers(min_value=-20_000, max_value=20_000) + NONNEGATIVE = st.integers(min_value=0, max_value=200_000) + # Each example builds Simulations; on a loaded runner input generation + # can trip the too_slow health check, which says nothing about the model. + SLOW = [hypothesis.HealthCheck.too_slow] + + @st.composite + def person(draw): + amounts = draw(st.sampled_from([AMOUNT, SMALL])) + p = zero_person() + p["employment_income_before_lsr"] = draw(st.one_of(st.just(0), NONNEGATIVE)) + for name in [*PERSON_LINE_16, *PERSON_LINE_17, "long_term_capital_gains"]: + p[name] = draw(st.one_of(st.just(0), amounts)) + p["early_withdrawal_penalty"] = draw( + st.one_of(st.just(0), st.integers(min_value=100, max_value=5_000)) + ) + p["health_insurance_premiums"] = draw( + st.one_of(st.just(0), st.integers(min_value=100, max_value=10_000)) + ) + return p + + @st.composite + def household(draw): + n = draw(st.sampled_from([1, 2])) + return { + "people": [draw(person()) for _ in range(n)], + "other_net_gain": draw(st.one_of(st.just(0), AMOUNT, SMALL)), + } + @hypothesis.settings(max_examples=25, deadline=None, suppress_health_check=SLOW) + @hypothesis.given(st.lists(household(), min_size=1, max_size=20)) + def test_properties(households): + assert_properties(households) -@hypothesis.settings(max_examples=40, deadline=None, suppress_health_check=SLOW) -@hypothesis.given(st.lists(household(), min_size=1, max_size=12)) -def test_properties(households): - assert_properties(households) - - -@hypothesis.settings(max_examples=30, deadline=None, suppress_health_check=SLOW) -@hypothesis.given( - st.lists(household(), min_size=1, max_size=8), - st.sampled_from(NO_SE_TAX_SOURCES), - st.integers(min_value=1, max_value=100_000), -) -def test_more_income_without_self_employment_tax(households, source, extra): - """5. More S corporation, estate, Form 4797, rental or farm rental income - raises household resources by between 0 and the increase.""" - raised = [] - for h in households: - r = {"people": [dict(p) for p in h["people"]], "other_net_gain": 0} - r["other_net_gain"] = h["other_net_gain"] - if source == "other_net_gain": - r["other_net_gain"] += extra - else: - r["people"][0][source] += extra - raised.append(r) - before = calculate(households)["mi_household_resources"] - after = calculate(raised)["mi_household_resources"] - for i, h in enumerate(raised): - change = float(after[i]) - float(before[i]) - tol = tolerance(h) - assert -tol <= change <= extra + tol, (households[i], source, extra) + @hypothesis.settings(max_examples=25, deadline=None, suppress_health_check=SLOW) + @hypothesis.given( + st.lists(household(), min_size=1, max_size=10), + st.sampled_from(NO_SE_TAX_SOURCES), + st.integers(min_value=1, max_value=100_000), + ) + def test_more_income_without_self_employment_tax(households, source, extra): + """5. More S corporation, estate, Form 4797, rental or farm rental + income raises household resources by between 0 and the increase.""" + raised = [] + for h in households: + r = {"people": [dict(p) for p in h["people"]]} + r["other_net_gain"] = h["other_net_gain"] + if source == "other_net_gain": + r["other_net_gain"] += extra + else: + r["people"][0][source] += extra + raised.append(r) + result = calculate(households + raised)["mi_household_resources"] + n = len(households) + for i, h in enumerate(raised): + change = float(result[n + i]) - float(result[i]) + tol = tolerance(h) + assert -tol <= change <= extra + tol, (households[i], source, extra) diff --git a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py index 6072da76d40..63be39fdbda 100644 --- a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py +++ b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py @@ -39,24 +39,37 @@ def formula(tax_unit, period, parameters): "estate_income", "farm_operations_income", } - # Line 17: U.S. Schedule E Parts I and V (rents, royalties and farm - # rental income). "If the total is negative enter 0." + # Line 17: U.S. Schedule E Parts I, IV and V (rents, royalties, REMIC + # income, which has no input here, and farm rental income). "If the + # total is negative enter 0." rental_sources = { "rental_income", "farm_rent_income", } + # MCL 206.508(3): "'Household' means a claimant and spouse." A + # dependent's business and rental items belong on the dependent's own + # return, so they neither add to nor net against lines 16 and 17, as + # loss_ald leaves them off this return. + person = tax_unit.members + not_dependent = ~person("is_tax_unit_dependent", period) + variables = tax_unit.simulation.tax_benefit_system.variables + business_income = 0 rental_income = 0 other_income = 0 for source in p.household_resources: - amount = add(tax_unit, period, [source]) - if source in business_sources: - business_income += amount - elif source in rental_sources: - rental_income += amount + if source in business_sources | rental_sources: + if variables[source].entity.is_person: + amount = tax_unit.sum(person(source, period) * not_dependent) + else: + amount = tax_unit(source, period) + if source in business_sources: + business_income += amount + else: + rental_income += amount else: - other_income += amount + other_income += add(tax_unit, period, [source]) total = other_income + max_(business_income, 0) + max_(rental_income, 0) # Line 30: "total adjustments from your U.S. Form 1040, Schedule 1". From e0ab86d99fa32ccfc1d47b4b5c05058be0a15c6c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 12:44:04 -0400 Subject: [PATCH 08/22] Count only the claimant and spouse in Michigan household resources MCL 206.508(3) defines the household as "a claimant and spouse", and the MI-1040CR asks for the income "you and your spouse received". Every household resources source except lines 16 and 17 was summed over all tax unit members, so a dependent's wages, interest, pensions and capital gains raised the claimant's household resources, and a dependent's Schedule 1 adjustments (such as the deduction for half of self-employment tax) lowered them. - Sources are summed over the head and spouse with tax_unit_non_dep_add. - Line 21 (Social Security, SSI and railroad retirement, including amounts received for minor children or dependent adults), line 22 (child support) and line 27 (public assistance paid to the household) still count for every member, as the form says. - Line 19 reads filer_loss_limited_net_capital_gains, the head and spouse's Schedule D. - Line 30 sums the head and spouse's Schedule 1 adjustments, with the self-employment adjustments read per person. - Line 31 premiums paid for the family are unchanged. Co-Authored-By: Claude Opus 5.5 --- ...usehold-resources-claimant-spouse.fixed.md | 1 + .../mi/tax/income/household_resources.yaml | 23 +- .../mi/tax/income/mi_household_resources.yaml | 263 +++++++++++- .../test_mi_household_resources_members.py | 400 ++++++++++++++++++ .../mi/tax/income/mi_household_resources.py | 84 +++- 5 files changed, 743 insertions(+), 28 deletions(-) create mode 100644 changelog.d/mi-household-resources-claimant-spouse.fixed.md create mode 100644 policyengine_us/tests/test_mi_household_resources_members.py diff --git a/changelog.d/mi-household-resources-claimant-spouse.fixed.md b/changelog.d/mi-household-resources-claimant-spouse.fixed.md new file mode 100644 index 00000000000..aed3a99302c --- /dev/null +++ b/changelog.d/mi-household-resources-claimant-spouse.fixed.md @@ -0,0 +1 @@ +Michigan household resources count only the claimant's and spouse's own income, capital gains and Schedule 1 adjustments (MCL 206.508(3)), keeping the Social Security, SSI, railroad retirement, child support and public assistance received for dependents (MI-1040CR lines 21, 22 and 27) and premiums paid for the family (line 31). diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml index d92aee43d73..87a5a591b4e 100644 --- a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml @@ -1,8 +1,13 @@ description: Michigan accounts for the following income sources as the household resources. values: 2021-01-01: + # Each source counts for the claimant and spouse only (MCL 206.508(3)), + # except the amounts received for the household on lines 21, 22 and 27; + # see mi_household_resources. + # MI-1040CR line 14, wages, including strike pay. - irs_employment_income - strike_benefits + # MI-1040CR line 15, interest and dividends. - dividend_income - interest_income # MI-1040CR line 16, net business income: U.S. Schedule C, Form 4797 @@ -18,21 +23,33 @@ values: # as a total. - rental_income - farm_rent_income + # MI-1040CR line 18, pensions and annuities. - pension_income - retirement_benefits_from_ss_exempt_employment - - loss_limited_net_capital_gains + # MI-1040CR line 19, net capital gains and losses from the filer's U.S. + # Schedule D, a loss limited to $3,000 ($1,500 married filing separately). + - filer_loss_limited_net_capital_gains + # MI-1040CR line 20, alimony and other taxable income. - alimony_income - gambling_winnings + # MI-1040CR line 21, Social Security, SSI and railroad retirement + # benefits, including amounts received for dependents. - social_security - - ssi + - ssi - railroad_benefits + # MI-1040CR line 22, child support. - child_support_received + # MI-1040CR line 23, unemployment compensation. - unemployment_compensation - # Gifts received or expenses paid on your behalf + # MI-1040CR line 24, gifts received or expenses paid on your behalf. - miscellaneous_income + # MI-1040CR line 26, veterans' benefits and workers' compensation. - veterans_benefits - workers_compensation + # MI-1040CR line 14, long-term disability benefits. - disability_benefits + # MI-1040CR line 27, payments to the household by MDHHS and other public + # assistance. - gi_cash_assistance - tanf - general_assistance diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml index a0ed4fad654..90a1bdd2fc9 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml @@ -8,12 +8,15 @@ # "If the total is negative enter 0." Line 30 is "total adjustments from your # U.S. Form 1040, Schedule 1", and line 31 is health insurance premiums. The # household is "a claimant and spouse" (MCL 206.508(3)), so a dependent's -# business and rental items stay off lines 16 and 17. +# income and Schedule 1 adjustments stay off the claimant's lines, except +# Social Security and SSI received for dependents (line 21), child support +# (line 22) and public assistance paid to the household (line 27). Premiums +# paid for the family (line 31) include dependents' coverage. - name: Cannot be negative period: 2024 input: - net_capital_gains: -2_000 + long_term_capital_gains: -2_000 state_code: MI health_insurance_premiums: 0 early_withdrawal_penalty: 1_000 @@ -27,7 +30,7 @@ - name: Reduced to 0 period: 2024 input: - net_capital_gains: 2_000 + long_term_capital_gains: 2_000 state_code: MI health_insurance_premiums: 3_000 early_withdrawal_penalty: 1_000 @@ -39,7 +42,7 @@ - name: Partially reduced period: 2024 input: - net_capital_gains: 10_000 + long_term_capital_gains: 10_000 state_code: MI health_insurance_premiums: 3_000 early_withdrawal_penalty: 1_000 @@ -465,3 +468,255 @@ # dependent's Schedule E income is not on line 16. Line 30: 0. # Line 33: 50,000. mi_household_resources: 50_000 + +- name: A working dependent's wages, interest and self-employment are not household resources + period: 2025 + input: + people: + person1: + age: 40 + employment_income_before_lsr: 40_000 + person2: + age: 17 + employment_income_before_lsr: 8_000 + taxable_interest_income: 200 + self_employment_income: 3_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # The household is the claimant and spouse (MCL 206.508(3)); the form + # asks for "all taxable and nontaxable income you and your spouse + # received". The dependent's wages (8,000), interest (200) and Schedule C + # (3,000) are on the dependent's own return. + # Line 14: 40,000. Lines 15 and 16: 0. + # Line 30: the dependent's deduction for half of self-employment tax + # (3,000 x 0.9235 x 15.3% / 2 = 211.94) is on the dependent's own + # Schedule 1, so 0. + # Line 33: 40,000. + mi_household_resources: 40_000 + +- name: Both spouses' income counts and a child's does not + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 30_000 + person2: + age: 44 + employment_income_before_lsr: 20_000 + taxable_interest_income: 500 + person3: + age: 12 + taxable_interest_income: 1_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + marital_units: + marital_unit: + members: [person1, person2] + child_marital_unit: + members: [person3] + households: + household: + members: [person1, person2, person3] + state_code: MI + output: + # Line 14: 30,000 + 20,000 = 50,000. Line 15: the spouse's 500; the + # child's 1,000 is the child's own income. + # Line 33: 50,500. + mi_household_resources: 50_500 + +- name: Social Security survivors benefits paid for a child count on line 21 + period: 2025 + input: + people: + person1: + age: 38 + employment_income_before_lsr: 30_000 + person2: + age: 10 + social_security_survivors: 9_000 + taxable_interest_income: 400 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 30,000. Line 15: 0; the child's 400 of interest is the + # child's own income. + # Line 21: "Include death benefits and amounts received for minor + # children or other dependent adults who live with you": 9,000. + # Line 33: 30,000 + 9,000 = 39,000. + mi_household_resources: 39_000 + +- name: SSI and child support received for a child count on lines 21 and 22 + period: 2025 + input: + people: + person1: + age: 30 + employment_income_before_lsr: 20_000 + person2: + age: 8 + ssi: 6_000 + child_support_received: 4_800 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 20,000. Line 21: SSI received for a minor child, 6,000. + # Line 22: "child support and all payments received as a foster + # parent", 4,800. + # Line 33: 20,000 + 6,000 + 4,800 = 30,800. + mi_household_resources: 30_800 + +- name: A dependent adult's Social Security counts but the dependent's pension does not + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + employment_income_before_lsr: 50_000 + person2: + age: 72 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + social_security_retirement: 15_000 + taxable_pension_income: 6_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1] + parent_marital_unit: + members: [person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 50,000. Line 18: 0; the dependent parent's pension is the + # parent's own income. + # Line 21: amounts received for "other dependent adults who live with + # you", 15,000. + # Line 33: 50,000 + 15,000 = 65,000. + mi_household_resources: 65_000 + +- name: A dependent's capital loss does not reduce the claimant's line 19 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + long_term_capital_gains: 2_000 + person2: + age: 16 + short_term_capital_gains: -5_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 19: net capital gains "from your U.S. Schedule D", the claimant's + # 2,000. The dependent's -5,000 is on the dependent's own Schedule D. + # Line 33: 50,000 + 2,000 = 52,000. + mi_household_resources: 52_000 + +- name: A dependent's early withdrawal penalty is not on the claimant's line 30 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + early_withdrawal_penalty: 1_000 + person2: + age: 16 + early_withdrawal_penalty: 500 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 30: "total adjustments from your U.S. Form 1040, Schedule 1": the + # claimant's penalty on early withdrawal of savings (Schedule 1 line 18), + # 1,000. The dependent's 500 is on the dependent's own Schedule 1. + # Line 33: 50,000 - 1,000 = 49,000. + mi_household_resources: 49_000 + +- name: A Family Independence Program grant for a parent and two children counts in full on line 27 + period: 2025 + input: + people: + person1: + age: 30 + employment_income_before_lsr: 10_000 + person2: + age: 6 + person3: + age: 4 + tax_units: + tax_unit: + members: [person1, person2, person3] + spm_units: + spm_unit: + members: [person1, person2, person3] + tanf: 6_000 + households: + household: + members: [person1, person2, person3] + state_code: MI + output: + # Line 14: 10,000. Line 27: "the total payments made to your household + # by MDHHS", the whole 6,000 grant, including the children's share. + # Line 33: 10,000 + 6,000 = 16,000. + mi_household_resources: 16_000 + +- name: Premiums paid for a child's coverage reduce household resources on line 31 + period: 2025 + input: + people: + person1: + age: 40 + employment_income_before_lsr: 40_000 + health_insurance_premiums: 2_400 + person2: + age: 9 + health_insurance_premiums: 1_200 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 31: "insurance premiums you paid for yourself and your family", + # 2,400 + 1,200 = 3,600. + # Line 33: 40,000 - 3,600 = 36,400. + mi_household_resources: 36_400 diff --git a/policyengine_us/tests/test_mi_household_resources_members.py b/policyengine_us/tests/test_mi_household_resources_members.py new file mode 100644 index 00000000000..2b7e6c2d27c --- /dev/null +++ b/policyengine_us/tests/test_mi_household_resources_members.py @@ -0,0 +1,400 @@ +"""Property tests: whose amounts count in Michigan household resources. + +MCL 206.508(3): "'Household' means a claimant and spouse." MCL 206.508(4) +counts "all income received by all persons of a household". The MI-1040CR +asks for "all taxable and nontaxable income you and your spouse received" +(2025 MI-1040 book, page 31), so a dependent's own income and Schedule 1 +adjustments are on the dependent's return, not the claimant's. The form +counts amounts the claimant receives for others in the household on three +lines (page 32): + +- line 21: Social Security, SSI and railroad retirement benefits, including + "amounts received for minor children or other dependent adults who live + with you"; +- line 22: child support and foster parent payments; +- line 27: payments to the household by MDHHS and other public assistance. + +Line 31 is "insurance premiums you paid for yourself and your family". + +So for every household: + +1. mi_household_resources equals a reference written from the form: the head + and spouse's lines 14 to 20, 23, 24 and 26 and their line 30 adjustments, + plus every member's lines 21, 22 and 27, less every member's premiums, + floored at zero. +2. A dependent's own income and adjustments do not matter: zeroing them + leaves household resources unchanged. +3. Amounts received for the household count wherever they are recorded: + moving a dependent's line 21, 22 or 27 amount, or premium, to the head + leaves household resources unchanged. +4. Raising a dependent's line 21, 22 or 27 amount by d raises household + resources by between 0 and d, and by exactly d when they were positive. + +Comparisons allow one cent or eight float32 spacings at the sum of the +household's absolute amounts, as in test_mi_household_resources_losses.py. +""" + +import itertools + +import numpy as np +import pytest + +from policyengine_us import Simulation + +YEAR = 2025 +CAPITAL_LOSS_LIMIT = 3_000 +TOLERANCE = 0.01 + +# Inputs that are the claimant's or spouse's own, by MI-1040CR line. +CLAIMANT_LINES = { + "employment_income_before_lsr": 14, + "strike_benefits": 14, + "disability_benefits": 14, + "taxable_interest_income": 15, + "tax_exempt_interest_income": 15, + "qualified_dividend_income": 15, + "self_employment_income": 16, + "s_corp_income": 16, + "rental_income": 17, + "taxable_pension_income": 18, + "long_term_capital_gains": 19, + "alimony_income": 20, + "gambling_winnings": 20, + "unemployment_compensation": 23, + "miscellaneous_income": 24, + "veterans_benefits": 26, + "workers_compensation": 26, + # Line 30: penalty on early withdrawal of savings (Schedule 1 line 18). + "early_withdrawal_penalty": 30, +} +# Inputs the claimant receives for the household, by MI-1040CR line. +HOUSEHOLD_LINES = { + "social_security_retirement": 21, + "social_security_survivors": 21, + "ssi": 21, + "railroad_benefits": 21, + "child_support_received": 22, + "general_assistance": 27, + "gi_cash_assistance": 27, +} +PREMIUMS = "health_insurance_premiums" +# Sources that can be negative. +SIGNED = {"self_employment_income", "s_corp_income", "rental_income"} +SIGNED.add("long_term_capital_gains") +PERSON_INPUTS = [*CLAIMANT_LINES, *HOUSEHOLD_LINES, PREMIUMS] +# Person-level line 30 amounts read from the model. +PERSON_ADJUSTMENTS = [ + "self_employment_tax_ald_person", + "self_employed_health_insurance_ald_person", +] + + +def zero_person(): + return {name: 0 for name in PERSON_INPUTS} + + +def build_situation(households): + """One Michigan tax unit per household: a head aged 45, an optional + spouse aged 44, and dependents aged 10 or 16 (children) or 75 (a + dependent parent).""" + people, tax_units, marital_units, households_out = {}, {}, {}, {} + for i, h in enumerate(households): + members, couple = [], [] + roles = [("head", h["head"])] + if h.get("spouse") is not None: + roles.append(("spouse", h["spouse"])) + roles += [("dependent", d) for d in h.get("dependents", [])] + for j, (role, p) in enumerate(roles): + name = f"person_{i}_{j}" + age = {"head": 45, "spouse": 44}.get(role, p.get("age", 10)) + person = {"age": {YEAR: age}} + for variable in PERSON_INPUTS: + person[variable] = {YEAR: p.get(variable, 0)} + # Roles are set for everyone: an adult dependent would otherwise + # be the spouse, and a variable input for some people gives the + # others its default value, not its formula. + person["is_tax_unit_head"] = {YEAR: role == "head"} + person["is_tax_unit_spouse"] = {YEAR: role == "spouse"} + person["is_tax_unit_dependent"] = {YEAR: role == "dependent"} + people[name] = person + members.append(name) + if role == "dependent": + marital_units[f"marital_unit_{i}_{j}"] = {"members": [name]} + else: + couple.append(name) + tax_units[f"tax_unit_{i}"] = {"members": members} + marital_units[f"marital_unit_{i}"] = {"members": couple} + households_out[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "MI"}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "households": households_out, + } + + +def filers(h): + return [h["head"]] + ([h["spouse"]] if h.get("spouse") is not None else []) + + +def everyone(h): + return filers(h) + list(h.get("dependents", [])) + + +def calculate(households): + simulation = Simulation(situation=build_situation(households)) + result = np.asarray(simulation.calculate("mi_household_resources", YEAR)) + # Each person's model amounts for the line 30 self-employment + # adjustments, in situation order. + adjustments = sum( + np.asarray(simulation.calculate(name, YEAR)) for name in PERSON_ADJUSTMENTS + ) + per_household, k = [], 0 + for h in households: + n = len(everyone(h)) + per_household.append(adjustments[k : k + n]) + k += n + return result, per_household + + +def amount(people, names): + return sum(p.get(name, 0) for p in people for name in names) + + +def by_line(lines, *wanted): + return [name for name, line in lines.items() if line in wanted] + + +def reference(h, person_adjustments): + """MI-1040CR line 33 from the form.""" + own = filers(h) + line_14 = amount(own, by_line(CLAIMANT_LINES, 14)) + line_15 = amount(own, by_line(CLAIMANT_LINES, 15)) + line_16 = max(0, amount(own, ["self_employment_income", "s_corp_income"])) + line_17 = max(0, amount(own, ["rental_income"])) + line_18 = amount(own, by_line(CLAIMANT_LINES, 18)) + line_19 = max(-CAPITAL_LOSS_LIMIT, amount(own, ["long_term_capital_gains"])) + lines_20_to_26 = amount(own, by_line(CLAIMANT_LINES, 20, 23, 24, 26)) + received = amount(everyone(h), list(HOUSEHOLD_LINES)) + # Line 30: the head and spouse come first in each household's slice. + line_30 = float(sum(person_adjustments[: len(own)])) + line_30 += amount(own, ["early_withdrawal_penalty"]) + line_31 = amount(everyone(h), [PREMIUMS]) + total = line_14 + line_15 + line_16 + line_17 + line_18 + line_19 + total += lines_20_to_26 + received + return max(0, total - line_30 - line_31) + + +def tolerance(h): + """One cent, or eight float32 spacings at the sum of the household's + absolute amounts.""" + scale = sum(abs(v) for p in everyone(h) for v in p.values()) + return max(TOLERANCE, 8 * float(np.spacing(np.float32(scale)))) + + +def copy(h): + out = {"head": dict(h["head"])} + if h.get("spouse") is not None: + out["spouse"] = dict(h["spouse"]) + out["dependents"] = [dict(d) for d in h.get("dependents", [])] + return out + + +def without_dependents_own_amounts(h): + out = copy(h) + for d in out["dependents"]: + for name in CLAIMANT_LINES: + d[name] = 0 + return out + + +def with_household_amounts_on_head(h): + out = copy(h) + for d in out["dependents"]: + for name in [*HOUSEHOLD_LINES, PREMIUMS]: + out["head"][name] = out["head"].get(name, 0) + d.get(name, 0) + d[name] = 0 + return out + + +def assert_properties(households): + """Properties 1, 2 and 3. The modified copies go in the same + simulation.""" + zeroed = [without_dependents_own_amounts(h) for h in households] + moved = [with_household_amounts_on_head(h) for h in households] + result, adjustments = calculate(households + zeroed + moved) + n = len(households) + for i, h in enumerate(households): + tol = tolerance(h) + # 1. The form. + assert result[i] == pytest.approx(reference(h, adjustments[i]), abs=tol), h + assert result[i] >= 0, h + # 2. A dependent's own income and adjustments do not count. + assert result[n + i] == pytest.approx(result[i], abs=tol), h + # 3. Amounts received for the household count wherever recorded. + assert result[2 * n + i] == pytest.approx(result[i], abs=tol), h + + +# --------------------------------------------------------------------------- +# Deterministic tests (no Hypothesis needed). +# --------------------------------------------------------------------------- + + +def base_household(dependent_age=10, spouse=False): + head = zero_person() + head["employment_income_before_lsr"] = 40_000 + return { + "head": head, + "spouse": zero_person() if spouse else None, + "dependents": [dict(zero_person(), age=dependent_age)], + } + + +def test_each_source_for_a_dependent(): + """For a child and for a dependent parent, every source on its own: a + dependent's own income and adjustments leave household resources at the + base, and an amount received for the household adds in full, as it + would for the head.""" + households, cases = [], [] + for age, spouse in itertools.product([10, 75], [False, True]): + base = base_household(age, spouse) + households.append(base) + cases.append((age, spouse, None, 0)) + for name in PERSON_INPUTS: + values = [7_000, -7_000] if name in SIGNED else [7_000] + for value in values: + h = copy(base) + h["dependents"][0][name] = value + households.append(h) + cases.append((age, spouse, name, value)) + if name in HOUSEHOLD_LINES or name == PREMIUMS: + on_head = copy(base) + on_head["head"][name] = value + households.append(on_head) + cases.append((age, spouse, f"head:{name}", value)) + result, adjustments = calculate(households) + base_result = {} + for i, (age, spouse, name, value) in enumerate(cases): + if name is None: + base_result[(age, spouse)] = result[i] + head_result = { + (age, spouse, name[5:], value): result[i] + for i, (age, spouse, name, value) in enumerate(cases) + if name is not None and name.startswith("head:") + } + for i, (age, spouse, name, value) in enumerate(cases): + h = households[i] + assert result[i] == pytest.approx( + reference(h, adjustments[i]), abs=TOLERANCE + ), cases[i] + if name is None or name.startswith("head:"): + continue + base = base_result[(age, spouse)] + if name in CLAIMANT_LINES: + assert result[i] == pytest.approx(base, abs=TOLERANCE), cases[i] + elif name in HOUSEHOLD_LINES: + assert result[i] == pytest.approx(base + value, abs=TOLERANCE) + assert result[i] == pytest.approx( + head_result[(age, spouse, name, value)], abs=TOLERANCE + ), cases[i] + else: # Premiums paid for the family. + assert result[i] == pytest.approx(base - value, abs=TOLERANCE) + assert result[i] == pytest.approx( + head_result[(age, spouse, name, value)], abs=TOLERANCE + ), cases[i] + + +def test_spouse_counts_like_the_head(): + """Both spouses' own amounts count: each source on the spouse adds what + it adds on the head.""" + households = [] + for name in CLAIMANT_LINES: + if name == "early_withdrawal_penalty": + continue + on_head = base_household(spouse=True) + on_head["head"][name] += 7_000 + on_spouse = base_household(spouse=True) + on_spouse["spouse"][name] = 7_000 + households += [on_head, on_spouse] + result, adjustments = calculate(households) + for k in range(0, len(households), 2): + assert result[k] == pytest.approx(result[k + 1], abs=TOLERANCE) + assert result[k] == pytest.approx( + reference(households[k], adjustments[k]), abs=TOLERANCE + ) + + +# --------------------------------------------------------------------------- +# Hypothesis properties. Hypothesis is a dev extra: without it these tests +# are not collected and the deterministic tests above still run. +# --------------------------------------------------------------------------- + +try: + import hypothesis + import hypothesis.strategies as st +except ImportError: # pragma: no cover + hypothesis = None + +if hypothesis is not None: + SIGNED_AMOUNT = st.integers(min_value=-200_000, max_value=200_000) + AMOUNT = st.integers(min_value=100, max_value=100_000) + SLOW = [hypothesis.HealthCheck.too_slow] + + @st.composite + def person(draw): + p = zero_person() + # Each person draws a few sources, so households are sparse enough + # that the floor at zero does not hide everything. + names = draw(st.lists(st.sampled_from(PERSON_INPUTS), max_size=5, unique=True)) + for name in names: + p[name] = draw(SIGNED_AMOUNT if name in SIGNED else AMOUNT) + return p + + @st.composite + def household(draw): + dependents = [] + for _ in range(draw(st.integers(min_value=0, max_value=2))): + d = draw(person()) + d["age"] = draw(st.sampled_from([10, 16, 75])) + dependents.append(d) + return { + "head": draw(person()), + "spouse": draw(st.one_of(st.none(), person())), + "dependents": dependents, + } + + @hypothesis.settings(max_examples=15, deadline=None, suppress_health_check=SLOW) + @hypothesis.given(st.lists(household(), min_size=1, max_size=15)) + def test_properties(households): + assert_properties(households) + + @hypothesis.settings(max_examples=10, deadline=None, suppress_health_check=SLOW) + @hypothesis.given( + st.lists(household(), min_size=1, max_size=10), + st.sampled_from(list(HOUSEHOLD_LINES)), + st.integers(min_value=1, max_value=50_000), + ) + def test_more_received_for_a_dependent(households, source, extra): + """4. More of a line 21, 22 or 27 amount for a dependent raises + household resources by between 0 and the increase, and by exactly + the increase when they were positive.""" + households = [h for h in households if h["dependents"]] or [base_household()] + raised = [] + for h in households: + r = copy(h) + r["dependents"][0][source] = r["dependents"][0].get(source, 0) + extra + raised.append(r) + result, _ = calculate(households + raised) + n = len(households) + for i, h in enumerate(raised): + tol = tolerance(h) + change = float(result[n + i]) - float(result[i]) + assert -tol <= change <= extra + tol, (households[i], source) + if result[i] > tol: + assert change == pytest.approx(extra, abs=tol), ( + households[i], + source, + ) diff --git a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py index 63be39fdbda..d61ef0f0a20 100644 --- a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py +++ b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py @@ -13,15 +13,19 @@ class mi_household_resources(Variable): "statute-act-281-of-1967/division-281-1967-1/division-281-1967-1-9/" "section-206-508/", "https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-508", + # MCL 206.510(1): "Income", including premiums paid for the family. + "https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-510", "https://web.archive.org/web/20250202150154/https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/BOOK_MI-1040CR-7.pdf", # 2025 MI-1040 book: "Total Household Resources" (page 26) and - # MI-1040CR lines 16 and 17 (page 31), 19 and 30 (page 32). + # MI-1040CR lines 14 to 17 (page 31) and 18 to 31 (pages 32 and 33). "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" "Forms/IIT/TY2025/MI-1040-Book.pdf#page=26", "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" "Forms/IIT/TY2025/MI-1040-Book.pdf#page=31", "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" "Forms/IIT/TY2025/MI-1040-Book.pdf#page=32", + "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" + "Forms/IIT/TY2025/MI-1040-Book.pdf#page=33", ) def formula(tax_unit, period, parameters): @@ -47,29 +51,51 @@ def formula(tax_unit, period, parameters): "farm_rent_income", } - # MCL 206.508(3): "'Household' means a claimant and spouse." A - # dependent's business and rental items belong on the dependent's own - # return, so they neither add to nor net against lines 16 and 17, as - # loss_ald leaves them off this return. - person = tax_unit.members - not_dependent = ~person("is_tax_unit_dependent", period) - variables = tax_unit.simulation.tax_benefit_system.variables + # MCL 206.508(3): "'Household' means a claimant and spouse", and + # (4) counts "all income received by all persons of a household". + # The MI-1040CR says "Include all taxable and nontaxable income you + # and your spouse received" (2025 MI-1040 book, page 31). A + # dependent's own income belongs on the dependent's return, so a + # source is summed over the head and spouse only, as in + # irs_gross_income. A tax-unit-level source (other_net_gain, + # filer_loss_limited_net_capital_gains) already describes the + # filer's return. + # The form counts amounts the claimant receives for others in the + # household on three lines (page 32), so these are summed over every + # member: + # Line 21: Social Security, SSI and railroad retirement benefits, + # including "amounts received for minor children or other dependent + # adults who live with you". + # Line 22: "child support and all payments received as a foster + # parent". + # Line 27: "the total payments made to your household by MDHHS and + # all other public assistance payments". A Family Independence + # Program grant covers the children in the case, and tanf, an SPM + # unit amount, reaches the tax unit in per-person shares. + received_for_household = { + "social_security", + "ssi", + "railroad_benefits", + "child_support_received", + "tanf", + "general_assistance", + "gi_cash_assistance", + } business_income = 0 rental_income = 0 other_income = 0 for source in p.household_resources: - if source in business_sources | rental_sources: - if variables[source].entity.is_person: - amount = tax_unit.sum(person(source, period) * not_dependent) - else: - amount = tax_unit(source, period) - if source in business_sources: - business_income += amount - else: - rental_income += amount + if source in received_for_household: + amount = add(tax_unit, period, [source]) else: - other_income += add(tax_unit, period, [source]) + amount = tax_unit_non_dep_add(tax_unit, period, [source]) + if source in business_sources: + business_income += amount + elif source in rental_sources: + rental_income += amount + else: + other_income += amount total = other_income + max_(business_income, 0) + max_(rental_income, 0) # Line 30: "total adjustments from your U.S. Form 1040, Schedule 1". @@ -77,15 +103,31 @@ def formula(tax_unit, period, parameters): # Part I and Schedule D), not Part II adjustments. They are netted # and floored on lines 16, 17 and 19 above, so loss_ald, which holds # them, is left out. - adjustments = add( + # The Schedule 1 is the claimant's own: a dependent's adjustments, + # like the dependent's income, are on the dependent's return. The + # self-employment adjustments are computed per person, so their + # person-level amounts are summed over the head and spouse. + per_person_adjustments = { + "self_employment_tax_ald", + "self_employed_health_insurance_ald", + "self_employed_pension_contribution_ald", + } + adjustments = tax_unit_non_dep_add( tax_unit, period, [ - deduction + ( + f"{deduction}_person" + if deduction in per_person_adjustments + else deduction + ) for deduction in parameters(period).gov.irs.ald.deductions if deduction != "loss_ald" ], ) - # Line 31: health insurance premiums. + # Line 31: "insurance premiums you paid for yourself and your + # family". MCL 206.510(1) lets a person deduct "the amount that + # person paid in premiums ... for that insurance plan for the + # person's family", so every member's premiums count. health_insurance_premiums = add(tax_unit, period, ["health_insurance_premiums"]) return max_(0, total - adjustments - health_insurance_premiums) From 31149d137c08553385fb3bf682d0292de0dd2cc4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 12:47:47 -0400 Subject: [PATCH 09/22] Give the membership property tests an explicit FIP grant With no income, the model computes a Michigan FIP grant (tanf) for the test household, which counts on line 27 but was missing from the test's reference. Each SPM unit now gets a tanf input, drawn at random in the Hypothesis test and counted in full on line 27 of the reference. The Hypothesis tests also run more examples (50 and 30). Co-Authored-By: Claude Opus 5.5 --- .../test_mi_household_resources_members.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/test_mi_household_resources_members.py b/policyengine_us/tests/test_mi_household_resources_members.py index 2b7e6c2d27c..91f417ae6a7 100644 --- a/policyengine_us/tests/test_mi_household_resources_members.py +++ b/policyengine_us/tests/test_mi_household_resources_members.py @@ -96,8 +96,11 @@ def zero_person(): def build_situation(households): """One Michigan tax unit per household: a head aged 45, an optional spouse aged 44, and dependents aged 10 or 16 (children) or 75 (a - dependent parent).""" + dependent parent). The SPM unit is the tax unit, and its Family + Independence Program grant (tanf) is an input, so the model does not + compute one from the household's income.""" people, tax_units, marital_units, households_out = {}, {}, {}, {} + spm_units = {} for i, h in enumerate(households): members, couple = [], [] roles = [("head", h["head"])] @@ -123,6 +126,10 @@ def build_situation(households): else: couple.append(name) tax_units[f"tax_unit_{i}"] = {"members": members} + spm_units[f"spm_unit_{i}"] = { + "members": members, + "tanf": {YEAR: h.get("tanf", 0)}, + } marital_units[f"marital_unit_{i}"] = {"members": couple} households_out[f"household_{i}"] = { "members": members, @@ -132,6 +139,7 @@ def build_situation(households): "people": people, "tax_units": tax_units, "marital_units": marital_units, + "spm_units": spm_units, "households": households_out, } @@ -179,6 +187,8 @@ def reference(h, person_adjustments): line_19 = max(-CAPITAL_LOSS_LIMIT, amount(own, ["long_term_capital_gains"])) lines_20_to_26 = amount(own, by_line(CLAIMANT_LINES, 20, 23, 24, 26)) received = amount(everyone(h), list(HOUSEHOLD_LINES)) + # Line 27: the household's Family Independence Program grant, in full. + received += h.get("tanf", 0) # Line 30: the head and spouse come first in each household's slice. line_30 = float(sum(person_adjustments[: len(own)])) line_30 += amount(own, ["early_withdrawal_penalty"]) @@ -192,11 +202,12 @@ def tolerance(h): """One cent, or eight float32 spacings at the sum of the household's absolute amounts.""" scale = sum(abs(v) for p in everyone(h) for v in p.values()) + scale += abs(h.get("tanf", 0)) return max(TOLERANCE, 8 * float(np.spacing(np.float32(scale)))) def copy(h): - out = {"head": dict(h["head"])} + out = {"head": dict(h["head"]), "tanf": h.get("tanf", 0)} if h.get("spouse") is not None: out["spouse"] = dict(h["spouse"]) out["dependents"] = [dict(d) for d in h.get("dependents", [])] @@ -364,14 +375,15 @@ def household(draw): "head": draw(person()), "spouse": draw(st.one_of(st.none(), person())), "dependents": dependents, + "tanf": draw(st.one_of(st.just(0), AMOUNT)), } - @hypothesis.settings(max_examples=15, deadline=None, suppress_health_check=SLOW) + @hypothesis.settings(max_examples=50, deadline=None, suppress_health_check=SLOW) @hypothesis.given(st.lists(household(), min_size=1, max_size=15)) def test_properties(households): assert_properties(households) - @hypothesis.settings(max_examples=10, deadline=None, suppress_health_check=SLOW) + @hypothesis.settings(max_examples=30, deadline=None, suppress_health_check=SLOW) @hypothesis.given( st.lists(household(), min_size=1, max_size=10), st.sampled_from(list(HOUSEHOLD_LINES)), From fcecf1f6f6115d53977edb895b0c75cb1854b9d9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 13:49:56 -0400 Subject: [PATCH 10/22] Keep dependents' above-the-line deductions off the filer's AGI irs_gross_income leaves tax unit dependents' income off the filer's return, but self_employment_tax_ald, self_employed_health_insurance_ald and self_employed_pension_contribution_ald summed every member's person-level amount, and above_the_line_deductions summed every member's IRA contributions, early withdrawal penalty, educator expenses, adoption assistance and education bond interest. A dependent's Schedule SE and Schedule 1 deductions belong on the dependent's own return. - The three self-employment aggregates sum their _person variables over non-dependents; alimony_expense_ald masks dependents. - above_the_line_deductions sums gov.irs.ald.deductions with tax_unit_non_dep_add. - Readers that sum the ALD list themselves do the same: taxable_ss_magi, taxable_uc_agi, student_loan_interest_ald_magi, adjusted_gross_income_person and ma_part_b_agi. - mo_adjusted_gross_income takes personal ALDs over non-dependents, so its remaining-ALD allocation never goes negative. - medicaid_adjusted_gross_income_person attributes a dependent's own person-level deductions to the dependent instead of the head. - ma_gross_income_loss_adjustment leaves out dependents' losses, as the federal gross income it adjusts does. The _person variables keep each person's own amount, which a dependent's own return (dependent_taxable_ss_magi) uses. Co-Authored-By: Claude Opus 5.5 --- .../fix-dependent-ald-filer-agi.fixed.md | 1 + .../tests/core/test_ald_determinism.py | 12 +- ...nt_above_the_line_deductions_invariants.py | 396 ++++++++++++++++++ ...medicaid_adjusted_gross_income_person.yaml | 33 ++ .../dependent_above_the_line_deductions.yaml | 241 +++++++++++ .../adjusted_gross_income/ma_part_b_agi.yaml | 26 ++ .../ma_gross_income_loss_adjustment.yaml | 26 ++ .../mo_adjusted_gross_income.yaml | 28 ++ .../medicaid_adjusted_gross_income_person.py | 15 +- .../above_the_line_deductions.py | 13 +- .../alimony_expense_ald.py | 4 +- .../self_employed_health_insurance_ald.py | 10 +- .../self_employed_pension_contribution_ald.py | 15 +- .../self_employment_tax_ald.py | 17 +- .../student_loan_interest_ald_magi.py | 2 +- .../adjusted_gross_income_person.py | 7 +- .../social_security/taxable_ss_magi.py | 3 +- .../unemployment_insurance/taxable_uc_agi.py | 5 +- .../adjusted_gross_income/ma_part_b_agi.py | 4 +- .../ma_gross_income_loss_adjustment.py | 16 +- .../mo_adjusted_gross_income.py | 8 +- 21 files changed, 853 insertions(+), 29 deletions(-) create mode 100644 changelog.d/fix-dependent-ald-filer-agi.fixed.md create mode 100644 policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependent_above_the_line_deductions.yaml diff --git a/changelog.d/fix-dependent-ald-filer-agi.fixed.md b/changelog.d/fix-dependent-ald-filer-agi.fixed.md new file mode 100644 index 00000000000..a00da41f5ff --- /dev/null +++ b/changelog.d/fix-dependent-ald-filer-agi.fixed.md @@ -0,0 +1 @@ +Keep tax unit dependents' above-the-line deductions (the deductible part of self-employment tax, self-employed health insurance and retirement plans, IRA contributions, early withdrawal penalties, educator expenses, alimony paid, adoption assistance and education bond interest) off the filer's AGI, Social Security and unemployment MAGIs and per-person AGI, and off the Missouri, Massachusetts and Medicaid AGIs built from them; also keep dependents' business losses out of Massachusetts gross income. diff --git a/policyengine_us/tests/core/test_ald_determinism.py b/policyengine_us/tests/core/test_ald_determinism.py index 3ef4ff22cca..082e610c11e 100644 --- a/policyengine_us/tests/core/test_ald_determinism.py +++ b/policyengine_us/tests/core/test_ald_determinism.py @@ -85,13 +85,19 @@ class ReachedTaxUnitSum(Exception): person = Person() def capture_add(entity, period, variables): - if entity is person.tax_unit: - captured.extend(variables) - raise ReachedTaxUnitSum + # Tax-unit deductions must be summed over non-dependents only, through + # tax_unit_non_dep_add, never with a plain add over every member. + assert entity is person assert variables == [f"{name}_person" for name in PERSON_ALDS] return np.array([0], dtype=np.float32) + def capture_tax_unit_add(entity, period, variables): + assert entity is person.tax_unit + captured.extend(variables) + raise ReachedTaxUnitSum + monkeypatch.setattr(module, "add", capture_add) + monkeypatch.setattr(module, "tax_unit_non_dep_add", capture_tax_unit_add) with pytest.raises(ReachedTaxUnitSum): getattr(module, variable_name).formula(person, 2026, lambda period: parameters) diff --git a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py new file mode 100644 index 00000000000..7f5bc0bc4bf --- /dev/null +++ b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py @@ -0,0 +1,396 @@ +"""Invariants for dependents' above-the-line deductions in the filer's AGI. + +A tax unit dependent's deductions belong on the dependent's own return: their +Schedule SE and Schedule 1 deductions (the deductible part of self-employment +tax, self-employed health insurance and retirement plans, the IRA deduction, +the penalty on early withdrawal of savings, educator expenses, alimony paid) +are figured with the dependent's own income. `irs_gross_income` already +leaves dependents' income off the filer's return, so every sum of +above-the-line deductions over a tax unit must leave dependents out too. + +Hypothesis draws batches of tax units (single, head of household with +dependents, joint with and without dependents, in Texas, Missouri and +Massachusetts), and a seeded population of 200 such units adds breadth. Each +batch runs as one vectorized simulation, twice: with +the dependents' deduction inputs as drawn and with them set to zero. For +every tax unit: + +1. The dependents' deduction inputs never change the filer's AGI, + above-the-line deductions, any tax-unit deduction aggregate, the Social + Security and unemployment compensation MAGIs, taxable Social Security, + Massachusetts gross income and Part B AGI, or the head's and spouse's + per-person AGI, + student loan interest MAGI and deduction, Medicaid AGI and Missouri AGI. +2. Accounting identities: the per-person AGIs of the members sum to the tax + unit's AGI, and so do the head's and spouse's Medicaid AGIs. +3. Differential: `above_the_line_deductions` and each aggregate equal an + independent numpy sum over the head and spouse. For units without + dependents this is also the previous all-member sum, so they see no change. +4. Each person's own deduction amounts are still computed for dependents + (half of each person's self-employment tax, for example), so a + dependent's own return can use them, and a dependent's Medicaid AGI is + their own gross income less their own person-level deductions. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars + +# Person-level inputs behind the deductions a dependent could claim on their +# own return. +DEPENDENT_DEDUCTION_INPUTS = [ + "self_employment_income", + "self_employed_health_insurance_premiums", + "self_employed_pension_contributions", + "traditional_ira_contributions", + "early_withdrawal_penalty", + "educator_expense", + "alimony_expense", + "qualified_adoption_assistance_expense", + "us_bonds_for_higher_ed", + "student_loan_interest", +] +# Person-level deduction amounts that make up above_the_line_deductions, +# other than loss_ald and alimony_expense_ald. +PERSON_DEDUCTIONS = [ + "self_employment_tax_ald_person", + "self_employed_health_insurance_ald_person", + "self_employed_pension_contribution_ald_person", + "student_loan_interest_ald", + "early_withdrawal_penalty", + "educator_expense", + "traditional_ira_contributions", + "qualified_adoption_assistance_expense", + "us_bonds_for_higher_ed", +] +AGGREGATES = { + "self_employment_tax_ald": "self_employment_tax_ald_person", + "self_employed_health_insurance_ald": "self_employed_health_insurance_ald_person", + "self_employed_pension_contribution_ald": "self_employed_pension_contribution_ald_person", +} +TAX_UNIT_OUTPUTS = [ + "adjusted_gross_income", + "above_the_line_deductions", + *AGGREGATES, + "alimony_expense_ald", + "loss_ald", + "taxable_ss_magi", + "taxable_uc_agi", + "tax_unit_taxable_social_security", + "ma_gross_income", + "ma_part_b_agi", +] +# Per-person outputs whose head and spouse values must not depend on the +# dependents' deductions. +FILER_PERSON_OUTPUTS = [ + "adjusted_gross_income_person", + "student_loan_interest_ald_magi", + "student_loan_interest_ald", + "medicaid_adjusted_gross_income_person", + "mo_adjusted_gross_income", +] +PERSON_OUTPUTS = [ + *FILER_PERSON_OUTPUTS, + *PERSON_DEDUCTIONS, + "self_employment_tax", + "alimony_expense", + "divorce_year", + "medicaid_irs_gross_income", + "irs_gross_income", +] + +amount = st.integers(0, 40_000).map(float) +small = st.one_of(st.just(0.0), st.integers(1, 7_000).map(float)) + + +@st.composite +def person_amounts(draw, *, dependent): + has = st.booleans() + return { + "employment_income": draw(st.one_of(st.just(0.0), amount)), + # Losses are loss_ald's business; draw mostly gains with some losses. + "self_employment_income": ( + draw(st.integers(-10_000, 60_000).map(float)) if draw(has) else 0.0 + ), + "self_employed_health_insurance_premiums": draw(small), + "self_employed_pension_contributions": draw(small), + "traditional_ira_contributions": draw(small), + "early_withdrawal_penalty": draw(st.integers(0, 500).map(float)), + "educator_expense": draw(st.integers(0, 300).map(float)), + "alimony_expense": draw(small), + "divorce_year": draw(st.sampled_from([2010, 2018, 2020])), + "qualified_adoption_assistance_expense": draw(small), + "us_bonds_for_higher_ed": draw(small), + "student_loan_interest": draw(st.integers(0, 3_000).map(float)), + "taxable_interest_income": draw(st.integers(0, 2_000).map(float)), + "social_security_retirement": ( + 0.0 if dependent else draw(st.one_of(st.just(0.0), amount)) + ), + "taxable_private_pension_income": ( + 0.0 if dependent else draw(st.one_of(st.just(0.0), amount)) + ), + "unemployment_compensation": draw( + st.one_of(st.just(0.0), st.integers(1, 15_000).map(float)) + ), + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "hoh", "joint", "joint_dependents"])) + n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 + return { + "state": draw(st.sampled_from(["TX", "MO", "MA"])), + "head": draw(person_amounts(dependent=False)), + "spouse": ( + draw(person_amounts(dependent=False)) if kind.startswith("joint") else None + ), + "dependents": [ + { + "age": draw(st.integers(5, 23)), + **draw(person_amounts(dependent=True)), + } + for _ in range(n_dependents) + ], + } + + +SEED = 20261005 + + +def _seeded_amounts(rng, *, dependent): + def some(high, p=0.5): + return float(round(rng.uniform(1, high))) if rng.random() < p else 0.0 + + return { + "employment_income": some(40_000, 0.6), + "self_employment_income": ( + float(round(rng.uniform(-10_000, 60_000))) if rng.random() < 0.6 else 0.0 + ), + "self_employed_health_insurance_premiums": some(7_000), + "self_employed_pension_contributions": some(7_000), + "traditional_ira_contributions": some(7_000), + "early_withdrawal_penalty": some(500), + "educator_expense": some(300), + "alimony_expense": some(7_000, 0.3), + "divorce_year": int(rng.choice([2010, 2018, 2020])), + "qualified_adoption_assistance_expense": some(7_000, 0.2), + "us_bonds_for_higher_ed": some(7_000, 0.2), + "student_loan_interest": some(3_000), + "taxable_interest_income": some(2_000), + "social_security_retirement": 0.0 if dependent else some(40_000, 0.3), + "taxable_private_pension_income": 0.0 if dependent else some(40_000, 0.3), + "unemployment_compensation": some(15_000, 0.2), + } + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + units = [] + for _ in range(n): + kind = rng.choice(["single", "hoh", "joint", "joint_dependents"]) + n_dependents = ( + int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + ) + units.append( + { + "state": str(rng.choice(["TX", "MO", "MA"])), + "head": _seeded_amounts(rng, dependent=False), + "spouse": ( + _seeded_amounts(rng, dependent=False) + if kind.startswith("joint") + else None + ), + "dependents": [ + { + "age": int(rng.integers(5, 24)), + **_seeded_amounts(rng, dependent=True), + } + for _ in range(n_dependents) + ], + } + ) + return units + + +def _situation(units, year, *, zero_dependents): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + for i, u in enumerate(units): + head = f"head_{i}" + people[head] = { + "age": 50, + "is_tax_unit_head": True, + "is_tax_unit_spouse": False, + "is_tax_unit_dependent": False, + **u["head"], + } + members, couple = [head], [head] + if u["spouse"] is not None: + spouse = f"spouse_{i}" + people[spouse] = { + "age": 48, + "is_tax_unit_head": False, + "is_tax_unit_spouse": True, + "is_tax_unit_dependent": False, + **u["spouse"], + } + members.append(spouse) + couple.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": couple} + for j, d in enumerate(u["dependents"]): + child = f"dependent_{i}_{j}" + amounts = {k: v for k, v in d.items() if k != "age"} + if zero_dependents: + amounts.update({name: 0.0 for name in DEPENDENT_DEDUCTION_INPUTS}) + people[child] = { + "age": d["age"], + "is_full_time_student": d["age"] >= 19, + "is_tax_unit_head": False, + "is_tax_unit_spouse": False, + "is_tax_unit_dependent": True, + **amounts, + } + members.append(child) + groups["marital_units"][f"single_{i}_{j}"] = {"members": [child]} + groups["tax_units"][f"tax_unit_{i}"] = {"members": members} + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: u["state"]}, + } + people = { + name: {k: {year: v} for k, v in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _run(units, year, *, zero_dependents): + sim = Simulation(situation=_situation(units, year, zero_dependents=zero_dependents)) + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in TAX_UNIT_OUTPUTS + PERSON_OUTPUTS + } + out["filing_status"] = np.asarray(sim.calculate("filing_status", year)) + out["is_dependent"] = np.asarray( + sim.calculate("is_tax_unit_dependent", year), dtype=bool + ) + out["unit"] = sim.populations["tax_unit"].members_entity_id + return out + + +def _unit_sum(run, values): + return np.bincount( + run["unit"], weights=values, minlength=len(run["adjusted_gross_income"]) + ) + + +def _check(units, year): + run = _run(units, year, zero_dependents=False) + zeroed = _run(units, year, zero_dependents=True) + filer = ~run["is_dependent"] + has_dependent = _unit_sum(run, run["is_dependent"].astype(float)) > 0 + + # 1. Dependents' deduction inputs never reach the filer's return. + assert (run["filing_status"] == zeroed["filing_status"]).all() + for name in TAX_UNIT_OUTPUTS: + np.testing.assert_allclose( + run[name], zeroed[name], atol=TOLERANCE, err_msg=name + ) + for name in FILER_PERSON_OUTPUTS: + np.testing.assert_allclose( + run[name][filer], zeroed[name][filer], atol=TOLERANCE, err_msg=name + ) + + # 2. Accounting identities. + np.testing.assert_allclose( + _unit_sum(run, run["adjusted_gross_income_person"]), + run["adjusted_gross_income"], + atol=TOLERANCE, + ) + np.testing.assert_allclose( + _unit_sum(run, filer * run["medicaid_adjusted_gross_income_person"]), + run["adjusted_gross_income"], + atol=TOLERANCE, + ) + + # 3. Differential against numpy sums over the head and spouse. Alimony + # is deductible for divorces before 2019 (26 USC 215, repealed by Pub. L. + # 115-97 sec. 11051 for later instruments). + alimony = run["alimony_expense"] * (run["divorce_year"] < 2019) + np.testing.assert_allclose( + run["alimony_expense_ald"], _unit_sum(run, filer * alimony), atol=TOLERANCE + ) + for aggregate, person_amount in AGGREGATES.items(): + np.testing.assert_allclose( + run[aggregate], + _unit_sum(run, filer * run[person_amount]), + atol=TOLERANCE, + err_msg=aggregate, + ) + person_total = sum(run[name] for name in PERSON_DEDUCTIONS) + reference = run["loss_ald"] + _unit_sum(run, filer * (person_total + alimony)) + np.testing.assert_allclose( + run["above_the_line_deductions"], reference, atol=TOLERANCE + ) + # Units without dependents: the previous all-member sum, unchanged. + all_members = run["loss_ald"] + _unit_sum(run, person_total + alimony) + np.testing.assert_allclose( + run["above_the_line_deductions"][~has_dependent], + all_members[~has_dependent], + atol=TOLERANCE, + ) + np.testing.assert_allclose( + run["adjusted_gross_income"], + _unit_sum(run, run["irs_gross_income"]) - reference, + atol=TOLERANCE, + ) + + # 4. Each person's own amounts survive for their own return. + np.testing.assert_allclose( + run["self_employment_tax_ald_person"], + 0.5 * run["self_employment_tax"], + atol=TOLERANCE, + ) + dependent = run["is_dependent"] + own_person_deductions = person_total - run["student_loan_interest_ald"] + np.testing.assert_allclose( + run["medicaid_adjusted_gross_income_person"][dependent], + (run["medicaid_irs_gross_income"] - own_person_deductions)[dependent], + atol=TOLERANCE, + ) + assert (run["student_loan_interest_ald"][dependent] == 0).all() + assert (run["adjusted_gross_income_person"][dependent] == 0).all() + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(tax_units(), min_size=10, max_size=40)) +def test_dependent_deductions_stay_off_the_filers_return_2025(units): + _check(units, 2025) + + +@settings(**SETTINGS) +@given(st.lists(tax_units(), min_size=10, max_size=40)) +def test_dependent_deductions_stay_off_the_filers_return_2020(units): + # 2020 adds the unemployment compensation exclusion, which reads + # taxable_uc_agi, and the tuition and fees deduction. + _check(units, 2020) + + +@pytest.mark.parametrize("year", [2020, 2025]) +def test_seeded_population(year): + _check(_seeded_units(), year) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml index dd5d926b7f3..766f45397c4 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml @@ -38,3 +38,36 @@ output: medicaid_adjusted_gross_income_person: [59_450, 39_450, 5_000] absolute_error_margin: 0.01 + +- name: Case 3, a dependent's own deductions reduce the dependent's AGI, not the head's. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_tax_unit_head: true + employment_income: 50_000 + student: + age: 23 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + employment_income: 6_000 + taxable_interest_income: 500 + traditional_ira_contributions: 2_000 + early_withdrawal_penalty: 100 + educator_expense: 300 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + # Each person's own Form 1040, line 11. Parent: 50,000 - 0. Student: + # line 9 = 6,000 + 500 = 6,500; Schedule 1, line 11 = 300, line 18 = 100, + # line 20 = 2,000, line 26 = 2,400; line 11 = 4,100. + medicaid_adjusted_gross_income_person: [50_000, 4_100] diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependent_above_the_line_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependent_above_the_line_deductions.yaml new file mode 100644 index 00000000000..b8aa750b8a5 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/dependent_above_the_line_deductions.yaml @@ -0,0 +1,241 @@ +# A tax unit dependent's above-the-line deductions belong on the dependent's +# own return, not the filer's. irs_gross_income already leaves the +# dependent's income off the filer's return; these tests check that the +# dependent's deductions are left off too. Only spouses aggregate on one +# return (26 USC 6013(a)); 26 USC 1(g)(7) moves only a child's interest and +# dividend gross income to a parent's return. Even under that election, the +# Instructions for Form 8814 (2025), p. 2, list "Penalty on early withdrawal +# of child's savings" among the deductions the parent cannot take. +# +# Expected values follow the TY2025 forms, figured in cents: +# - Schedule SE (Form 1040) 2025, lines 2-13, filed by each person with +# self-employment income: +# https://www.irs.gov/pub/irs-prior/f1040sse--2025.pdf +# - Schedule 1 (Form 1040) 2025, Part II, lines 11-26 (adjustments): +# https://www.irs.gov/pub/irs-prior/f1040s1--2025.pdf +# - Form 1040 (2025) lines 9-11, and the Social Security Benefits Worksheet +# and Student Loan Interest Deduction Worksheet in the 2025 Form 1040 +# instructions: https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=32 +# and #page=99. +# The model does not round Schedule SE lines to cents, so the margin is 0.01. + +- name: Child's Schedule C income and self-employment tax stay off the head of household parent's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 17 + self_employment_income: 10_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + is_tax_unit_dependent: [false, true] + # Child's own Schedule SE: line 2 = line 3 = 10,000; line 4a = line 6 = + # 10,000 x 0.9235 = 9,235.00; line 9 = 176,100 (no wages); line 10 = + # 9,235 x 0.124 = 1,145.14; line 11 = 9,235 x 0.029 = 267.82; + # line 12 = 1,412.96; line 13 = 1,412.96 x 0.50 = 706.48. Line 13 goes on + # the child's own Schedule 1, line 15. + self_employment_tax: [0, 1_412.96] + self_employment_tax_ald_person: [0, 706.48] + # Parent's Schedule 1, line 15: no self-employment tax of the parent's. + self_employment_tax_ald: 0 + # Parent's Schedule 1, line 26, and Form 1040, line 10. + above_the_line_deductions: 0 + # Parent's Form 1040: line 1a = line 9 = 40,000; line 11 = 40,000 - 0. + adjusted_gross_income: 40_000 + adjusted_gross_income_person: [40_000, 0] + +- name: Spouse's self-employment deductions stay on the joint return and the child's do not + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 44 + self_employment_income: 20_000 + child: + age: 16 + self_employment_income: 8_000 + self_employed_health_insurance_premiums: 1_000 + self_employed_pension_contributions: 500 + tax_units: + tax_unit: + members: [head, spouse, child] + households: + household: + members: [head, spouse, child] + state_code: TX + output: + filing_status: JOINT + is_tax_unit_dependent: [false, false, true] + # Spouse's Schedule SE: line 2 = 20,000; line 4a = line 6 = 18,470.00; + # line 10 = 18,470 x 0.124 = 2,290.28; line 11 = 18,470 x 0.029 = + # 535.63; line 12 = 2,825.91; line 13 = 1,412.96 (1,412.955 unrounded). + # Child's own Schedule SE: line 2 = 8,000; line 4a = line 6 = 7,388.00; + # line 10 = 916.11; line 11 = 214.25; line 12 = 1,130.36; + # line 13 = 565.18. The child's line 13, health insurance (1,000) and + # SEP contribution (500) go on the child's own Schedule 1, lines 15-17. + self_employment_tax: [0, 2_825.91, 1_130.36] + self_employment_tax_ald_person: [0, 1_412.96, 565.18] + self_employed_health_insurance_ald_person: [0, 0, 1_000] + self_employed_pension_contribution_ald_person: [0, 0, 500] + # Joint return's Schedule 1: line 15 = the spouse's 1,412.96 only; + # lines 16 and 17 = 0; line 26 = 1,412.96. + self_employment_tax_ald: 1_412.96 + self_employed_pension_contribution_ald: 0 + self_employed_health_insurance_ald: 0 + above_the_line_deductions: 1_412.96 + # Joint Form 1040: line 1a = 60,000; Schedule 1, line 3 = 20,000; line 9 + # = 80,000; line 10 = 1,412.96; line 11 = 78,587.04. + adjusted_gross_income: 78_587.04 + adjusted_gross_income_person: [60_000, 18_587.04, 0] + +- name: Student dependent's IRA deduction, early withdrawal penalty and educator expenses stay off the parent's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_tax_unit_head: true + employment_income: 50_000 + student: + age: 23 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + employment_income: 6_000 + taxable_interest_income: 500 + traditional_ira_contributions: 2_000 + early_withdrawal_penalty: 100 + educator_expense: 300 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + filing_status: HEAD_OF_HOUSEHOLD + is_tax_unit_dependent: [false, true] + # The student's own Schedule 1: line 11 = 300, line 18 = 100, line 20 = + # 2,000, line 26 = 2,400. The parent's Schedule 1, line 26 = 0. + above_the_line_deductions: 0 + # Parent's Form 1040: line 9 = 50,000; line 10 = 0; line 11 = 50,000. + adjusted_gross_income: 50_000 + adjusted_gross_income_person: [50_000, 0] + +- name: Dependent parent's alimony paid stays off the filer's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + filer: + age: 45 + is_tax_unit_head: true + employment_income: 50_000 + mother: + age: 72 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + alimony_expense: 3_000 + divorce_year: 2010 + tax_units: + tax_unit: + members: [filer, mother] + households: + household: + members: [filer, mother] + state_code: TX + output: + filing_status: HEAD_OF_HOUSEHOLD + # The mother's pre-2019 alimony goes on her own Schedule 1, line 19a. + alimony_expense_ald: 0 + above_the_line_deductions: 0 + adjusted_gross_income: 50_000 + +- name: Grandchild's self-employment tax does not lower the grandparent's taxable Social Security + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 67 + social_security_retirement: 24_000 + taxable_private_pension_income: 20_000 + grandchild: + age: 17 + self_employment_income: 10_000 + tax_units: + tax_unit: + members: [grandparent, grandchild] + households: + household: + members: [grandparent, grandchild] + state_code: TX + output: + filing_status: HEAD_OF_HOUSEHOLD + # Social Security Benefits Worksheet (2025): line 1 = 24,000; line 2 = + # 12,000; line 3 = Form 1040 line 5b = 20,000; line 4 = 0; line 5 = + # 32,000; line 6 = the grandparent's Schedule 1, lines 11-20, 23, 25 = 0 + # (the grandchild's line 15 deduction is on the grandchild's return). + # Line 3 + line 4 - line 6: + taxable_ss_magi: 20_000 + # Line 7 = 32,000; line 8 = 25,000; line 9 = 7,000; line 10 = 9,000; + # line 11 = 0; line 12 = 7,000; line 13 = 3,500; line 14 = 3,500; + # line 15 = 0; line 16 = 3,500; line 17 = 20,400; line 18 = 3,500. + tax_unit_taxable_social_security: 3_500 + # Form 1040: line 9 = 20,000 + 3,500 = 23,500; line 10 = 0; line 11. + adjusted_gross_income: 23_500 + +- name: Dependent's IRA deduction does not lower the parent's student loan interest MAGI + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_tax_unit_head: true + employment_income: 91_000 + student_loan_interest: 2_500 + student: + age: 20 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + employment_income: 6_000 + traditional_ira_contributions: 2_000 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + filing_status: HEAD_OF_HOUSEHOLD + # Student Loan Interest Deduction Worksheet (2025): line 1 = 2,500; + # line 2 = Form 1040 line 9 = 91,000; line 3 = the parent's Schedule 1, + # lines 11-20, 23, 25 = 0; line 4 = 91,000. + student_loan_interest_ald_magi: [91_000, 0] + # Line 5 = 85,000; line 6 = 6,000; line 7 = 6,000 / 15,000 = 0.400; + # line 8 = 1,000; line 9 = 1,500 (Schedule 1, line 21). + student_loan_interest_ald: [1_500, 0] + # Form 1040: line 9 = 91,000; line 10 = 1,500; line 11 = 89,500. + adjusted_gross_income: 89_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.yaml index 838ec7604af..e350f4cb091 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.yaml @@ -7,3 +7,29 @@ traditional_ira_contributions_desired: 1_000 output: ma_part_b_agi: 10_000 + +- name: Dependent's self-employed health insurance deduction does not reduce the parent's Part B AGI + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 17 + self_employment_income: 10_000 + self_employed_health_insurance_premiums: 1_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: MA + output: + # Part B gross income starts from federal gross income, which leaves out + # the child's Schedule C income, so the child's self-employed health + # insurance deduction is left out too: it is on the child's own return. + ma_part_b_gross_income: 40_000 + ma_part_b_agi: 40_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/ma_gross_income_loss_adjustment.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/ma_gross_income_loss_adjustment.yaml index f217778eca9..29b26502099 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/ma_gross_income_loss_adjustment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/ma_gross_income_loss_adjustment.yaml @@ -5,3 +5,29 @@ state_code: MA output: ma_gross_income_loss_adjustment: -10_000 + +- name: A dependent's Schedule C loss stays on the dependent's own Form 1 + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 40_000 + rental_income: 2_000 + child: + age: 17 + self_employment_income: -3_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: MA + output: + # Federal gross income leaves the child out, so the child's loss is not + # subtracted on the parent's line 6a: MA total 5.0% income is the + # parent's wages (line 3) plus rental income (line 7). + ma_gross_income_loss_adjustment: 0 + ma_gross_income: 42_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.yaml index 5a918062d68..4a0f71ad81f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.yaml @@ -47,3 +47,31 @@ state_code: MO output: mo_adjusted_gross_income: 0 # AGI cannot be negative + +- name: Dependent's self-employment tax deduction neither reduces nor raises the parent's Missouri AGI + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 17 + self_employment_income: 10_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: MO + output: + # The child's Schedule SE line 13 (706.48) is on the child's own federal + # and Missouri returns. The parent's federal AGI (Form 1040, line 11) is + # 40,000 and has no Missouri additions or subtractions. The parent's + # personal deductions and the tax unit's deductions both leave the child + # out, so the remaining deductions allocated between spouses are 0, not + # -706.48. + adjusted_gross_income: 40_000 + mo_adjusted_gross_income: [40_000, 0] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py index 4b83de2ba8b..0b8562829b4 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py @@ -31,7 +31,18 @@ def formula(person, period, parameters): for ald in all_alds if ald not in TAX_UNIT_AGI_ALDS_WITH_PERSON_LEVEL_EQUIVALENTS ] - ald_sum_taxunit = add(person.tax_unit, period, other_alds) + # The head's and spouse's deductions are shared between them, as on + # their return. A tax unit dependent's own person-level deductions, + # such as their IRA deduction, reduce the dependent's own AGI, since + # the dependent's income is figured on the dependent's own return. + ald_sum_taxunit = tax_unit_non_dep_add(person.tax_unit, period, other_alds) + person_level_other_alds = [ + ald + for ald in other_alds + if person.entity.get_variable(ald, check_existence=True).entity.is_person + ] + is_dependent = person("is_tax_unit_dependent", period) + dependent_own_ald = is_dependent * add(person, period, person_level_other_alds) filing_status = person.tax_unit("filing_status", period) frac = where( filing_status == filing_status.possible_values.JOINT, @@ -40,7 +51,7 @@ def formula(person, period, parameters): ) head_or_spouse = person("is_tax_unit_head_or_spouse", period) shared_ald = head_or_spouse * ald_sum_taxunit * frac - agi = gross_income - ald_sum_person - shared_ald + agi = gross_income - ald_sum_person - dependent_own_ald - shared_ald if parameters(period).gov.contrib.ubi_center.basic_income.taxable: basic_income = person.tax_unit("basic_income", period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions.py index e7b18296389..e184d5d2747 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions.py @@ -7,9 +7,18 @@ class above_the_line_deductions(Variable): label = "Above-the-line deductions" unit = USD documentation = ( - "Deductions applied to reach adjusted gross income from gross income." + "Deductions applied to reach adjusted gross income from gross income " + "(Schedule 1, line 26). A tax unit dependent's own deductions, such as " + "the deductible part of their self-employment tax, their IRA " + "deduction or their penalty on early withdrawal of savings, are on " + "the dependent's own return, as their income is." ) definition_period = YEAR reference = "https://www.law.cornell.edu/uscode/text/26/62" - adds = "gov.irs.ald.deductions" + def formula(tax_unit, period, parameters): + deductions = parameters(period).gov.irs.ald.deductions + # Person-level deductions are summed over the head and spouse only, + # as irs_gross_income sums their income only. Tax-unit-level + # deductions already describe this return. + return tax_unit_non_dep_add(tax_unit, period, deductions) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald.py index 5fede15f1a6..9c7d5d34f6c 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald.py @@ -16,4 +16,6 @@ def formula(tax_unit, period, parameters): alimony_expense = person("alimony_expense", period) p = parameters(period).gov.irs.ald.alimony_expense eligible_person = p.divorce_year_threshold.calc(divorce_year) - return tax_unit.sum(alimony_expense * eligible_person) + # A tax unit dependent who pays alimony deducts it on their own return. + not_dependent = ~person("is_tax_unit_dependent", period) + return tax_unit.sum(alimony_expense * eligible_person * not_dependent) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.py index af773521b85..67cbe52df57 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.py @@ -7,9 +7,15 @@ class self_employed_health_insurance_ald(Variable): label = "Self-employed health insurance ALD" unit = USD documentation = ( - "Above-the-line deduction for self-employed health insurance contributions." + "Above-the-line deduction for the head's and spouse's self-employed " + "health insurance (Schedule 1, line 17). A tax unit dependent who is " + "self-employed takes this deduction, limited to their own earned " + "income, on their own return." ) definition_period = YEAR reference = "https://www.law.cornell.edu/uscode/text/26/162#l" - adds = ["self_employed_health_insurance_ald_person"] + def formula(tax_unit, period, parameters): + return tax_unit_non_dep_sum( + "self_employed_health_insurance_ald_person", tax_unit, period + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.py index 5ac5dcf9496..9281488390d 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.py @@ -7,9 +7,18 @@ class self_employed_pension_contribution_ald(Variable): label = "Self-employed pension contribution ALD" unit = USD documentation = ( - "Above-the-line deduction for self-employed pension plan contributions." + "Above-the-line deduction for the head's and spouse's self-employed " + "SEP, SIMPLE and qualified plan contributions (Schedule 1, line 16). " + "A tax unit dependent who is self-employed takes this deduction on " + "their own return." ) definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/162#l" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/62#a_6", + "https://www.law.cornell.edu/uscode/text/26/404", + ) - adds = ["self_employed_pension_contribution_ald_person"] + def formula(tax_unit, period, parameters): + return tax_unit_non_dep_sum( + "self_employed_pension_contribution_ald_person", tax_unit, period + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_tax_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_tax_ald.py index 9049293d1f6..d0515850b92 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_tax_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_tax_ald.py @@ -6,8 +6,19 @@ class self_employment_tax_ald(Variable): entity = TaxUnit label = "Self-employment tax ALD deduction" unit = USD - documentation = "Above-the-line deduction for self-employment tax" + documentation = ( + "Above-the-line deduction for the deductible part of the head's and " + "spouse's self-employment tax (Schedule 1, line 15). A tax unit " + "dependent figures their own self-employment tax on their own " + "Schedule SE and deducts its deductible part on their own return." + ) definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/164#f" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/164#f", + "https://www.irs.gov/pub/irs-prior/f1040sse--2025.pdf", + ) - adds = ["self_employment_tax_ald_person"] + def formula(tax_unit, period, parameters): + # A dependent's self-employment income is left off this return by + # irs_gross_income, so the deduction for their tax is left off too. + return tax_unit_non_dep_sum("self_employment_tax_ald_person", tax_unit, period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py index d4d6df1142e..deae2072d98 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py @@ -35,7 +35,7 @@ def formula(person, period, parameters): - set(person_alds) - set(p_irs.ald.student_loan_interest.magi.excluded_alds) ) - ald_sum_taxunit = add(person.tax_unit, period, other_alds) + ald_sum_taxunit = tax_unit_non_dep_add(person.tax_unit, period, other_alds) filing_status = person.tax_unit("filing_status", period) joint = filing_status == filing_status.possible_values.JOINT frac = where(joint, 0.5, 1.0) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py index 1ca12f01e0a..1a6911854c2 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py @@ -11,6 +11,9 @@ class adjusted_gross_income_person(Variable): def formula(person, period, parameters): gross_income = person("irs_gross_income", period) + # A tax unit dependent's deductions are on their own return, as + # irs_gross_income leaves their income off this one. + not_dependent = ~person("is_tax_unit_dependent", period) # calculate ald sums by person PERSON_ALDS = [ "self_employment_tax_ald", @@ -18,11 +21,11 @@ def formula(person, period, parameters): "self_employed_pension_contribution_ald", ] person_ald_vars = [f"{ald}_person" for ald in PERSON_ALDS] - ald_sum_person = add(person, period, person_ald_vars) + ald_sum_person = not_dependent * add(person, period, person_ald_vars) # split other alds evenly between head and spouse all_alds = parameters(period).gov.irs.ald.deductions other_alds = sorted(set(all_alds) - set(PERSON_ALDS)) - ald_sum_taxunit = add(person.tax_unit, period, other_alds) + ald_sum_taxunit = tax_unit_non_dep_add(person.tax_unit, period, other_alds) is_head = person("is_tax_unit_head", period) is_spouse = person("is_tax_unit_spouse", period) fstatus = person.tax_unit("filing_status", period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py index 0e8a6beb1fb..c6eeb38f7d6 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py @@ -42,5 +42,6 @@ def formula(tax_unit, period, parameters): for deduction in above_the_line_deductions if deduction not in revoked_deductions ] - total_deductions = add(tax_unit, period, deductions) + # A tax unit dependent's deductions are on their own return. + total_deductions = tax_unit_non_dep_add(tax_unit, period, deductions) return max_(0, gross_income - total_deductions) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/unemployment_insurance/taxable_uc_agi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/unemployment_insurance/taxable_uc_agi.py index cf332450c08..d2dd776c94d 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/unemployment_insurance/taxable_uc_agi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/unemployment_insurance/taxable_uc_agi.py @@ -26,5 +26,8 @@ def formula(tax_unit, period, parameters): gross_income += not_dependent * max_(0, add(person, period, [source])) gross_income = tax_unit.sum(gross_income) above_the_line_deductions = irs.ald.deductions - total_deductions = add(tax_unit, period, above_the_line_deductions) + # A tax unit dependent's deductions are on their own return. + total_deductions = tax_unit_non_dep_add( + tax_unit, period, above_the_line_deductions + ) return max_(0, gross_income - total_deductions) diff --git a/policyengine_us/variables/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.py b/policyengine_us/variables/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.py index fa66c70a8e6..4d55f2a34b8 100644 --- a/policyengine_us/variables/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.py +++ b/policyengine_us/variables/gov/states/ma/tax/income/adjusted_gross_income/ma_part_b_agi.py @@ -20,7 +20,9 @@ def formula(tax_unit, period, parameters): for deduction in federal_deductions if deduction not in disallowed_deductions ] - deduction_value = add(tax_unit, period, deductions) + # Part B gross income starts from irs_gross_income, which leaves out + # tax unit dependents, so their deductions are left out too. + deduction_value = tax_unit_non_dep_add(tax_unit, period, deductions) # U.S. government bond interest is exempt from MA tax. us_govt_interest = add(tax_unit, period, ["us_govt_interest"]) return max_( diff --git a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income_loss_adjustment.py b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income_loss_adjustment.py index 720c6be2ced..c09ea512110 100644 --- a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income_loss_adjustment.py +++ b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income_loss_adjustment.py @@ -16,14 +16,20 @@ def formula(tax_unit, period, parameters): # zero, so this variable captures the losses that were dropped. # Line 10 instruction: "Be sure to subtract any losses # in lines 6 or 7." + # irs_gross_income leaves out tax unit dependents, whose items are + # on their own returns, so their losses are left out here too. # Line 6a: Business/profession loss (Schedule C) - se_income = add(tax_unit, period, ["total_self_employment_income"]) + se_income = tax_unit_non_dep_add( + tax_unit, period, ["total_self_employment_income"] + ) # Line 6b: Farm loss (Schedule F) - farm = add(tax_unit, period, ["farm_operations_income"]) + farm = tax_unit_non_dep_add(tax_unit, period, ["farm_operations_income"]) # Line 7: Rental, partnership, S-corp, farm rent losses - rental = add(tax_unit, period, ["rental_income"]) - partnership = add(tax_unit, period, ["partnership_s_corp_income"]) - farm_rent = add(tax_unit, period, ["farm_rent_income"]) + rental = tax_unit_non_dep_add(tax_unit, period, ["rental_income"]) + partnership = tax_unit_non_dep_add( + tax_unit, period, ["partnership_s_corp_income"] + ) + farm_rent = tax_unit_non_dep_add(tax_unit, period, ["farm_rent_income"]) return ( min_(se_income, 0) + min_(farm, 0) diff --git a/policyengine_us/variables/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.py b/policyengine_us/variables/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.py index 12ee4121a06..2c10fab998c 100644 --- a/policyengine_us/variables/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.py +++ b/policyengine_us/variables/gov/states/mo/tax/income/adjusted_gross_income/mo_adjusted_gross_income.py @@ -23,8 +23,12 @@ def formula(person, period, parameters): "self_employed_pension_contribution_ald_person", ] tax_unit = person.tax_unit - ind_total_personal_alds = add(person, period, PERSONAL_ALDS) - unit_total_personal_alds = add(tax_unit, period, PERSONAL_ALDS) + # A tax unit dependent's deductions are on their own return, and are + # left out of above_the_line_deductions, as their income is left out + # of irs_gross_income. + not_dependent = ~person("is_tax_unit_dependent", period) + ind_total_personal_alds = not_dependent * add(person, period, PERSONAL_ALDS) + unit_total_personal_alds = tax_unit.sum(ind_total_personal_alds) # ... subtract remaining ALDs by adhoc allocation between spouses unit_total_alds = tax_unit("above_the_line_deductions", period) unit_remaining_alds = unit_total_alds - unit_total_personal_alds From 1a97062090eccc16d913dfbf838e1f1d9178bd79 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 13:53:50 -0400 Subject: [PATCH 11/22] Test that the ALD total stays between 0 and the all-member sum Co-Authored-By: Claude Opus 5.5 --- .../test_dependent_above_the_line_deductions_invariants.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py index 7f5bc0bc4bf..e1342a66bad 100644 --- a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py @@ -25,7 +25,8 @@ unit's AGI, and so do the head's and spouse's Medicaid AGIs. 3. Differential: `above_the_line_deductions` and each aggregate equal an independent numpy sum over the head and spouse. For units without - dependents this is also the previous all-member sum, so they see no change. + dependents this is also the previous all-member sum, so they see no change; + for others it is between 0 and that sum. 4. Each person's own deduction amounts are still computed for dependents (half of each person's self-employment tax, for example), so a dependent's own return can use them, and a dependent's Medicaid AGI is @@ -338,7 +339,10 @@ def _check(units, year): run["above_the_line_deductions"], reference, atol=TOLERANCE ) # Units without dependents: the previous all-member sum, unchanged. + # Units with dependents: never more than it, and never negative. all_members = run["loss_ald"] + _unit_sum(run, person_total + alimony) + assert (run["above_the_line_deductions"] >= -TOLERANCE).all() + assert (run["above_the_line_deductions"] <= all_members + TOLERANCE).all() np.testing.assert_allclose( run["above_the_line_deductions"][~has_dependent], all_members[~has_dependent], From 3f22244c137dffff24917b19334cab9899716cf8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 14:36:30 -0400 Subject: [PATCH 12/22] Read the federal Schedule 1 aggregates on Michigan line 30; reformable member lists Review r1: - Line 30 reads the tax-unit adjustments as they are, so supplied inputs and reforms of self_employment_tax_ald and the other aggregates are respected. #9801 (merged in) makes those aggregates and alimony_expense_ald leave dependents out. - The every-member sources (lines 21, 22 and 27) move to a parameter, gov.states.mi.tax.income.household_resources_all_members, and a source defined for a group larger than the tax unit (an SPM unit amount) is aggregated as a household amount, so a reform cannot hit an entity error. - gi_cash_assistance counts only for the claimant and spouse: nothing shows a pilot payment is public assistance paid to the claimant's household. - Tests: dependent alimony and guaranteed income YAML cases; property tests always have a dependent and a positive-resources household; a reform test. Co-Authored-By: Claude Opus 5.5 --- .../mi/tax/income/household_resources.yaml | 6 +- .../household_resources_all_members.yaml | 28 ++++++ .../mi/tax/income/mi_household_resources.yaml | 74 ++++++++++++++- .../test_mi_household_resources_members.py | 93 +++++++++++++++++-- .../mi/tax/income/mi_household_resources.py | 52 ++++------- 5 files changed, 207 insertions(+), 46 deletions(-) create mode 100644 policyengine_us/parameters/gov/states/mi/tax/income/household_resources_all_members.yaml diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml index 87a5a591b4e..df06e1c1e64 100644 --- a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml @@ -2,8 +2,7 @@ description: Michigan accounts for the following income sources as the household values: 2021-01-01: # Each source counts for the claimant and spouse only (MCL 206.508(3)), - # except the amounts received for the household on lines 21, 22 and 27; - # see mi_household_resources. + # except those in household_resources_all_members (lines 21, 22 and 27). # MI-1040CR line 14, wages, including strike pay. - irs_employment_income - strike_benefits @@ -48,9 +47,10 @@ values: - workers_compensation # MI-1040CR line 14, long-term disability benefits. - disability_benefits + # Guaranteed income pilot payments, the recipient's own. + - gi_cash_assistance # MI-1040CR line 27, payments to the household by MDHHS and other public # assistance. - - gi_cash_assistance - tanf - general_assistance metadata: diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_all_members.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_all_members.yaml new file mode 100644 index 00000000000..ae527614639 --- /dev/null +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_all_members.yaml @@ -0,0 +1,28 @@ +description: Michigan counts these household resources sources for every tax unit member, not only the claimant and spouse, as amounts the claimant receives for others in the household. +values: + 2021-01-01: + # MI-1040CR line 21: Social Security, SSI and railroad retirement + # benefits, including "amounts received for minor children or other + # dependent adults who live with you". + - social_security + - ssi + - railroad_benefits + # MI-1040CR line 22: "child support and all payments received as a foster + # parent". + - child_support_received + # MI-1040CR line 27: "the total payments made to your household by MDHHS + # and all other public assistance payments". A Family Independence + # Program grant covers the children in the case. + - tanf + - general_assistance +metadata: + unit: list + period: year + label: Michigan household resources sources counted for every member + reference: + - title: MCL 206.508(3) and (4), household and total household resources + href: https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-508 + - title: Michigan 2025 Individual Income Tax Instructions, MI-1040CR lines 21, 22 and 27 + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=32 + - title: Michigan 2024 Home Heating Credit Instructions, MI-1040CR-7 lines 26, 27 and 32 + href: https://web.archive.org/web/20250202150154/https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/BOOK_MI-1040CR-7.pdf#page=8 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml index 90a1bdd2fc9..dc824bd1429 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml @@ -717,6 +717,78 @@ state_code: MI output: # Line 31: "insurance premiums you paid for yourself and your family", - # 2,400 + 1,200 = 3,600. + # 2,400 + 1,200 = 3,600, reading the child's premium as one the claimant + # paid for the child's coverage. # Line 33: 40,000 - 3,600 = 36,400. mi_household_resources: 36_400 + +- name: A dependent's alimony payment is not on the claimant's line 30 + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + employment_income_before_lsr: 40_000 + person2: + age: 30 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + alimony_expense: 2_000 + divorce_year: 2015 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1] + dependent_marital_unit: + members: [person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 40,000. Line 30: the dependent's deductible alimony (a 2015 + # divorce) is on the dependent's own Schedule 1, so 0. + # Line 33: 40,000. + mi_household_resources: 40_000 + +- name: Guaranteed income pilot payments count only for the claimant and spouse + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + employment_income_before_lsr: 40_000 + gi_cash_assistance: 1_000 + person2: + age: 19 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + gi_cash_assistance: 7_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1] + dependent_marital_unit: + members: [person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 40,000. The claimant's own pilot payment, 1,000, counts. The + # dependent's 7,000 is the dependent's own income: nothing shows it is a + # public assistance payment to the claimant's household (line 27). + # Line 33: 41,000. + mi_household_resources: 41_000 diff --git a/policyengine_us/tests/test_mi_household_resources_members.py b/policyengine_us/tests/test_mi_household_resources_members.py index 91f417ae6a7..fd9f10f8a5b 100644 --- a/policyengine_us/tests/test_mi_household_resources_members.py +++ b/policyengine_us/tests/test_mi_household_resources_members.py @@ -14,12 +14,14 @@ - line 22: child support and foster parent payments; - line 27: payments to the household by MDHHS and other public assistance. -Line 31 is "insurance premiums you paid for yourself and your family". +Line 31 is "insurance premiums you paid for yourself and your family"; a +premium on any member's record is read as one the claimant or spouse paid +for the family's coverage. So for every household: 1. mi_household_resources equals a reference written from the form: the head - and spouse's lines 14 to 20, 23, 24 and 26 and their line 30 adjustments, + and spouse's lines 14 to 20 and 23 to 26 and their line 30 adjustments, plus every member's lines 21, 22 and 27, less every member's premiums, floored at zero. 2. A dependent's own income and adjustments do not matter: zeroing them @@ -29,6 +31,13 @@ leaves household resources unchanged. 4. Raising a dependent's line 21, 22 or 27 amount by d raises household resources by between 0 and d, and by exactly d when they were positive. +5. Each of the spouse's own sources adds what it adds on the head. +6. The lists are reformable: a source moved into + household_resources_all_members counts for every member, and an SPM unit + source in household_resources counts in full. + +Every generated household has a dependent, so properties 2 to 4 always bind, +and property 4 always includes a household with positive resources. Comparisons allow one cent or eight float32 spacings at the sum of the household's absolute amounts, as in test_mi_household_resources_losses.py. @@ -39,6 +48,9 @@ import numpy as np import pytest +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + from policyengine_us import Simulation YEAR = 2025 @@ -64,6 +76,8 @@ "miscellaneous_income": 24, "veterans_benefits": 26, "workers_compensation": 26, + # Guaranteed income pilot payments: the recipient's own (line 25). + "gi_cash_assistance": 25, # Line 30: penalty on early withdrawal of savings (Schedule 1 line 18). "early_withdrawal_penalty": 30, } @@ -75,7 +89,6 @@ "railroad_benefits": 21, "child_support_received": 22, "general_assistance": 27, - "gi_cash_assistance": 27, } PREMIUMS = "health_insurance_premiums" # Sources that can be negative. @@ -185,7 +198,7 @@ def reference(h, person_adjustments): line_17 = max(0, amount(own, ["rental_income"])) line_18 = amount(own, by_line(CLAIMANT_LINES, 18)) line_19 = max(-CAPITAL_LOSS_LIMIT, amount(own, ["long_term_capital_gains"])) - lines_20_to_26 = amount(own, by_line(CLAIMANT_LINES, 20, 23, 24, 26)) + lines_20_to_26 = amount(own, by_line(CLAIMANT_LINES, 20, 23, 24, 25, 26)) received = amount(everyone(h), list(HOUSEHOLD_LINES)) # Line 27: the household's Family Independence Program grant, in full. received += h.get("tanf", 0) @@ -338,6 +351,72 @@ def test_spouse_counts_like_the_head(): ) +def reform_lists(household_resources=None, all_members=None): + """A reform that replaces Michigan's source lists for the year.""" + + class reform(Reform): + def apply(self): + def modify(parameters): + p = parameters.gov.states.mi.tax.income + for node, values in [ + (p.household_resources, household_resources), + (p.household_resources_all_members, all_members), + ]: + if values is not None: + node.update( + start=instant(f"{YEAR}-01-01"), + stop=instant(f"{YEAR}-12-31"), + value=values, + ) + return parameters + + self.modify_parameters(modify) + + return reform + + +def test_reformed_lists(): + """6. A source moved into household_resources_all_members counts for + every member, and an SPM unit source put in household_resources in place + of tanf counts in full.""" + h = base_household() + h["dependents"][0]["gi_cash_assistance"] = 7_000 + baseline = Simulation(situation=build_situation([h])) + p = baseline.tax_benefit_system.parameters.gov.states.mi.tax.income + sources = list(p.household_resources(f"{YEAR}-01-01")) + all_members = list(p.household_resources_all_members(f"{YEAR}-01-01")) + # The dependent's own pilot payment is not household resources... + assert baseline.calculate("mi_household_resources", YEAR)[0] == pytest.approx( + 40_000, abs=TOLERANCE + ) + # ...unless a reform counts it for every member. + moved = Simulation( + situation=build_situation([h]), + reform=reform_lists(all_members=all_members + ["gi_cash_assistance"]), + ) + assert moved.calculate("mi_household_resources", YEAR)[0] == pytest.approx( + 47_000, abs=TOLERANCE + ) + # A parent with wages of 10,000 and a child, and an SPM unit grant of + # 6,000 given as tanf_if_takes_up, which the reform lists in place of tanf. + family = base_household() + family["head"]["employment_income_before_lsr"] = 10_000 + situation = build_situation([family]) + situation["spm_units"]["spm_unit_0"]["tanf_if_takes_up"] = {YEAR: 6_000} + swapped = Simulation( + situation=situation, + reform=reform_lists( + household_resources=[ + "tanf_if_takes_up" if s == "tanf" else s for s in sources + ], + all_members=[s for s in all_members if s != "tanf"], + ), + ) + assert swapped.calculate("mi_household_resources", YEAR)[0] == pytest.approx( + 16_000, abs=TOLERANCE + ) + + # --------------------------------------------------------------------------- # Hypothesis properties. Hypothesis is a dev extra: without it these tests # are not collected and the deterministic tests above still run. @@ -367,7 +446,7 @@ def person(draw): @st.composite def household(draw): dependents = [] - for _ in range(draw(st.integers(min_value=0, max_value=2))): + for _ in range(draw(st.integers(min_value=1, max_value=2))): d = draw(person()) d["age"] = draw(st.sampled_from([10, 16, 75])) dependents.append(d) @@ -393,7 +472,9 @@ def test_more_received_for_a_dependent(households, source, extra): """4. More of a line 21, 22 or 27 amount for a dependent raises household resources by between 0 and the increase, and by exactly the increase when they were positive.""" - households = [h for h in households if h["dependents"]] or [base_household()] + # A household with positive resources, so the exact-increase branch + # always runs. + households = households + [base_household()] raised = [] for h in households: r = copy(h) diff --git a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py index d61ef0f0a20..9115cd4c583 100644 --- a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py +++ b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py @@ -60,33 +60,20 @@ def formula(tax_unit, period, parameters): # irs_gross_income. A tax-unit-level source (other_net_gain, # filer_loss_limited_net_capital_gains) already describes the # filer's return. - # The form counts amounts the claimant receives for others in the - # household on three lines (page 32), so these are summed over every - # member: - # Line 21: Social Security, SSI and railroad retirement benefits, - # including "amounts received for minor children or other dependent - # adults who live with you". - # Line 22: "child support and all payments received as a foster - # parent". - # Line 27: "the total payments made to your household by MDHHS and - # all other public assistance payments". A Family Independence - # Program grant covers the children in the case, and tanf, an SPM - # unit amount, reaches the tax unit in per-person shares. - received_for_household = { - "social_security", - "ssi", - "railroad_benefits", - "child_support_received", - "tanf", - "general_assistance", - "gi_cash_assistance", - } + # The sources in household_resources_all_members are amounts the + # claimant receives for others in the household (lines 21, 22 and + # 27), so they are summed over every member. So is a source defined + # for a larger group than the tax unit, such as tanf for the SPM + # unit: add gives the tax unit its members' shares. + all_members = p.household_resources_all_members business_income = 0 rental_income = 0 other_income = 0 for source in p.household_resources: - if source in received_for_household: + entity = tax_unit.entity.get_variable(source).entity + own_return = entity.is_person or entity.key == tax_unit.entity.key + if source in all_members or not own_return: amount = add(tax_unit, period, [source]) else: amount = tax_unit_non_dep_add(tax_unit, period, [source]) @@ -104,23 +91,15 @@ def formula(tax_unit, period, parameters): # and floored on lines 16, 17 and 19 above, so loss_ald, which holds # them, is left out. # The Schedule 1 is the claimant's own: a dependent's adjustments, - # like the dependent's income, are on the dependent's return. The - # self-employment adjustments are computed per person, so their - # person-level amounts are summed over the head and spouse. - per_person_adjustments = { - "self_employment_tax_ald", - "self_employed_health_insurance_ald", - "self_employed_pension_contribution_ald", - } + # like the dependent's income, are on the dependent's return. + # Person-level adjustments are summed over the head and spouse, and + # tax-unit-level ones are the filer's own: the self-employment and + # alimony deductions sum only the head and spouse. adjustments = tax_unit_non_dep_add( tax_unit, period, [ - ( - f"{deduction}_person" - if deduction in per_person_adjustments - else deduction - ) + deduction for deduction in parameters(period).gov.irs.ald.deductions if deduction != "loss_ald" ], @@ -128,6 +107,7 @@ def formula(tax_unit, period, parameters): # Line 31: "insurance premiums you paid for yourself and your # family". MCL 206.510(1) lets a person deduct "the amount that # person paid in premiums ... for that insurance plan for the - # person's family", so every member's premiums count. + # person's family". A premium on any member's record is read as one + # the claimant or spouse paid for the family's coverage. health_insurance_premiums = add(tax_unit, period, ["health_insurance_premiums"]) return max_(0, total - adjustments - health_insurance_premiums) From 638c794a18e4a664af48b2926e6061e54761fd66 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 22:46:49 -0400 Subject: [PATCH 13/22] Add the combined #9781 regression case, a joint Maryland surtax case, and move the distributions comment Co-Authored-By: Claude Opus 5.5 --- .../gov/irs/gross_income/sources.yaml | 2 +- ...schedule_d_capital_gain_distributions.yaml | 32 +++++++++++++++++++ .../md_capital_gains_surtax.yaml | 31 ++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/policyengine_us/parameters/gov/irs/gross_income/sources.yaml b/policyengine_us/parameters/gov/irs/gross_income/sources.yaml index d5ed2911232..af8391cacdf 100644 --- a/policyengine_us/parameters/gov/irs/gross_income/sources.yaml +++ b/policyengine_us/parameters/gov/irs/gross_income/sources.yaml @@ -9,10 +9,10 @@ values: - farm_rent_income - other_net_gain_gross_income - capital_gains - - non_sch_d_capital_gains # Capital gain distributions reported on Form 1040 line 7a. With capital # losses they go on Schedule D line 13; loss_ald nets the losses against # them (capital_losses_allowed_against_gains). + - non_sch_d_capital_gains - taxable_interest_income - rental_income # Royalties included in rental income diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml index 681e96b22f0..7110f79b8fa 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml @@ -388,3 +388,35 @@ net_investment_income: 0 has_qdiv_or_ltcg: false + +- name: Netted distributions are taxed at ordinary rates + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 100_000 + long_term_capital_gains: -8_000 + non_sch_d_capital_gains: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # With PolicyEngine/policyengine-us#9781 (section 1(h)) on main, AGI and + # the rate schedule agree. Schedule D line 13 = 5,000; line 15 = -3,000 + # = line 16; line 21 = -3,000. + adjusted_gross_income: 97_000 + # 97,000 - 15,750. + taxable_income: 81_250 + # Lines 15 and 16 are losses: no net capital gain (26 U.S.C. 1222(11)), + # so the distributions get no preferential rate. + net_capital_gain: 0 + capital_gains_tax: 0 + # 2025 single brackets: 1,192.50 + 12% x 36,550 + 22% x (81,250 - 48,475) + # = 1,192.50 + 4,386 + 7,210.50. + income_tax_main_rates: 12_789 diff --git a/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml b/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml index 1e93b88aea1..1e12d9fed73 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml @@ -167,6 +167,37 @@ # Surtax: $100,000 * 2% = $2,000 md_capital_gains_surtax: 2_000 +- name: MD Capital gains surtax 2025 - One spouse's loss within the other's gains + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + long_term_capital_gains: 100_000 + person2: + age: 48 + short_term_capital_gains: -20_000 + tax_units: + tax_unit: + members: [person1, person2] + adjusted_gross_income: 450_000 + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MD + output: + # Joint federal Schedule D line 16 = $100,000 - $20,000 = $80,000. The + # loss is within the gains, so 26 U.S.C. 1211(b) allows it in full and + # no $3,000 capped loss is left to subtract again. + # 2025 Form 502CG line 1 (federal line 7a): $80,000 + # Surtax: $80,000 * 2% = $1,600 (subtracting a $3,000 capped loss again + # gave $1,540) + md_capital_gains_surtax: 1_600 + - name: MD Capital gains surtax 2025 - High earner with large gains period: 2025 absolute_error_margin: 0.01 From 0a2ca21b4a3c7be6e2c587ac10dee44df8dfcfa0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 23:04:29 -0400 Subject: [PATCH 14/22] Use tax_unit_non_dep_add for lines 16 and 17; describe the dependent change in the changelog Co-Authored-By: Claude Opus 5.5 --- ...usehold-resources-business-losses.fixed.md | 2 +- .../mi/tax/income/mi_household_resources.py | 21 +++++++------------ 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/changelog.d/mi-household-resources-business-losses.fixed.md b/changelog.d/mi-household-resources-business-losses.fixed.md index 05062154438..7e9b0108f23 100644 --- a/changelog.d/mi-household-resources-business-losses.fixed.md +++ b/changelog.d/mi-household-resources-business-losses.fixed.md @@ -1 +1 @@ -Michigan household resources net business and rental income within MI-1040CR lines 16 and 17 before flooring each total at zero, count estate and trust income and Form 4797 ordinary gains on line 16, and no longer subtract business, rental or capital losses again as Schedule 1 adjustments on line 30. +Michigan household resources net business and rental income within MI-1040CR lines 16 and 17 before flooring each total at zero, count only the claimant's and spouse's business and rental items on those two lines (MCL 206.508(3)), count estate and trust income and Form 4797 ordinary gains on line 16, and no longer subtract business, rental or capital losses again as Schedule 1 adjustments on line 30. diff --git a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py index 63be39fdbda..5a2b4d4e27a 100644 --- a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py +++ b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py @@ -50,24 +50,17 @@ def formula(tax_unit, period, parameters): # MCL 206.508(3): "'Household' means a claimant and spouse." A # dependent's business and rental items belong on the dependent's own # return, so they neither add to nor net against lines 16 and 17, as - # loss_ald leaves them off this return. - person = tax_unit.members - not_dependent = ~person("is_tax_unit_dependent", period) - variables = tax_unit.simulation.tax_benefit_system.variables - + # loss_ald leaves them off this return. tax_unit_non_dep_add sums a + # person-level source over the head and spouse and takes a + # tax-unit-level one (other_net_gain) as is. business_income = 0 rental_income = 0 other_income = 0 for source in p.household_resources: - if source in business_sources | rental_sources: - if variables[source].entity.is_person: - amount = tax_unit.sum(person(source, period) * not_dependent) - else: - amount = tax_unit(source, period) - if source in business_sources: - business_income += amount - else: - rental_income += amount + if source in business_sources: + business_income += tax_unit_non_dep_add(tax_unit, period, [source]) + elif source in rental_sources: + rental_income += tax_unit_non_dep_add(tax_unit, period, [source]) else: other_income += add(tax_unit, period, [source]) total = other_income + max_(business_income, 0) + max_(rental_income, 0) From b10ac14936720e346dc355cf31c23182df653613 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 01:07:36 -0400 Subject: [PATCH 15/22] Keep a supplied net_capital_gains amount on the filer's line 7a; add aggregate-input cases Review r1 finding 1: filer_loss_limited_net_capital_gains summed the head's and spouse's components, so a tax unit net_capital_gains input was ignored (wages 10,000 plus a 5,000 aggregate gain gave 10,000, not 15,000). It now takes net_capital_gains less the dependents' own gains and losses. Co-Authored-By: Claude Opus 5.5 --- ...usehold-resources-claimant-spouse.fixed.md | 2 +- .../mi/tax/income/mi_household_resources.yaml | 38 +++++++++++++++++++ .../filer_loss_limited_net_capital_gains.py | 18 +++++++-- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/changelog.d/mi-household-resources-claimant-spouse.fixed.md b/changelog.d/mi-household-resources-claimant-spouse.fixed.md index aed3a99302c..499b2f25abc 100644 --- a/changelog.d/mi-household-resources-claimant-spouse.fixed.md +++ b/changelog.d/mi-household-resources-claimant-spouse.fixed.md @@ -1 +1 @@ -Michigan household resources count only the claimant's and spouse's own income, capital gains and Schedule 1 adjustments (MCL 206.508(3)), keeping the Social Security, SSI, railroad retirement, child support and public assistance received for dependents (MI-1040CR lines 21, 22 and 27) and premiums paid for the family (line 31). +Michigan household resources count only the claimant's and spouse's own income, capital gains and Schedule 1 adjustments (MCL 206.508(3)), keeping the Social Security, SSI, railroad retirement, child support and public assistance received for dependents (MI-1040CR lines 21, 22 and 27) and premiums paid for the family (line 31). The filer's Form 1040 line 7a amount now keeps a net capital gains amount supplied for the tax unit, less only the dependents' own gains and losses. diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml index dc824bd1429..a1460b9e04c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml @@ -792,3 +792,41 @@ # public assistance payment to the claimant's household (line 27). # Line 33: 41,000. mi_household_resources: 41_000 + +- name: A supplied net capital gains amount counts on line 19 + period: 2025 + input: + employment_income_before_lsr: 10_000 + net_capital_gains: 5_000 + state_code: MI + output: + # A tax unit net_capital_gains input is the filer's Schedule D amount. + # Line 14: 10,000. Line 19: 5,000. + filer_loss_limited_net_capital_gains: 5_000 + mi_household_resources: 15_000 + +- name: A supplied net capital loss is limited to $3,000 on line 19 + period: 2025 + input: + employment_income_before_lsr: 10_000 + net_capital_gains: -5_000 + state_code: MI + output: + # Line 19: "losses cannot exceed $3,000", so -3,000. + # Line 33: 10,000 - 3,000 = 7,000. + filer_loss_limited_net_capital_gains: -3_000 + mi_household_resources: 7_000 + +- name: Supplied Schedule 1 adjustment amounts come off line 30 + period: 2025 + input: + employment_income_before_lsr: 10_000 + self_employment_tax_ald: 1_000 + self_employed_pension_contribution_ald: 1_000 + state_code: MI + output: + # Tax unit inputs for the deduction for self-employment tax and the SEP + # deduction are the filer's Schedule 1 amounts. + # Line 33: 10,000 - 1,000 - 1,000 = 8,000. + mi_household_resources: 8_000 + diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py index 120ca814d65..f6223c6afcf 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py @@ -25,14 +25,24 @@ def formula(tax_unit, period, parameters): p = parameters(period).gov.irs filing_status = tax_unit("filing_status", period) loss_limit = p.capital_gains.loss_limit[filing_status] - net_capital_gains = tax_unit_non_dep_add( - tax_unit, period, ["long_term_capital_gains", "short_term_capital_gains"] + # net_capital_gains sums every member's Schedule D gains and losses, + # or holds a tax unit amount supplied as an input. A tax unit + # dependent's own gains and losses are on the dependent's return, so + # they come out here; a supplied amount is kept. + person = tax_unit.members + dependent = person("is_tax_unit_dependent", period) + dependent_gains = tax_unit.sum( + dependent + * ( + person("long_term_capital_gains", period) + + person("short_term_capital_gains", period) + ) ) + net_capital_gains = tax_unit("net_capital_gains", period) - dependent_gains # Capital gain distributions go on Schedule D line 13 with the other # gains and losses, so they net before the limit. Each filer's input # is floored at zero, as in irs_gross_income. - person = tax_unit.members - not_dependent = ~person("is_tax_unit_dependent", period) + not_dependent = ~dependent distributions = tax_unit.sum( not_dependent * max_(0, person("non_sch_d_capital_gains", period)) ) From 8d494dedec12e45866301d2977887c30e4809e13 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 02:10:29 -0400 Subject: [PATCH 16/22] Document supplied net_capital_gains on the filer's line 7a; add NII and SE health insurance cases Review r2 follow-ups: a supplied net_capital_gains reaches net investment income (YAML case), a supplied self-employed health insurance deduction comes off Michigan line 30 (YAML case), and the variable documents how a supplied total combines with dependents' person-level gains. Co-Authored-By: Claude Opus 5.5 --- .../mi-household-resources-claimant-spouse.fixed.md | 2 +- .../dependent_net_investment_income.yaml | 12 ++++++++++++ .../states/mi/tax/income/mi_household_resources.yaml | 10 ++++++++++ .../filer_loss_limited_net_capital_gains.py | 5 ++++- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/changelog.d/mi-household-resources-claimant-spouse.fixed.md b/changelog.d/mi-household-resources-claimant-spouse.fixed.md index 499b2f25abc..aef8da850af 100644 --- a/changelog.d/mi-household-resources-claimant-spouse.fixed.md +++ b/changelog.d/mi-household-resources-claimant-spouse.fixed.md @@ -1 +1 @@ -Michigan household resources count only the claimant's and spouse's own income, capital gains and Schedule 1 adjustments (MCL 206.508(3)), keeping the Social Security, SSI, railroad retirement, child support and public assistance received for dependents (MI-1040CR lines 21, 22 and 27) and premiums paid for the family (line 31). The filer's Form 1040 line 7a amount now keeps a net capital gains amount supplied for the tax unit, less only the dependents' own gains and losses. +Michigan household resources count only the claimant's and spouse's own income, capital gains and Schedule 1 adjustments (MCL 206.508(3)), keeping the Social Security, SSI, railroad retirement, child support and public assistance received for dependents (MI-1040CR lines 21, 22 and 27) and premiums paid for the family (line 31). The filer's Form 1040 line 7a amount, read by Michigan and by federal net investment income, now keeps a net capital gains amount supplied for the tax unit, less only the dependents' own gains and losses. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/dependent_net_investment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/dependent_net_investment_income.yaml index 473aa347a36..f5ea071528e 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/dependent_net_investment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/dependent_net_investment_income.yaml @@ -401,3 +401,15 @@ net_investment_income: 6_000 # 3.8% x 6,000 net_investment_income_tax: 228 + +- name: A supplied net capital gains amount is net investment income + period: 2025 + input: + employment_income: 300_000 + net_capital_gains: 100_000 + state_code: TX + output: + # Form 8960 line 5a: the filer's Form 1040 line 7a, 100,000. + filer_loss_limited_net_capital_gains: 100_000 + net_investment_income: 100_000 + diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml index a1460b9e04c..0d93c96b8fa 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml @@ -830,3 +830,13 @@ # Line 33: 10,000 - 1,000 - 1,000 = 8,000. mi_household_resources: 8_000 +- name: A supplied self-employed health insurance deduction comes off line 30 + period: 2025 + input: + employment_income_before_lsr: 10_000 + self_employed_health_insurance_ald: 1_000 + state_code: MI + output: + # Line 33: 10,000 - 1,000 = 9,000. + mi_household_resources: 9_000 + diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py index f6223c6afcf..7af9f28e9b9 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py @@ -13,7 +13,10 @@ class filer_loss_limited_net_capital_gains(Variable): "13), with a net loss limited under 26 USC 1211(b). This is Form 8960 " "line 5a without Schedule 1 line 4. A tax unit dependent's gains and " "losses are on the dependent's own return. Unlike " - "loss_limited_net_capital_gains, this leaves dependents out." + "loss_limited_net_capital_gains, this leaves dependents out. It " + "starts from net_capital_gains, so a tax unit amount supplied there " + "is kept; that amount is read as covering every member, and any " + "dependent's person-level gains and losses are then taken out." ) reference = ( "https://www.law.cornell.edu/uscode/text/26/1211#b", From 546c6011d47cd00a95e357479dd5332b9063f6fd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 04:43:04 -0400 Subject: [PATCH 17/22] Pass include_dependents on Michigan line 30; test a dependent's positive Schedule C and F income With #9801 on main: line 30 sums gov.irs.ald.filer_amounts_recorded_on_dependents over every member, as above_the_line_deductions does, and the losses test now draws positive dependent Schedule C and F income, whose self-employment tax deduction is on the dependent's own return. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_mi_household_resources_losses.py | 9 +++++---- .../states/mi/tax/income/mi_household_resources.py | 12 ++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/policyengine_us/tests/test_mi_household_resources_losses.py b/policyengine_us/tests/test_mi_household_resources_losses.py index d72a0d40574..844c8930a7d 100644 --- a/policyengine_us/tests/test_mi_household_resources_losses.py +++ b/policyengine_us/tests/test_mi_household_resources_losses.py @@ -311,10 +311,11 @@ def test_worked_example(): DEPENDENT_VALUES = { - # Losses only for Schedule C and F: a dependent's self-employment tax - # deduction is a separate question (it is not on this return either). - "self_employment_income": [-20_000, 0], - "farm_operations_income": [-10_000, 0], + # Schedule C and F income and losses: a dependent's self-employment tax + # deduction is on the dependent's own return, so line 30 leaves it out + # (#9801). + "self_employment_income": [-20_000, 0, 20_000], + "farm_operations_income": [-10_000, 0, 10_000], "s_corp_income": [-20_000, 0, 15_000], "estate_income": [-5_000, 0, 5_000], "rental_income": [-20_000, 0, 15_000], diff --git a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py index 9115cd4c583..0dd39fda73b 100644 --- a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py +++ b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py @@ -94,15 +94,15 @@ def formula(tax_unit, period, parameters): # like the dependent's income, are on the dependent's return. # Person-level adjustments are summed over the head and spouse, and # tax-unit-level ones are the filer's own: the self-employment and - # alimony deductions sum only the head and spouse. + # alimony deductions sum only the head and spouse. Amounts that are + # the filer's even when recorded on a dependent are summed over every + # member, as in above_the_line_deductions. + ald = parameters(period).gov.irs.ald adjustments = tax_unit_non_dep_add( tax_unit, period, - [ - deduction - for deduction in parameters(period).gov.irs.ald.deductions - if deduction != "loss_ald" - ], + [deduction for deduction in ald.deductions if deduction != "loss_ald"], + include_dependents=ald.filer_amounts_recorded_on_dependents, ) # Line 31: "insurance premiums you paid for yourself and your # family". MCL 206.510(1) lets a person deduct "the amount that From 78a4631469067f7223707ad9e7e1a1c700e41762 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 07:26:42 -0400 Subject: [PATCH 18/22] Use shared filer Schedule D lines without dependent rounding drift --- .../filer-schedule-d-line16-rounding.fixed.md | 1 + .../capital_gains/net_capital_gain.yaml | 131 ++++++++++++++++++ .../filer_loss_limited_net_capital_gains.py | 30 +--- .../capital_gains/filer_schedule_d_lines.py | 37 ++--- 4 files changed, 160 insertions(+), 39 deletions(-) create mode 100644 changelog.d/filer-schedule-d-line16-rounding.fixed.md diff --git a/changelog.d/filer-schedule-d-line16-rounding.fixed.md b/changelog.d/filer-schedule-d-line16-rounding.fixed.md new file mode 100644 index 00000000000..78fd2e51d9f --- /dev/null +++ b/changelog.d/filer-schedule-d-line16-rounding.fixed.md @@ -0,0 +1 @@ +Use the filers' shared Schedule D lines for Form 1040 capital gains, preserving supplied aggregate inputs without letting dependents' large gains introduce rounding errors. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml index 1b8d0064fed..3e2a07411ad 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml @@ -287,3 +287,134 @@ capital_gains_tax: 1_500 # The same filer income and HOH tax calculation as the child-dividends case. regular_tax_before_credits: 22_938 + + +- name: A dependent's very large gain cannot round a dollar off the filer's Schedule D + period: 2025 + input: + people: + head: + age: 30 + employment_income: 20_000 + long_term_capital_gains: 977 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 16_776_240 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # The float32 all-member aggregate rounds 16,777,217 to 16,777,216. + # Subtracting the child's 16,776,240 would give 976, rather than 977. + # Schedule D line 16 and Form 1040 line 7a use the filer's own gain. + filer_loss_limited_net_capital_gains: 977 + net_capital_gain: 977 + dwks09: 977 + dwks10: 977 + adjusted_gross_income: 20_977 + +- name: Each spouse's distributions are floored before they enter the shared Schedule D + period: 2025 + input: + people: + head: + age: 35 + is_tax_unit_head: true + long_term_capital_gains: 6_000 + non_sch_d_capital_gains: -2_000 + spouse: + age: 34 + is_tax_unit_spouse: true + short_term_capital_gains: -3_000 + non_sch_d_capital_gains: 1_500 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 10_000 + non_sch_d_capital_gains: 9_000 + tax_units: + tax_unit: + members: [head, spouse, child] + marital_units: + couple: + members: [head, spouse] + child_unit: + members: [child] + households: + household: + members: [head, spouse, child] + state_code: TX + output: + # Line 13 is max(0, -2,000) + max(0, 1,500) = 1,500, with no child + # distributions. Line 15 is 7,500; line 7 is -3,000; line 16 is 4,500. + filing_status: JOINT + filer_loss_limited_net_capital_gains: 4_500 + net_capital_gain: 4_500 + dwks09: 4_500 + dwks10: 4_500 + +- name: A supplied aggregate still excludes the dependent in Form 1040 line 7a + period: 2025 + input: + people: + head: + age: 35 + long_term_capital_gains: 6_000 + short_term_capital_gains: -1_000 + non_sch_d_capital_gains: 1_500 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 10_000 + short_term_capital_gains: -2_000 + non_sch_d_capital_gains: 9_000 + tax_units: + tax_unit: + members: [head, child] + net_capital_gains: 15_000 + households: + household: + members: [head, child] + state_code: TX + output: + # The supplied 15,000 covers all members. Remove the child's 8,000 and + # add the head's 1,500 distributions: Schedule D line 16 is 8,500. + filer_loss_limited_net_capital_gains: 8_500 + # Preserve the documented aggregate/component split: line 15 is 7,500, + # so worksheet line 9 is min(7,500, 8,500), while the statutory amount + # uses component-derived lines 15 and 7: 7,500 - 1,000 = 6,500. + dwks09: 7_500 + dwks10: 7_500 + net_capital_gain: 6_500 + + +- name: A large supplied aggregate keeps the dollar in its component adjustment + period: 2025 + input: + people: + head: + age: 35 + long_term_capital_gains: -1 + tax_units: + tax_unit: + members: [head] + net_capital_gains: 16_777_216 + households: + household: + members: [head] + state_code: TX + output: + # The aggregate is supplied, rather than the component-derived -1. Its + # difference from -1 is 16,777,217, which rounds down by a dollar in + # float32. Subtract in float64 before adding the filer's -1 back. + filer_loss_limited_net_capital_gains: 16_777_216 + # The component-derived line 15 remains a loss, so the preexisting + # aggregate/component split gives no statutory gain or worksheet gain. + net_capital_gain: 0 + dwks09: 0 + dwks10: 0 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py index 7af9f28e9b9..28cb37c3abf 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.capital_gains.filer_schedule_d_lines import ( + filer_schedule_d_lines, +) class filer_loss_limited_net_capital_gains(Variable): @@ -14,7 +17,8 @@ class filer_loss_limited_net_capital_gains(Variable): "line 5a without Schedule 1 line 4. A tax unit dependent's gains and " "losses are on the dependent's own return. Unlike " "loss_limited_net_capital_gains, this leaves dependents out. It " - "starts from net_capital_gains, so a tax unit amount supplied there " + "uses the same filer Schedule D lines as the tax worksheet. A tax " + "unit net_capital_gains amount supplied as an input " "is kept; that amount is read as covering every member, and any " "dependent's person-level gains and losses are then taken out." ) @@ -28,25 +32,5 @@ def formula(tax_unit, period, parameters): p = parameters(period).gov.irs filing_status = tax_unit("filing_status", period) loss_limit = p.capital_gains.loss_limit[filing_status] - # net_capital_gains sums every member's Schedule D gains and losses, - # or holds a tax unit amount supplied as an input. A tax unit - # dependent's own gains and losses are on the dependent's return, so - # they come out here; a supplied amount is kept. - person = tax_unit.members - dependent = person("is_tax_unit_dependent", period) - dependent_gains = tax_unit.sum( - dependent - * ( - person("long_term_capital_gains", period) - + person("short_term_capital_gains", period) - ) - ) - net_capital_gains = tax_unit("net_capital_gains", period) - dependent_gains - # Capital gain distributions go on Schedule D line 13 with the other - # gains and losses, so they net before the limit. Each filer's input - # is floored at zero, as in irs_gross_income. - not_dependent = ~dependent - distributions = tax_unit.sum( - not_dependent * max_(0, person("non_sch_d_capital_gains", period)) - ) - return max_(-loss_limit, net_capital_gains + distributions) + lines = filer_schedule_d_lines(tax_unit, period) + return max_(-loss_limit, lines.line_16) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py index a0e3654534f..efb2715c5e5 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py @@ -9,8 +9,9 @@ def filer_schedule_d_lines(tax_unit, period): """Schedule D (Form 1040) lines 7, 15 and 16 of the head and spouse, shared - by the Form 1040 line 16 routing (has_qdiv_or_ltcg), net_capital_gain and - the Schedule D Tax Worksheet. + by Form 1040 line 7a (filer_loss_limited_net_capital_gains), the line 16 + routing (has_qdiv_or_ltcg), net_capital_gain and the Schedule D Tax + Worksheet. Line 7 is the net short-term capital gain or loss. Line 15 is the net long-term capital gain or loss, including capital gain distributions @@ -19,10 +20,13 @@ def filer_schedule_d_lines(tax_unit, period): A tax unit dependent's gains, losses and distributions belong on the dependent's own return, so they are left out, as irs_gross_income leaves - them out of adjusted gross income. Line 16 starts from net_capital_gains, - so a tax unit amount supplied there is kept; that amount is read as - covering every member, and any dependent's person-level gains and losses - are then taken out. An explicit aggregate override can make line 16 + them out of adjusted gross income. Line 16 combines the filers' lines 7 + and 15 directly, so a large dependent's gain cannot round away a filer's + smaller gain in the float32 tax unit aggregate. A supplied + net_capital_gains is kept by adding its difference from the computed + all-member aggregate, rounded to the same storage dtype. The supplied + amount covers every member, so dependents' components are left out. + An explicit aggregate override can make line 16 differ from lines 7 and 15 combined, and the worksheet and statutory net-capital-gain paths can then disagree. @@ -43,14 +47,15 @@ def filer_schedule_d_lines(tax_unit, period): tax_unit_non_dep_add(tax_unit, period, ["long_term_capital_gains"]) + distributions ) - # Line 16: lines 7 and 15 combined, figured from net_capital_gains so a - # supplied tax unit amount is kept. - dependent_gains = tax_unit.sum( - dependent - * ( - person("long_term_capital_gains", period) - + person("short_term_capital_gains", period) - ) - ) - line_16 = tax_unit("net_capital_gains", period) - dependent_gains + distributions + # Sum the filers directly: subtracting a dependent's gain from a float32 + # all-member total can lose dollars of the filer's smaller gain. Keep a + # supplied tax-unit aggregate as its difference from the computed total. + # Match net_capital_gains' adds and storage dtype, so this difference is + # exactly zero when the aggregate is computed rather than supplied. + net_capital_gains = tax_unit("net_capital_gains", period) + computed_gains = add( + tax_unit, period, ["long_term_capital_gains", "short_term_capital_gains"] + ).astype(net_capital_gains.dtype) + aggregate_adjustment = net_capital_gains.astype(float) - computed_gains + line_16 = line_7 + line_15 + aggregate_adjustment return FilerScheduleDLines(line_7=line_7, line_15=line_15, line_16=line_16) From 27ffb3b8c2d20ccc9ab4889ba56598c34901fba1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 07:59:30 -0400 Subject: [PATCH 19/22] Preserve supplied capital gains without rounded residuals --- .../capital_gains/net_capital_gain.yaml | 58 ++++++++++++++++++- .../capital_gains/filer_schedule_d_lines.py | 34 ++++++----- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml index 3e2a07411ad..f5d12dd1015 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml @@ -409,12 +409,64 @@ members: [head] state_code: TX output: - # The aggregate is supplied, rather than the component-derived -1. Its - # difference from -1 is 16,777,217, which rounds down by a dollar in - # float32. Subtract in float64 before adding the filer's -1 back. + # The aggregate is supplied, rather than the component-derived -1. + # Reconciling through a float32 difference from -1 rounds 16,777,217 + # down by a dollar. Read the differing supplied aggregate directly. filer_loss_limited_net_capital_gains: 16_777_216 # The component-derived line 15 remains a loss, so the preexisting # aggregate/component split gives no statutory gain or worksheet gain. net_capital_gain: 0 dwks09: 0 dwks10: 0 + +- name: A supplied aggregate removes a large dependent gain without borrowing its rounding error + period: 2025 + input: + people: + head: + age: 35 + long_term_capital_gains: 977 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 16_776_240 + tax_units: + tax_unit: + members: [head, child] + net_capital_gains: 20_000_000 + households: + household: + members: [head, child] + state_code: TX + output: + # The supplied all-member gain is 20,000,000. Removing the child's + # own 16,776,240 gives 3,223,760. A residual against the rounded + # computed aggregate (16,777,216) incorrectly adds a dollar to this. + filer_loss_limited_net_capital_gains: 3_223_760 + # Component-derived line 15 remains 977, so the preexisting + # aggregate/component split gives that gain to the worksheet and + # statutory net-capital-gain calculation. + net_capital_gain: 977 + dwks09: 977 + dwks10: 977 + +- name: A supplied capital gain survives a much larger filer component + period: 2025 + input: + people: + head: + age: 35 + long_term_capital_gains: 1_152_921_504_606_846_976 + tax_units: + tax_unit: + members: [head] + net_capital_gains: 1 + households: + household: + members: [head] + state_code: TX + output: + # The component gain is 2**60, exactly representable in float32. + # Even a float64 residual of 1 - 2**60 loses the supplied dollar when + # added back to the component gain. No dependent gains need removal. + filer_loss_limited_net_capital_gains: 1 diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py index efb2715c5e5..7f15bcc6666 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py @@ -22,13 +22,14 @@ def filer_schedule_d_lines(tax_unit, period): dependent's own return, so they are left out, as irs_gross_income leaves them out of adjusted gross income. Line 16 combines the filers' lines 7 and 15 directly, so a large dependent's gain cannot round away a filer's - smaller gain in the float32 tax unit aggregate. A supplied - net_capital_gains is kept by adding its difference from the computed - all-member aggregate, rounded to the same storage dtype. The supplied - amount covers every member, so dependents' components are left out. - An explicit aggregate override can make line 16 - differ from lines 7 and 15 combined, and the worksheet and statutory - net-capital-gain paths can then disagree. + smaller gain in the float32 tax unit aggregate. When net_capital_gains + differs from the computed all-member aggregate in its storage precision, + that supplied amount covers every member, so dependents' own gains and + losses are removed before the filers' distributions are added. An amount + equal to the computed aggregate uses the direct filer components. + An explicit aggregate override can make line 16 differ from lines 7 and + 15 combined, and the worksheet and statutory net-capital-gain paths can + then disagree. Capital gain distributions reported without Schedule D (non_sch_d_capital_gains) are long-term capital gains (26 U.S.C. @@ -47,15 +48,20 @@ def filer_schedule_d_lines(tax_unit, period): tax_unit_non_dep_add(tax_unit, period, ["long_term_capital_gains"]) + distributions ) - # Sum the filers directly: subtracting a dependent's gain from a float32 - # all-member total can lose dollars of the filer's smaller gain. Keep a - # supplied tax-unit aggregate as its difference from the computed total. - # Match net_capital_gains' adds and storage dtype, so this difference is - # exactly zero when the aggregate is computed rather than supplied. + # A component-derived aggregate may have rounded away a filer's dollar, + # so sum the filers directly when it matches its normal stored value. + # A differing supplied aggregate covers every member: subtract the + # dependents directly instead of reconciling it through a rounded total. net_capital_gains = tax_unit("net_capital_gains", period) computed_gains = add( tax_unit, period, ["long_term_capital_gains", "short_term_capital_gains"] ).astype(net_capital_gains.dtype) - aggregate_adjustment = net_capital_gains.astype(float) - computed_gains - line_16 = line_7 + line_15 + aggregate_adjustment + dependent_gains = tax_unit.sum( + dependent * person("long_term_capital_gains", period) + ) + tax_unit.sum(dependent * person("short_term_capital_gains", period)) + line_16 = where( + net_capital_gains == computed_gains, + line_7 + line_15, + net_capital_gains.astype(float) - dependent_gains + distributions, + ) return FilerScheduleDLines(line_7=line_7, line_15=line_15, line_16=line_16) From b09d151287e33eedf519305f811382c0aff25bd6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 09:30:36 -0400 Subject: [PATCH 20/22] Recognize Michigan dependent pension SSI interaction (d1143) --- ...endent-retirement-ssi-invariant.changed.md | 1 + ...dent_retirement_income_state_invariants.py | 40 +++++++- .../home_heating/mi_home_heating_credit.yaml | 94 +++++++++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 changelog.d/mi-dependent-retirement-ssi-invariant.changed.md diff --git a/changelog.d/mi-dependent-retirement-ssi-invariant.changed.md b/changelog.d/mi-dependent-retirement-ssi-invariant.changed.md new file mode 100644 index 00000000000..7918a3f7217 --- /dev/null +++ b/changelog.d/mi-dependent-retirement-ssi-invariant.changed.md @@ -0,0 +1 @@ +Recognize the intended Michigan dependent-retirement SSI interaction in state tax invariants and add a regression for the resulting heating credit increase. diff --git a/policyengine_us/tests/core/test_dependent_retirement_income_state_invariants.py b/policyengine_us/tests/core/test_dependent_retirement_income_state_invariants.py index 6d484de82fb..49783dcfb0f 100644 --- a/policyengine_us/tests/core/test_dependent_retirement_income_state_invariants.py +++ b/policyengine_us/tests/core/test_dependent_retirement_income_state_invariants.py @@ -28,12 +28,15 @@ 1. `tax_unit_non_dep_add` equals a numpy reference: the head's and spouse's amounts of person-level variables plus tax-unit-level variables. For tax units without dependents it equals `add`. -2. In every state with an income tax, giving the dependents retirement income - never lowers the filer's state income tax. Outside +2. Outside STATES_WITH_INTENDED_RETIREMENT_TAX_DECREASES, giving the + dependents retirement income never lowers the filer's state income tax. + Michigan is an intended exception: a dependent adult's pension can lower + their SSI and increase the claimant's refundable credit. Outside STATES_COUNTING_DEPENDENT_INCOME it changes neither the state AGI nor the state taxable income. (State income tax itself may still rise: credits keyed to household income, such as Oklahoma's sales tax relief credit, - count every household member's income.) + count every household member's income.) Michigan's pension benefits, + subtractions and tax before refundable credits must remain unchanged. 3. A unit whose dependents have no income has the same inputs in both populations, so its state income tax, AGI and taxable income are the same in both: no unit's results depend on another unit's inputs. @@ -368,6 +371,14 @@ def test_tax_unit_non_dep_add_edge_cases(): # subtracting income that never entered AGI; property 2 checks only that it # never lowers tax there. STATES_COUNTING_DEPENDENT_INCOME = {"AL", "AR", "IA", "MS", "NJ"} +# Intended exception per ruling d1143 (2026-10-09): follow MI-1040CR lines +# 18 and 21 (2025 MI-1040 booklet, PDF page 28 / printed page 32): +# https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=28 +# Line 18 counts pensions only for the claimant and spouse; line 21 counts +# SSI received for dependent adults who live with them. A dependent adult's +# pension can reduce SSI, lower household resources and raise refundable +# credits, while the claimant's pension subtractions and tax base stay fixed. +STATES_WITH_INTENDED_RETIREMENT_TAX_DECREASES = {"MI"} YEARS = [2025, 2026] @@ -402,11 +413,32 @@ def test_dependents_retirement_income_never_reduces_state_income_tax(year): assert not leaked.any(), sorted(set(states[leaked])) lowered = tax_with < tax_without - TOLERANCE - assert not lowered.any(), sorted(set(states[lowered])) + intended_exception = np.isin( + states, list(STATES_WITH_INTENDED_RETIREMENT_TAX_DECREASES) + ) + unexpectedly_lowered = lowered & ~intended_exception + assert not unexpectedly_lowered.any(), sorted(set(states[unexpectedly_lowered])) counts_dependents = np.isin(states, list(STATES_COUNTING_DEPENDENT_INCOME)) changed &= ~counts_dependents assert not changed.any(), sorted(set(states[changed])) + + # Michigan's intended exception concerns refundable credits only; a + # dependent's pension must never enter the claimant's pension subtraction. + mi = states == "MI" + for variable in [ + "mi_pension_benefit", + "mi_subtractions", + "mi_income_tax_before_refundable_credits", + ]: + np.testing.assert_allclose( + with_income.calculate(variable, year)[mi], + without_income.calculate(variable, year)[mi], + rtol=0, + atol=TOLERANCE, + err_msg=f"Michigan {variable} changed with dependent retirement income", + ) + # The population must exercise the property, including the paths gated on # the filers' Social Security and on ages of 73 or more. assert has_dependent_income.sum() >= len(INCOME_TAX_STATES) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml index 4ebed014ab9..31f1c40e94a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml @@ -184,3 +184,97 @@ state_code: MI output: mi_home_heating_credit: 0 + +# Ruling d1143 (2026-10-09): follow MI-1040CR lines 18 and 21. +# 2025 MI-1040 book, PDF page 28 (printed page 32): +# https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=28 +# Line 18 excludes a dependent adult's pension; line 21 includes their SSI. +- name: Dependent adult pension lowers Michigan tax through SSI (d1143) + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head_without_pension: + age: 40 + employment_income: 10_000 + is_tax_unit_head: true + is_tax_unit_dependent: false + ssi: 0 + dependent_without_pension: + age: 75 + is_tax_unit_head: false + is_tax_unit_dependent: true + taxable_private_pension_income: 0 + takes_up_ssi_if_eligible: true + head_with_pension: + age: 40 + employment_income: 10_000 + is_tax_unit_head: true + is_tax_unit_dependent: false + ssi: 0 + dependent_with_pension: + age: 75 + is_tax_unit_head: false + is_tax_unit_dependent: true + taxable_private_pension_income: 2_400 + takes_up_ssi_if_eligible: true + marital_units: + head_without_pension: + members: [head_without_pension] + dependent_without_pension: + members: [dependent_without_pension] + head_with_pension: + members: [head_with_pension] + dependent_with_pension: + members: [dependent_with_pension] + tax_units: + without_pension: + members: [head_without_pension, dependent_without_pension] + heating_expenses: 0 + mi_home_heating_credit_heat_included_in_rent: false + mi_eitc: 0 + mi_homestead_property_tax_credit: 0 + with_pension: + members: [head_with_pension, dependent_with_pension] + heating_expenses: 0 + mi_home_heating_credit_heat_included_in_rent: false + mi_eitc: 0 + mi_homestead_property_tax_credit: 0 + spm_units: + without_pension: + members: [head_without_pension, dependent_without_pension] + tanf: 0 + with_pension: + members: [head_with_pension, dependent_with_pension] + tanf: 0 + families: + without_pension: + members: [head_without_pension, dependent_without_pension] + with_pension: + members: [head_with_pension, dependent_with_pension] + households: + without_pension: + members: [head_without_pension, dependent_without_pension] + state_code: MI + with_pension: + members: [head_with_pension, dependent_with_pension] + state_code: MI + output: + # Annual SSI: 12 * $967 = $11,604. Countable annual pension: + # $2,400 - 12 * $20 general exclusion = $2,160; $11,604 - $2,160 = $9,444. + ssi: [0, 11_604, 0, 9_444] + # Claimant wages + dependent SSI; the dependent's pension is excluded. + mi_household_resources: [21_604, 19_444] + adjusted_gross_income: [10_000, 10_000] + mi_pension_benefit: [0, 0] + mi_subtractions: [0, 0] + mi_taxable_income: [0, 0] + mi_income_tax_before_refundable_credits: [0, 0] + # Two exemptions: $815 - 3.5% * household resources. + mi_standard_home_heating_credit: [58.86, 134.46] + # Apply the 2025 60% funding factor: $58.86 * .60; $134.46 * .60. + mi_home_heating_credit: [35.316, 80.676] + # Only the heating credit changes: tax falls by $45.36, + # equal to $2,160 * 3.5% * 60% = $80.676 - $35.316. + mi_income_tax: [-35.316, -80.676] + state_income_tax: [-35.316, -80.676] From dd85603c7fc1d5d3e2d8ff431901e7d4e1167d5a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 12:07:02 -0400 Subject: [PATCH 21/22] Compute dependent SSI in Michigan retirement regression --- .../income/credits/home_heating/mi_home_heating_credit.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml index 31f1c40e94a..46943e5822e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml @@ -199,7 +199,7 @@ employment_income: 10_000 is_tax_unit_head: true is_tax_unit_dependent: false - ssi: 0 + takes_up_ssi_if_eligible: false dependent_without_pension: age: 75 is_tax_unit_head: false @@ -211,7 +211,7 @@ employment_income: 10_000 is_tax_unit_head: true is_tax_unit_dependent: false - ssi: 0 + takes_up_ssi_if_eligible: false dependent_with_pension: age: 75 is_tax_unit_head: false From 08f20a99e95726c9c558047859366f399c136543 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 13:29:20 -0400 Subject: [PATCH 22/22] Cite the 2025 MI-1040CR-7 book for Michigan household resources michigan.gov has removed the 2025 home heating credit book (TY2025/MI-1040CR-7-Book.pdf now returns 404), so cite the Wayback Machine capture of 2026-02-18. It is byte-identical to the NBER TAXSIM mirror (sha256 f540a3dd...). Its total household resources lines 17-33 say the same as MI-1040CR lines 14-31 about whose income counts: "you and your spouse", Social Security "for minor children or other dependent adults who live with you" (line 24), child support and foster care payments (line 25), MDHHS payments "made to your household" (line 30), and premiums "for yourself and your family" (line 33). References only; no formula or value changes. Co-Authored-By: Claude Opus 5.5 --- .../mi/tax/income/household_resources_all_members.yaml | 2 ++ .../gov/states/mi/tax/income/mi_household_resources.py | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_all_members.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_all_members.yaml index ae527614639..c08195a2bcf 100644 --- a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_all_members.yaml +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_all_members.yaml @@ -26,3 +26,5 @@ metadata: href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=32 - title: Michigan 2024 Home Heating Credit Instructions, MI-1040CR-7 lines 26, 27 and 32 href: https://web.archive.org/web/20250202150154/https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/BOOK_MI-1040CR-7.pdf#page=8 + - title: Michigan 2025 Home Heating Credit Instructions, MI-1040CR-7 lines 24, 25 and 30 + href: https://web.archive.org/web/20260218211009/https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040CR-7-Book.pdf?rev=e4ba4fedc63942e48442037517312d4a&hash=AF396E577B1932DF55BFD7F22D7EF06A#page=9 diff --git a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py index 9aad3da0171..103dffc9cdc 100644 --- a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py +++ b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py @@ -16,6 +16,15 @@ class mi_household_resources(Variable): # MCL 206.510(1): "Income", including premiums paid for the family. "https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-510", "https://web.archive.org/web/20250202150154/https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/BOOK_MI-1040CR-7.pdf", + # 2025 MI-1040CR-7 book (archived; michigan.gov removed it): + # "Total Household Resources" (page 4) and lines 17 to 23 (page 8) + # and 24 to 33 (page 9). They match MI-1040CR lines 14 to 31 on whose + # income counts: CR-7 lines 24, 25 and 30 are MI-1040CR lines 21, 22 + # and 27. + "https://web.archive.org/web/20260218211009/https://www.michigan.gov/" + "taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/" + "MI-1040CR-7-Book.pdf?rev=e4ba4fedc63942e48442037517312d4a" + "&hash=AF396E577B1932DF55BFD7F22D7EF06A#page=8", # 2025 MI-1040 book: "Total Household Resources" (page 26) and # MI-1040CR lines 14 to 17 (page 31) and 18 to 31 (pages 32 and 33). "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/"