From a6f908541d84c018ee3ea8b06732bbc89910d373 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 08:31:04 -0400 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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"] )