From 0bcebf6127bfb3bd1e6f824413b85130cd8c1887 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 15:13:29 -0400 Subject: [PATCH 01/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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 d94846ecda35fbccd679a15521c65821b7c29613 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 21:53:33 -0400 Subject: [PATCH 16/26] Subtract Michigan premiums once and exclude the first $300 of winnings and gifts MI-1040CR line 31 leaves out premiums deducted on line 30, which holds the self-employed health insurance deduction. MCL 206.510(1)(a) and (b) exclude the first $300 of gifts and of gambling winnings (lines 24 and 20). Co-Authored-By: Claude Opus 5.5 --- ...ehold-resources-premiums-gambling.fixed.md | 1 + .../mi/tax/income/household_resources.yaml | 16 +- ...ousehold_resources_gambling_exclusion.yaml | 23 ++ .../household_resources_gift_exclusion.yaml | 23 ++ .../mi/tax/income/mi_household_resources.yaml | 266 ++++++++++++++++ .../test_mi_household_resources_exclusions.py | 298 ++++++++++++++++++ .../test_mi_household_resources_members.py | 51 ++- .../mi/tax/income/mi_household_resources.py | 65 +++- 8 files changed, 716 insertions(+), 27 deletions(-) create mode 100644 changelog.d/mi-household-resources-premiums-gambling.fixed.md create mode 100644 policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gambling_exclusion.yaml create mode 100644 policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gift_exclusion.yaml create mode 100644 policyengine_us/tests/test_mi_household_resources_exclusions.py diff --git a/changelog.d/mi-household-resources-premiums-gambling.fixed.md b/changelog.d/mi-household-resources-premiums-gambling.fixed.md new file mode 100644 index 00000000000..d40626aef46 --- /dev/null +++ b/changelog.d/mi-household-resources-premiums-gambling.fixed.md @@ -0,0 +1 @@ +Subtract Michigan household resources premiums once (MI-1040CR line 31 leaves out the self-employed health insurance deduction on line 30), and exclude the first $300 of gambling winnings and of gifts (MCL 206.510(1)(a) and (b), lines 20 and 24), counting gifts from relatives and friends on line 24. 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 df06e1c1e64..9a966904cd0 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 @@ -28,9 +28,13 @@ values: # 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. + # MI-1040CR line 20, alimony and other taxable income: "awards, prizes, + # lottery, bingo, and other gambling winnings over $300" (the first $300 + # is household_resources_gambling_exclusion) and other taxable income, + # such as miscellaneous_income (26 CFR 1.61-14), in full. - alimony_income - gambling_winnings + - miscellaneous_income # MI-1040CR line 21, Social Security, SSI and railroad retirement # benefits, including amounts received for dependents. - social_security @@ -40,8 +44,12 @@ values: - child_support_received # MI-1040CR line 23, unemployment compensation. - unemployment_compensation - # MI-1040CR line 24, gifts received or expenses paid on your behalf. - - miscellaneous_income + # MI-1040CR line 24, "the value over $300 in gifts of cash or merchandise + # received, or expenses paid on your behalf ... by parents, relatives, or + # friends" (the first $300 is household_resources_gift_exclusion). Gifts + # are excluded from federal gross income (IRC 102) but are total + # household resources (MCL 206.510(1)). + - financial_assistance # MI-1040CR line 26, veterans' benefits and workers' compensation. - veterans_benefits - workers_compensation @@ -74,3 +82,5 @@ metadata: 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/ + - title: MCL 206.510(1), "Income" defined + href: https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-510 diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gambling_exclusion.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gambling_exclusion.yaml new file mode 100644 index 00000000000..680d341655b --- /dev/null +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gambling_exclusion.yaml @@ -0,0 +1,23 @@ +description: Michigan excludes this amount of awards, prizes, lottery, bingo and other gambling winnings from household resources. +values: + # MCL 206.510(1)(b): a fixed amount in the statute, not indexed. The + # MI-1040CR counts winnings "over $300" on line 20, one amount for the + # claimant and spouse. + 2021-01-01: 300 +metadata: + unit: currency-USD + period: year + label: Michigan household resources gambling winnings exclusion + reference: + - title: MCL 206.510(1)(b), "The first $300.00 received from awards, prizes, lottery, bingo, or other gambling winnings" + href: https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-510 + - title: 2022 MI-1040 Instructions, MI-1040CR line 20 + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2022/BOOK_MI-1040.pdf#page=32 + - title: 2023 MI-1040 Instructions, MI-1040CR line 20 + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2023/BOOK_MI-1040.pdf#page=32 + - title: 2024 MI-1040 Instructions, MI-1040CR line 20 + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/MI-1040-Instructions.pdf#page=32 + - title: 2025 MI-1040 Instructions, MI-1040CR line 20 + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=32 + - title: 2025 MI-1040 Instructions, total household resources do not include the first $300 from gambling, bingo, lottery, awards, or prizes + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=27 diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gift_exclusion.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gift_exclusion.yaml new file mode 100644 index 00000000000..b20c037b478 --- /dev/null +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gift_exclusion.yaml @@ -0,0 +1,23 @@ +description: Michigan excludes this amount of gifts in cash or kind from nongovernmental sources from household resources. +values: + # MCL 206.510(1)(a): a fixed amount in the statute, not indexed. The + # MI-1040CR counts "the value over $300 in gifts" on line 24, one amount + # for the claimant and spouse. + 2021-01-01: 300 +metadata: + unit: currency-USD + period: year + label: Michigan household resources gift exclusion + reference: + - title: MCL 206.510(1)(a), "The first $300.00 of gifts in cash or kind from nongovernmental sources" + href: https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-510 + - title: 2022 MI-1040 Instructions, MI-1040CR line 24 + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2022/BOOK_MI-1040.pdf#page=32 + - title: 2023 MI-1040 Instructions, MI-1040CR line 24 + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2023/BOOK_MI-1040.pdf#page=32 + - title: 2024 MI-1040 Instructions, MI-1040CR line 24 + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/MI-1040-Instructions.pdf#page=32 + - title: 2025 MI-1040 Instructions, MI-1040CR line 24 + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=32 + - title: 2025 MI-1040 Instructions, total household resources do not include the first $300 in gifts of cash or merchandise + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=27 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..e7dfb14a57a 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,269 @@ # Line 33: 10,000 - 1,000 - 1,000 = 8,000. mi_household_resources: 8_000 +# Line 31 (2025 MI-1040 book, pages 32 and 33): "insurance premiums you paid +# for yourself and your family ... Do not include any insurance premiums +# deducted on lines 21 or 30". Line 30 includes the self-employed health +# insurance deduction (Schedule 1 line 17), so a premium deducted there is +# not subtracted again on line 31. + +- name: A self-employed claimant's premiums are subtracted once, on line 30 + period: 2025 + input: + self_employment_income: 50_000 + is_self_employed: true + health_insurance_premiums: 5_000 + state_code: MI + output: + # Line 16: Schedule C 50,000. + # Line 30: deduction for self-employment tax, half of 50,000 x 0.9235 x + # 15.3% = 3,532.39, plus the self-employed health insurance deduction, + # the lesser of the 5,000 premiums and the 50,000 net earnings = 5,000. + # 8,532.39 in all. + # Line 31: premiums of 5,000, less the 5,000 deducted on line 30 = 0. + # Line 33: 50,000 - 8,532.39 - 0 = 41,467.61. Subtracting the premiums + # on both lines would give 36,467.61. + mi_household_resources: 41_467.61 + absolute_error_margin: 0.01 + +- name: Premiums above the self-employed health insurance deduction stay on line 31 + period: 2025 + input: + employment_income_before_lsr: 40_000 + self_employment_income: 3_000 + is_self_employed: true + health_insurance_premiums: 5_000 + state_code: MI + output: + # Line 14: 40,000. Line 16: 3,000. + # Line 30: deduction for self-employment tax, half of 3,000 x 0.9235 x + # 15.3% = 211.94, plus the self-employed health insurance deduction, + # limited to the 3,000 net earnings = 3,000. 3,211.94 in all. + # Line 31: premiums of 5,000, less the 3,000 deducted on line 30 = 2,000. + # Line 33: 43,000 - 3,211.94 - 2,000 = 37,788.06. The premiums come off + # once in all: 3,000 on line 30 and 2,000 on line 31. + mi_household_resources: 37_788.06 + absolute_error_margin: 0.01 + +- name: A joint return subtracts each spouse's premiums once + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + self_employment_income: 50_000 + is_self_employed: true + health_insurance_premiums: 5_000 + person2: + age: 44 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + employment_income_before_lsr: 30_000 + health_insurance_premiums: 2_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 14: 30,000. Line 16: 50,000. + # Line 30: 3,532.39 for self-employment tax plus the head's 5,000 + # self-employed health insurance deduction = 8,532.39. The spouse is not + # self-employed, so the spouse's premiums are not on line 30. + # Line 31: premiums of 5,000 + 2,000 = 7,000, less the 5,000 deducted on + # line 30 = 2,000. + # Line 33: 80,000 - 8,532.39 - 2,000 = 69,467.61. + mi_household_resources: 69_467.61 + absolute_error_margin: 0.01 + +- name: Medicare premiums withheld from Social Security are subtracted once + period: 2025 + input: + age: 70 + social_security_retirement: 20_000 + health_insurance_premiums: 2_220 + state_code: MI + output: + # The form puts Medicare premiums withheld from benefits on line 21: + # "Medicare premiums reported on your Social Security or Railroad + # Retirement statement should be deducted" (page 32), so line 21 is + # 20,000 - 2,220 = 17,780 and line 31 leaves them out. MCL 206.510(1)(h) + # excludes "Amounts deducted from monthly social security or railroad + # retirement benefits for medicare premiums" from income. + # The model enters gross benefits on line 21 and the premiums on line + # 31: 20,000 - 2,220. Either way the premiums come off once. + # Line 33: 17,780. + mi_household_resources: 17_780 + +# MCL 206.510(1): income does not include "(a) The first $300.00 of gifts in +# cash or kind from nongovernmental sources" or "(b) The first $300.00 +# received from awards, prizes, lottery, bingo, or other gambling winnings". +# The MI-1040CR enters "awards, prizes, lottery, bingo, and other gambling +# winnings over $300" on line 20 and "the value over $300 in gifts of cash or +# merchandise received, or expenses paid on your behalf ... by parents, +# relatives, or friends" on line 24 (2025 MI-1040 book, page 32), each once +# for the claimant and spouse. + +- name: Gambling winnings below $300 are not household resources + period: 2025 + input: + employment_income_before_lsr: 20_000 + gambling_winnings: 200 + state_code: MI + output: + # Line 14: 20,000. Line 20: winnings over $300 = 0. + mi_household_resources: 20_000 + +- name: Gambling winnings of exactly $300 are not household resources + period: 2025 + input: + employment_income_before_lsr: 20_000 + gambling_winnings: 300 + state_code: MI + output: + # Line 20: 300 - 300 = 0. + mi_household_resources: 20_000 + +- name: Gambling winnings above $300 count over $300 + period: 2025 + input: + employment_income_before_lsr: 20_000 + gambling_winnings: 1_000 + state_code: MI + output: + # Line 20: 1,000 - 300 = 700. + # Line 33: 20,000 + 700 = 20,700. + mi_household_resources: 20_700 + +- name: The $300 gambling exclusion applies once to a married couple + 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: 20_000 + gambling_winnings: 200 + person2: + age: 44 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + gambling_winnings: 200 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 20 is one amount for the claimant and spouse: winnings of + # 200 + 200 = 400, over $300 = 100. + # Line 33: 20,000 + 100 = 20,100. + mi_household_resources: 20_100 + +- name: Gifts below $300 are not household resources + period: 2025 + input: + employment_income_before_lsr: 20_000 + financial_assistance: 200 + state_code: MI + output: + # Line 24: cash gifts from relatives or friends over $300 = 0. + mi_household_resources: 20_000 + +- name: Gifts of exactly $300 are not household resources + period: 2025 + input: + employment_income_before_lsr: 20_000 + financial_assistance: 300 + state_code: MI + output: + # Line 24: 300 - 300 = 0. + mi_household_resources: 20_000 + +- name: Gifts above $300 count over $300 + period: 2025 + input: + employment_income_before_lsr: 20_000 + financial_assistance: 1_000 + state_code: MI + output: + # Line 24: 1,000 - 300 = 700. Gifts are excluded from federal gross + # income (IRC 102), and total household resources include income + # excluded from AGI (MCL 206.510(1)). + # Line 33: 20,000 + 700 = 20,700. + mi_household_resources: 20_700 + +- name: The $300 gift exclusion applies once to a married couple + 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: 20_000 + financial_assistance: 250 + person2: + age: 44 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + financial_assistance: 250 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 24: gifts of 250 + 250 = 500, over $300 = 200. + # Line 33: 20,000 + 200 = 20,200. + mi_household_resources: 20_200 + +- name: Gambling winnings and gifts each have their own $300 exclusion + period: 2025 + input: + employment_income_before_lsr: 20_000 + gambling_winnings: 500 + financial_assistance: 500 + state_code: MI + output: + # Line 20: 500 - 300 = 200. Line 24: 500 - 300 = 200. + # Line 33: 20,000 + 200 + 200 = 20,400. + mi_household_resources: 20_400 + +- name: Miscellaneous taxable income counts in full on line 20 + period: 2025 + input: + employment_income_before_lsr: 20_000 + miscellaneous_income: 1_000 + state_code: MI + output: + # miscellaneous_income is other taxable income (26 CFR 1.61-14), in + # federal gross income, so it is not a gift. It is "other taxable + # income" on line 20, and the $300 exclusion covers only awards, prizes, + # lottery, bingo and other gambling winnings. + # Line 33: 20,000 + 1,000 = 21,000. + mi_household_resources: 21_000 diff --git a/policyengine_us/tests/test_mi_household_resources_exclusions.py b/policyengine_us/tests/test_mi_household_resources_exclusions.py new file mode 100644 index 00000000000..3ee6b4416fa --- /dev/null +++ b/policyengine_us/tests/test_mi_household_resources_exclusions.py @@ -0,0 +1,298 @@ +"""Property tests: premiums and the $300 exclusions in Michigan household resources. + +MI-1040CR line 30 subtracts "total adjustments from your U.S. Form 1040, +Schedule 1", which include the self-employed health insurance deduction +(Schedule 1 line 17). Line 31 subtracts "insurance premiums you paid for +yourself and your family", but "Do not include any insurance premiums deducted +on lines 21 or 30" (2025 MI-1040 book, page 33). + +MCL 206.510(1) excludes from income "(a) The first $300.00 of gifts in cash or +kind from nongovernmental sources" and "(b) The first $300.00 received from +awards, prizes, lottery, bingo, or other gambling winnings". The MI-1040CR +enters gambling winnings "over $300" on line 20 and "the value over $300 in +gifts" on line 24 (page 32), each one amount for the claimant and spouse. +Gifts are financial_assistance, cash from friends or relatives outside the +household. + +So for every household: + +1. Each premium dollar is subtracted once. Zeroing every member's premiums + raises household resources by between 0 and the household's total + premiums, and by exactly that total when household resources were + positive. +2. Household resources are non-decreasing in the head's gambling winnings. + They do not change while the claimant's and spouse's winnings total at + most $300, and when they were positive they rise by the change in + winnings over $300. +3. The same holds for gifts. + +The households have a head, an optional spouse and an optional dependent, who +may be self-employed and pay premiums. SSI and the Family Independence +Program grant are inputs set to zero, so more gifts cannot lower a computed +benefit. 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 numpy as np +import pytest + +from policyengine_us import Simulation + +YEAR = 2025 +TOLERANCE = 0.01 +# MCL 206.510(1)(a) and (b): a fixed amount in the statute, not indexed. +EXCLUSION = 300 + +AMOUNTS = [ + "employment_income_before_lsr", + "self_employment_income", + "health_insurance_premiums", + "gambling_winnings", + "financial_assistance", +] +PREMIUMS = "health_insurance_premiums" + + +def person(**amounts): + p = {name: 0 for name in AMOUNTS} + p["is_self_employed"] = False + p.update(amounts) + return p + + +def build_situation(households): + """One Michigan tax unit per household: a head aged 45, an optional + spouse aged 44 and an optional dependent aged 16.""" + people, tax_units, marital_units, spm_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"])) + if h.get("dependent") is not None: + roles.append(("dependent", h["dependent"])) + for j, (role, p) in enumerate(roles): + name = f"person_{i}_{j}" + people[name] = { + "age": {YEAR: {"head": 45, "spouse": 44}.get(role, 16)}, + "is_tax_unit_head": {YEAR: role == "head"}, + "is_tax_unit_spouse": {YEAR: role == "spouse"}, + "is_tax_unit_dependent": {YEAR: role == "dependent"}, + "ssi": {YEAR: 0}, + **{name_: {YEAR: p[name_]} for name_ in [*AMOUNTS, "is_self_employed"]}, + } + 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} + spm_units[f"spm_unit_{i}"] = {"members": members, "tanf": {YEAR: 0}} + households_out[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "MI"}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "spm_units": spm_units, + "households": households_out, + } + + +def calculate(households): + simulation = Simulation(situation=build_situation(households)) + return np.asarray(simulation.calculate("mi_household_resources", YEAR)) + + +def members(h): + return [p for p in (h["head"], h.get("spouse"), h.get("dependent")) if p] + + +def filers(h): + return [p for p in (h["head"], h.get("spouse")) if p] + + +def total(people, name): + return sum(p[name] for p in people) + + +def copy(h): + return { + role: (dict(h[role]) if h.get(role) is not None else None) + for role in ("head", "spouse", "dependent") + } + + +def tolerance(h): + """One cent, or eight float32 spacings at the sum of the household's + absolute amounts.""" + scale = sum(abs(p[name]) for p in members(h) for name in AMOUNTS) + return max(TOLERANCE, 8 * float(np.spacing(np.float32(scale)))) + + +def over_exclusion(amount): + return max(0, amount - EXCLUSION) + + +def assert_premiums_subtracted_once(households): + """Property 1.""" + zeroed = [] + for h in households: + z = copy(h) + for p in members(z): + p[PREMIUMS] = 0 + zeroed.append(z) + result = calculate(households + zeroed) + n = len(households) + for i, h in enumerate(households): + tol = tolerance(h) + premiums = total(members(h), PREMIUMS) + subtracted = float(result[n + i]) - float(result[i]) + assert -tol <= subtracted <= premiums + tol, h + if result[i] > tol: + assert subtracted == pytest.approx(premiums, abs=tol), h + + +def assert_exclusion(households, name, extra): + """Properties 2 and 3: raise the head's amount of name by extra.""" + raised = [] + for h in households: + r = copy(h) + r["head"][name] += extra + raised.append(r) + result = calculate(households + raised) + n = len(households) + for i, h in enumerate(households): + tol = tolerance(raised[i]) + before = total(filers(h), name) + after = before + extra + change = float(result[n + i]) - float(result[i]) + assert change >= -tol, (h, name, extra) + if after <= EXCLUSION: + assert change == pytest.approx(0, abs=tol), (h, name, extra) + if result[i] > tol: + expected = over_exclusion(after) - over_exclusion(before) + assert change == pytest.approx(expected, abs=tol), (h, name, extra) + + +# --------------------------------------------------------------------------- +# Deterministic tests (no Hypothesis needed). +# --------------------------------------------------------------------------- + + +def test_self_employed_premiums_subtracted_once(): + """The self-employed health insurance deduction (line 30) and line 31 + together subtract each premium once: for a self-employed claimant, a + self-employed couple, premiums above the deduction's earnings limit, and + a self-employed dependent whose premium the claimant is read to pay.""" + households = [ + {"head": person(self_employment_income=50_000, **{PREMIUMS: 5_000})}, + { + "head": person( + employment_income_before_lsr=40_000, + self_employment_income=3_000, + **{PREMIUMS: 5_000}, + ) + }, + { + "head": person(self_employment_income=60_000, **{PREMIUMS: 4_000}), + "spouse": person(self_employment_income=20_000, **{PREMIUMS: 3_000}), + }, + { + "head": person(employment_income_before_lsr=40_000, **{PREMIUMS: 2_000}), + "dependent": person(self_employment_income=8_000, **{PREMIUMS: 1_000}), + }, + ] + for h in households: + for p in members(h): + p["is_self_employed"] = p["self_employment_income"] > 0 + assert_premiums_subtracted_once(households) + + +@pytest.mark.parametrize("name", ["gambling_winnings", "financial_assistance"]) +def test_exclusion_grid(name): + """For single and joint claimants, every step from 0 to $1,000 in $100s: + nothing counts until the claimant's and spouse's total passes $300.""" + households = [] + for spouse_amount in [None, 0, 200]: + for start in range(0, 1_000, 100): + h = {"head": person(employment_income_before_lsr=20_000, **{name: start})} + if spouse_amount is not None: + h["spouse"] = person(**{name: spouse_amount}) + households.append(h) + assert_exclusion(households, name, 100) + + +def test_dependent_amounts_do_not_use_the_exclusion(): + """A dependent's own winnings and gifts are on the dependent's return: + they neither count nor use up the claimant's $300.""" + base = { + "head": person(employment_income_before_lsr=20_000), + "dependent": person(gambling_winnings=5_000, financial_assistance=5_000), + } + with_head = copy(base) + with_head["head"]["gambling_winnings"] = 500 + with_head["head"]["financial_assistance"] = 500 + result = calculate([base, with_head]) + assert result[0] == pytest.approx(20_000, abs=TOLERANCE) + assert result[1] == pytest.approx(20_000 + 200 + 200, 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: + # Each example builds a Simulation; on a loaded runner input generation + # can trip the too_slow health check, which says nothing about the model. + SLOW = [hypothesis.HealthCheck.too_slow] + INCOME = st.one_of(st.just(0), st.integers(min_value=100, max_value=150_000)) + SELF_EMPLOYMENT = st.one_of( + st.just(0), st.integers(min_value=-30_000, max_value=150_000) + ) + PREMIUM = st.one_of(st.just(0), st.integers(min_value=100, max_value=20_000)) + # Small amounts, so that many households stay within the $300. + SMALL = st.one_of(st.just(0), st.integers(min_value=1, max_value=1_000)) + + @st.composite + def a_person(draw): + return person( + employment_income_before_lsr=draw(INCOME), + self_employment_income=draw(SELF_EMPLOYMENT), + is_self_employed=draw(st.booleans()), + gambling_winnings=draw(SMALL), + financial_assistance=draw(SMALL), + **{PREMIUMS: draw(PREMIUM)}, + ) + + @st.composite + def household(draw): + return { + "head": draw(a_person()), + "spouse": draw(st.one_of(st.none(), a_person())), + "dependent": draw(st.one_of(st.none(), a_person())), + } + + @hypothesis.settings(max_examples=40, deadline=None, suppress_health_check=SLOW) + @hypothesis.given(st.lists(household(), min_size=1, max_size=15)) + def test_premiums_subtracted_once(households): + assert_premiums_subtracted_once(households) + + @hypothesis.settings(max_examples=40, deadline=None, suppress_health_check=SLOW) + @hypothesis.given( + st.lists(household(), min_size=1, max_size=15), + st.sampled_from(["gambling_winnings", "financial_assistance"]), + st.integers(min_value=1, max_value=600), + ) + def test_exclusions(households, name, extra): + assert_exclusion(households, name, extra) diff --git a/policyengine_us/tests/test_mi_household_resources_members.py b/policyengine_us/tests/test_mi_household_resources_members.py index fd9f10f8a5b..3522d63c2c9 100644 --- a/policyengine_us/tests/test_mi_household_resources_members.py +++ b/policyengine_us/tests/test_mi_household_resources_members.py @@ -16,14 +16,18 @@ 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. +for the family's coverage. Line 31 leaves out "any insurance premiums +deducted on lines 21 or 30" (page 33), so the head's and spouse's +self-employed health insurance deduction, which is on line 30, is not +subtracted again. Lines 20 and 24 count gambling winnings and gifts "over +$300" (MCL 206.510(1)(a) and (b)), one amount for the claimant and spouse. So for every household: 1. mi_household_resources equals a reference written from the form: the head 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. + plus every member's lines 21, 22 and 27, less every member's premiums not + deducted on line 30, 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: @@ -72,8 +76,10 @@ "long_term_capital_gains": 19, "alimony_income": 20, "gambling_winnings": 20, + "miscellaneous_income": 20, "unemployment_compensation": 23, - "miscellaneous_income": 24, + # Cash gifts from relatives or friends outside the household. + "financial_assistance": 24, "veterans_benefits": 26, "workers_compensation": 26, # Guaranteed income pilot payments: the recipient's own (line 25). @@ -91,6 +97,8 @@ "general_assistance": 27, } PREMIUMS = "health_insurance_premiums" +# MCL 206.510(1)(a) and (b): the first $300 of gifts and of gambling winnings. +EXCLUSION = 300 # Sources that can be negative. SIGNED = {"self_employment_income", "s_corp_income", "rental_income"} SIGNED.add("long_term_capital_gains") @@ -126,6 +134,8 @@ def build_situation(households): person = {"age": {YEAR: age}} for variable in PERSON_INPUTS: person[variable] = {YEAR: p.get(variable, 0)} + # Whether the self-employed health insurance deduction applies. + person["is_self_employed"] = {YEAR: p.get("is_self_employed", False)} # 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. @@ -170,13 +180,16 @@ def calculate(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 - ) + adjustments = { + name: 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]) + per_household.append( + {name: values[k : k + n] for name, values in adjustments.items()} + ) k += n return result, per_household @@ -198,14 +211,31 @@ 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, 25, 26)) + excluded = ["gambling_winnings", "financial_assistance"] + lines_20_to_26 = amount( + own, + [ + name + for name in by_line(CLAIMANT_LINES, 20, 23, 24, 25, 26) + if name not in excluded + ], + ) + # Lines 20 and 24: gambling winnings and gifts over $300. + for name in excluded: + lines_20_to_26 += max(0, amount(own, [name]) - EXCLUSION) 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)])) + filer_adjustments = { + name: float(sum(values[: len(own)])) + for name, values in person_adjustments.items() + } + line_30 = sum(filer_adjustments.values()) line_30 += amount(own, ["early_withdrawal_penalty"]) + # Line 31: every member's premiums, less those deducted on line 30. line_31 = amount(everyone(h), [PREMIUMS]) + line_31 -= filer_adjustments["self_employed_health_insurance_ald_person"] 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) @@ -441,6 +471,7 @@ def person(draw): 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) + p["is_self_employed"] = draw(st.booleans()) return p @st.composite 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..c30f2c8e8c2 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,11 +16,14 @@ 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-1040 book: "Total Household Resources" (page 26) and - # MI-1040CR lines 14 to 17 (page 31) and 18 to 31 (pages 32 and 33). + # 2025 MI-1040 book: "Total Household Resources" (pages 26 and 27, + # with what they do not include) 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/" "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=27", + "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", @@ -50,6 +53,15 @@ def formula(tax_unit, period, parameters): "rental_income", "farm_rent_income", } + # MCL 206.510(1): income does not include "(a) The first $300.00 of + # gifts in cash or kind from nongovernmental sources" or "(b) The + # first $300.00 received from awards, prizes, lottery, bingo, or + # other gambling winnings". The MI-1040CR enters gambling winnings + # "over $300" on line 20 and "the value over $300 in gifts" on line + # 24 (2025 MI-1040 book, page 32), one amount for the claimant and + # spouse, so the exclusion applies once to their total. + winnings_sources = {"gambling_winnings"} + gift_sources = {"financial_assistance"} # MCL 206.508(3): "'Household' means a claimant and spouse", and # (4) counts "all income received by all persons of a household". @@ -69,6 +81,8 @@ def formula(tax_unit, period, parameters): business_income = 0 rental_income = 0 + winnings = 0 + gifts = 0 other_income = 0 for source in p.household_resources: entity = tax_unit.entity.get_variable(source).entity @@ -81,9 +95,19 @@ def formula(tax_unit, period, parameters): business_income += amount elif source in rental_sources: rental_income += amount + elif source in winnings_sources: + winnings += amount + elif source in gift_sources: + gifts += amount else: other_income += amount - total = other_income + max_(business_income, 0) + max_(rental_income, 0) + total = ( + other_income + + max_(business_income, 0) + + max_(rental_income, 0) + + max_(winnings - p.household_resources_gambling_exclusion, 0) + + max_(gifts - p.household_resources_gift_exclusion, 0) + ) # Line 30: "total adjustments from your U.S. Form 1040, Schedule 1". # Business, rental and capital losses are income items (Schedule 1 @@ -95,19 +119,32 @@ def formula(tax_unit, period, parameters): # 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, - [ - deduction - for deduction in parameters(period).gov.irs.ald.deductions - if deduction != "loss_ald" - ], - ) + deductions = [ + deduction + for deduction in parameters(period).gov.irs.ald.deductions + if deduction != "loss_ald" + ] + adjustments = tax_unit_non_dep_add(tax_unit, period, deductions) # 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". 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) + premiums = add(tax_unit, period, ["health_insurance_premiums"]) + # "Do not include any insurance premiums deducted on lines 21 or 30" + # (2025 MI-1040 book, page 33). Line 30 includes the self-employed + # health insurance deduction (Schedule 1 line 17), so those premiums + # leave line 31 and each premium dollar is subtracted once. + # Line 21 nets out Medicare premiums withheld from Social Security or + # railroad retirement benefits (MCL 206.510(1)(h)). Here line 21 has + # gross benefits and line 31 the premiums, which subtracts them once + # as well. + premiums_on_line_30 = ( + tax_unit_non_dep_add( + tax_unit, period, ["self_employed_health_insurance_ald"] + ) + if "self_employed_health_insurance_ald" in deductions + else 0 + ) + line_31 = max_(premiums - premiums_on_line_30, 0) + return max_(0, total - adjustments - line_31) From c57e5b6216d39a36cb391f2b8a90faf450ffeabd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 22:38:59 -0400 Subject: [PATCH 17/26] Test that dropping the federal deduction moves premiums to line 31 Co-Authored-By: Claude Opus 5.5 --- .../test_mi_household_resources_exclusions.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/policyengine_us/tests/test_mi_household_resources_exclusions.py b/policyengine_us/tests/test_mi_household_resources_exclusions.py index 3ee6b4416fa..618ff4c0e38 100644 --- a/policyengine_us/tests/test_mi_household_resources_exclusions.py +++ b/policyengine_us/tests/test_mi_household_resources_exclusions.py @@ -25,6 +25,9 @@ most $300, and when they were positive they rise by the change in winnings over $300. 3. The same holds for gifts. +4. A reform that drops the self-employed health insurance deduction from the + federal list moves those premiums from line 30 to line 31 and leaves + household resources unchanged. The households have a head, an optional spouse and an optional dependent, who may be self-employed and pay premiums. SSI and the Family Independence @@ -36,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 @@ -212,6 +218,67 @@ def test_self_employed_premiums_subtracted_once(): assert_premiums_subtracted_once(households) +def without_self_employed_health_insurance_ald(): + """A reform that removes the self-employed health insurance deduction + 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 != "self_employed_health_insurance_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 + + +def test_premiums_move_to_line_31_without_the_federal_deduction(): + """Without the federal deduction, line 30 no longer subtracts the + premiums, so line 31 does: household resources are unchanged, and the + premiums still come off once.""" + households = [ + {"head": person(self_employment_income=50_000, **{PREMIUMS: 5_000})}, + { + "head": person( + employment_income_before_lsr=40_000, + self_employment_income=3_000, + **{PREMIUMS: 5_000}, + ) + }, + ] + for h in households: + h["head"]["is_self_employed"] = True + situation = build_situation(households) + baseline = Simulation(situation=situation) + reformed = Simulation( + situation=situation, reform=without_self_employed_health_insurance_ald() + ) + # The reform takes the deduction (5,000 and 3,000) out of federal AGI. + deduction = baseline.calculate("self_employed_health_insurance_ald", YEAR) + assert deduction == pytest.approx([5_000, 3_000], abs=TOLERANCE) + agi_change = reformed.calculate("adjusted_gross_income", YEAR) - baseline.calculate( + "adjusted_gross_income", YEAR + ) + assert agi_change == pytest.approx(deduction, abs=TOLERANCE) + expected = baseline.calculate("mi_household_resources", YEAR) + result = reformed.calculate("mi_household_resources", YEAR) + assert result == pytest.approx(expected, abs=TOLERANCE) + # Line 33 from the form: 50,000 - 3,532.39 - 5,000 and + # 43,000 - 211.94 - 5,000. + assert result == pytest.approx([41_467.61, 37_788.06], abs=TOLERANCE) + + @pytest.mark.parametrize("name", ["gambling_winnings", "financial_assistance"]) def test_exclusion_grid(name): """For single and joint claimants, every step from 0 to $1,000 in $100s: From 8d494dedec12e45866301d2977887c30e4809e13 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 02:10:29 -0400 Subject: [PATCH 18/26] 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 7fa23e51f6af795175a522a014e80afd350fbc58 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 02:16:05 -0400 Subject: [PATCH 19/26] Test that the exclusions read their parameters; note awards and gifts in kind Review r1 nits 3-5: a reform raising both exclusions to 500 changes household resources (a hard-coded 300 fails it), the gambling exclusion parameter notes that awards entered as miscellaneous_income count in full, and line 24 notes that financial_assistance is cash only. Co-Authored-By: Claude Opus 5.5 --- .../mi/tax/income/household_resources.yaml | 4 +- ...ousehold_resources_gambling_exclusion.yaml | 4 +- .../test_mi_household_resources_exclusions.py | 46 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 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 9a966904cd0..afb229c21ad 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 @@ -48,7 +48,9 @@ values: # received, or expenses paid on your behalf ... by parents, relatives, or # friends" (the first $300 is household_resources_gift_exclusion). Gifts # are excluded from federal gross income (IRC 102) but are total - # household resources (MCL 206.510(1)). + # household resources (MCL 206.510(1)). financial_assistance is cash + # only; expenses others pay on the claimant's behalf (gifts in kind) have + # no input. - financial_assistance # MI-1040CR line 26, veterans' benefits and workers' compensation. - veterans_benefits diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gambling_exclusion.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gambling_exclusion.yaml index 680d341655b..b4d7d786c89 100644 --- a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gambling_exclusion.yaml +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gambling_exclusion.yaml @@ -2,7 +2,9 @@ description: Michigan excludes this amount of awards, prizes, lottery, bingo and values: # MCL 206.510(1)(b): a fixed amount in the statute, not indexed. The # MI-1040CR counts winnings "over $300" on line 20, one amount for the - # claimant and spouse. + # claimant and spouse. The model applies it to gambling_winnings; it has no + # separate input for awards and prizes, and one entered as + # miscellaneous_income counts in full on line 20. 2021-01-01: 300 metadata: unit: currency-USD diff --git a/policyengine_us/tests/test_mi_household_resources_exclusions.py b/policyengine_us/tests/test_mi_household_resources_exclusions.py index 618ff4c0e38..98502ece7d7 100644 --- a/policyengine_us/tests/test_mi_household_resources_exclusions.py +++ b/policyengine_us/tests/test_mi_household_resources_exclusions.py @@ -279,6 +279,52 @@ def test_premiums_move_to_line_31_without_the_federal_deduction(): assert result == pytest.approx([41_467.61, 37_788.06], abs=TOLERANCE) +def with_exclusions(amount): + """A reform that sets both $300 exclusions to amount.""" + + class reform(Reform): + def apply(self): + def modify(parameters): + p = parameters.gov.states.mi.tax.income + for node in ( + p.household_resources_gambling_exclusion, + p.household_resources_gift_exclusion, + ): + node.update( + start=instant(f"{YEAR}-01-01"), + stop=instant(f"{YEAR}-12-31"), + value=amount, + ) + return parameters + + self.modify_parameters(modify) + + return reform + + +def test_exclusions_read_the_parameters(): + """The formula reads the two exclusion parameters, not a fixed 300: + raising both to 500 changes what counts.""" + h = { + "head": person( + employment_income_before_lsr=20_000, + gambling_winnings=400, + financial_assistance=600, + ) + } + situation = build_situation([h]) + baseline = Simulation(situation=situation) + reformed = Simulation(situation=situation, reform=with_exclusions(500)) + # $300: 100 of winnings and 300 of gifts count. + assert baseline.calculate("mi_household_resources", YEAR)[0] == pytest.approx( + 20_400, abs=TOLERANCE + ) + # $500: no winnings and 100 of gifts count. + assert reformed.calculate("mi_household_resources", YEAR)[0] == pytest.approx( + 20_100, abs=TOLERANCE + ) + + @pytest.mark.parametrize("name", ["gambling_winnings", "financial_assistance"]) def test_exclusion_grid(name): """For single and joint claimants, every step from 0 to $1,000 in $100s: From 546c6011d47cd00a95e357479dd5332b9063f6fd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 04:43:04 -0400 Subject: [PATCH 20/26] 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 8f25dbb82e73f40da256c293877207004f3cd528 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 23:47:50 -0400 Subject: [PATCH 21/26] Test Michigan premium netting against both deduction lists --- .../test_mi_household_resources_exclusions.py | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/policyengine_us/tests/test_mi_household_resources_exclusions.py b/policyengine_us/tests/test_mi_household_resources_exclusions.py index 98502ece7d7..3b3e0c0fb5d 100644 --- a/policyengine_us/tests/test_mi_household_resources_exclusions.py +++ b/policyengine_us/tests/test_mi_household_resources_exclusions.py @@ -25,9 +25,9 @@ most $300, and when they were positive they rise by the change in winnings over $300. 3. The same holds for gifts. -4. A reform that drops the self-employed health insurance deduction from the - federal list moves those premiums from line 30 to line 31 and leaves - household resources unchanged. +4. A reform that drops the self-employed health insurance deduction from + either the federal list or Michigan's line-30 list moves those premiums + from line 30 to line 31 and leaves household resources unchanged. The households have a head, an optional spouse and an optional dependent, who may be self-employed and pay premiums. SSI and the Family Independence @@ -218,14 +218,17 @@ def test_self_employed_premiums_subtracted_once(): assert_premiums_subtracted_once(households) -def without_self_employed_health_insurance_ald(): - """A reform that removes the self-employed health insurance deduction - from gov.irs.ald.deductions.""" +def without_self_employed_health_insurance_ald(michigan_only=False): + """Remove the deduction from the federal or Michigan line-30 list.""" class reform(Reform): def apply(self): def modify(parameters): - deductions = parameters.gov.irs.ald.deductions + deductions = ( + parameters.gov.states.mi.tax.income.household_resources_adjustments + if michigan_only + else parameters.gov.irs.ald.deductions + ) values = [ d for d in deductions(f"{YEAR}-01-01") @@ -243,10 +246,10 @@ def modify(parameters): return reform -def test_premiums_move_to_line_31_without_the_federal_deduction(): - """Without the federal deduction, line 30 no longer subtracts the - premiums, so line 31 does: household resources are unchanged, and the - premiums still come off once.""" +@pytest.mark.parametrize("michigan_only", [False, True]) +def test_premiums_move_to_line_31_without_the_line_30_deduction(michigan_only): + """Removing the deduction from either list moves premiums to line 31. + Removing it only from Michigan's list leaves federal AGI unchanged.""" households = [ {"head": person(self_employment_income=50_000, **{PREMIUMS: 5_000})}, { @@ -262,15 +265,20 @@ def test_premiums_move_to_line_31_without_the_federal_deduction(): situation = build_situation(households) baseline = Simulation(situation=situation) reformed = Simulation( - situation=situation, reform=without_self_employed_health_insurance_ald() + situation=situation, + reform=without_self_employed_health_insurance_ald(michigan_only), ) - # The reform takes the deduction (5,000 and 3,000) out of federal AGI. + # The deduction is still computed under either list reform. deduction = baseline.calculate("self_employed_health_insurance_ald", YEAR) assert deduction == pytest.approx([5_000, 3_000], abs=TOLERANCE) agi_change = reformed.calculate("adjusted_gross_income", YEAR) - baseline.calculate( "adjusted_gross_income", YEAR ) - assert agi_change == pytest.approx(deduction, abs=TOLERANCE) + expected_agi_change = np.zeros_like(deduction) if michigan_only else deduction + assert agi_change == pytest.approx(expected_agi_change, abs=TOLERANCE) + assert reformed.calculate( + "self_employed_health_insurance_ald", YEAR + ) == pytest.approx(deduction, abs=TOLERANCE) expected = baseline.calculate("mi_household_resources", YEAR) result = reformed.calculate("mi_household_resources", YEAR) assert result == pytest.approx(expected, abs=TOLERANCE) From b70c9bf11516724dd97e14489a18350ad8d43117 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 23:48:19 -0400 Subject: [PATCH 22/26] Make Michigan household resource properties deterministic --- .../tests/test_mi_household_resources_exclusions.py | 8 ++++++-- .../tests/test_mi_household_resources_losses.py | 8 ++++++-- .../tests/test_mi_household_resources_members.py | 8 ++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/policyengine_us/tests/test_mi_household_resources_exclusions.py b/policyengine_us/tests/test_mi_household_resources_exclusions.py index 3b3e0c0fb5d..9a7952de13c 100644 --- a/policyengine_us/tests/test_mi_household_resources_exclusions.py +++ b/policyengine_us/tests/test_mi_household_resources_exclusions.py @@ -404,12 +404,16 @@ def household(draw): "dependent": draw(st.one_of(st.none(), a_person())), } - @hypothesis.settings(max_examples=40, deadline=None, suppress_health_check=SLOW) + @hypothesis.settings( + max_examples=40, deadline=None, derandomize=True, suppress_health_check=SLOW + ) @hypothesis.given(st.lists(household(), min_size=1, max_size=15)) def test_premiums_subtracted_once(households): assert_premiums_subtracted_once(households) - @hypothesis.settings(max_examples=40, deadline=None, suppress_health_check=SLOW) + @hypothesis.settings( + max_examples=40, deadline=None, derandomize=True, suppress_health_check=SLOW + ) @hypothesis.given( st.lists(household(), min_size=1, max_size=15), st.sampled_from(["gambling_winnings", "financial_assistance"]), diff --git a/policyengine_us/tests/test_mi_household_resources_losses.py b/policyengine_us/tests/test_mi_household_resources_losses.py index b8e2af53f29..344d91f82ff 100644 --- a/policyengine_us/tests/test_mi_household_resources_losses.py +++ b/policyengine_us/tests/test_mi_household_resources_losses.py @@ -408,12 +408,16 @@ def household(draw): "other_net_gain": draw(st.one_of(st.just(0), AMOUNT, SMALL)), } - @hypothesis.settings(max_examples=25, deadline=None, suppress_health_check=SLOW) + @hypothesis.settings( + max_examples=25, deadline=None, derandomize=True, 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=25, deadline=None, suppress_health_check=SLOW) + @hypothesis.settings( + max_examples=25, deadline=None, derandomize=True, suppress_health_check=SLOW + ) @hypothesis.given( st.lists(household(), min_size=1, max_size=10), st.sampled_from(NO_SE_TAX_SOURCES), diff --git a/policyengine_us/tests/test_mi_household_resources_members.py b/policyengine_us/tests/test_mi_household_resources_members.py index 3522d63c2c9..08eadb4c63a 100644 --- a/policyengine_us/tests/test_mi_household_resources_members.py +++ b/policyengine_us/tests/test_mi_household_resources_members.py @@ -488,12 +488,16 @@ def household(draw): "tanf": draw(st.one_of(st.just(0), AMOUNT)), } - @hypothesis.settings(max_examples=50, deadline=None, suppress_health_check=SLOW) + @hypothesis.settings( + max_examples=50, deadline=None, derandomize=True, 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=30, deadline=None, suppress_health_check=SLOW) + @hypothesis.settings( + max_examples=30, deadline=None, derandomize=True, suppress_health_check=SLOW + ) @hypothesis.given( st.lists(household(), min_size=1, max_size=10), st.sampled_from(list(HOUSEHOLD_LINES)), From a68d7810e156b4d1345bb7cf6e06def7e1efd4e6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 23:48:56 -0400 Subject: [PATCH 23/26] Document supplied premium overlap and cover separate spouse premiums --- .../mi/tax/income/mi_household_resources.yaml | 39 +++++++++++++++++++ .../mi/tax/income/mi_household_resources.py | 3 ++ 2 files changed, 42 insertions(+) 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 f5d3107a835..3685d7df6d6 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 @@ -1356,6 +1356,45 @@ # Line 33: 20,000 + 1,000 = 21,000. mi_household_resources: 21_000 +- name: Supplied health insurance deduction preserves the spouse's separate premiums + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_self_employed: false + employment_income_before_lsr: 40_000 + health_insurance_premiums: 5_000 + person2: + age: 44 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + is_self_employed: false + employment_income_before_lsr: 30_000 + health_insurance_premiums: 2_000 + tax_units: + tax_unit: + members: [person1, person2] + self_employed_health_insurance_ald: 5_000 + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # The supplied deduction and all related premiums are represented, even + # without the self-employment flags used to compute the federal deduction. + # Line 14: 70,000. Line 30: 5,000. + # Line 31: 7,000 - 5,000 = 2,000. + # Line 33: 70,000 - 5,000 - 2,000 = 63,000. + mi_household_resources: 63_000 + - name: A supplied self-employed health insurance deduction comes off line 30 period: 2025 input: 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 3b02782de67..d3f691cae75 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 @@ -157,6 +157,9 @@ def formula(tax_unit, period, parameters): # (2025 MI-1040 book, page 33). Line 30 includes the self-employed # health insurance deduction (Schedule 1 line 17), so those premiums # leave line 31 and each premium dollar is subtracted once. + # A supplied tax-unit deduction is assumed to cover premiums also + # represented in health_insurance_premiums. The aggregate inputs do + # not identify the overlap if the deducted plan's premiums are omitted. # Line 21 nets out Medicare premiums withheld from Social Security or # railroad retirement benefits (MCL 206.510(1)(h)). Here line 21 has # gross benefits and line 31 the premiums, which subtracts them once From 655f8b482f43729c74da6877faac0ec7853b198d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 23:49:58 -0400 Subject: [PATCH 24/26] Distinguish federal model simplification from Form 7206 limits --- .../mi/tax/income/mi_household_resources.yaml | 15 +++++++++++---- .../test_mi_household_resources_exclusions.py | 4 ++++ 2 files changed, 15 insertions(+), 4 deletions(-) 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 3685d7df6d6..2cabfc2cd9b 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 @@ -1106,7 +1106,8 @@ # Line 16: Schedule C 50,000. # Line 30: deduction for self-employment tax, half of 50,000 x 0.9235 x # 15.3% = 3,532.39, plus the self-employed health insurance deduction, - # the lesser of the 5,000 premiums and the 50,000 net earnings = 5,000. + # 5,000 (the premiums are below both PolicyEngine's earnings limit and + # Form 7206's earnings limit after deductible self-employment tax). # 8,532.39 in all. # Line 31: premiums of 5,000, less the 5,000 deducted on line 30 = 0. # Line 33: 50,000 - 8,532.39 - 0 = 41,467.61. Subtracting the premiums @@ -1124,12 +1125,18 @@ state_code: MI output: # Line 14: 40,000. Line 16: 3,000. - # Line 30: deduction for self-employment tax, half of 3,000 x 0.9235 x - # 15.3% = 211.94, plus the self-employed health insurance deduction, - # limited to the 3,000 net earnings = 3,000. 3,211.94 in all. + # PolicyEngine's pre-existing federal simplification limits the health + # insurance deduction to net self-employment income before deductible + # self-employment tax: 3,000 here. + # Line 30 in PolicyEngine: self-employment tax deduction, half of 3,000 x + # 0.9235 x 15.3% = 211.94, plus health insurance 3,000 = 3,211.94. # Line 31: premiums of 5,000, less the 3,000 deducted on line 30 = 2,000. # Line 33: 43,000 - 3,211.94 - 2,000 = 37,788.06. The premiums come off # once in all: 3,000 on line 30 and 2,000 on line 31. + # 2025 Form 7206 lines 7-8 instead limit the health insurance deduction + # to 3,000 - 211.94 = 2,788.06. Then line 30 is 3,000 and line 31 is + # 5,000 - 2,788.06 = 2,211.94: 43,000 - 3,000 - 2,211.94 = 37,788.06. + # The Michigan total is unchanged because every premium comes off once. mi_household_resources: 37_788.06 absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/test_mi_household_resources_exclusions.py b/policyengine_us/tests/test_mi_household_resources_exclusions.py index 9a7952de13c..5d38793260d 100644 --- a/policyengine_us/tests/test_mi_household_resources_exclusions.py +++ b/policyengine_us/tests/test_mi_household_resources_exclusions.py @@ -270,6 +270,10 @@ def test_premiums_move_to_line_31_without_the_line_30_deduction(michigan_only): ) # The deduction is still computed under either list reform. deduction = baseline.calculate("self_employed_health_insurance_ald", YEAR) + # These are PolicyEngine's federal amounts: its pre-existing earnings + # limit is before deductible self-employment tax. Form 7206 gives + # 3,000 - 211.94 = 2,788.06 in the second case. Michigan resources are + # unchanged: line 30 is 3,000 and line 31 is 2,211.94, totaling 5,211.94. assert deduction == pytest.approx([5_000, 3_000], abs=TOLERANCE) agi_change = reformed.calculate("adjusted_gross_income", YEAR) - baseline.calculate( "adjusted_gross_income", YEAR From ee82052c33ef46e507d51f905a3873a203aeb83b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 23:50:25 -0400 Subject: [PATCH 25/26] Cover Michigan gambling and premium rules in 2023 --- .../mi/tax/income/mi_household_resources.yaml | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 2cabfc2cd9b..30536916550 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 @@ -1411,3 +1411,27 @@ output: # Line 33: 10,000 - 1,000 = 9,000. mi_household_resources: 9_000 + +- name: Gambling winnings above $300 count over $300 in 2023 + period: 2023 + input: + employment_income_before_lsr: 20_000 + gambling_winnings: 400 + state_code: MI + output: + # 2023 MI-1040CR line 20 has the same $300 exclusion as 2025. + # Line 33: 20,000 + max(400 - 300, 0) = 20,100. + mi_household_resources: 20_100 + +- name: A self-employed claimant's premiums are subtracted once in 2023 + period: 2023 + input: + self_employment_income: 50_000 + is_self_employed: true + health_insurance_premiums: 5_000 + state_code: MI + output: + # 2023 MI-1040CR lines 30-31 exclude premiums already deducted on line 30. + # Line 33: 50,000 - (50,000 x 0.9235 x 15.3% / 2) - 5,000 = 41,467.61. + mi_household_resources: 41_467.61 + absolute_error_margin: 0.01 From ccd50aededeb74b06cf7f2ff4de11641cb9f4782 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 23:50:58 -0400 Subject: [PATCH 26/26] Consolidate Michigan household resource source references --- .../gov/states/mi/tax/income/household_resources.yaml | 4 +--- .../income/household_resources_gambling_exclusion.yaml | 4 +--- .../tax/income/household_resources_gift_exclusion.yaml | 4 +--- .../gov/states/mi/tax/income/mi_household_resources.py | 9 +-------- 4 files changed, 4 insertions(+), 17 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 d1792139e81..6f7d5353bb5 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 @@ -78,9 +78,7 @@ metadata: href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2023/BOOK_MI-1040.pdf#page=31 - title: Michigan 2024 Individual Income Tax Instructions 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 + - title: "Michigan 2025 Individual Income Tax Instructions, Total Household Resources #page 26-27,31-32" 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/parameters/gov/states/mi/tax/income/household_resources_gambling_exclusion.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gambling_exclusion.yaml index b4d7d786c89..4c588f1622b 100644 --- a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gambling_exclusion.yaml +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gambling_exclusion.yaml @@ -19,7 +19,5 @@ metadata: href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2023/BOOK_MI-1040.pdf#page=32 - title: 2024 MI-1040 Instructions, MI-1040CR line 20 href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/MI-1040-Instructions.pdf#page=32 - - title: 2025 MI-1040 Instructions, MI-1040CR line 20 - href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=32 - - title: 2025 MI-1040 Instructions, total household resources do not include the first $300 from gambling, bingo, lottery, awards, or prizes + - title: "2025 MI-1040 Instructions, MI-1040CR line 20 and total household resources #page 27,32" href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=27 diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gift_exclusion.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gift_exclusion.yaml index b20c037b478..00bd2d3463c 100644 --- a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gift_exclusion.yaml +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_gift_exclusion.yaml @@ -17,7 +17,5 @@ metadata: href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2023/BOOK_MI-1040.pdf#page=32 - title: 2024 MI-1040 Instructions, MI-1040CR line 24 href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/MI-1040-Instructions.pdf#page=32 - - title: 2025 MI-1040 Instructions, MI-1040CR line 24 - href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=32 - - title: 2025 MI-1040 Instructions, total household resources do not include the first $300 in gifts of cash or merchandise + - title: "2025 MI-1040 Instructions, MI-1040CR line 24 and total household resources #page 27,32" href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=27 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 d3f691cae75..d4301481468 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 @@ -27,16 +27,9 @@ class mi_household_resources(Variable): "&hash=AF396E577B1932DF55BFD7F22D7EF06A#page=8", # 2025 MI-1040 book: "Total Household Resources" (pages 26 and 27) # and MI-1040CR lines 14 to 17 (page 31) and 18 to 31 (pages 32 and 33). + # PDF pages 26-27, 31-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=27", - "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):