From a6f908541d84c018ee3ea8b06732bbc89910d373 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 08:31:04 -0400 Subject: [PATCH 01/12] WIP: count only the head's and spouse's amounts in EITC investment income Co-Authored-By: Claude Opus 5.5 --- ...ndent_eitc_investment_income_invariants.py | 316 ++++++++++++++++++ .../eitc_relevant_investment_income.yaml | 286 ++++++++++++++++ .../eitc_relevant_investment_income.py | 24 +- 3 files changed, 619 insertions(+), 7 deletions(-) create mode 100644 policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml diff --git a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py new file mode 100644 index 00000000000..e70564ebffc --- /dev/null +++ b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py @@ -0,0 +1,316 @@ +"""Invariants for dependents' income in the filer's EITC investment income test. + +26 USC 32(i)(1) denies the credit when the "disqualified income of the +taxpayer" exceeds the limit. A tax unit dependent's interest, dividends, +capital gains, rents and passive income are on the dependent's own return, and +Publication 596 Worksheet 1 picks up a child's interest and dividends only +through a Form 8814 election, which the model does not implement. So +`eitc_relevant_investment_income` counts the head and spouse only, as +`irs_gross_income` does for AGI. + +Hypothesis draws batches of tax units (single, head of household with +dependents, joint with and without dependents), and a seeded population of 200 +such units adds breadth. Each batch runs as one vectorized simulation, twice: +with the dependents' investment income inputs as drawn, of either sign, and +with them set to zero. For every tax unit: + +1. The dependents' investment income never changes the filer's + `eitc_relevant_investment_income`, `eitc_investment_income_eligible`, + `eitc`, `filer_loss_limited_net_capital_gains` or AGI. +2. Differential: `eitc_relevant_investment_income` equals an independent numpy + computation of Worksheet 1 over the head and spouse: interest, tax-exempt + interest and dividends, plus their Schedule D gain with capital gain + distributions floored at zero (line 7), plus their net rental and passive + income floored at zero (line 13). +3. Bounds: it is at least the filers' interest and dividends, so never + negative, and a filer's extra dollar of interest raises it by a dollar. +4. For tax units without dependents and without a negative distributions + input, it equals the previous all-member formula, so leaving dependents out + changes nothing for them. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars + +PORTFOLIO_INPUTS = [ + "taxable_interest_income", + "tax_exempt_interest_income", + "qualified_dividend_income", + "non_qualified_dividend_income", +] +CAPITAL_INPUTS = ["long_term_capital_gains", "short_term_capital_gains"] +DISTRIBUTIONS = "non_sch_d_capital_gains" +# passive_partnership_s_corp_income is the passive subset of +# partnership_s_corp_income; both are drawn together below. +PASSIVE_INPUTS = ["rental_income", "passive_partnership_s_corp_income"] +INVESTMENT_INPUTS = ( + PORTFOLIO_INPUTS + + CAPITAL_INPUTS + + [DISTRIBUTIONS] + + PASSIVE_INPUTS + + ["partnership_s_corp_income"] +) +# The filer's amounts, which a dependent's income must not change. +FILER_OUTPUTS = [ + "eitc_relevant_investment_income", + "eitc_investment_income_eligible", + "eitc", + "filer_loss_limited_net_capital_gains", + "adjusted_gross_income", +] +# Sums every member, for the previous formula in property 4. +OUTPUTS = FILER_OUTPUTS + ["net_capital_gains"] +# An extra dollar of the head's interest in the second run. +INTEREST_SHIFT = 1_000.0 + +nonnegative = st.one_of(st.just(0.0), st.integers(1, 8_000).map(float)) +signed = st.one_of(st.just(0.0), st.integers(-12_000, 12_000).map(float)) + + +@st.composite +def investment_amounts(draw): + passive = draw(signed) + return { + **{name: draw(nonnegative) for name in PORTFOLIO_INPUTS}, + **{name: draw(signed) for name in CAPITAL_INPUTS}, + # Form 1099-DIV box 2a amounts are not negative, but the input can be; + # the model floors each person's amount at zero. + DISTRIBUTIONS: draw(st.one_of(nonnegative, st.integers(-3_000, -1).map(float))), + "rental_income": draw(signed), + "partnership_s_corp_income": passive, + "passive_partnership_s_corp_income": passive, + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "hoh", "joint", "joint_dependents"])) + n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 + return { + # Wages where the credit is available, so eligibility moves eitc. + "head_wages": float(draw(st.integers(0, 40_000))), + "head": draw(investment_amounts()), + "spouse": draw(investment_amounts()) if kind.startswith("joint") else None, + "dependents": [ + {"age": draw(st.integers(1, 17)), **draw(investment_amounts())} + for _ in range(n_dependents) + ], + } + + +SEED = 20261006 + + +def _seeded_amounts(rng): + def some(low, high, p=0.5): + return float(round(rng.uniform(low, high))) if rng.random() < p else 0.0 + + passive = some(-12_000, 12_000) + return { + **{name: some(1, 8_000) for name in PORTFOLIO_INPUTS}, + **{name: some(-12_000, 12_000) for name in CAPITAL_INPUTS}, + DISTRIBUTIONS: some(-3_000, 8_000), + "rental_income": some(-12_000, 12_000), + "partnership_s_corp_income": passive, + "passive_partnership_s_corp_income": passive, + } + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + units = [] + for _ in range(n): + kind = rng.choice(["single", "hoh", "joint", "joint_dependents"]) + n_dependents = ( + int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + ) + units.append( + { + "head_wages": float(round(rng.uniform(0, 40_000))), + "head": _seeded_amounts(rng), + "spouse": ( + _seeded_amounts(rng) if kind.startswith("joint") else None + ), + "dependents": [ + {"age": int(rng.integers(1, 18)), **_seeded_amounts(rng)} + for _ in range(n_dependents) + ], + } + ) + return units + + +def _situation(units, year, *, zero_dependents=False, interest_shift=0.0): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + for i, u in enumerate(units): + head = f"head_{i}" + people[head] = { + "age": 35, + "is_tax_unit_head": True, + "is_tax_unit_dependent": False, + "employment_income": u["head_wages"], + **u["head"], + "taxable_interest_income": ( + u["head"]["taxable_interest_income"] + interest_shift + ), + } + members, couple = [head], [head] + if u["spouse"] is not None: + spouse = f"spouse_{i}" + people[spouse] = { + "age": 33, + "is_tax_unit_spouse": True, + "is_tax_unit_dependent": False, + **u["spouse"], + } + members.append(spouse) + couple.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": couple} + for j, d in enumerate(u["dependents"]): + child = f"dependent_{i}_{j}" + amounts = {k: v for k, v in d.items() if k != "age"} + if zero_dependents: + amounts = {name: 0.0 for name in amounts} + people[child] = { + "age": d["age"], + "is_tax_unit_dependent": True, + **amounts, + } + members.append(child) + groups["marital_units"][f"single_{i}_{j}"] = {"members": [child]} + groups["tax_units"][f"tax_unit_{i}"] = {"members": members} + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "TX"}, + } + people = { + name: {k: {year: v} for k, v in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _run(units, year, **kwargs): + sim = Simulation(situation=_situation(units, year, **kwargs)) + out = {name: np.asarray(sim.calculate(name, year), dtype=float) for name in OUTPUTS} + out["is_dependent"] = np.asarray( + sim.calculate("is_tax_unit_dependent", year), dtype=bool + ) + out["unit"] = sim.populations["tax_unit"].members_entity_id + eitc = sim.tax_benefit_system.parameters(f"{year}-01-01").gov.irs.credits.eitc + out["limit"] = eitc.phase_out.max_investment_income + return out + + +def _by_person(units, key): + """Person-level input values (or the dependent flag) in member order.""" + rows = [] + for u in units: + rows.append((u["head"], False)) + if u["spouse"] is not None: + rows.append((u["spouse"], False)) + rows += [(amounts, True) for amounts in u["dependents"]] + if key == "is_dependent": + return np.array([dependent for _, dependent in rows]) + return np.array([amounts[key] for amounts, _ in rows]) + + +def _unit_sum(run, values): + return np.bincount( + run["unit"], weights=values, minlength=len(run["eitc_relevant_investment_income"]) + ) + + +def _check(units, year): + run = _run(units, year) + zeroed = _run(units, year, zero_dependents=True) + shifted = _run(units, year, interest_shift=INTEREST_SHIFT) + filer = ~_by_person(units, "is_dependent") + assert np.array_equal(~run["is_dependent"], filer) + has_dependent = _unit_sum(run, (~filer).astype(float)) > 0 + + # 1. The dependents' investment income never reaches the filer's test. + for name in FILER_OUTPUTS: + np.testing.assert_allclose( + run[name], zeroed[name], atol=TOLERANCE, err_msg=name + ) + + # 2. Differential: Worksheet 1 over the head and spouse, from the inputs. + def filer_sum(names): + return _unit_sum(run, filer * sum(_by_person(units, n) for n in names)) + + portfolio = filer_sum(PORTFOLIO_INPUTS) + distributions = _unit_sum( + run, filer * np.maximum(0, _by_person(units, DISTRIBUTIONS)) + ) + capital = np.maximum(0, filer_sum(CAPITAL_INPUTS) + distributions) + passive = np.maximum(0, filer_sum(PASSIVE_INPUTS)) + expected = portfolio + capital + passive + investment_income = run["eitc_relevant_investment_income"] + np.testing.assert_allclose(investment_income, expected, atol=TOLERANCE) + np.testing.assert_array_equal( + run["eitc_investment_income_eligible"].astype(bool), + expected <= run["limit"], + ) + assert not (run["eitc"][expected > run["limit"]] > 0).any() + + # 3. Bounds and an exact shift in the filer's interest. + assert (investment_income >= portfolio - TOLERANCE).all() + assert (investment_income >= -TOLERANCE).all() + np.testing.assert_allclose( + shifted["eitc_relevant_investment_income"] - investment_income, + INTEREST_SHIFT, + atol=TOLERANCE, + ) + + # 4. No dependents and no negative distributions input: the previous + # all-member formula, unchanged. + no_negative_distributions = ( + _unit_sum(run, (_by_person(units, DISTRIBUTIONS) < 0).astype(float)) == 0 + ) + unchanged = ~has_dependent & no_negative_distributions + all_member = ( + _unit_sum(run, sum(_by_person(units, n) for n in PORTFOLIO_INPUTS)) + + np.maximum( + 0, + run["net_capital_gains"] + + _unit_sum(run, _by_person(units, DISTRIBUTIONS)), + ) + + np.maximum( + 0, _unit_sum(run, sum(_by_person(units, n) for n in PASSIVE_INPUTS)) + ) + ) + np.testing.assert_allclose( + investment_income[unchanged], all_member[unchanged], atol=TOLERANCE + ) + return has_dependent, unchanged + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(tax_units(), min_size=10, max_size=40)) +def test_dependent_investment_income_stays_off_the_filers_eitc_test(units): + _check(units, 2025) + + +@pytest.mark.parametrize("year", [2021, 2025]) +def test_seeded_population(year): + has_dependent, unchanged = _check(_seeded_units(), year) + # The population exercises both branches of each property. + assert has_dependent.sum() > 50 + assert unchanged.sum() > 20 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml new file mode 100644 index 00000000000..8e5af5b5359 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml @@ -0,0 +1,286 @@ +# A tax unit dependent's investment income is on the dependent's own return, +# not the filer's. 26 USC 32(i)(1) denies the credit when the "disqualified +# income of the taxpayer" exceeds the limit, and 32(i)(2)(A) counts interest +# and dividends only "to the extent includible in gross income", which a +# dependent's are not (irs_gross_income leaves them out of AGI). Publication +# 596 Worksheet 1 reads the filer's own Form 1040 and picks up a child's +# interest and dividends only through a Form 8814 election (lines 2 and 4), +# which is not modeled; from $13,500 the child must file their own return +# (2025 Form 8814, line 4). The worksheet's capital gain line (line 5) is the +# filer's own Form 1040 line 7a. So eitc_relevant_investment_income counts the +# head and spouse only, in both directions: a dependent's income cannot deny +# the credit, and a dependent's loss cannot offset the filer's income. +# +# Unless noted, the filer is a 30-year-old head of household in TX for 2025 +# with $20,000 of wages and one qualifying child aged 8. 2025 amounts: +# investment income limit 11,950 +# maximum credit with one child 4,328 (34% x 12,730) +# phase-out start with one child 23,350 (30,470 for joint filers) +# Each "Before" figure is the pre-change formula, which summed every member. + +- name: Control, no investment income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 20_000 + child: + age: 8 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + eitc_relevant_investment_income: 0 + eitc_investment_income_eligible: true + adjusted_gross_income: 20_000 + # Earned income 20,000 is past the 12,730 earned income amount and under + # the 23,350 phase-out start. + eitc: 4_328 + +- name: A dependent's dividends stay off the filer's investment income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 20_000 + child: + age: 8 + is_tax_unit_dependent: true + qualified_dividend_income: 15_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # Before: 15,000, so the credit was denied (eitc 0). + eitc_relevant_investment_income: 0 + eitc_investment_income_eligible: true + adjusted_gross_income: 20_000 + eitc: 4_328 + +- name: A dependent's long-term capital gain stays off the filer's investment income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 20_000 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 15_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # Before: 15,000, so the credit was denied (eitc 0). + eitc_relevant_investment_income: 0 + eitc_investment_income_eligible: true + adjusted_gross_income: 20_000 + eitc: 4_328 + +- name: A dependent's taxable and tax-exempt interest stay off the filer's investment income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 20_000 + child: + age: 8 + is_tax_unit_dependent: true + taxable_interest_income: 12_500 + tax_exempt_interest_income: 2_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # Before: 14,500, so the credit was denied (eitc 0). + eitc_relevant_investment_income: 0 + eitc_investment_income_eligible: true + eitc: 4_328 + +- name: A dependent's capital loss cannot offset the filer's capital gain + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 20_000 + taxable_interest_income: 9_000 + long_term_capital_gains: 4_000 + child: + age: 8 + is_tax_unit_dependent: true + short_term_capital_gains: -4_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # Worksheet 1: line 1 9,000 + line 7 4,000 (the head's own line 7a). + # Before: 9,000 + max(0, 4,000 - 4,000) = 9,000, so the credit was allowed. + eitc_relevant_investment_income: 13_000 + eitc_investment_income_eligible: false + adjusted_gross_income: 33_000 + eitc: 0 + +- name: A dependent's rental loss cannot offset the filer's rental income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 20_000 + taxable_interest_income: 9_000 + rental_income: 4_000 + child: + age: 8 + is_tax_unit_dependent: true + rental_income: -4_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # Worksheet 1: line 1 9,000 + line 13 4,000. + # Before: 9,000 + max(0, 4,000 - 4,000) = 9,000. + eitc_relevant_investment_income: 13_000 + eitc_investment_income_eligible: false + eitc: 0 + +- name: A dependent's passive loss cannot offset the filer's passive income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 20_000 + taxable_interest_income: 9_000 + partnership_s_corp_income: 4_000 + passive_partnership_s_corp_income: 4_000 + child: + age: 8 + is_tax_unit_dependent: true + partnership_s_corp_income: -4_000 + passive_partnership_s_corp_income: -4_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # Worksheet 1: line 1 9,000 + line 13 4,000. + # Before: 9,000 + max(0, 4,000 - 4,000) = 9,000. + eitc_relevant_investment_income: 13_000 + eitc_investment_income_eligible: false + eitc: 0 + +- name: A negative capital gain distributions input does not offset capital gains + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 20_000 + taxable_interest_income: 7_000 + long_term_capital_gains: 5_000 + non_sch_d_capital_gains: -2_000 + child: + age: 8 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # Capital gain distributions (Form 1099-DIV box 2a) are not negative, so + # each filer's input is floored at zero, as irs_gross_income and + # filer_loss_limited_net_capital_gains floor it. Line 7a and AGI include + # the full 5,000 gain: 7,000 + 5,000 = 12,000 > 11,950. + # Before: 7,000 + max(0, 5,000 - 2,000) = 10,000, so the credit was allowed. + eitc_relevant_investment_income: 12_000 + eitc_investment_income_eligible: false + adjusted_gross_income: 32_000 + eitc: 0 + +- name: Joint filers count both spouses' income and net their capital gains, not a dependent's + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 20_000 + taxable_interest_income: 3_000 + long_term_capital_gains: 6_000 + spouse: + age: 30 + qualified_dividend_income: 5_000 + short_term_capital_gains: -3_000 + child: + age: 8 + is_tax_unit_dependent: true + taxable_interest_income: 10_000 + long_term_capital_gains: 10_000 + tax_units: + tax_unit: + members: [head, spouse, child] + marital_units: + couple: + members: [head, spouse] + child_unit: + members: [child] + households: + household: + members: [head, spouse, child] + state_code: TX + output: + filing_status: JOINT + # Worksheet 1: lines 1 and 3, 3,000 + 5,000; line 7, the joint Schedule D + # gain 6,000 - 3,000 = 3,000. Total 11,000 <= 11,950. + # Before: 3,000 + 5,000 + 10,000 + max(0, 6,000 - 3,000 + 10,000) = 31,000, + # so the credit was denied. + eitc_relevant_investment_income: 11_000 + eitc_investment_income_eligible: true + # 20,000 + 3,000 + 5,000 + 3,000 + adjusted_gross_income: 31_000 + # 4,328 - 15.98% x (31,000 - 30,470) = 4,328 - 84.694 + eitc: 4_243.31 diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py index 06ba983fd4f..d1e1c7061c4 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py @@ -5,18 +5,27 @@ class eitc_relevant_investment_income(Variable): value_type = float entity = TaxUnit label = "EITC-relevant investment income" + documentation = ( + "Disqualified income of the head and spouse under 26 USC 32(i)(2), " + "figured as on Publication 596 Worksheet 1. A tax unit dependent's " + "interest, dividends, rents, passive income and capital gains are on " + "the dependent's own return, as in irs_gross_income. The worksheet " + "picks up a child's interest and dividends only through a Form 8814 " + "election (lines 2 and 4), which is not modeled." + ) unit = USD definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/32#i_2", - "https://www.irs.gov/pub/irs-prior/p596--2021.pdf#page=7", + "https://www.irs.gov/pub/irs-prior/p596--2025.pdf#page=7", + "https://www.irs.gov/pub/irs-prior/f8814--2025.pdf#page=1", ) def formula(tax_unit, period, parameters): # Publication 596 Worksheet 1 keeps portfolio income, net capital # gains, and net passive income in separate baskets. A loss in one # basket cannot offset positive income in another. - portfolio_income = add( + portfolio_income = tax_unit_non_dep_add( tax_unit, period, [ @@ -25,13 +34,14 @@ def formula(tax_unit, period, parameters): "dividend_income", ], ) - capital_gains = add( - tax_unit, period, ["net_capital_gains", "non_sch_d_capital_gains"] - ) + # Worksheet 1 line 5: Form 1040 line 7a, the head and spouse's + # Schedule D gain with capital gain distributions, or zero if a loss. + capital_gains = tax_unit("filer_loss_limited_net_capital_gains", period) # The model's undifferentiated rental input is treated as passive # rental income, consistently with its NIIT income mapping. Net the - # passive amounts across the tax unit before applying the zero floor. - passive_income = add( + # passive amounts across the head and spouse before applying the zero + # floor. + passive_income = tax_unit_non_dep_add( tax_unit, period, ["rental_income", "passive_partnership_s_corp_income"] ) return portfolio_income + max_(0, capital_gains) + max_(0, passive_income) From e1d780bc96d227ca96499228070e6ed4214a41df Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 08:35:59 -0400 Subject: [PATCH 02/12] Format, drop an unused constant, add the changelog fragment Co-Authored-By: Claude Opus 5.5 --- .../eitc-dependent-investment-income.fixed.md | 1 + ...endent_eitc_investment_income_invariants.py | 18 +++++------------- 2 files changed, 6 insertions(+), 13 deletions(-) create mode 100644 changelog.d/eitc-dependent-investment-income.fixed.md diff --git a/changelog.d/eitc-dependent-investment-income.fixed.md b/changelog.d/eitc-dependent-investment-income.fixed.md new file mode 100644 index 00000000000..803f7522087 --- /dev/null +++ b/changelog.d/eitc-dependent-investment-income.fixed.md @@ -0,0 +1 @@ +The EITC investment income test (26 U.S.C. 32(i)) now counts only the head's and spouse's interest, dividends, capital gains, rents and passive income. A tax unit dependent's income no longer denies the credit, and a dependent's losses no longer offset the filer's income. diff --git a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py index e70564ebffc..bf56316fb96 100644 --- a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py +++ b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py @@ -49,13 +49,6 @@ # passive_partnership_s_corp_income is the passive subset of # partnership_s_corp_income; both are drawn together below. PASSIVE_INPUTS = ["rental_income", "passive_partnership_s_corp_income"] -INVESTMENT_INPUTS = ( - PORTFOLIO_INPUTS - + CAPITAL_INPUTS - + [DISTRIBUTIONS] - + PASSIVE_INPUTS - + ["partnership_s_corp_income"] -) # The filer's amounts, which a dependent's income must not change. FILER_OUTPUTS = [ "eitc_relevant_investment_income", @@ -134,9 +127,7 @@ def _seeded_units(n=200): { "head_wages": float(round(rng.uniform(0, 40_000))), "head": _seeded_amounts(rng), - "spouse": ( - _seeded_amounts(rng) if kind.startswith("joint") else None - ), + "spouse": (_seeded_amounts(rng) if kind.startswith("joint") else None), "dependents": [ {"age": int(rng.integers(1, 18)), **_seeded_amounts(rng)} for _ in range(n_dependents) @@ -223,7 +214,9 @@ def _by_person(units, key): def _unit_sum(run, values): return np.bincount( - run["unit"], weights=values, minlength=len(run["eitc_relevant_investment_income"]) + run["unit"], + weights=values, + minlength=len(run["eitc_relevant_investment_income"]), ) @@ -279,8 +272,7 @@ def filer_sum(names): _unit_sum(run, sum(_by_person(units, n) for n in PORTFOLIO_INPUTS)) + np.maximum( 0, - run["net_capital_gains"] - + _unit_sum(run, _by_person(units, DISTRIBUTIONS)), + run["net_capital_gains"] + _unit_sum(run, _by_person(units, DISTRIBUTIONS)), ) + np.maximum( 0, _unit_sum(run, sum(_by_person(units, n) for n in PASSIVE_INPUTS)) From e993a95bc1f87d82a0777c79167849f9911938cc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 09:02:41 -0400 Subject: [PATCH 03/12] Keep a supplied net_capital_gains aggregate in the EITC capital gain line Start from net_capital_gains, take out dependents' gains and losses, and add the filers' floored capital gain distributions, so a tax unit amount supplied for net_capital_gains still counts, as it did before. Co-Authored-By: Claude Opus 5.5 --- .../eitc_relevant_investment_income.yaml | 36 +++++++++++++++++++ .../eitc_relevant_investment_income.py | 27 ++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml index 8e5af5b5359..6bd0bcf455d 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml @@ -284,3 +284,39 @@ adjusted_gross_income: 31_000 # 4,328 - 15.98% x (31,000 - 30,470) = 4,328 - 84.694 eitc: 4_243.31 + +- name: A tax unit net capital gains input still counts + period: 2025 + absolute_error_margin: 0.01 + input: + net_capital_gains: 12_000 + output: + # A tax unit amount supplied for net_capital_gains is kept, as before. + eitc_relevant_investment_income: 12_000 + eitc_investment_income_eligible: false + +- name: A supplied tax unit net capital gains amount covers every member + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 20_000 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 3_000 + tax_units: + tax_unit: + members: [head, child] + net_capital_gains: 10_000 + households: + household: + members: [head, child] + state_code: TX + output: + # The supplied 10,000 is read as the whole unit's net gain, including the + # child's 3,000, which is then taken out: 10,000 - 3,000 = 7,000. + eitc_relevant_investment_income: 7_000 + eitc_investment_income_eligible: true diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py index d1e1c7061c4..91070fb5dbf 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py @@ -11,7 +11,9 @@ class eitc_relevant_investment_income(Variable): "interest, dividends, rents, passive income and capital gains are on " "the dependent's own return, as in irs_gross_income. The worksheet " "picks up a child's interest and dividends only through a Form 8814 " - "election (lines 2 and 4), which is not modeled." + "election (lines 2 and 4), which is not modeled. A net_capital_gains " + "amount supplied for the tax unit is read as covering every member, " + "before any dependent's gains and losses are taken out." ) unit = USD definition_period = YEAR @@ -35,8 +37,27 @@ def formula(tax_unit, period, parameters): ], ) # Worksheet 1 line 5: Form 1040 line 7a, the head and spouse's - # Schedule D gain with capital gain distributions, or zero if a loss. - capital_gains = tax_unit("filer_loss_limited_net_capital_gains", period) + # Schedule D gain with capital gain distributions (line 13), or zero + # if a loss. Start from net_capital_gains so a tax unit amount + # supplied there is kept; it is read as covering every member, and + # any dependent's gains and losses are then taken out. Capital gain + # distributions (Form 1099-DIV box 2a) are not negative, so each + # filer's input is floored at zero, as in irs_gross_income. + person = tax_unit.members + dependent = person("is_tax_unit_dependent", period) + dependent_gains = tax_unit.sum( + dependent + * ( + person("long_term_capital_gains", period) + + person("short_term_capital_gains", period) + ) + ) + distributions = tax_unit.sum( + ~dependent * max_(0, person("non_sch_d_capital_gains", period)) + ) + capital_gains = ( + tax_unit("net_capital_gains", period) - dependent_gains + distributions + ) # The model's undifferentiated rental input is treated as passive # rental income, consistently with its NIIT income mapping. Net the # passive amounts across the head and spouse before applying the zero From 257fe3444ae67aef0bebef0ef57c8491a364a96a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 10:00:40 -0400 Subject: [PATCH 04/12] Sum the filers' capital gains directly unless a tax unit aggregate is supplied net_capital_gains is stored in float32, so subtracting a dependent's very large gain from it could move the filers' capital gain line by a few dollars and flip eligibility at the limit (independent review finding, reproduced in the model: a child's 20,000,000 gain turned 11,951 into 11,950). Use the head's and spouse's person-level gains, and read net_capital_gains only where it differs from the members' own gains, that is, where a tax unit amount was supplied. Also widen the property test to dependents' gains of 16 to 64 million, add the two boundary cases, correct the Form 8814 explanation (an election also reaches Worksheet 1 lines 3 and 5) and mention the distributions floor in the changelog. Co-Authored-By: Claude Opus 5.5 --- .../eitc-dependent-investment-income.fixed.md | 2 +- ...ndent_eitc_investment_income_invariants.py | 47 ++++++++++---- .../eitc_relevant_investment_income.yaml | 63 +++++++++++++++++-- .../eitc_relevant_investment_income.py | 53 +++++++++------- 4 files changed, 126 insertions(+), 39 deletions(-) diff --git a/changelog.d/eitc-dependent-investment-income.fixed.md b/changelog.d/eitc-dependent-investment-income.fixed.md index 803f7522087..851bb4e31fa 100644 --- a/changelog.d/eitc-dependent-investment-income.fixed.md +++ b/changelog.d/eitc-dependent-investment-income.fixed.md @@ -1 +1 @@ -The EITC investment income test (26 U.S.C. 32(i)) now counts only the head's and spouse's interest, dividends, capital gains, rents and passive income. A tax unit dependent's income no longer denies the credit, and a dependent's losses no longer offset the filer's income. +The EITC investment income test (26 U.S.C. 32(i)) now counts only the head's and spouse's interest, dividends, capital gains, rents and passive income. A tax unit dependent's income no longer denies the credit, and a dependent's losses no longer offset the filer's income. A negative capital gain distributions input is floored at zero for each filer, as in adjusted gross income, instead of offsetting other capital gains. diff --git a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py index bf56316fb96..3766baa6da3 100644 --- a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py +++ b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py @@ -10,11 +10,14 @@ Hypothesis draws batches of tax units (single, head of household with dependents, joint with and without dependents), and a seeded population of 200 -such units adds breadth. Each batch runs as one vectorized simulation, twice: -with the dependents' investment income inputs as drawn, of either sign, and -with them set to zero. For every tax unit: - -1. The dependents' investment income never changes the filer's +such units adds breadth. Dependents' capital gains are sometimes $16 million +or more, where float32 rounding of a tax unit total would show. Each batch runs +as one vectorized simulation three times: as drawn; with the dependents' +investment income inputs set to zero; and with $1,000 more of the head's +interest. For every tax unit: + +1. The dependents' investment income, of any size or sign, never changes the + filer's `eitc_relevant_investment_income`, `eitc_investment_income_eligible`, `eitc`, `filer_loss_limited_net_capital_gains` or AGI. 2. Differential: `eitc_relevant_investment_income` equals an independent numpy @@ -22,11 +25,15 @@ interest and dividends, plus their Schedule D gain with capital gain distributions floored at zero (line 7), plus their net rental and passive income floored at zero (line 13). -3. Bounds: it is at least the filers' interest and dividends, so never - negative, and a filer's extra dollar of interest raises it by a dollar. +3. Bounds: it is at least the filers' interest and dividends (drawn + nonnegative), so never negative, and $1,000 more of the head's interest + raises it by exactly $1,000. 4. For tax units without dependents and without a negative distributions input, it equals the previous all-member formula, so leaving dependents out changes nothing for them. + +Gains are entered per person, so `net_capital_gains` is computed rather than +supplied; the YAML cases cover a supplied tax unit amount. """ import numpy as np @@ -64,14 +71,17 @@ nonnegative = st.one_of(st.just(0.0), st.integers(1, 8_000).map(float)) signed = st.one_of(st.just(0.0), st.integers(-12_000, 12_000).map(float)) +# $16 million to $64 million, in multiples of 16 so float32 holds them exactly. +very_large = st.integers(-4_000_000, 4_000_000).map(lambda x: 16.0 * x) @st.composite -def investment_amounts(draw): +def investment_amounts(draw, dependent=False): passive = draw(signed) + capital = st.one_of(signed, very_large) if dependent else signed return { **{name: draw(nonnegative) for name in PORTFOLIO_INPUTS}, - **{name: draw(signed) for name in CAPITAL_INPUTS}, + **{name: draw(capital) for name in CAPITAL_INPUTS}, # Form 1099-DIV box 2a amounts are not negative, but the input can be; # the model floors each person's amount at zero. DISTRIBUTIONS: draw(st.one_of(nonnegative, st.integers(-3_000, -1).map(float))), @@ -91,7 +101,10 @@ def tax_units(draw): "head": draw(investment_amounts()), "spouse": draw(investment_amounts()) if kind.startswith("joint") else None, "dependents": [ - {"age": draw(st.integers(1, 17)), **draw(investment_amounts())} + { + "age": draw(st.integers(1, 17)), + **draw(investment_amounts(dependent=True)), + } for _ in range(n_dependents) ], } @@ -100,14 +113,19 @@ def tax_units(draw): SEED = 20261006 -def _seeded_amounts(rng): +def _seeded_amounts(rng, dependent=False): def some(low, high, p=0.5): return float(round(rng.uniform(low, high))) if rng.random() < p else 0.0 + def capital(): + if dependent and rng.random() < 0.2: + return 16.0 * int(rng.integers(1_000_000, 4_000_000)) + return some(-12_000, 12_000) + passive = some(-12_000, 12_000) return { **{name: some(1, 8_000) for name in PORTFOLIO_INPUTS}, - **{name: some(-12_000, 12_000) for name in CAPITAL_INPUTS}, + **{name: capital() for name in CAPITAL_INPUTS}, DISTRIBUTIONS: some(-3_000, 8_000), "rental_income": some(-12_000, 12_000), "partnership_s_corp_income": passive, @@ -129,7 +147,10 @@ def _seeded_units(n=200): "head": _seeded_amounts(rng), "spouse": (_seeded_amounts(rng) if kind.startswith("joint") else None), "dependents": [ - {"age": int(rng.integers(1, 18)), **_seeded_amounts(rng)} + { + "age": int(rng.integers(1, 18)), + **_seeded_amounts(rng, dependent=True), + } for _ in range(n_dependents) ], } diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml index 6bd0bcf455d..2f32d1ec044 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml @@ -3,10 +3,10 @@ # income of the taxpayer" exceeds the limit, and 32(i)(2)(A) counts interest # and dividends only "to the extent includible in gross income", which a # dependent's are not (irs_gross_income leaves them out of AGI). Publication -# 596 Worksheet 1 reads the filer's own Form 1040 and picks up a child's -# interest and dividends only through a Form 8814 election (lines 2 and 4), -# which is not modeled; from $13,500 the child must file their own return -# (2025 Form 8814, line 4). The worksheet's capital gain line (line 5) is the +# 596 Worksheet 1 reads the filer's own Form 1040 and picks up a child's income +# only through a Form 8814 election (lines 2 and 4 directly, lines 3 and 5 +# through Form 1040 lines 3b and 7a), which is not modeled; from $13,500 the +# child must file their own return (2025 Form 8814, line 4). The worksheet's capital gain line (line 5) is the # filer's own Form 1040 line 7a. So eitc_relevant_investment_income counts the # head and spouse only, in both directions: a dependent's income cannot deny # the credit, and a dependent's loss cannot offset the filer's income. @@ -320,3 +320,58 @@ # child's 3,000, which is then taken out: 10,000 - 3,000 = 7,000. eitc_relevant_investment_income: 7_000 eitc_investment_income_eligible: true + +- name: A dependent's very large gain does not move the filer across the limit from below + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 20_000 + taxable_interest_income: 11_950 + long_term_capital_gains: 1 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 20_000_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # 11,950 + 1 = 11,951 > 11,950. net_capital_gains is stored in float32, + # where 20,000,001 rounds to 20,000,000, so taking the child's gain out + # of it would give 11,950 and allow the credit. + eitc_relevant_investment_income: 11_951 + eitc_investment_income_eligible: false + +- name: A dependent's very large gain does not move the filer across the limit from above + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 20_000 + taxable_interest_income: 11_947 + long_term_capital_gains: 3 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 20_000_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # 11,947 + 3 = 11,950 <= 11,950. In float32, 20,000,003 rounds to + # 20,000,004, so taking the child's gain out of it would give 11,951. + eitc_relevant_investment_income: 11_950 + eitc_investment_income_eligible: true diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py index 91070fb5dbf..79a02ef1115 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py @@ -10,10 +10,14 @@ class eitc_relevant_investment_income(Variable): "figured as on Publication 596 Worksheet 1. A tax unit dependent's " "interest, dividends, rents, passive income and capital gains are on " "the dependent's own return, as in irs_gross_income. The worksheet " - "picks up a child's interest and dividends only through a Form 8814 " - "election (lines 2 and 4), which is not modeled. A net_capital_gains " - "amount supplied for the tax unit is read as covering every member, " - "before any dependent's gains and losses are taken out." + "picks up a child's income only through a Form 8814 election: line 2 " + "adds Form 8814 line 1b, line 4 the Schedule 1 line 8z amount, and the " + "elected dividends and capital gain distributions reach lines 3 and 5 " + "through Form 1040 lines 3b and 7a. The election is not modeled, which " + "can overstate the credit for a parent who makes it. A " + "net_capital_gains amount supplied for the tax unit is read as " + "covering every member, before any dependent's gains and losses are " + "taken out." ) unit = USD definition_period = YEAR @@ -21,6 +25,7 @@ class eitc_relevant_investment_income(Variable): "https://www.law.cornell.edu/uscode/text/26/32#i_2", "https://www.irs.gov/pub/irs-prior/p596--2025.pdf#page=7", "https://www.irs.gov/pub/irs-prior/f8814--2025.pdf#page=1", + "https://www.irs.gov/pub/irs-prior/i8814--2025.pdf#page=4", ) def formula(tax_unit, period, parameters): @@ -38,26 +43,32 @@ def formula(tax_unit, period, parameters): ) # Worksheet 1 line 5: Form 1040 line 7a, the head and spouse's # Schedule D gain with capital gain distributions (line 13), or zero - # if a loss. Start from net_capital_gains so a tax unit amount - # supplied there is kept; it is read as covering every member, and - # any dependent's gains and losses are then taken out. Capital gain - # distributions (Form 1099-DIV box 2a) are not negative, so each - # filer's input is floored at zero, as in irs_gross_income. - person = tax_unit.members - dependent = person("is_tax_unit_dependent", period) - dependent_gains = tax_unit.sum( - dependent - * ( - person("long_term_capital_gains", period) - + person("short_term_capital_gains", period) - ) + # if a loss. + gains = ["long_term_capital_gains", "short_term_capital_gains"] + filer_gains = tax_unit_non_dep_add(tax_unit, period, gains) + member_gains = add(tax_unit, period, gains) + net_capital_gains = tax_unit("net_capital_gains", period) + # A tax unit amount supplied for net_capital_gains is kept: it is read + # as covering every member, and any dependent's gains and losses are + # taken out. Otherwise net_capital_gains is the members' own gains + # and losses (its adds), stored in float32, so the head's and + # spouse's are summed directly; subtracting a dependent's gain from + # the rounded total could move the filers' amount once the total + # passes about $16.8 million. + aggregate_supplied = net_capital_gains != member_gains.astype(np.float32) + schedule_d_gains = where( + aggregate_supplied, + net_capital_gains - (member_gains - filer_gains), + filer_gains, ) + # Capital gain distributions (Form 1099-DIV box 2a) are not negative, + # so each filer's input is floored at zero, as in irs_gross_income. + person = tax_unit.members distributions = tax_unit.sum( - ~dependent * max_(0, person("non_sch_d_capital_gains", period)) - ) - capital_gains = ( - tax_unit("net_capital_gains", period) - dependent_gains + distributions + ~person("is_tax_unit_dependent", period) + * max_(0, person("non_sch_d_capital_gains", period)) ) + capital_gains = schedule_d_gains + distributions # The model's undifferentiated rental input is treated as passive # rental income, consistently with its NIIT income mapping. Net the # passive amounts across the head and spouse before applying the zero From 46d22d861fdf56b256b4cf88b4ee91fd9c09d112 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 10:02:42 -0400 Subject: [PATCH 05/12] Reflow a docstring item and describe the large-gain draws accurately Co-Authored-By: Claude Opus 5.5 --- .../test_dependent_eitc_investment_income_invariants.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py index 3766baa6da3..ea3857b488c 100644 --- a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py +++ b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py @@ -17,8 +17,7 @@ interest. For every tax unit: 1. The dependents' investment income, of any size or sign, never changes the - filer's - `eitc_relevant_investment_income`, `eitc_investment_income_eligible`, + filer's `eitc_relevant_investment_income`, `eitc_investment_income_eligible`, `eitc`, `filer_loss_limited_net_capital_gains` or AGI. 2. Differential: `eitc_relevant_investment_income` equals an independent numpy computation of Worksheet 1 over the head and spouse: interest, tax-exempt @@ -71,7 +70,8 @@ nonnegative = st.one_of(st.just(0.0), st.integers(1, 8_000).map(float)) signed = st.one_of(st.just(0.0), st.integers(-12_000, 12_000).map(float)) -# $16 million to $64 million, in multiples of 16 so float32 holds them exactly. +# Up to $64 million either way, in multiples of 16 so float32 holds them +# exactly; a tax unit total above about $16.8 million rounds in float32. very_large = st.integers(-4_000_000, 4_000_000).map(lambda x: 16.0 * x) From 069db9a27a46356aa753207c474f033a1de63f15 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 10:36:57 -0400 Subject: [PATCH 06/12] Read net_capital_gains as an aggregate only when it was supplied Comparing values alone read an abolished or stale net_capital_gains as a supplied aggregate and subtracted dependents' gains from it (Opus delta review S1; with gov.abolitions.net_capital_gains a child's 5,000 gain made the parent eligible). Check core's input record (is_input, next to the existing _input_keys in tools/period_branch.py) as well. Add a test for that branch and a guard that the formula's gain list matches net_capital_gains.adds; fix a stale test comment and a long YAML header line. Co-Authored-By: Claude Opus 5.5 --- ...ndent_eitc_investment_income_invariants.py | 43 ++++++++++++++++++- .../eitc_relevant_investment_income.yaml | 9 ++-- policyengine_us/tools/general.py | 1 + policyengine_us/tools/period_branch.py | 12 ++++++ .../eitc_relevant_investment_income.py | 19 +++++--- 5 files changed, 73 insertions(+), 11 deletions(-) diff --git a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py index ea3857b488c..32331ab2c23 100644 --- a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py +++ b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py @@ -65,7 +65,7 @@ ] # Sums every member, for the previous formula in property 4. OUTPUTS = FILER_OUTPUTS + ["net_capital_gains"] -# An extra dollar of the head's interest in the second run. +# More of the head's interest, in the third run. INTEREST_SHIFT = 1_000.0 nonnegative = st.one_of(st.just(0.0), st.integers(1, 8_000).map(float)) @@ -327,3 +327,44 @@ def test_seeded_population(year): # The population exercises both branches of each property. assert has_dependent.sum() > 50 assert unchanged.sum() > 20 + + +def test_unsupplied_net_capital_gains_is_not_read_as_an_aggregate(): + # net_capital_gains can hold a value that was neither supplied nor the + # members' current gains: abolished (gov.abolitions.net_capital_gains + # makes it 0), or calculated before an input changed, as here. Only a + # supplied amount is read as an aggregate, so the capital gain line stays + # the filers' own gains and the dependent's gain changes nothing. A + # reform simulation would cost a parameter-tree copy; the cached value + # reaches the same branch. + year = 2025 + situation = { + "people": { + "head": { + "age": {year: 30}, + "employment_income": {year: 20_000}, + "taxable_interest_income": {year: 7_000}, + DISTRIBUTIONS: {year: 5_000}, + }, + "child": { + "age": {year: 8}, + "is_tax_unit_dependent": {year: True}, + "long_term_capital_gains": {year: 0}, + }, + }, + "tax_units": {"tax_unit": {"members": ["head", "child"]}}, + "households": { + "household": {"members": ["head", "child"], "state_code": {year: "TX"}} + }, + } + sim = Simulation(situation=situation) + # The formula lists net_capital_gains' components itself; keep them in + # step. + assert sim.tax_benefit_system.get_variable("net_capital_gains").adds == [ + "long_term_capital_gains", + "short_term_capital_gains", + ] + assert sim.calculate("net_capital_gains", year)[0] == 0 + sim.set_input("long_term_capital_gains", year, np.array([0, 5_000])) + # 7,000 of interest + max(0, 0 + 5,000 of distributions). + assert sim.calculate("eitc_relevant_investment_income", year)[0] == 12_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml index 2f32d1ec044..bec9b03f270 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml @@ -6,10 +6,11 @@ # 596 Worksheet 1 reads the filer's own Form 1040 and picks up a child's income # only through a Form 8814 election (lines 2 and 4 directly, lines 3 and 5 # through Form 1040 lines 3b and 7a), which is not modeled; from $13,500 the -# child must file their own return (2025 Form 8814, line 4). The worksheet's capital gain line (line 5) is the -# filer's own Form 1040 line 7a. So eitc_relevant_investment_income counts the -# head and spouse only, in both directions: a dependent's income cannot deny -# the credit, and a dependent's loss cannot offset the filer's income. +# child must file their own return (2025 Form 8814, line 4). The worksheet's +# capital gain line (line 5) is the filer's own Form 1040 line 7a. So +# eitc_relevant_investment_income counts the head and spouse only, in both +# directions: a dependent's income cannot deny the credit, and a dependent's +# loss cannot offset the filer's income. # # Unless noted, the filer is a 30-year-old head of household in TX for 2025 # with $20,000 of wages and one qualifying child aged 8. 2025 amounts: diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index b730a0a30ca..e9f82eab0c7 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -4,6 +4,7 @@ from policyengine_us.tools.period_branch import ( get_branch_for_period, get_override_branch, + is_input, ) from pathlib import Path import pandas as pd diff --git a/policyengine_us/tools/period_branch.py b/policyengine_us/tools/period_branch.py index 5e47797cf20..fcb24b74f76 100644 --- a/policyengine_us/tools/period_branch.py +++ b/policyengine_us/tools/period_branch.py @@ -73,6 +73,18 @@ def _input_keys(branch: Simulation) -> Set[Tuple[str, str, Period]]: } +def is_input(simulation: Simulation, variable: str, period: Period) -> bool: + """Whether ``set_input`` stored ``variable`` for ``period``. + + That is, a dataset, situation or branch input on ``simulation`` or a + branch it reads, as opposed to a value its formula calculated. + """ + return any( + name == variable and key_period == period + for name, _, key_period in _input_keys(simulation) + ) + + def drop_inherited_values(branch: Simulation) -> None: """Delete every array ``branch`` holds except inputs. diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py index 79a02ef1115..54bb2b731bf 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py @@ -50,12 +50,19 @@ def formula(tax_unit, period, parameters): net_capital_gains = tax_unit("net_capital_gains", period) # A tax unit amount supplied for net_capital_gains is kept: it is read # as covering every member, and any dependent's gains and losses are - # taken out. Otherwise net_capital_gains is the members' own gains - # and losses (its adds), stored in float32, so the head's and - # spouse's are summed directly; subtracting a dependent's gain from - # the rounded total could move the filers' amount once the total - # passes about $16.8 million. - aggregate_supplied = net_capital_gains != member_gains.astype(np.float32) + # taken out. It counts as supplied only where it was set as an input + # and differs from the members' own gains and losses (its adds), so an + # abolished or stale net_capital_gains is not read as supplied. A + # supplied amount is stored in float32, like any input. Otherwise the + # head's and spouse's gains are summed directly: net_capital_gains is + # stored in float32, and subtracting a dependent's gain from the + # rounded total could move the filers' amount once the total passes + # about $16.8 million. Core sums tax unit totals in float64 before + # storing them, so a computed net_capital_gains equals + # float32(member_gains) exactly. + aggregate_supplied = is_input( + tax_unit.simulation, "net_capital_gains", period + ) & (net_capital_gains != member_gains.astype(np.float32)) schedule_d_gains = where( aggregate_supplied, net_capital_gains - (member_gains - filer_gains), From f4c4e6640684f0f93849c85e1e8570108058406c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 10:42:46 -0400 Subject: [PATCH 07/12] Pin a supplied aggregate equal to the members' gains in float32; narrow invariant 4 A supplied net_capital_gains that equals the members' own gains and losses in float32 adds nothing to them, so the filers' gains are summed exactly; two YAML cases pin both rounding directions as intended (Sol delta review). Invariant 4 holds for filer gains in the drawn range; past about 16.8 million the previous formula's float32 total could be off by a few dollars, which the direct sum corrects. Co-Authored-By: Claude Opus 5.5 --- ...ndent_eitc_investment_income_invariants.py | 4 +- .../eitc_relevant_investment_income.yaml | 64 +++++++++++++++++++ .../eitc_relevant_investment_income.py | 5 +- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py index 32331ab2c23..04cc557b106 100644 --- a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py +++ b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py @@ -29,7 +29,9 @@ raises it by exactly $1,000. 4. For tax units without dependents and without a negative distributions input, it equals the previous all-member formula, so leaving dependents out - changes nothing for them. + changes nothing for them. (With filer gains in the drawn range. Past about + $16.8 million the previous formula's float32 tax unit total could be off by + a few dollars, which the direct sum corrects.) Gains are entered per person, so `net_capital_gains` is computed rather than supplied; the YAML cases cover a supplied tax unit amount. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml index bec9b03f270..d0fd66e0585 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml @@ -376,3 +376,67 @@ # 20,000,004, so taking the child's gain out of it would give 11,951. eitc_relevant_investment_income: 11_950 eitc_investment_income_eligible: true + +# A supplied net_capital_gains that equals the members' own gains and losses +# in float32 adds nothing to them, so the head's and spouse's gains are summed +# exactly rather than taking the dependents' gains out of the rounded total. +# Intended: aggregate minus dependents is then the filers' own gains, and the +# exact sum avoids the float32 cancellation the two cases above guard against. + +- name: A supplied aggregate equal to the members' gains in float32 keeps the head's own gain (rounded down) + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 20_000 + taxable_interest_income: 11_950 + long_term_capital_gains: 1 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 20_000_000 + tax_units: + tax_unit: + members: [head, child] + # float32(20,000,001) = 20,000,000. + net_capital_gains: 20_000_000 + households: + household: + members: [head, child] + state_code: TX + output: + # 11,950 + the head's 1. Subtracting the child's 20,000,000 from the + # stored 20,000,000 would give 0. + eitc_relevant_investment_income: 11_951 + eitc_investment_income_eligible: false + +- name: A supplied aggregate equal to the members' gains in float32 keeps the head's own gain (rounded up) + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 20_000 + taxable_interest_income: 11_947 + long_term_capital_gains: 3 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 20_000_000 + tax_units: + tax_unit: + members: [head, child] + # float32(20,000,003) = 20,000,004. + net_capital_gains: 20_000_004 + households: + household: + members: [head, child] + state_code: TX + output: + # 11,947 + the head's 3. Subtracting the child's 20,000,000 from the + # stored 20,000,004 would give 4. + eitc_relevant_investment_income: 11_950 + eitc_investment_income_eligible: true diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py index 54bb2b731bf..65bddb3072e 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py @@ -53,7 +53,10 @@ def formula(tax_unit, period, parameters): # taken out. It counts as supplied only where it was set as an input # and differs from the members' own gains and losses (its adds), so an # abolished or stale net_capital_gains is not read as supplied. A - # supplied amount is stored in float32, like any input. Otherwise the + # supplied amount equal to the members' own gains and losses in + # float32 adds nothing to them: aggregate minus dependents is then the + # filers' own gains, which are summed exactly instead. A supplied + # amount is stored in float32, like any input. Otherwise the # head's and spouse's gains are summed directly: net_capital_gains is # stored in float32, and subtracting a dependent's gain from the # rounded total could move the filers' amount once the total passes From bafe4feeb2ead483a4c9dd5b9134a44e69a65364 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 12:21:43 -0400 Subject: [PATCH 08/12] Use the filers' own capital gains and stop reading a supplied net_capital_gains Return to the capital gain line the task specified: the head's and spouse's long- and short-term gains (tax_unit_non_dep_add) plus their floored capital gain distributions. Reading a supplied tax unit net_capital_gains needs to know which tax units supplied it, which core does not record, and every value test for it let a dependent's gain move the filers' result somewhere (four review rounds: float32 collisions, abolished, stale and default-filled values). A supplied tax unit total is now not read, as in irs_gross_income and the NIIT base; no PolicyEngine repo or the microdata supplies it. Drop the is_input helper, the cases that pinned the aggregate reading and the input-record test; pin instead that a supplied total is not read. Add two downstream pins from a 504-household sweep (session bbf31ca6): a dependent's income no longer removes MN child and working family credits or the NM EITC. Co-Authored-By: Claude Opus 5.5 --- ...ndent_eitc_investment_income_invariants.py | 46 +------- .../eitc_relevant_investment_income.yaml | 104 ++---------------- ...mn_child_and_working_families_credits.yaml | 31 ++++++ .../nm/tax/income/credits/eitc/nm_eitc.yaml | 30 +++++ policyengine_us/tools/general.py | 1 - policyengine_us/tools/period_branch.py | 12 -- .../eitc_relevant_investment_income.py | 45 +++----- 7 files changed, 86 insertions(+), 183 deletions(-) diff --git a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py index 04cc557b106..2e1d60925d7 100644 --- a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py +++ b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py @@ -33,8 +33,9 @@ $16.8 million the previous formula's float32 tax unit total could be off by a few dollars, which the direct sum corrects.) -Gains are entered per person, so `net_capital_gains` is computed rather than -supplied; the YAML cases cover a supplied tax unit amount. +The formula reads the head's and spouse's person-level gains, never the tax +unit's `net_capital_gains`; a YAML case pins that a supplied tax unit amount is +not read. """ import numpy as np @@ -329,44 +330,3 @@ def test_seeded_population(year): # The population exercises both branches of each property. assert has_dependent.sum() > 50 assert unchanged.sum() > 20 - - -def test_unsupplied_net_capital_gains_is_not_read_as_an_aggregate(): - # net_capital_gains can hold a value that was neither supplied nor the - # members' current gains: abolished (gov.abolitions.net_capital_gains - # makes it 0), or calculated before an input changed, as here. Only a - # supplied amount is read as an aggregate, so the capital gain line stays - # the filers' own gains and the dependent's gain changes nothing. A - # reform simulation would cost a parameter-tree copy; the cached value - # reaches the same branch. - year = 2025 - situation = { - "people": { - "head": { - "age": {year: 30}, - "employment_income": {year: 20_000}, - "taxable_interest_income": {year: 7_000}, - DISTRIBUTIONS: {year: 5_000}, - }, - "child": { - "age": {year: 8}, - "is_tax_unit_dependent": {year: True}, - "long_term_capital_gains": {year: 0}, - }, - }, - "tax_units": {"tax_unit": {"members": ["head", "child"]}}, - "households": { - "household": {"members": ["head", "child"], "state_code": {year: "TX"}} - }, - } - sim = Simulation(situation=situation) - # The formula lists net_capital_gains' components itself; keep them in - # step. - assert sim.tax_benefit_system.get_variable("net_capital_gains").adds == [ - "long_term_capital_gains", - "short_term_capital_gains", - ] - assert sim.calculate("net_capital_gains", year)[0] == 0 - sim.set_input("long_term_capital_gains", year, np.array([0, 5_000])) - # 7,000 of interest + max(0, 0 + 5,000 of distributions). - assert sim.calculate("eitc_relevant_investment_income", year)[0] == 12_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml index d0fd66e0585..5499593bd30 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml @@ -286,42 +286,6 @@ # 4,328 - 15.98% x (31,000 - 30,470) = 4,328 - 84.694 eitc: 4_243.31 -- name: A tax unit net capital gains input still counts - period: 2025 - absolute_error_margin: 0.01 - input: - net_capital_gains: 12_000 - output: - # A tax unit amount supplied for net_capital_gains is kept, as before. - eitc_relevant_investment_income: 12_000 - eitc_investment_income_eligible: false - -- name: A supplied tax unit net capital gains amount covers every member - period: 2025 - absolute_error_margin: 0.01 - input: - people: - head: - age: 30 - employment_income: 20_000 - child: - age: 8 - is_tax_unit_dependent: true - long_term_capital_gains: 3_000 - tax_units: - tax_unit: - members: [head, child] - net_capital_gains: 10_000 - households: - household: - members: [head, child] - state_code: TX - output: - # The supplied 10,000 is read as the whole unit's net gain, including the - # child's 3,000, which is then taken out: 10,000 - 3,000 = 7,000. - eitc_relevant_investment_income: 7_000 - eitc_investment_income_eligible: true - - name: A dependent's very large gain does not move the filer across the limit from below period: 2025 absolute_error_margin: 0.01 @@ -377,66 +341,16 @@ eitc_relevant_investment_income: 11_950 eitc_investment_income_eligible: true -# A supplied net_capital_gains that equals the members' own gains and losses -# in float32 adds nothing to them, so the head's and spouse's gains are summed -# exactly rather than taking the dependents' gains out of the rounded total. -# Intended: aggregate minus dependents is then the filers' own gains, and the -# exact sum avoids the float32 cancellation the two cases above guard against. - -- name: A supplied aggregate equal to the members' gains in float32 keeps the head's own gain (rounded down) - period: 2025 - absolute_error_margin: 0.01 - input: - people: - head: - age: 30 - employment_income: 20_000 - taxable_interest_income: 11_950 - long_term_capital_gains: 1 - child: - age: 8 - is_tax_unit_dependent: true - long_term_capital_gains: 20_000_000 - tax_units: - tax_unit: - members: [head, child] - # float32(20,000,001) = 20,000,000. - net_capital_gains: 20_000_000 - households: - household: - members: [head, child] - state_code: TX - output: - # 11,950 + the head's 1. Subtracting the child's 20,000,000 from the - # stored 20,000,000 would give 0. - eitc_relevant_investment_income: 11_951 - eitc_investment_income_eligible: false - -- name: A supplied aggregate equal to the members' gains in float32 keeps the head's own gain (rounded up) +- name: A net capital gains amount supplied for the tax unit is not read period: 2025 absolute_error_margin: 0.01 input: - people: - head: - age: 30 - employment_income: 20_000 - taxable_interest_income: 11_947 - long_term_capital_gains: 3 - child: - age: 8 - is_tax_unit_dependent: true - long_term_capital_gains: 20_000_000 - tax_units: - tax_unit: - members: [head, child] - # float32(20,000,003) = 20,000,004. - net_capital_gains: 20_000_004 - households: - household: - members: [head, child] - state_code: TX + net_capital_gains: 12_000 output: - # 11,947 + the head's 3. Subtracting the child's 20,000,000 from the - # stored 20,000,004 would give 4. - eitc_relevant_investment_income: 11_950 - eitc_investment_income_eligible: true + # Capital gains are the head's and spouse's own person-level gains, as in + # irs_gross_income and the NIIT base, so the supplied tax unit total is + # not read. Reading it and taking dependents' gains out of it would let a + # dependent's large gain move the filers' amount through float32 rounding. + # Before: 12,000. + eitc_relevant_investment_income: 0 + adjusted_gross_income: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/mn_child_and_working_families_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/mn_child_and_working_families_credits.yaml index 4de751088e0..e46118e93c9 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/mn_child_and_working_families_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/mn_child_and_working_families_credits.yaml @@ -566,3 +566,34 @@ state_code: MN output: mn_child_and_working_families_credits: 2_129.2 + +- name: A dependent's interest does not remove Minnesota's child and working family credits + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 22_000 + child: + age: 10 + is_tax_unit_dependent: true + taxable_interest_income: 50_000 + tax_units: + tax_unit: + members: [head, child] + spm_units: + spm_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: MN + output: + # The child's interest is on the child's own return; the head's + # Worksheet 1 amount is 0, under the 2025 limit of 11,950. + eitc_investment_income_eligible: true + mn_wfc_eligible: true + # CTC 1 x 1,750 + WFC 4% x 9,480 = 379.20; income 22,000 is under + # the 31,950 phase-out threshold. Main gives 0. + mn_child_and_working_families_credits: 2_129.20 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/eitc/nm_eitc.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/eitc/nm_eitc.yaml index 5496ac4e9d1..2c3965ab3d7 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/eitc/nm_eitc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/eitc/nm_eitc.yaml @@ -30,3 +30,33 @@ state_code: NM output: nm_eitc: 140 + +- name: A dependent's capital gain does not remove New Mexico's working families tax credit + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 22_000 + child: + age: 10 + is_tax_unit_dependent: true + long_term_capital_gains: 50_000 + tax_units: + tax_unit: + members: [head, child] + spm_units: + spm_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: NM + output: + # Federal EITC, one child: the 2025 maximum 4,328 (earnings 22,000 + # are under the 23,350 phase-out start). NM matches 25%: 1,082. + # Main gives 0 for both. + eitc: 4_328 + nm_eitc_eligible: true + nm_eitc: 1_082 diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index e9f82eab0c7..b730a0a30ca 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -4,7 +4,6 @@ from policyengine_us.tools.period_branch import ( get_branch_for_period, get_override_branch, - is_input, ) from pathlib import Path import pandas as pd diff --git a/policyengine_us/tools/period_branch.py b/policyengine_us/tools/period_branch.py index fcb24b74f76..5e47797cf20 100644 --- a/policyengine_us/tools/period_branch.py +++ b/policyengine_us/tools/period_branch.py @@ -73,18 +73,6 @@ def _input_keys(branch: Simulation) -> Set[Tuple[str, str, Period]]: } -def is_input(simulation: Simulation, variable: str, period: Period) -> bool: - """Whether ``set_input`` stored ``variable`` for ``period``. - - That is, a dataset, situation or branch input on ``simulation`` or a - branch it reads, as opposed to a value its formula calculated. - """ - return any( - name == variable and key_period == period - for name, _, key_period in _input_keys(simulation) - ) - - def drop_inherited_values(branch: Simulation) -> None: """Delete every array ``branch`` holds except inputs. diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py index 65bddb3072e..f3827765f45 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py @@ -14,10 +14,9 @@ class eitc_relevant_investment_income(Variable): "adds Form 8814 line 1b, line 4 the Schedule 1 line 8z amount, and the " "elected dividends and capital gain distributions reach lines 3 and 5 " "through Form 1040 lines 3b and 7a. The election is not modeled, which " - "can overstate the credit for a parent who makes it. A " - "net_capital_gains amount supplied for the tax unit is read as " - "covering every member, before any dependent's gains and losses are " - "taken out." + "can overstate the credit for a parent who makes it. Capital gains " + "are the head's and spouse's own person-level gains; a " + "net_capital_gains amount supplied for the tax unit is not read." ) unit = USD definition_period = YEAR @@ -43,33 +42,15 @@ def formula(tax_unit, period, parameters): ) # Worksheet 1 line 5: Form 1040 line 7a, the head and spouse's # Schedule D gain with capital gain distributions (line 13), or zero - # if a loss. - gains = ["long_term_capital_gains", "short_term_capital_gains"] - filer_gains = tax_unit_non_dep_add(tax_unit, period, gains) - member_gains = add(tax_unit, period, gains) - net_capital_gains = tax_unit("net_capital_gains", period) - # A tax unit amount supplied for net_capital_gains is kept: it is read - # as covering every member, and any dependent's gains and losses are - # taken out. It counts as supplied only where it was set as an input - # and differs from the members' own gains and losses (its adds), so an - # abolished or stale net_capital_gains is not read as supplied. A - # supplied amount equal to the members' own gains and losses in - # float32 adds nothing to them: aggregate minus dependents is then the - # filers' own gains, which are summed exactly instead. A supplied - # amount is stored in float32, like any input. Otherwise the - # head's and spouse's gains are summed directly: net_capital_gains is - # stored in float32, and subtracting a dependent's gain from the - # rounded total could move the filers' amount once the total passes - # about $16.8 million. Core sums tax unit totals in float64 before - # storing them, so a computed net_capital_gains equals - # float32(member_gains) exactly. - aggregate_supplied = is_input( - tax_unit.simulation, "net_capital_gains", period - ) & (net_capital_gains != member_gains.astype(np.float32)) - schedule_d_gains = where( - aggregate_supplied, - net_capital_gains - (member_gains - filer_gains), - filer_gains, + # if a loss. Sum their own gains and losses rather than taking + # dependents' out of the tax unit's net_capital_gains: core stores + # that total in float32, so a dependent's large gain would move the + # filers' amount once the total passes about $16.8 million. A + # net_capital_gains amount supplied for the tax unit is therefore not + # read, as in irs_gross_income and the NIIT base + # (filer_loss_limited_net_capital_gains). + filer_gains = tax_unit_non_dep_add( + tax_unit, period, ["long_term_capital_gains", "short_term_capital_gains"] ) # Capital gain distributions (Form 1099-DIV box 2a) are not negative, # so each filer's input is floored at zero, as in irs_gross_income. @@ -78,7 +59,7 @@ def formula(tax_unit, period, parameters): ~person("is_tax_unit_dependent", period) * max_(0, person("non_sch_d_capital_gains", period)) ) - capital_gains = schedule_d_gains + distributions + capital_gains = filer_gains + distributions # The model's undifferentiated rental input is treated as passive # rental income, consistently with its NIIT income mapping. Net the # passive amounts across the head and spouse before applying the zero From 6dae716b9c2b41d1282339ce269503faee32ecb6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 16:26:15 -0400 Subject: [PATCH 09/12] Disclose in the changelog that a supplied net_capital_gains is not read; clarify the capital gain comment Docs only, from the bafe4feeb2 reviews: the changelog now says the test reads the head's and spouse's long- and short-term gains and no longer a supplied tax unit net_capital_gains; the variable documentation also says a capital_gains amount set directly on a person is not read; the code comment gives the actual reason the tax unit total is not read. Co-Authored-By: Claude Opus 5.5 --- .../eitc-dependent-investment-income.fixed.md | 2 +- .../eitc_relevant_investment_income.py | 20 ++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/changelog.d/eitc-dependent-investment-income.fixed.md b/changelog.d/eitc-dependent-investment-income.fixed.md index 851bb4e31fa..a8ec1ddc496 100644 --- a/changelog.d/eitc-dependent-investment-income.fixed.md +++ b/changelog.d/eitc-dependent-investment-income.fixed.md @@ -1 +1 @@ -The EITC investment income test (26 U.S.C. 32(i)) now counts only the head's and spouse's interest, dividends, capital gains, rents and passive income. A tax unit dependent's income no longer denies the credit, and a dependent's losses no longer offset the filer's income. A negative capital gain distributions input is floored at zero for each filer, as in adjusted gross income, instead of offsetting other capital gains. +The EITC investment income test (26 U.S.C. 32(i)) now counts only the head's and spouse's interest, dividends, capital gains, rents and passive income. A tax unit dependent's income no longer denies the credit, and a dependent's losses no longer offset the filer's income. A negative capital gain distributions input is floored at zero for each filer, as in adjusted gross income, instead of offsetting other capital gains. The test now reads the head's and spouse's long_term_capital_gains and short_term_capital_gains, as the net investment income tax does; a net_capital_gains amount supplied for the tax unit is no longer read. diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py index f3827765f45..b43210665ee 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py @@ -15,8 +15,10 @@ class eitc_relevant_investment_income(Variable): "elected dividends and capital gain distributions reach lines 3 and 5 " "through Form 1040 lines 3b and 7a. The election is not modeled, which " "can overstate the credit for a parent who makes it. Capital gains " - "are the head's and spouse's own person-level gains; a " - "net_capital_gains amount supplied for the tax unit is not read." + "are the head's and spouse's own long_term_capital_gains and " + "short_term_capital_gains; a net_capital_gains amount supplied for " + "the tax unit, or a capital_gains amount set directly on a person, " + "is not read." ) unit = USD definition_period = YEAR @@ -42,13 +44,13 @@ def formula(tax_unit, period, parameters): ) # Worksheet 1 line 5: Form 1040 line 7a, the head and spouse's # Schedule D gain with capital gain distributions (line 13), or zero - # if a loss. Sum their own gains and losses rather than taking - # dependents' out of the tax unit's net_capital_gains: core stores - # that total in float32, so a dependent's large gain would move the - # filers' amount once the total passes about $16.8 million. A - # net_capital_gains amount supplied for the tax unit is therefore not - # read, as in irs_gross_income and the NIIT base - # (filer_loss_limited_net_capital_gains). + # if a loss. Sum their own gains and losses directly, as the NIIT base + # (filer_loss_limited_net_capital_gains) does. Reading the tax unit's + # net_capital_gains and taking dependents' gains out of it would let a + # dependent's large gain move the filers' amount through float32 + # rounding of the total, and core does not record which tax units + # supplied that total. So a net_capital_gains amount supplied for the + # tax unit is not read. filer_gains = tax_unit_non_dep_add( tax_unit, period, ["long_term_capital_gains", "short_term_capital_gains"] ) From 4d9ea75300bec06bba1b73eaf45ce59ec1bee762 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 09:11:04 -0400 Subject: [PATCH 10/12] Count farm rent and leave out passive income that is also earned in the EITC investment income test (#9635 items 2 and 3) Add the signed Person input eitc_passive_income_also_in_earned_income and subtract it from the passive basket before the zero floor, as 26 U.S.C. 32(i)(2)(E) requires. Include farm_rent_income, income and losses, in the passive basket (Pub. 596 Worksheet 1 lines 11-12, Schedule E line 40). Co-Authored-By: Claude Opus 5.5 --- ...-passive-earned-overlap-farm-rent.fixed.md | 1 + ...ndent_eitc_investment_income_invariants.py | 88 +++-- .../eitc_relevant_investment_income.yaml | 359 ++++++++++++++++++ ...tc_passive_income_also_in_earned_income.py | 29 ++ .../eitc_relevant_investment_income.py | 26 +- 5 files changed, 476 insertions(+), 27 deletions(-) create mode 100644 changelog.d/eitc-passive-earned-overlap-farm-rent.fixed.md create mode 100644 policyengine_us/variables/gov/irs/credits/earned_income/eitc_passive_income_also_in_earned_income.py diff --git a/changelog.d/eitc-passive-earned-overlap-farm-rent.fixed.md b/changelog.d/eitc-passive-earned-overlap-farm-rent.fixed.md new file mode 100644 index 00000000000..c4ad1b93d62 --- /dev/null +++ b/changelog.d/eitc-passive-earned-overlap-farm-rent.fixed.md @@ -0,0 +1 @@ +The EITC investment income test now includes farm rental income and losses in the passive basket, and leaves out passive partnership and S corporation income or loss that is also earned income (26 U.S.C. 32(i)(2)(E)), entered through the new eitc_passive_income_also_in_earned_income input. diff --git a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py index 2e1d60925d7..61f2202b0be 100644 --- a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py +++ b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py @@ -1,4 +1,4 @@ -"""Invariants for dependents' income in the filer's EITC investment income test. +"""Invariants for the EITC investment income test. 26 USC 32(i)(1) denies the credit when the "disqualified income of the taxpayer" exceeds the limit. A tax unit dependent's interest, dividends, @@ -6,7 +6,10 @@ Publication 596 Worksheet 1 picks up a child's interest and dividends only through a Form 8814 election, which the model does not implement. So `eitc_relevant_investment_income` counts the head and spouse only, as -`irs_gross_income` does for AGI. +`irs_gross_income` does for AGI. 32(i)(2)(E) determines passive income and +losses without regard to amounts included in earned income, so the passive +basket removes the signed `eitc_passive_income_also_in_earned_income`, and it +includes farm rental income, which Worksheet 1 lines 11 and 12 name. Hypothesis draws batches of tax units (single, head of household with dependents, joint with and without dependents), and a seeded population of 200 @@ -14,7 +17,8 @@ or more, where float32 rounding of a tax unit total would show. Each batch runs as one vectorized simulation three times: as drawn; with the dependents' investment income inputs set to zero; and with $1,000 more of the head's -interest. For every tax unit: +interest and different partnership self-employment earnings for the head and +spouse. For every tax unit: 1. The dependents' investment income, of any size or sign, never changes the filer's `eitc_relevant_investment_income`, `eitc_investment_income_eligible`, @@ -22,16 +26,19 @@ 2. Differential: `eitc_relevant_investment_income` equals an independent numpy computation of Worksheet 1 over the head and spouse: interest, tax-exempt interest and dividends, plus their Schedule D gain with capital gain - distributions floored at zero (line 7), plus their net rental and passive - income floored at zero (line 13). + distributions floored at zero (line 7), plus their net rental, farm rental + and passive income less the signed part also in earned income, floored at + zero (line 13). 3. Bounds: it is at least the filers' interest and dividends (drawn nonnegative), so never negative, and $1,000 more of the head's interest - raises it by exactly $1,000. + raises it by exactly $1,000 even though the filers' partnership + self-employment earnings change too: only the explicit overlap input moves + passive income out of the basket. 4. For tax units without dependents and without a negative distributions - input, it equals the previous all-member formula, so leaving dependents out - changes nothing for them. (With filer gains in the drawn range. Past about - $16.8 million the previous formula's float32 tax unit total could be off by - a few dollars, which the direct sum corrects.) + input, it equals the same worksheet summed over every member, so leaving + dependents out changes nothing for them. (With filer gains in the drawn + range. Past about $16.8 million the previous formula's float32 tax unit + total could be off by a few dollars, which the direct sum corrects.) The formula reads the head's and spouse's person-level gains, never the tax unit's `net_capital_gains`; a YAML case pins that a supplied tax unit amount is @@ -57,7 +64,13 @@ DISTRIBUTIONS = "non_sch_d_capital_gains" # passive_partnership_s_corp_income is the passive subset of # partnership_s_corp_income; both are drawn together below. -PASSIVE_INPUTS = ["rental_income", "passive_partnership_s_corp_income"] +PASSIVE_INPUTS = [ + "rental_income", + "farm_rent_income", + "passive_partnership_s_corp_income", +] +OVERLAP = "eitc_passive_income_also_in_earned_income" +SE_EARNINGS = "partnership_self_employment_net_earnings" # The filer's amounts, which a dependent's income must not change. FILER_OUTPUTS = [ "eitc_relevant_investment_income", @@ -68,8 +81,10 @@ ] # Sums every member, for the previous formula in property 4. OUTPUTS = FILER_OUTPUTS + ["net_capital_gains"] -# More of the head's interest, in the third run. +# More of the head's interest, and different partnership self-employment +# earnings for the head and spouse, in the third run. INTEREST_SHIFT = 1_000.0 +SE_EARNINGS_SHIFT = 5_000.0 nonnegative = st.one_of(st.just(0.0), st.integers(1, 8_000).map(float)) signed = st.one_of(st.just(0.0), st.integers(-12_000, 12_000).map(float)) @@ -89,8 +104,20 @@ def investment_amounts(draw, dependent=False): # the model floors each person's amount at zero. DISTRIBUTIONS: draw(st.one_of(nonnegative, st.integers(-3_000, -1).map(float))), "rental_income": draw(signed), + "farm_rent_income": draw(signed), "partnership_s_corp_income": passive, "passive_partnership_s_corp_income": passive, + # None, all, half or an unrelated signed amount of the passive share + # is also earned income; the formula removes exactly this amount. + OVERLAP: draw( + st.one_of( + st.just(0.0), + st.just(passive), + st.just(float(round(passive / 2))), + signed, + ) + ), + SE_EARNINGS: draw(signed), } @@ -126,13 +153,22 @@ def capital(): return some(-12_000, 12_000) passive = some(-12_000, 12_000) + overlap = rng.choice(["none", "all", "half", "other"]) return { **{name: some(1, 8_000) for name in PORTFOLIO_INPUTS}, **{name: capital() for name in CAPITAL_INPUTS}, DISTRIBUTIONS: some(-3_000, 8_000), "rental_income": some(-12_000, 12_000), + "farm_rent_income": some(-12_000, 12_000, 0.3), "partnership_s_corp_income": passive, "passive_partnership_s_corp_income": passive, + OVERLAP: { + "none": 0.0, + "all": passive, + "half": float(round(passive / 2)), + "other": some(-12_000, 12_000), + }[overlap], + SE_EARNINGS: some(-12_000, 12_000), } @@ -161,7 +197,7 @@ def _seeded_units(n=200): return units -def _situation(units, year, *, zero_dependents=False, interest_shift=0.0): +def _situation(units, year, *, zero_dependents=False, shift=False): people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} for i, u in enumerate(units): head = f"head_{i}" @@ -171,10 +207,10 @@ def _situation(units, year, *, zero_dependents=False, interest_shift=0.0): "is_tax_unit_dependent": False, "employment_income": u["head_wages"], **u["head"], - "taxable_interest_income": ( - u["head"]["taxable_interest_income"] + interest_shift - ), } + if shift: + people[head]["taxable_interest_income"] += INTEREST_SHIFT + people[head][SE_EARNINGS] += SE_EARNINGS_SHIFT members, couple = [head], [head] if u["spouse"] is not None: spouse = f"spouse_{i}" @@ -184,6 +220,8 @@ def _situation(units, year, *, zero_dependents=False, interest_shift=0.0): "is_tax_unit_dependent": False, **u["spouse"], } + if shift: + people[spouse][SE_EARNINGS] -= SE_EARNINGS_SHIFT members.append(spouse) couple.append(spouse) groups["marital_units"][f"couple_{i}"] = {"members": couple} @@ -247,7 +285,7 @@ def _unit_sum(run, values): def _check(units, year): run = _run(units, year) zeroed = _run(units, year, zero_dependents=True) - shifted = _run(units, year, interest_shift=INTEREST_SHIFT) + shifted = _run(units, year, shift=True) filer = ~_by_person(units, "is_dependent") assert np.array_equal(~run["is_dependent"], filer) has_dependent = _unit_sum(run, (~filer).astype(float)) > 0 @@ -267,7 +305,7 @@ def filer_sum(names): run, filer * np.maximum(0, _by_person(units, DISTRIBUTIONS)) ) capital = np.maximum(0, filer_sum(CAPITAL_INPUTS) + distributions) - passive = np.maximum(0, filer_sum(PASSIVE_INPUTS)) + passive = np.maximum(0, filer_sum(PASSIVE_INPUTS) - filer_sum([OVERLAP])) expected = portfolio + capital + passive investment_income = run["eitc_relevant_investment_income"] np.testing.assert_allclose(investment_income, expected, atol=TOLERANCE) @@ -277,7 +315,8 @@ def filer_sum(names): ) assert not (run["eitc"][expected > run["limit"]] > 0).any() - # 3. Bounds and an exact shift in the filer's interest. + # 3. Bounds, and an exact shift in the head's interest while the filers' + # partnership self-employment earnings change too. assert (investment_income >= portfolio - TOLERANCE).all() assert (investment_income >= -TOLERANCE).all() np.testing.assert_allclose( @@ -286,8 +325,8 @@ def filer_sum(names): atol=TOLERANCE, ) - # 4. No dependents and no negative distributions input: the previous - # all-member formula, unchanged. + # 4. No dependents and no negative distributions input: the same worksheet + # summed over every member, unchanged. no_negative_distributions = ( _unit_sum(run, (_by_person(units, DISTRIBUTIONS) < 0).astype(float)) == 0 ) @@ -299,7 +338,12 @@ def filer_sum(names): run["net_capital_gains"] + _unit_sum(run, _by_person(units, DISTRIBUTIONS)), ) + np.maximum( - 0, _unit_sum(run, sum(_by_person(units, n) for n in PASSIVE_INPUTS)) + 0, + _unit_sum( + run, + sum(_by_person(units, n) for n in PASSIVE_INPUTS) + - _by_person(units, OVERLAP), + ), ) ) np.testing.assert_allclose( diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml index 5499593bd30..0370b1b52c7 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml @@ -354,3 +354,362 @@ # Before: 12,000. eitc_relevant_investment_income: 0 adjusted_gross_income: 0 + +# Passive income that is also earned income, and farm rent (#9635 items 2 +# and 3). 26 USC 32(i)(2)(E) determines passive income and losses "without +# regard to any amount included in earned income under subsection (c)(2)", and +# the Worksheet 1 instructions for lines 11 and 12 leave out "any income (or +# loss) included in your earned income". eitc_passive_income_also_in_earned_income +# is that amount, signed. Lines 11 and 12 name Schedule E line 40, net farm +# rental income or loss from Form 4835, which the model treats as passive. +# +# Unless noted, the household is a 35-year-old head of household in TX for +# 2025 with two qualifying children aged 15 and 5. 2025 amounts (Rev. Proc. +# 2024-40 section 2.06): investment income limit 11,950; maximum credit with +# two children 7,152; phase-out start 23,350; phase-out rate 21.06%. + +- name: A general partner's passive share that is also earned income is left out + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 15_000 + taxable_interest_income: 5_000 + partnership_income: 8_000 + passive_partnership_s_corp_income: 8_000 + partnership_self_employment_net_earnings: 8_000 + eitc_passive_income_also_in_earned_income: 8_000 + child1: + age: 15 + child2: + age: 5 + tax_units: + tax_unit: + members: [head, child1, child2] + households: + household: + members: [head, child1, child2] + state_code: TX + output: + # Line 1 5,000 + line 13 max(0, 8,000 - 8,000) = 5,000. + eitc_relevant_investment_income: 5_000 + eitc_investment_income_eligible: true + # Self-employment tax 8,000 x 0.9235 x 0.153 = 1,130.364, half deducted. + # AGI 15,000 + 5,000 + 8,000 - 565.182 = 27,434.818 exceeds earned income + # 22,434.818, so 7,152 - 0.2106 x (27,434.818 - 23,350) = 6,291.74. + eitc: 6_291.74 + +- name: A general partner's passive loss that is also earned income does not offset rental income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 20_000 + taxable_interest_income: 9_000 + rental_income: 4_000 + partnership_income: -4_000 + passive_partnership_s_corp_income: -4_000 + partnership_self_employment_net_earnings: -4_000 + eitc_passive_income_also_in_earned_income: -4_000 + child1: + age: 15 + child2: + age: 5 + tax_units: + tax_unit: + members: [head, child1, child2] + households: + household: + members: [head, child1, child2] + state_code: TX + output: + # 9,000 + max(0, 4,000 - 4,000 - (-4,000)) = 13,000. + eitc_relevant_investment_income: 13_000 + eitc_investment_income_eligible: false + eitc: 0 + +- name: A limited partner's passive share stays in the passive basket + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 15_000 + taxable_interest_income: 5_000 + partnership_income: 8_000 + passive_partnership_s_corp_income: 8_000 + child1: + age: 15 + child2: + age: 5 + tax_units: + tax_unit: + members: [head, child1, child2] + households: + household: + members: [head, child1, child2] + state_code: TX + output: + # 26 USC 1402(a)(13) keeps a limited partner's share out of net earnings + # from self-employment, so none of it is earned: 5,000 + 8,000 = 13,000. + eitc_relevant_investment_income: 13_000 + eitc_investment_income_eligible: false + eitc: 0 + +- name: Only the part of a passive share included in earned income is left out + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 15_000 + taxable_interest_income: 5_000 + partnership_income: 8_000 + passive_partnership_s_corp_income: 8_000 + partnership_self_employment_net_earnings: 5_000 + eitc_passive_income_also_in_earned_income: 5_000 + child1: + age: 15 + child2: + age: 5 + tax_units: + tax_unit: + members: [head, child1, child2] + households: + household: + members: [head, child1, child2] + state_code: TX + output: + # 5,000 + (8,000 - 5,000) = 8,000. + eitc_relevant_investment_income: 8_000 + eitc_investment_income_eligible: true + +- name: An unrelated self-employment loss does not change passive income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 15_000 + taxable_interest_income: 5_000 + partnership_income: 5_000 + passive_partnership_s_corp_income: 8_000 + partnership_self_employment_net_earnings: -3_000 + child1: + age: 15 + child2: + age: 5 + tax_units: + tax_unit: + members: [head, child1, child2] + households: + household: + members: [head, child1, child2] + state_code: TX + output: + # An 8,000 passive limited partnership share and a separate active + # general partnership loss of 3,000; none of the passive share is earned. + # 5,000 + 8,000 = 13,000. + eitc_relevant_investment_income: 13_000 + eitc_investment_income_eligible: false + eitc: 0 + +- name: A spouse's self-employment earnings do not change the head's passive income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 15_000 + taxable_interest_income: 5_000 + partnership_income: 8_000 + passive_partnership_s_corp_income: 8_000 + spouse: + age: 33 + partnership_income: 8_000 + partnership_self_employment_net_earnings: 8_000 + child1: + age: 15 + child2: + age: 5 + tax_units: + tax_unit: + members: [head, spouse, child1, child2] + marital_units: + couple: + members: [head, spouse] + child1_unit: + members: [child1] + child2_unit: + members: [child2] + households: + household: + members: [head, spouse, child1, child2] + state_code: TX + output: + # 5,000 + the head's 8,000 = 13,000. + eitc_relevant_investment_income: 13_000 + eitc_investment_income_eligible: false + eitc: 0 + +- name: Passive S corporation income is not offset by unrelated partnership earnings + period: 2025 + absolute_error_margin: 0.01 + input: + taxable_interest_income: 5_000 + s_corp_income: 8_000 + partnership_income: 8_000 + passive_partnership_s_corp_income: 8_000 + partnership_self_employment_net_earnings: 8_000 + output: + # The passive 8,000 is the S corporation share; the partnership's 8,000 + # of self-employment earnings is not passive. Overlap 0: 5,000 + 8,000. + eitc_relevant_investment_income: 13_000 + eitc_investment_income_eligible: false + +- name: Earned overlap can exceed net passive income + period: 2025 + absolute_error_margin: 0.01 + input: + taxable_interest_income: 9_000 + rental_income: 4_000 + partnership_income: 3_000 + passive_partnership_s_corp_income: 3_000 + partnership_self_employment_net_earnings: 8_000 + eitc_passive_income_also_in_earned_income: 8_000 + output: + # The passive 3,000 is an earned profit of 8,000 and an unrelated passive + # loss of 5,000: 9,000 + max(0, 4,000 + 3,000 - 8,000) = 9,000. + eitc_relevant_investment_income: 9_000 + eitc_investment_income_eligible: true + +- name: Removing an earned loss keeps an unrelated passive profit + period: 2025 + absolute_error_margin: 0.01 + input: + taxable_interest_income: 9_000 + partnership_income: -3_000 + passive_partnership_s_corp_income: -3_000 + partnership_self_employment_net_earnings: -8_000 + eitc_passive_income_also_in_earned_income: -8_000 + output: + # 9,000 + max(0, -3,000 - (-8,000)) = 14,000. + eitc_relevant_investment_income: 14_000 + eitc_investment_income_eligible: false + +- name: Farm rental income is passive income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 20_000 + taxable_interest_income: 8_000 + farm_rent_income: 5_000 + child1: + age: 15 + child2: + age: 5 + tax_units: + tax_unit: + members: [head, child1, child2] + households: + household: + members: [head, child1, child2] + state_code: TX + output: + # Line 11 5,000: 8,000 + 5,000 = 13,000. + eitc_relevant_investment_income: 13_000 + eitc_investment_income_eligible: false + eitc: 0 + +- name: A farm rental loss nets against rental income in the passive basket + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 20_000 + taxable_interest_income: 9_000 + rental_income: 4_000 + farm_rent_income: -4_000 + child1: + age: 15 + child2: + age: 5 + tax_units: + tax_unit: + members: [head, child1, child2] + households: + household: + members: [head, child1, child2] + state_code: TX + output: + # Lines 11 and 12 are 4,000 and -4,000, so line 13 is 0: 9,000. + eitc_relevant_investment_income: 9_000 + eitc_investment_income_eligible: true + # AGI 20,000 + 9,000 + 4,000 - 4,000 = 29,000: + # 7,152 - 0.2106 x (29,000 - 23,350) = 5,962.11. + adjusted_gross_income: 29_000 + eitc: 5_962.11 + +- name: Farm rent at the 2025 investment income limit + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 20_000 + farm_rent_income: 11_950 + child1: + age: 15 + child2: + age: 5 + tax_units: + tax_unit: + members: [head, child1, child2] + households: + household: + members: [head, child1, child2] + state_code: TX + output: + eitc_relevant_investment_income: 11_950 + eitc_investment_income_eligible: true + # 7,152 - 0.2106 x (31,950 - 23,350) = 5,340.84. + eitc: 5_340.84 + +- name: Farm rent one dollar over the 2025 investment income limit + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 20_000 + farm_rent_income: 11_951 + child1: + age: 15 + child2: + age: 5 + tax_units: + tax_unit: + members: [head, child1, child2] + households: + household: + members: [head, child1, child2] + state_code: TX + output: + eitc_relevant_investment_income: 11_951 + eitc_investment_income_eligible: false + eitc: 0 diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_passive_income_also_in_earned_income.py b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_passive_income_also_in_earned_income.py new file mode 100644 index 00000000000..fb86cc6f71f --- /dev/null +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_passive_income_also_in_earned_income.py @@ -0,0 +1,29 @@ +from policyengine_us.model_api import * + + +class eitc_passive_income_also_in_earned_income(Variable): + value_type = float + entity = Person + label = "Passive income or loss also included in EITC earned income" + unit = USD + documentation = ( + "The part of the person's passive partnership and S corporation income " + "or loss (passive_partnership_s_corp_income) that is also included in " + "earned income under 26 USC 32(c)(2). An example is a general " + "partner's distributive share from a partnership in which they do not " + "materially participate: it is passive under section 469 and net " + "earnings from self-employment under section 1402(a). Enter income as " + "a positive amount and a loss as a negative amount. 26 USC " + "32(i)(2)(E) determines passive income and losses without regard to " + "any amount included in earned income, so this amount is removed from " + "the passive basket of the EITC investment income test. It classifies " + "part of passive_partnership_s_corp_income and is not additional " + "income." + ) + definition_period = YEAR + default_value = 0 + reference = ( + "https://www.law.cornell.edu/uscode/text/26/32#i_2_E", + "https://www.irs.gov/pub/irs-prior/p596--2025.pdf#page=7", + "https://www.law.cornell.edu/uscode/text/26/1402#a", + ) diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py index b43210665ee..59ce06307f2 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py @@ -27,6 +27,7 @@ class eitc_relevant_investment_income(Variable): "https://www.irs.gov/pub/irs-prior/p596--2025.pdf#page=7", "https://www.irs.gov/pub/irs-prior/f8814--2025.pdf#page=1", "https://www.irs.gov/pub/irs-prior/i8814--2025.pdf#page=4", + "https://www.irs.gov/pub/irs-prior/f4835--2025.pdf#page=2", ) def formula(tax_unit, period, parameters): @@ -62,11 +63,26 @@ def formula(tax_unit, period, parameters): * max_(0, person("non_sch_d_capital_gains", period)) ) capital_gains = filer_gains + distributions - # The model's undifferentiated rental input is treated as passive - # rental income, consistently with its NIIT income mapping. Net the - # passive amounts across the head and spouse before applying the zero - # floor. + # Worksheet 1 lines 11-13. The model's undifferentiated rental input is + # treated as passive rental income, consistently with its NIIT income + # mapping. Farm rental income (Form 4835, Schedule E line 40, which + # lines 11 and 12 name) is treated as passive in the same way; Form + # 4835 is for rental activities under the passive activity rules, + # and the model has no input for a real estate professional's + # nonpassive farm rental. 32(i)(2)(E) determines passive income and + # losses without regard to amounts included in earned income, so the + # signed part of passive income also in earned income is removed. Net + # the passive amounts across the head and spouse before applying the + # zero floor. passive_income = tax_unit_non_dep_add( - tax_unit, period, ["rental_income", "passive_partnership_s_corp_income"] + tax_unit, + period, + [ + "rental_income", + "farm_rent_income", + "passive_partnership_s_corp_income", + ], + ) - tax_unit_non_dep_add( + tax_unit, period, ["eitc_passive_income_also_in_earned_income"] ) return portfolio_income + max_(0, capital_gains) + max_(0, passive_income) From d3f9e9d41503471745b68d4de25142aacd75c030 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 16:43:42 -0400 Subject: [PATCH 11/12] Document the overlap input's scope and farm rent passivity; test spouse and dependent overlap From the #9889 reviews: the overlap input documentation now says it is a partnership share that must also be in partnership_self_employment_net_earnings, that S corporation shares have none, that it can exceed or oppose the net passive and partnership SE amounts, and that a dependent's is not read. The investment income documentation now describes the passive basket and the real estate professional limitation. Two YAML cases cover a spouse's and a dependent's overlap, and the seeded property test asserts that farm rent and the overlap decide eligibility for some units. Co-Authored-By: Claude Opus 5.5 --- ...ndent_eitc_investment_income_invariants.py | 16 +++- .../eitc_relevant_investment_income.yaml | 73 +++++++++++++++++++ ...tc_passive_income_also_in_earned_income.py | 29 +++++--- .../eitc_relevant_investment_income.py | 8 +- 4 files changed, 112 insertions(+), 14 deletions(-) diff --git a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py index 61f2202b0be..b3d78eb832f 100644 --- a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py +++ b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py @@ -307,6 +307,17 @@ def filer_sum(names): capital = np.maximum(0, filer_sum(CAPITAL_INPUTS) + distributions) passive = np.maximum(0, filer_sum(PASSIVE_INPUTS) - filer_sum([OVERLAP])) expected = portfolio + capital + passive + # Units whose eligibility turns on farm rent or the overlap input, so the + # seeded population shows both terms are exercised. + without_new_terms = ( + portfolio + + capital + + np.maximum( + 0, + filer_sum([n for n in PASSIVE_INPUTS if n != "farm_rent_income"]), + ) + ) + new_terms_decide = (expected <= run["limit"]) != (without_new_terms <= run["limit"]) investment_income = run["eitc_relevant_investment_income"] np.testing.assert_allclose(investment_income, expected, atol=TOLERANCE) np.testing.assert_array_equal( @@ -349,7 +360,7 @@ def filer_sum(names): np.testing.assert_allclose( investment_income[unchanged], all_member[unchanged], atol=TOLERANCE ) - return has_dependent, unchanged + return has_dependent, unchanged, new_terms_decide # A batch's cost is mostly per-variable overhead, so each example is a large @@ -370,7 +381,8 @@ def test_dependent_investment_income_stays_off_the_filers_eitc_test(units): @pytest.mark.parametrize("year", [2021, 2025]) def test_seeded_population(year): - has_dependent, unchanged = _check(_seeded_units(), year) + has_dependent, unchanged, new_terms_decide = _check(_seeded_units(), year) # The population exercises both branches of each property. assert has_dependent.sum() > 50 assert unchanged.sum() > 20 + assert new_terms_decide.sum() > 5 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml index 0370b1b52c7..85ed1c36d79 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml @@ -713,3 +713,76 @@ eitc_relevant_investment_income: 11_951 eitc_investment_income_eligible: false eitc: 0 + +- name: A spouse's passive share that is also earned income is left out on a joint return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 15_000 + taxable_interest_income: 5_000 + spouse: + age: 33 + partnership_income: 8_000 + passive_partnership_s_corp_income: 8_000 + partnership_self_employment_net_earnings: 8_000 + eitc_passive_income_also_in_earned_income: 8_000 + child1: + age: 15 + child2: + age: 5 + tax_units: + tax_unit: + members: [head, spouse, child1, child2] + marital_units: + couple: + members: [head, spouse] + child1_unit: + members: [child1] + child2_unit: + members: [child2] + households: + household: + members: [head, spouse, child1, child2] + state_code: TX + output: + # The spouse's 8,000 is earned: 5,000 + max(0, 8,000 - 8,000) = 5,000. + eitc_relevant_investment_income: 5_000 + eitc_investment_income_eligible: true + +- name: A dependent's overlap input does not remove the filer's passive income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 20_000 + taxable_interest_income: 9_000 + partnership_income: 4_000 + passive_partnership_s_corp_income: 4_000 + child1: + age: 15 + is_tax_unit_dependent: true + partnership_income: 4_000 + passive_partnership_s_corp_income: 4_000 + partnership_self_employment_net_earnings: 4_000 + eitc_passive_income_also_in_earned_income: 4_000 + child2: + age: 5 + tax_units: + tax_unit: + members: [head, child1, child2] + households: + household: + members: [head, child1, child2] + state_code: TX + output: + # The head is a limited partner with 4,000 of passive income; the child's + # amounts, overlap included, are on the child's own return: + # 9,000 + 4,000 = 13,000. Counting the child's overlap would give 9,000. + eitc_relevant_investment_income: 13_000 + eitc_investment_income_eligible: false + eitc: 0 diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_passive_income_also_in_earned_income.py b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_passive_income_also_in_earned_income.py index fb86cc6f71f..783720189de 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_passive_income_also_in_earned_income.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_passive_income_also_in_earned_income.py @@ -4,19 +4,26 @@ class eitc_passive_income_also_in_earned_income(Variable): value_type = float entity = Person - label = "Passive income or loss also included in EITC earned income" + label = "Passive partnership income or loss also included in EITC earned income" unit = USD documentation = ( - "The part of the person's passive partnership and S corporation income " - "or loss (passive_partnership_s_corp_income) that is also included in " - "earned income under 26 USC 32(c)(2). An example is a general " - "partner's distributive share from a partnership in which they do not " - "materially participate: it is passive under section 469 and net " - "earnings from self-employment under section 1402(a). Enter income as " - "a positive amount and a loss as a negative amount. 26 USC " - "32(i)(2)(E) determines passive income and losses without regard to " - "any amount included in earned income, so this amount is removed from " - "the passive basket of the EITC investment income test. It classifies " + "The part of the person's passive_partnership_s_corp_income that is " + "also included in earned income under 26 USC 32(c)(2): a partnership " + "distributive share that is passive under section 469 and net earnings " + "from self-employment under section 1402(a), as for a general partner " + "who does not materially participate. The same amount must also be " + "included in partnership_self_employment_net_earnings, which is what " + "puts it in EITC earned income; nothing here checks that. An S " + "corporation share is never net earnings from self-employment, so it " + "has no such part. Enter income as a positive amount and a loss as a " + "negative amount. It is a sum over the person's activities, so it can " + "exceed the person's net passive income or net partnership " + "self-employment earnings, or have the opposite sign, when those net " + "earned and unrelated passive amounts together; do not limit it to " + "them. 26 USC 32(i)(2)(E) determines passive income and losses without " + "regard to amounts included in earned income, so the head's and " + "spouse's amounts are removed from the passive basket of the EITC " + "investment income test; a dependent's are not read. It classifies " "part of passive_partnership_s_corp_income and is not additional " "income." ) diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py index 59ce06307f2..771a6369b24 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_relevant_investment_income.py @@ -18,7 +18,13 @@ class eitc_relevant_investment_income(Variable): "are the head's and spouse's own long_term_capital_gains and " "short_term_capital_gains; a net_capital_gains amount supplied for " "the tax unit, or a capital_gains amount set directly on a person, " - "is not read." + "is not read. The passive basket (lines 11-13) nets rental income, " + "farm rental income and passive partnership and S corporation income, " + "less eitc_passive_income_also_in_earned_income, then floors the " + "result at zero. All rental and farm rental income is treated as " + "passive: the model has no input for a real estate professional's " + "nonpassive farm rental, whose loss Form 4835 says is not subject to " + "the passive activity loss limitations." ) unit = USD definition_period = YEAR From fa23f6fc807ec8b0c4919b738aca19f8c45b6356 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 17:11:19 -0400 Subject: [PATCH 12/12] Assert farm rent and the overlap each decide eligibility; pin the spouse case's credit; tidy wording From the d3f9e9d415 review: the seeded test now asserts each new term decides eligibility for some units on its own, the spouse overlap case pins its EITC (7,152, under the joint phase-out start), the changelog no longer mentions S corporations, and one sentence of the input documentation is reworded. Co-Authored-By: Claude Opus 5.5 --- ...-passive-earned-overlap-farm-rent.fixed.md | 2 +- ...ndent_eitc_investment_income_invariants.py | 23 ++++++++++++------- .../eitc_relevant_investment_income.yaml | 5 ++++ ...tc_passive_income_also_in_earned_income.py | 6 ++--- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/changelog.d/eitc-passive-earned-overlap-farm-rent.fixed.md b/changelog.d/eitc-passive-earned-overlap-farm-rent.fixed.md index c4ad1b93d62..3069917c630 100644 --- a/changelog.d/eitc-passive-earned-overlap-farm-rent.fixed.md +++ b/changelog.d/eitc-passive-earned-overlap-farm-rent.fixed.md @@ -1 +1 @@ -The EITC investment income test now includes farm rental income and losses in the passive basket, and leaves out passive partnership and S corporation income or loss that is also earned income (26 U.S.C. 32(i)(2)(E)), entered through the new eitc_passive_income_also_in_earned_income input. +The EITC investment income test now includes farm rental income and losses in the passive basket, and leaves out passive partnership income or loss that is also earned income (26 U.S.C. 32(i)(2)(E)), entered through the new eitc_passive_income_also_in_earned_income input. diff --git a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py index b3d78eb832f..ef5baf2c040 100644 --- a/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py +++ b/policyengine_us/tests/core/test_dependent_eitc_investment_income_invariants.py @@ -307,17 +307,21 @@ def filer_sum(names): capital = np.maximum(0, filer_sum(CAPITAL_INPUTS) + distributions) passive = np.maximum(0, filer_sum(PASSIVE_INPUTS) - filer_sum([OVERLAP])) expected = portfolio + capital + passive - # Units whose eligibility turns on farm rent or the overlap input, so the - # seeded population shows both terms are exercised. - without_new_terms = ( + # Units whose eligibility turns on farm rent alone, or on the overlap input + # alone, so the seeded population shows each term is exercised. + eligible = expected <= run["limit"] + without_farm_rent = ( portfolio + capital + np.maximum( 0, - filer_sum([n for n in PASSIVE_INPUTS if n != "farm_rent_income"]), + filer_sum([n for n in PASSIVE_INPUTS if n != "farm_rent_income"]) + - filer_sum([OVERLAP]), ) ) - new_terms_decide = (expected <= run["limit"]) != (without_new_terms <= run["limit"]) + without_overlap = portfolio + capital + np.maximum(0, filer_sum(PASSIVE_INPUTS)) + farm_rent_decides = eligible != (without_farm_rent <= run["limit"]) + overlap_decides = eligible != (without_overlap <= run["limit"]) investment_income = run["eitc_relevant_investment_income"] np.testing.assert_allclose(investment_income, expected, atol=TOLERANCE) np.testing.assert_array_equal( @@ -360,7 +364,7 @@ def filer_sum(names): np.testing.assert_allclose( investment_income[unchanged], all_member[unchanged], atol=TOLERANCE ) - return has_dependent, unchanged, new_terms_decide + return has_dependent, unchanged, farm_rent_decides, overlap_decides # A batch's cost is mostly per-variable overhead, so each example is a large @@ -381,8 +385,11 @@ def test_dependent_investment_income_stays_off_the_filers_eitc_test(units): @pytest.mark.parametrize("year", [2021, 2025]) def test_seeded_population(year): - has_dependent, unchanged, new_terms_decide = _check(_seeded_units(), year) + has_dependent, unchanged, farm_rent_decides, overlap_decides = _check( + _seeded_units(), year + ) # The population exercises both branches of each property. assert has_dependent.sum() > 50 assert unchanged.sum() > 20 - assert new_terms_decide.sum() > 5 + assert farm_rent_decides.sum() > 2 + assert overlap_decides.sum() > 2 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml index 85ed1c36d79..6225bc6902a 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_relevant_investment_income.yaml @@ -751,6 +751,11 @@ # The spouse's 8,000 is earned: 5,000 + max(0, 8,000 - 8,000) = 5,000. eitc_relevant_investment_income: 5_000 eitc_investment_income_eligible: true + # Self-employment tax 8,000 x 0.9235 x 0.153 = 1,130.364, half deducted. + # Earned income 22,434.818 reaches the 17,880 earned income amount, so the + # phase-in gives the 7,152 maximum; AGI 27,434.818 is under the 30,470 + # joint phase-out start, so there is no reduction. + eitc: 7_152 - name: A dependent's overlap input does not remove the filer's passive income period: 2025 diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_passive_income_also_in_earned_income.py b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_passive_income_also_in_earned_income.py index 783720189de..00c5938d42e 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_passive_income_also_in_earned_income.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_passive_income_also_in_earned_income.py @@ -18,9 +18,9 @@ class eitc_passive_income_also_in_earned_income(Variable): "has no such part. Enter income as a positive amount and a loss as a " "negative amount. It is a sum over the person's activities, so it can " "exceed the person's net passive income or net partnership " - "self-employment earnings, or have the opposite sign, when those net " - "earned and unrelated passive amounts together; do not limit it to " - "them. 26 USC 32(i)(2)(E) determines passive income and losses without " + "self-employment earnings, or have the opposite sign, because each of " + "those nets earned and unrelated passive amounts together; do not " + "limit it to them. 26 USC 32(i)(2)(E) determines passive income and losses without " "regard to amounts included in earned income, so the head's and " "spouse's amounts are removed from the passive basket of the EITC " "investment income test; a dependent's are not read. It classifies "