From 0bcebf6127bfb3bd1e6f824413b85130cd8c1887 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 15:13:29 -0400 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 ab04d396faeeb01e17805c1f5a0979dc899c4026 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 12:38:03 -0400 Subject: [PATCH 08/14] Subtract only Schedule 1 adjustments on MI-1040CR line 30 Line 30 is "total adjustments from your U.S. Form 1040, Schedule 1". It used the federal above-the-line list without loss_ald, which still held four exclusions from gross income: us_bonds_for_higher_ed (IRC 135), qualified_adoption_assistance_expense (137), specified_possession_income (931) and puerto_rico_income (933). Total household resources include "all income exempt or excluded from AGI" (2025 MI-1040 book, page 26), and the excluded amounts already sit in the line 14 and 15 sources (line 15 is "All interest and dividend income (including nontaxable interest)"; adoption benefits are reported on Form W-2, box 12 code T), so line 30 took them back out. A new parameter, gov.states.mi.tax.income.household_resources_adjustments, lists the federal above-the-line deductions that are Schedule 1 Part II adjustments. Line 30 sums those the federal list deducts, so a federal reform that drops one drops it here too. Co-Authored-By: Claude Opus 5.5 --- ...-resources-schedule-1-adjustments.fixed.md | 1 + .../household_resources_adjustments.yaml | 30 ++ .../mi/tax/income/mi_household_resources.yaml | 140 +++++++ ...test_mi_household_resources_adjustments.py | 378 ++++++++++++++++++ .../test_mi_household_resources_losses.py | 11 +- .../mi/tax/income/mi_household_resources.py | 25 +- 6 files changed, 573 insertions(+), 12 deletions(-) create mode 100644 changelog.d/mi-household-resources-schedule-1-adjustments.fixed.md create mode 100644 policyengine_us/parameters/gov/states/mi/tax/income/household_resources_adjustments.yaml create mode 100644 policyengine_us/tests/test_mi_household_resources_adjustments.py diff --git a/changelog.d/mi-household-resources-schedule-1-adjustments.fixed.md b/changelog.d/mi-household-resources-schedule-1-adjustments.fixed.md new file mode 100644 index 00000000000..aab3b1a98d8 --- /dev/null +++ b/changelog.d/mi-household-resources-schedule-1-adjustments.fixed.md @@ -0,0 +1 @@ +Michigan household resources (MI-1040CR line 30) subtract only the U.S. Schedule 1 adjustments to income, so income excluded under IRC 135, 137, 931 and 933 (savings bond interest for higher education, employer adoption benefits, possession and Puerto Rico income) stays in household resources. diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_adjustments.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_adjustments.yaml new file mode 100644 index 00000000000..2aeb7d941fe --- /dev/null +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_adjustments.yaml @@ -0,0 +1,30 @@ +description: Michigan subtracts the following federal above-the-line deductions, the U.S. Schedule 1 adjustments to income, from household resources. +values: + # MI-1040CR line 30: "Enter total adjustments from your U.S. Form 1040, + # Schedule 1." Each entry is a Schedule 1 Part II line. Federal + # above-the-line deductions that are not Part II adjustments stay off this + # list: loss_ald (business, rental and capital losses, which are income + # items) and the exclusions from gross income under IRC 135, 137, 931 and + # 933 (us_bonds_for_higher_ed, qualified_adoption_assistance_expense, + # specified_possession_income and puerto_rico_income). + 2021-01-01: + - educator_expense # line 11 + - health_savings_account_ald # line 13 + - self_employment_tax_ald # line 15 + - self_employed_pension_contribution_ald # line 16 + - self_employed_health_insurance_ald # line 17 + - early_withdrawal_penalty # line 18 + - alimony_expense_ald # line 19a + - traditional_ira_contributions # line 20 + - student_loan_interest_ald # line 21 +metadata: + unit: list + period: year + label: Michigan household resources adjustments + reference: + - title: Michigan 2024 Individual Income Tax Instructions, MI-1040CR line 30 + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/MI-1040-Instructions.pdf#page=33 + - title: Michigan 2025 Individual Income Tax Instructions, MI-1040CR line 30 + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=32 + - title: 2025 Schedule 1 (Form 1040), Part II, Adjustments to Income + href: https://www.irs.gov/pub/irs-prior/f1040s1--2025.pdf#page=2 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..acd3d86fbdd 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 @@ -48,6 +48,146 @@ # Line 33: 10,000 - (1,000 + 3,000) = 6,000. mi_household_resources: 6_000 +- name: Savings bond interest excluded under IRC 135 stays in household resources + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 30_000 + taxable_interest_income: 2_000 + us_bonds_for_higher_ed: 1_500 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Federal: Form 8815 excludes 1,500 of the 2,000 of interest (Schedule B + # line 3), so AGI is 30,000 + 500 = 30,500. + adjusted_gross_income: 30_500 + # Line 14: 30,000. + # Line 15: "All interest and dividend income (including nontaxable + # interest)": 2,000. + # Line 30: 0. The Form 8815 exclusion is not a Schedule 1 adjustment. + # Line 33: 30,000 + 2,000 = 32,000. + mi_household_resources: 32_000 + +- name: Employer adoption benefits excluded under IRC 137 stay in household resources + period: 2025 + input: + people: + person1: + age: 45 + # Includes 5,000 the employer paid for qualified adoption expenses. + employment_income_before_lsr: 60_000 + qualified_adoption_assistance_expense: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Federal: the 5,000 is excluded (Form 8839 Part III) and left out of + # W-2 box 1, so AGI is 55,000. + adjusted_gross_income: 55_000 + # Line 14: "all compensation received as an employee ... and any other + # amounts reported to you on Form W-2". The adoption benefits are + # reported in W-2 box 12 (code T): 55,000 + 5,000 = 60,000. + # Line 30: 0. The exclusion is not a Schedule 1 adjustment. + # Line 33: 60,000. + mi_household_resources: 60_000 + +- name: Puerto Rico income excluded under IRC 933 stays in household resources + period: 2025 + input: + people: + person1: + age: 45 + # Includes 15,000 earned in Puerto Rico before moving to Michigan. + employment_income_before_lsr: 45_000 + tax_units: + tax_unit: + members: [person1] + puerto_rico_income: 15_000 + households: + household: + members: [person1] + state_code: MI + output: + # Federal: section 933 excludes the 15,000, so AGI is 30,000. + adjusted_gross_income: 30_000 + # Line 14: all wages, 45,000. Total household resources include "all + # income exempt or excluded from AGI" (book page 26). + # Line 30: 0. The exclusion is not a Schedule 1 adjustment. + # Line 33: 45,000. + mi_household_resources: 45_000 + +- name: Possession income excluded under IRC 931 stays in household resources + period: 2025 + input: + people: + person1: + age: 45 + # Includes 8,000 of income from a specified possession. + employment_income_before_lsr: 40_000 + tax_units: + tax_unit: + members: [person1] + specified_possession_income: 8_000 + households: + household: + members: [person1] + state_code: MI + output: + # Federal: section 931 excludes the 8,000, so AGI is 32,000. + adjusted_gross_income: 32_000 + # Line 14: all wages, 40,000, as "all income exempt or excluded from AGI" + # counts (book page 26). + # Line 30: 0. The exclusion is not a Schedule 1 adjustment. + # Line 33: 40,000. + mi_household_resources: 40_000 + +- name: Schedule 1 adjustments reduce household resources and federal exclusions do not + period: 2025 + input: + people: + person1: + age: 45 + # Includes 4,000 of employer adoption benefits (W-2 box 12, code T). + employment_income_before_lsr: 50_000 + qualified_adoption_assistance_expense: 4_000 + taxable_interest_income: 3_000 + us_bonds_for_higher_ed: 1_000 + traditional_ira_contributions: 2_000 + early_withdrawal_penalty: 300 + student_loan_interest: 1_000 + health_insurance_premiums: 1_200 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Federal: 50,000 + 3,000 - 4,000 (IRC 137) - 1,000 (IRC 135) = 48,000 + # of gross income, less Schedule 1 line 26 (2,000 + 300 + 1,000 = + # 3,300): AGI 44,700, under the student loan interest phase-out. + adjusted_gross_income: 44_700 + # Line 14: 50,000. Line 15: 3,000, including the excluded 1,000. + # Line 28: 53,000. + # Line 30: IRA deduction (Schedule 1 line 20) 2,000 + penalty on early + # withdrawal of savings (line 18) 300 + student loan interest deduction + # (line 21) 1,000 = 3,300. + # Line 31: 1,200. + # Line 33: 53,000 - (3,300 + 1,200) = 48,500. + mi_household_resources: 48_500 + - name: Negative business income floored at 0 period: 2024 input: diff --git a/policyengine_us/tests/test_mi_household_resources_adjustments.py b/policyengine_us/tests/test_mi_household_resources_adjustments.py new file mode 100644 index 00000000000..d4c673bcb08 --- /dev/null +++ b/policyengine_us/tests/test_mi_household_resources_adjustments.py @@ -0,0 +1,378 @@ +"""Property tests: MI-1040CR line 30 in Michigan household resources. + +Line 30 is "total adjustments from your U.S. Form 1040, Schedule 1" (2025 +MI-1040 book, page 32): the Schedule 1 Part II adjustments to income, which +gov.states.mi.tax.income.household_resources_adjustments lists. The federal +above-the-line list, gov.irs.ald.deductions, also holds items that are not +Part II adjustments: + +- loss_ald: business, rental and capital losses, which are income items + (lines 16, 17 and 19); +- us_bonds_for_higher_ed, qualified_adoption_assistance_expense, + specified_possession_income and puerto_rico_income: income that IRC 135, + 137, 931 and 933 exclude from gross income. Total household resources are + "AGI ... plus all income exempt or excluded from AGI" (page 26), and line 15 + is "All interest and dividend income (including nontaxable interest)". + +The federal list subtracts each exclusion from an income source that holds it +(savings bond interest, wages), so the model's income inputs include the +excluded amounts. Line 30 must not take them back out. + +Invariants, for every input: + +1. Every federal above-the-line deduction from 2021 on is either a Michigan + line 30 adjustment or one of the five items above, never both, and every + line 30 adjustment is a federal above-the-line deduction. +2. mi_household_resources = max(0, wages + all interest + max(0, Schedule C) + - Schedule 1 adjustments - premiums). The adjustments are read from the + model one by one (the self-employment tax and student loan interest + deductions depend on income); the reference adds none of the exclusions. + This is a differential test against a reference written from the form. +3. Household resources do not depend on the four exclusions: a household + with them equals the same household without them, while federal AGI + falls by their total. Households with student loan interest are left + out of this check on purpose: the student loan interest deduction's + modified AGI applies sections 135 and 137 (IRC 221(b)(2)(C)), so an + exclusion can change that deduction, which line 30 then follows. +4. Line 30 follows the federal list: a reform that removes the early + withdrawal penalty and the IRA deduction from gov.irs.ald.deductions + removes them from line 30, and one that removes an exclusion from the + federal list leaves household resources unchanged. + +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. +""" + +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 +from policyengine_us.system import system + +try: + import hypothesis + import hypothesis.strategies as st +except ImportError: # Hypothesis is a dev extra. + hypothesis = None + +YEAR = 2025 +TOLERANCE = 0.01 + +# Federal above-the-line deductions that are not Schedule 1 Part II +# adjustments. +NOT_SCHEDULE_1_ADJUSTMENTS = { + "loss_ald", + "us_bonds_for_higher_ed", + "qualified_adoption_assistance_expense", + "specified_possession_income", + "puerto_rico_income", +} +PERSON_EXCLUSIONS = ["us_bonds_for_higher_ed", "qualified_adoption_assistance_expense"] +UNIT_EXCLUSIONS = ["specified_possession_income", "puerto_rico_income"] +PERSON_INPUTS = [ + "employment_income_before_lsr", + "taxable_interest_income", + "tax_exempt_interest_income", + "self_employment_income", + *PERSON_EXCLUSIONS, + "early_withdrawal_penalty", + "traditional_ira_contributions", + "student_loan_interest", + "health_insurance_premiums", +] + + +def build_situation(households): + """One single-person Michigan tax unit per household.""" + people, tax_units, households_out = {}, {}, {} + for i, h in enumerate(households): + name = f"person_{i}" + people[name] = {"age": {YEAR: 45}} + for variable in PERSON_INPUTS: + people[name][variable] = {YEAR: h[variable]} + # 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": [name], + **{variable: {YEAR: h[variable]} for variable in UNIT_EXCLUSIONS}, + } + households_out[f"household_{i}"] = { + "members": [name], + "state_code": {YEAR: "MI"}, + } + return {"people": people, "tax_units": tax_units, "households": households_out} + + +def without_federal_deductions(removed): + """A reform that removes deductions 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 not in removed] + 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 calculate(households, reform=None): + simulation = Simulation(situation=build_situation(households), reform=reform) + results = { + v: np.asarray(simulation.calculate(v, YEAR)) + for v in ["mi_household_resources", "adjusted_gross_income"] + } + # Each Schedule 1 adjustment the federal list deducts, per tax unit. + gov = simulation.tax_benefit_system.parameters(f"{YEAR}-01-01").gov + results["adjustments"] = { + d: np.asarray(simulation.calculate(d, YEAR, map_to="tax_unit")) + for d in gov.states.mi.tax.income.household_resources_adjustments + if d in gov.irs.ald.deductions + } + return results + + +def tolerance(h): + """One cent, or eight float32 spacings at the household's total absolute + amount.""" + scale = sum(abs(h[v]) for v in [*PERSON_INPUTS, *UNIT_EXCLUSIONS]) + return max(TOLERANCE, 8 * float(np.spacing(np.float32(scale)))) + + +def reference(h, line_30): + """MI-1040CR line 33, total household resources, from the form.""" + line_14 = h["employment_income_before_lsr"] + # "All interest and dividend income (including nontaxable interest)", + # savings bond interest excluded under section 135 included. + line_15 = h["taxable_interest_income"] + h["tax_exempt_interest_income"] + line_16 = max(0, h["self_employment_income"]) + line_31 = h["health_insurance_premiums"] + return max(0, line_14 + line_15 + line_16 - line_30 - line_31) + + +def without_exclusions(h): + return {**h, **{v: 0 for v in [*PERSON_EXCLUSIONS, *UNIT_EXCLUSIONS]}} + + +def exclusions(h): + return sum(h[v] for v in [*PERSON_EXCLUSIONS, *UNIT_EXCLUSIONS]) + + +def assert_properties(households): + """Invariants 2 and 3 on a list of households.""" + twins = [without_exclusions(h) for h in households] + model = calculate(households + twins) + n = len(households) + for i, h in enumerate(households): + tol = tolerance(h) + line_30 = sum(float(values[i]) for values in model["adjustments"].values()) + # 2. Differential against the form. + assert model["mi_household_resources"][i] == pytest.approx( + reference(h, line_30), abs=tol + ), h + if h["student_loan_interest"] == 0: + # 3. The exclusions change AGI but not household resources. + assert model["mi_household_resources"][i] == pytest.approx( + model["mi_household_resources"][n + i], abs=tol + ), h + assert model["adjusted_gross_income"][n + i] - model[ + "adjusted_gross_income" + ][i] == pytest.approx(exclusions(h), abs=tol), h + + +def household(**amounts): + h = {v: 0 for v in [*PERSON_INPUTS, *UNIT_EXCLUSIONS]} + h.update(amounts) + return h + + +# Each exclusion is a share of the income source that holds it. +EXCLUSION_PATTERNS = [ + {}, + {"us_bonds_for_higher_ed": 1_500}, + {"qualified_adoption_assistance_expense": 5_000}, + {"puerto_rico_income": 10_000}, + {"specified_possession_income": 8_000}, + { + "us_bonds_for_higher_ed": 1_500, + "qualified_adoption_assistance_expense": 5_000, + "puerto_rico_income": 10_000, + "specified_possession_income": 8_000, + }, +] + + +def grid_households(): + """Wages, interest, Schedule C income, exclusions, Schedule 1 + adjustments and premiums in every combination (1,536 households).""" + households = [] + for ( + wages, + interest, + exempt, + schedule_c, + pattern, + ewp, + ira, + sli, + premiums, + ) in itertools.product( + [0, 40_000], + [0, 3_000], + [0, 800], + [0, 20_000], + EXCLUSION_PATTERNS, + [0, 400], + [0, 2_500], + [0, 1_500], + [0, 3_000], + ): + h = household( + employment_income_before_lsr=wages, + taxable_interest_income=interest, + tax_exempt_interest_income=exempt, + self_employment_income=schedule_c, + early_withdrawal_penalty=ewp, + traditional_ira_contributions=ira, + student_loan_interest=sli, + health_insurance_premiums=premiums, + ) + # Savings bond interest needs interest; the rest are wages. + for name, amount in pattern.items(): + source = interest if name == "us_bonds_for_higher_ed" else wages + h[name] = amount if source >= amount else 0 + households.append(h) + return households + + +def test_federal_deductions_are_classified(): + """Invariant 1, for every year from 2021 (when the Michigan list starts) + and every date the federal list changes after that.""" + parameters = system.parameters + federal_node = parameters.gov.irs.ald.deductions + michigan_node = parameters.gov.states.mi.tax.income.household_resources_adjustments + dates = {f"{year}-01-01" for year in range(2021, 2036)} + dates |= { + entry.instant_str + for node in [federal_node, michigan_node] + for entry in node.values_list + if entry.instant_str >= "2021-01-01" + } + for date in sorted(dates): + federal = set(federal_node(date)) + michigan = set(michigan_node(date)) + assert not michigan & NOT_SCHEDULE_1_ADJUSTMENTS, date + assert michigan <= federal, (date, michigan - federal) + unclassified = federal - michigan - NOT_SCHEDULE_1_ADJUSTMENTS + assert not unclassified, ( + f"{date}: classify {sorted(unclassified)} for MI-1040CR line 30: add " + "it to gov.states.mi.tax.income.household_resources_adjustments if " + "it is a U.S. Schedule 1 Part II adjustment, otherwise to " + "NOT_SCHEDULE_1_ADJUSTMENTS here." + ) + + +def test_worked_example(): + """Wages of 50,000 (4,000 of them excluded adoption benefits) and 3,000 of + interest (1,000 excluded under section 135); IRA 2,000, early withdrawal + penalty 300 and student loan interest 1,000 on Schedule 1; premiums 1,200. + Line 33 = 53,000 - 3,300 - 1,200 = 48,500. Federal AGI is 44,700.""" + model = calculate( + [ + household( + employment_income_before_lsr=50_000, + qualified_adoption_assistance_expense=4_000, + taxable_interest_income=3_000, + us_bonds_for_higher_ed=1_000, + traditional_ira_contributions=2_000, + early_withdrawal_penalty=300, + student_loan_interest=1_000, + health_insurance_premiums=1_200, + ) + ] + ) + assert model["mi_household_resources"][0] == 48_500 + assert model["adjusted_gross_income"][0] == 44_700 + + +def test_grid(): + """Invariants 2 and 3 on every grid household.""" + assert_properties(grid_households()) + + +def test_line_30_follows_the_federal_list(): + """Invariant 4. Removing the early withdrawal penalty and the IRA + deduction from the federal list takes them off line 30. Removing two + exclusions as well changes AGI but not household resources, because they + were never on line 30.""" + households = grid_households() + removed = { + "early_withdrawal_penalty", + "traditional_ira_contributions", + "us_bonds_for_higher_ed", + "puerto_rico_income", + } + model = calculate(households, reform=without_federal_deductions(removed)) + assert "early_withdrawal_penalty" not in model["adjustments"] + assert "traditional_ira_contributions" not in model["adjustments"] + for i, h in enumerate(households): + line_30 = sum(float(values[i]) for values in model["adjustments"].values()) + assert model["mi_household_resources"][i] == pytest.approx( + reference(h, line_30), abs=tolerance(h) + ), h + + +if hypothesis is not None: + WAGES = st.integers(min_value=0, max_value=300_000) + SMALL = st.integers(min_value=0, max_value=20_000) + # 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] + + @st.composite + def drawn_household(draw): + h = household( + employment_income_before_lsr=draw(WAGES), + taxable_interest_income=draw(st.one_of(st.just(0), SMALL)), + tax_exempt_interest_income=draw(st.one_of(st.just(0), SMALL)), + self_employment_income=draw( + st.one_of(st.just(0), st.integers(-50_000, 200_000)) + ), + early_withdrawal_penalty=draw(st.one_of(st.just(0), SMALL)), + traditional_ira_contributions=draw( + st.one_of(st.just(0), st.integers(0, 7_000)) + ), + student_loan_interest=draw(st.one_of(st.just(0), st.integers(0, 5_000))), + health_insurance_premiums=draw(st.one_of(st.just(0), SMALL)), + ) + # Exclusions are drawn as shares of the income that holds them, and + # the wage-based ones together do not exceed wages. + h["us_bonds_for_higher_ed"] = draw(st.integers(0, h["taxable_interest_income"])) + remaining = h["employment_income_before_lsr"] + for name in [ + "qualified_adoption_assistance_expense", + "puerto_rico_income", + "specified_possession_income", + ]: + h[name] = draw(st.one_of(st.just(0), st.integers(0, remaining))) + remaining -= h[name] + return h + + @hypothesis.settings(max_examples=25, deadline=None, suppress_health_check=SLOW) + @hypothesis.given(st.lists(drawn_household(), min_size=1, max_size=40)) + def test_properties(households): + """Invariants 2 and 3 on drawn households.""" + assert_properties(households) diff --git a/policyengine_us/tests/test_mi_household_resources_losses.py b/policyengine_us/tests/test_mi_household_resources_losses.py index d72a0d40574..10e1d6d7594 100644 --- a/policyengine_us/tests/test_mi_household_resources_losses.py +++ b/policyengine_us/tests/test_mi_household_resources_losses.py @@ -158,13 +158,14 @@ def modify(parameters): 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 + # Line 30 summed directly, as the formula does: the Schedule 1 + # adjustments the federal list deducts. Subtracting a large loss_ald from + # above_the_line_deductions would lose cents in float32. + gov = simulation.tax_benefit_system.parameters(f"{YEAR}-01-01").gov 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" + for d in gov.states.mi.tax.income.household_resources_adjustments + if d in gov.irs.ald.deductions ) return results 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..97b513688a2 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 @@ -72,18 +72,29 @@ def formula(tax_unit, period, parameters): 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". - # 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. + # Line 30: "Enter total adjustments from your U.S. Form 1040, + # Schedule 1." These are the Schedule 1 Part II adjustments, counted + # while the federal above-the-line list deducts them, so a reform that + # drops one federally drops it here too. The rest of the federal list + # is not on line 30: + # - loss_ald holds business, rental and capital losses, which are + # income items netted and floored on lines 16, 17 and 19 above; + # - us_bonds_for_higher_ed, qualified_adoption_assistance_expense, + # specified_possession_income and puerto_rico_income are income + # that IRC 135, 137, 931 and 933 exclude from gross income. Total + # household resources include "all income exempt or excluded from + # AGI" (book page 26). The federal list subtracts these amounts from + # income sources that hold them, such as savings bond interest on + # line 15 ("including nontaxable interest") and adoption benefits, + # reported on Form W-2, on line 14, so they stay in those lines. + federal_deductions = parameters(period).gov.irs.ald.deductions adjustments = add( tax_unit, period, [ deduction - for deduction in parameters(period).gov.irs.ald.deductions - if deduction != "loss_ald" + for deduction in p.household_resources_adjustments + if deduction in federal_deductions ], ) # Line 31: health insurance premiums. From 2823f4aae2d5fa58fc53ab0195514b9e1d8e9853 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 13:40:13 -0400 Subject: [PATCH 09/14] Tighten MI line 30 property tests and describe the input convention Review r1 on #9795: - The formula comment stated as model behaviour what is an input convention: because the federal list subtracts each exclusion from gross income, the excluded amount is entered in the income it comes from. - The differential reference took line 30's variables from the parameter under test. It now uses SCHEDULE_1_ADJUSTMENTS, written in the test from the 2025 Schedule 1, and the classification test asserts the parameter equals it. - Invariant 3 skipped every household with student loan interest. It now allows for the change D in that deduction (household resources = max(0, without - D); AGI lower by the exclusions plus D), and the grid adds six phase-out households so that case is exercised. - test_worked_example duplicated a YAML case and is dropped. Co-Authored-By: Claude Opus 5.5 --- ...test_mi_household_resources_adjustments.py | 147 +++++++++++------- .../mi/tax/income/mi_household_resources.py | 9 +- 2 files changed, 93 insertions(+), 63 deletions(-) diff --git a/policyengine_us/tests/test_mi_household_resources_adjustments.py b/policyengine_us/tests/test_mi_household_resources_adjustments.py index d4c673bcb08..3f0e9110cad 100644 --- a/policyengine_us/tests/test_mi_household_resources_adjustments.py +++ b/policyengine_us/tests/test_mi_household_resources_adjustments.py @@ -14,30 +14,36 @@ "AGI ... plus all income exempt or excluded from AGI" (page 26), and line 15 is "All interest and dividend income (including nontaxable interest)". -The federal list subtracts each exclusion from an income source that holds it -(savings bond interest, wages), so the model's income inputs include the -excluded amounts. Line 30 must not take them back out. +Because the federal list subtracts each exclusion from gross income, the +excluded amount is entered in the income it comes from (savings bond interest +in interest income, adoption benefits and possession income in wages, as these +tests do). Line 30 must not take it back out. Invariants, for every input: -1. Every federal above-the-line deduction from 2021 on is either a Michigan - line 30 adjustment or one of the five items above, never both, and every - line 30 adjustment is a federal above-the-line deduction. +1. The Michigan list is exactly the Schedule 1 Part II adjustments named in + SCHEDULE_1_ADJUSTMENTS below (written from the 2025 Schedule 1). Every + federal above-the-line deduction from 2021 on is either one of them or one + of the five items above, never both, and each is a federal above-the-line + deduction. 2. mi_household_resources = max(0, wages + all interest + max(0, Schedule C) - - Schedule 1 adjustments - premiums). The adjustments are read from the - model one by one (the self-employment tax and student loan interest - deductions depend on income); the reference adds none of the exclusions. - This is a differential test against a reference written from the form. -3. Household resources do not depend on the four exclusions: a household - with them equals the same household without them, while federal AGI - falls by their total. Households with student loan interest are left - out of this check on purpose: the student loan interest deduction's - modified AGI applies sections 135 and 137 (IRC 221(b)(2)(C)), so an - exclusion can change that deduction, which line 30 then follows. + - Schedule 1 adjustments - premiums), where the adjustments are the + SCHEDULE_1_ADJUSTMENTS the federal list deducts. Their amounts are read + from the model one by one, since the self-employment tax and student loan + interest deductions depend on income; which variables count comes from + this file, not from the parameter. The reference subtracts none of the + exclusions. This is a differential test against a reference written from + the form. +3. The exclusions reach household resources only through the student loan + interest deduction, whose modified AGI applies sections 135 and 137 + (IRC 221(b)(2)(C)). Compared with the same household without exclusions, + with D the change in that deduction: household resources are + max(0, resources without - D) (equal when D = 0), and federal AGI is + lower by the exclusions plus D. 4. Line 30 follows the federal list: a reform that removes the early withdrawal penalty and the IRA deduction from gov.irs.ald.deductions - removes them from line 30, and one that removes an exclusion from the - federal list leaves household resources unchanged. + removes them from line 30, and removing an exclusion from the federal + list does not put it on line 30. 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 @@ -64,6 +70,19 @@ YEAR = 2025 TOLERANCE = 0.01 +# Federal above-the-line deductions that are U.S. Schedule 1 Part II +# adjustments (2025 Schedule 1 line), written from the form. +SCHEDULE_1_ADJUSTMENTS = { + "educator_expense": "11", + "health_savings_account_ald": "13", + "self_employment_tax_ald": "15", + "self_employed_pension_contribution_ald": "16", + "self_employed_health_insurance_ald": "17", + "early_withdrawal_penalty": "18", + "alimony_expense_ald": "19a", + "traditional_ira_contributions": "20", + "student_loan_interest_ald": "21", +} # Federal above-the-line deductions that are not Schedule 1 Part II # adjustments. NOT_SCHEDULE_1_ADJUSTMENTS = { @@ -136,12 +155,17 @@ def calculate(households, reform=None): for v in ["mi_household_resources", "adjusted_gross_income"] } # Each Schedule 1 adjustment the federal list deducts, per tax unit. - gov = simulation.tax_benefit_system.parameters(f"{YEAR}-01-01").gov + federal = simulation.tax_benefit_system.parameters.gov.irs.ald.deductions( + f"{YEAR}-01-01" + ) results["adjustments"] = { d: np.asarray(simulation.calculate(d, YEAR, map_to="tax_unit")) - for d in gov.states.mi.tax.income.household_resources_adjustments - if d in gov.irs.ald.deductions + for d in SCHEDULE_1_ADJUSTMENTS + if d in federal } + results["student_loan_interest_ald"] = np.asarray( + simulation.calculate("student_loan_interest_ald", YEAR, map_to="tax_unit") + ) return results @@ -172,10 +196,12 @@ def exclusions(h): def assert_properties(households): - """Invariants 2 and 3 on a list of households.""" + """Invariants 2 and 3 on a list of households. Returns how many + households' exclusions changed the student loan interest deduction.""" twins = [without_exclusions(h) for h in households] model = calculate(households + twins) n = len(households) + changed = 0 for i, h in enumerate(households): tol = tolerance(h) line_30 = sum(float(values[i]) for values in model["adjustments"].values()) @@ -183,14 +209,24 @@ def assert_properties(households): assert model["mi_household_resources"][i] == pytest.approx( reference(h, line_30), abs=tol ), h - if h["student_loan_interest"] == 0: - # 3. The exclusions change AGI but not household resources. + # 3. The exclusions move household resources only through the + # student loan interest deduction. + change = ( + model["student_loan_interest_ald"][i] + - model["student_loan_interest_ald"][n + i] + ) + assert model["adjusted_gross_income"][n + i] - model["adjusted_gross_income"][ + i + ] == pytest.approx(exclusions(h) + change, abs=tol), h + changed += abs(change) > tol + without = model["mi_household_resources"][n + i] + # Resources without exclusions of 0 hide how far below zero they + # were; a larger deduction (change >= 0) keeps them at 0. + if without > 0 or change >= 0: assert model["mi_household_resources"][i] == pytest.approx( - model["mi_household_resources"][n + i], abs=tol + max(0, without - change), abs=tol ), h - assert model["adjusted_gross_income"][n + i] - model[ - "adjusted_gross_income" - ][i] == pytest.approx(exclusions(h), abs=tol), h + return changed def household(**amounts): @@ -217,7 +253,8 @@ def household(**amounts): def grid_households(): """Wages, interest, Schedule C income, exclusions, Schedule 1 - adjustments and premiums in every combination (1,536 households).""" + adjustments and premiums in every combination (1,536 households), plus + each exclusion pattern in the student loan interest phase-out (6).""" households = [] for ( wages, @@ -255,6 +292,17 @@ def grid_households(): source = interest if name == "us_bonds_for_higher_ed" else wages h[name] = amount if source >= amount else 0 households.append(h) + # In the student loan interest phase-out ($85,000 to $100,000 of modified + # AGI for a single filer in 2025), where an exclusion changes that + # deduction. + for pattern in EXCLUSION_PATTERNS: + h = household( + employment_income_before_lsr=92_000, + taxable_interest_income=3_000, + student_loan_interest=2_500, + ) + h.update(pattern) + households.append(h) return households @@ -274,50 +322,31 @@ def test_federal_deductions_are_classified(): for date in sorted(dates): federal = set(federal_node(date)) michigan = set(michigan_node(date)) + assert michigan == set(SCHEDULE_1_ADJUSTMENTS), date assert not michigan & NOT_SCHEDULE_1_ADJUSTMENTS, date assert michigan <= federal, (date, michigan - federal) unclassified = federal - michigan - NOT_SCHEDULE_1_ADJUSTMENTS assert not unclassified, ( f"{date}: classify {sorted(unclassified)} for MI-1040CR line 30: add " - "it to gov.states.mi.tax.income.household_resources_adjustments if " - "it is a U.S. Schedule 1 Part II adjustment, otherwise to " - "NOT_SCHEDULE_1_ADJUSTMENTS here." + "it to gov.states.mi.tax.income.household_resources_adjustments " + "and SCHEDULE_1_ADJUSTMENTS here if it is a U.S. Schedule 1 Part " + "II adjustment, otherwise to NOT_SCHEDULE_1_ADJUSTMENTS." ) -def test_worked_example(): - """Wages of 50,000 (4,000 of them excluded adoption benefits) and 3,000 of - interest (1,000 excluded under section 135); IRA 2,000, early withdrawal - penalty 300 and student loan interest 1,000 on Schedule 1; premiums 1,200. - Line 33 = 53,000 - 3,300 - 1,200 = 48,500. Federal AGI is 44,700.""" - model = calculate( - [ - household( - employment_income_before_lsr=50_000, - qualified_adoption_assistance_expense=4_000, - taxable_interest_income=3_000, - us_bonds_for_higher_ed=1_000, - traditional_ira_contributions=2_000, - early_withdrawal_penalty=300, - student_loan_interest=1_000, - health_insurance_premiums=1_200, - ) - ] - ) - assert model["mi_household_resources"][0] == 48_500 - assert model["adjusted_gross_income"][0] == 44_700 - - def test_grid(): - """Invariants 2 and 3 on every grid household.""" - assert_properties(grid_households()) + """Invariants 2 and 3 on every grid household. The phase-out households + make invariant 3's student loan interest case non-vacuous.""" + assert assert_properties(grid_households()) > 0 def test_line_30_follows_the_federal_list(): """Invariant 4. Removing the early withdrawal penalty and the IRA deduction from the federal list takes them off line 30. Removing two - exclusions as well changes AGI but not household resources, because they - were never on line 30.""" + exclusions as well changes AGI, and household resources still match the + form: the exclusions were never on line 30, and only the student loan + interest deduction, read from the model, follows the higher modified + AGI.""" households = grid_households() removed = { "early_withdrawal_penalty", 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 97b513688a2..7d24018409c 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 @@ -83,10 +83,11 @@ def formula(tax_unit, period, parameters): # specified_possession_income and puerto_rico_income are income # that IRC 135, 137, 931 and 933 exclude from gross income. Total # household resources include "all income exempt or excluded from - # AGI" (book page 26). The federal list subtracts these amounts from - # income sources that hold them, such as savings bond interest on - # line 15 ("including nontaxable interest") and adoption benefits, - # reported on Form W-2, on line 14, so they stay in those lines. + # AGI" (book page 26). Since the federal list subtracts each from + # gross income, the excluded amount is entered in the income it + # comes from: savings bond interest in interest income (line 15, + # "including nontaxable interest"), adoption benefits, reported on + # Form W-2, in wages (line 14). Line 30 leaves it there. federal_deductions = parameters(period).gov.irs.ald.deductions adjustments = add( tax_unit, From 3b26b046e0d4847cf873c4b9bdd3bbed0d679aab Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 14:00:15 -0400 Subject: [PATCH 10/14] Check each exclusion's effect on the student loan deduction in the MI grid Review r2 on #9795: in the phase-out households, savings bond interest and adoption benefits must change the student loan interest deduction and Puerto Rico income (added back under IRC 221(b)(2)(C)) must not; the grid now asserts each instead of "at least one". Possession income is left unchecked, since its effect depends on the federal modified AGI's treatment of section 931. The formula comment adds possession and Puerto Rico income to the input convention. Co-Authored-By: Claude Opus 5.5 --- ...test_mi_household_resources_adjustments.py | 36 ++++++++++++++----- .../mi/tax/income/mi_household_resources.py | 3 +- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/policyengine_us/tests/test_mi_household_resources_adjustments.py b/policyengine_us/tests/test_mi_household_resources_adjustments.py index 3f0e9110cad..907a80b2997 100644 --- a/policyengine_us/tests/test_mi_household_resources_adjustments.py +++ b/policyengine_us/tests/test_mi_household_resources_adjustments.py @@ -196,12 +196,12 @@ def exclusions(h): def assert_properties(households): - """Invariants 2 and 3 on a list of households. Returns how many - households' exclusions changed the student loan interest deduction.""" + """Invariants 2 and 3 on a list of households. Returns each household's + change in the student loan interest deduction from its exclusions.""" twins = [without_exclusions(h) for h in households] model = calculate(households + twins) n = len(households) - changed = 0 + changes = [] for i, h in enumerate(households): tol = tolerance(h) line_30 = sum(float(values[i]) for values in model["adjustments"].values()) @@ -218,7 +218,7 @@ def assert_properties(households): assert model["adjusted_gross_income"][n + i] - model["adjusted_gross_income"][ i ] == pytest.approx(exclusions(h) + change, abs=tol), h - changed += abs(change) > tol + changes.append(float(change)) without = model["mi_household_resources"][n + i] # Resources without exclusions of 0 hide how far below zero they # were; a larger deduction (change >= 0) keeps them at 0. @@ -226,7 +226,7 @@ def assert_properties(households): assert model["mi_household_resources"][i] == pytest.approx( max(0, without - change), abs=tol ), h - return changed + return changes def household(**amounts): @@ -251,6 +251,16 @@ def household(**amounts): ] +PATTERN_NAMES = [ + "none", + "us_bonds_for_higher_ed", + "qualified_adoption_assistance_expense", + "puerto_rico_income", + "specified_possession_income", + "all", +] + + def grid_households(): """Wages, interest, Schedule C income, exclusions, Schedule 1 adjustments and premiums in every combination (1,536 households), plus @@ -335,9 +345,19 @@ def test_federal_deductions_are_classified(): def test_grid(): - """Invariants 2 and 3 on every grid household. The phase-out households - make invariant 3's student loan interest case non-vacuous.""" - assert assert_properties(grid_households()) > 0 + """Invariants 2 and 3 on every grid household. In the phase-out, savings + bond interest and adoption benefits lower the student loan interest + deduction's modified AGI, so they change that deduction; Puerto Rico + income is added back (IRC 221(b)(2)(C)), so it does not. Possession + income is not checked here: its effect depends on the federal modified + AGI's treatment of section 931, which is outside this test.""" + changes = assert_properties(grid_households()) + phase_out = dict(zip(PATTERN_NAMES, changes[-len(EXCLUSION_PATTERNS) :])) + assert phase_out["none"] == 0 + assert phase_out["puerto_rico_income"] == pytest.approx(0, abs=TOLERANCE) + for name in ["us_bonds_for_higher_ed", "qualified_adoption_assistance_expense"]: + assert phase_out[name] > 1, name + assert phase_out["all"] > 1 def test_line_30_follows_the_federal_list(): 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 7d24018409c..6ebca722a66 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 @@ -87,7 +87,8 @@ def formula(tax_unit, period, parameters): # gross income, the excluded amount is entered in the income it # comes from: savings bond interest in interest income (line 15, # "including nontaxable interest"), adoption benefits, reported on - # Form W-2, in wages (line 14). Line 30 leaves it there. + # Form W-2, in wages (line 14), and possession or Puerto Rico income + # in the source that earned it. Line 30 leaves it there. federal_deductions = parameters(period).gov.irs.ald.deductions adjustments = add( tax_unit, From 638c794a18e4a664af48b2926e6061e54761fd66 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 22:46:49 -0400 Subject: [PATCH 11/14] 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 12/14] 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 6d436afaaf23e4649e949f704f1315944290b9ca Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 04:59:58 -0400 Subject: [PATCH 13/14] Sum Michigan line 30 over the head and spouse Review r5: since #9801, the federal above-the-line total leaves dependents' person-level items out, but line 30 summed educator_expense, early_withdrawal_penalty and traditional_ira_contributions over every member with add. It now uses tax_unit_non_dep_add, as above_the_line_deductions does. Regression case: a dependent's IRA 2,000, penalty 100 and educator expenses 300 gave 47,600; the form gives 50,000. Co-Authored-By: Claude Opus 5.5 --- ...-resources-schedule-1-adjustments.fixed.md | 2 +- .../mi/tax/income/mi_household_resources.yaml | 39 +++++++++++++++++++ .../mi/tax/income/mi_household_resources.py | 12 ++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/changelog.d/mi-household-resources-schedule-1-adjustments.fixed.md b/changelog.d/mi-household-resources-schedule-1-adjustments.fixed.md index aab3b1a98d8..49eea69919e 100644 --- a/changelog.d/mi-household-resources-schedule-1-adjustments.fixed.md +++ b/changelog.d/mi-household-resources-schedule-1-adjustments.fixed.md @@ -1 +1 @@ -Michigan household resources (MI-1040CR line 30) subtract only the U.S. Schedule 1 adjustments to income, so income excluded under IRC 135, 137, 931 and 933 (savings bond interest for higher education, employer adoption benefits, possession and Puerto Rico income) stays in household resources. +Michigan household resources (MI-1040CR line 30) subtract only the claimant's and spouse's U.S. Schedule 1 adjustments to income, so income excluded under IRC 135, 137, 931 and 933 (savings bond interest for higher education, employer adoption benefits, possession and Puerto Rico income) stays in household resources, and a dependent's own IRA deduction, early withdrawal penalty or educator expenses no longer come off. 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 acd3d86fbdd..bd20e46ef47 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 @@ -605,3 +605,42 @@ # dependent's Schedule E income is not on line 16. Line 30: 0. # Line 33: 50,000. mi_household_resources: 50_000 + +- name: A dependent's own Schedule 1 adjustments are 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: 50_000 + person2: + age: 20 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + traditional_ira_contributions: 2_000 + early_withdrawal_penalty: 100 + educator_expense: 300 + 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: 50,000. Line 30 is "your U.S. Form 1040, Schedule 1": the + # dependent's IRA deduction, early withdrawal penalty and educator + # expenses are on the dependent's own Schedule 1, so line 30 is 0. + # Line 33: 50,000. + mi_household_resources: 50_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 e429b926861..7655c1a2c48 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 @@ -82,15 +82,21 @@ def formula(tax_unit, period, parameters): # "including nontaxable interest"), adoption benefits, reported on # Form W-2, in wages (line 14), and possession or Puerto Rico income # in the source that earned it. Line 30 leaves it there. - federal_deductions = parameters(period).gov.irs.ald.deductions - adjustments = add( + # The Schedule 1 is the claimant's own (MCL 206.508(3)): a person-level + # adjustment, such as a dependent's IRA contribution, early withdrawal + # penalty or educator expenses, is summed over the head and spouse, as + # above_the_line_deductions does. A tax-unit-level adjustment already + # describes the filer's return. + ald = parameters(period).gov.irs.ald + adjustments = tax_unit_non_dep_add( tax_unit, period, [ deduction for deduction in p.household_resources_adjustments - if deduction in federal_deductions + if deduction in ald.deductions ], + include_dependents=ald.filer_amounts_recorded_on_dependents, ) # Line 31: health insurance premiums. health_insurance_premiums = add(tax_unit, period, ["health_insurance_premiums"]) From c5842079b7dbe46d32a672976159f6867a474979 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 11:52:57 -0400 Subject: [PATCH 14/14] Restore capped educator expenses in Michigan household resources --- ...-resources-schedule-1-adjustments.fixed.md | 2 +- .../household_resources_adjustments.yaml | 2 +- .../mi/tax/income/mi_household_resources.yaml | 80 +++++++++++++++++++ ...test_mi_household_resources_adjustments.py | 2 +- .../mi/tax/income/mi_household_resources.py | 7 +- 5 files changed, 86 insertions(+), 7 deletions(-) diff --git a/changelog.d/mi-household-resources-schedule-1-adjustments.fixed.md b/changelog.d/mi-household-resources-schedule-1-adjustments.fixed.md index 49eea69919e..75c68fa20b7 100644 --- a/changelog.d/mi-household-resources-schedule-1-adjustments.fixed.md +++ b/changelog.d/mi-household-resources-schedule-1-adjustments.fixed.md @@ -1 +1 @@ -Michigan household resources (MI-1040CR line 30) subtract only the claimant's and spouse's U.S. Schedule 1 adjustments to income, so income excluded under IRC 135, 137, 931 and 933 (savings bond interest for higher education, employer adoption benefits, possession and Puerto Rico income) stays in household resources, and a dependent's own IRA deduction, early withdrawal penalty or educator expenses no longer come off. +Michigan household resources (MI-1040CR line 30) subtract only the claimant's and spouse's U.S. Schedule 1 adjustments to income, so income excluded under IRC 135, 137, 931 and 933 (savings bond interest for higher education, employer adoption benefits, possession and Puerto Rico income) stays in household resources, and a dependent's own IRA deduction, early withdrawal penalty or educator expenses no longer come off. Educator expenses use the federal deduction capped per eligible educator. diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_adjustments.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_adjustments.yaml index 2aeb7d941fe..70e6dfd9fe4 100644 --- a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_adjustments.yaml +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_adjustments.yaml @@ -8,7 +8,7 @@ values: # 933 (us_bonds_for_higher_ed, qualified_adoption_assistance_expense, # specified_possession_income and puerto_rico_income). 2021-01-01: - - educator_expense # line 11 + - educator_expense_ald # line 11, capped per eligible educator - health_savings_account_ald # line 13 - self_employment_tax_ald # line 15 - self_employed_pension_contribution_ald # line 16 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 bd20e46ef47..74cfed89ef5 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 @@ -644,3 +644,83 @@ # Line 33: 50,000. mi_household_resources: 50_000 +- name: Claimant educator expenses reduce household resources through Schedule 1 line 11 + period: 2025 + input: + people: + claimant: + age: 45 + employment_income_before_lsr: 50_000 + educator_expense: 300 + tax_units: + tax_unit: + members: [claimant] + households: + household: + members: [claimant] + state_code: MI + output: + educator_expense_ald: 300 + above_the_line_deductions: 300 + adjusted_gross_income: 49_700 + # Line 14: 50,000. Line 30: Schedule 1 line 11, 300. + # Line 33: 50,000 - 300 = 49,700. + mi_household_resources: 49_700 + +- name: Spouse educator expenses reduce household resources on a joint return + period: 2025 + input: + people: + claimant: + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + employment_income_before_lsr: 50_000 + spouse: + age: 45 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + educator_expense: 300 + tax_units: + tax_unit: + members: [claimant, spouse] + filing_status: JOINT + marital_units: + marital_unit: + members: [claimant, spouse] + households: + household: + members: [claimant, spouse] + state_code: MI + output: + educator_expense_ald: 300 + above_the_line_deductions: 300 + adjusted_gross_income: 49_700 + # Line 30 includes the spouse's Schedule 1 line 11 deduction, 300. + # Line 33: 50,000 - 300 = 49,700. + mi_household_resources: 49_700 + +- name: Educator expenses above the per-educator cap reduce household resources only by the capped deduction + period: 2025 + input: + people: + claimant: + age: 45 + employment_income_before_lsr: 50_000 + educator_expense: 1_000 + tax_units: + tax_unit: + members: [claimant] + households: + household: + members: [claimant] + state_code: MI + output: + educator_expense_ald: 300 + above_the_line_deductions: 300 + adjusted_gross_income: 49_700 + # Schedule 1 line 11 and MI-1040CR line 30 use the 2025 cap of 300. + # Line 33: 50,000 - 300 = 49,700. + mi_household_resources: 49_700 diff --git a/policyengine_us/tests/test_mi_household_resources_adjustments.py b/policyengine_us/tests/test_mi_household_resources_adjustments.py index 907a80b2997..979df9c4e7f 100644 --- a/policyengine_us/tests/test_mi_household_resources_adjustments.py +++ b/policyengine_us/tests/test_mi_household_resources_adjustments.py @@ -73,7 +73,7 @@ # Federal above-the-line deductions that are U.S. Schedule 1 Part II # adjustments (2025 Schedule 1 line), written from the form. SCHEDULE_1_ADJUSTMENTS = { - "educator_expense": "11", + "educator_expense_ald": "11", "health_savings_account_ald": "13", "self_employment_tax_ald": "15", "self_employed_pension_contribution_ald": "16", 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 7655c1a2c48..8380194837e 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 @@ -83,10 +83,9 @@ def formula(tax_unit, period, parameters): # Form W-2, in wages (line 14), and possession or Puerto Rico income # in the source that earned it. Line 30 leaves it there. # The Schedule 1 is the claimant's own (MCL 206.508(3)): a person-level - # adjustment, such as a dependent's IRA contribution, early withdrawal - # penalty or educator expenses, is summed over the head and spouse, as - # above_the_line_deductions does. A tax-unit-level adjustment already - # describes the filer's return. + # adjustment, such as a dependent's IRA contribution or early withdrawal + # penalty, is summed over the head and spouse, as above_the_line_deductions + # does. A tax-unit-level adjustment already describes the filer's return. ald = parameters(period).gov.irs.ald adjustments = tax_unit_non_dep_add( tax_unit,