diff --git a/changelog.d/tob-chapter-1-liability.fixed.md b/changelog.d/tob-chapter-1-liability.fixed.md new file mode 100644 index 00000000000..60422d7a742 --- /dev/null +++ b/changelog.d/tob-chapter-1-liability.fixed.md @@ -0,0 +1 @@ +Measure Social Security benefit-taxation trust-fund revenue using chapter 1 income tax liability after nonrefundable credits and before refundable credits, excluding net investment income tax and unreported payroll tax. diff --git a/docs/usage/social-security-oasdi-accounting.md b/docs/usage/social-security-oasdi-accounting.md index 4b3f3044242..84f562444c4 100644 --- a/docs/usage/social-security-oasdi-accounting.md +++ b/docs/usage/social-security-oasdi-accounting.md @@ -25,11 +25,16 @@ leaf reaches the sum through that leaf. The owners' draw reform's `self_employment_social_security_tax` adds `owners_draw_oasdi_tax`, so the draw's OASDI tax counts once; don't add it again. -`oasdi_benefit_taxation_revenue` is the OASDI share of income tax on benefits: -the tax with section 86 inclusion as before the 1993 amendments, less the tax -with no inclusion. The HI share, `tob_revenue_medicare_hi`, stays out. The share -carries a sign: a unit whose tax falls when its benefits enter income contributes -a negative amount, so sum units without a floor. +`oasdi_benefit_taxation_revenue` is the OASDI share of income tax on benefits. +It measures `income_tax_chapter_1`, chapter 1 liability after nonrefundable +credits and before refundable credits. The share is that liability with section +86 inclusion as before the 1993 amendments, capped at actual inclusion, less the +liability with no inclusion. At current law the cap changes nothing. When a +reform cuts inclusion below the pre-1993 amount, the 1993 amendments add +nothing, so OASDI takes the whole increase. The HI share, +`tob_revenue_medicare_hi`, stays out. The share carries a sign: a unit whose tax +falls when its benefits enter income contributes a negative amount, so sum units +without a floor. `oasdi_niit_revenue` is the OASDI transfer that the enabled S.770 section 8 options select. It is zero unless an option applies, and it counts only the @@ -52,20 +57,17 @@ for the transfer modes. income, isn't part of `oasdi_income`. A score that enables that reform adds it separately. -### Benefit taxation and the NIIT transfer overlap - -`tob_revenue_oasdi` takes the difference in `income_tax`, which includes -`net_investment_income_tax`. Including benefits raises adjusted gross income and -so `niit_magi`, so any NIIT that benefit inclusion triggers lands in the benefit -taxation term. The trust fund transfer covers only chapter 1 income tax -liabilities, and the NIIT sits in chapter 2A, so that piece doesn't belong in -the term even at baseline. With an S.770 section 8 option on, -`oasdi_niit_revenue` also transfers shares of the section 8 add-on and active -taxes, and in bill or combined-rate mode of the existing-rate NIIT. Benefit -inclusion raises those taxes too, so `oasdi_income` counts that part of the -increase in both terms. The fix belongs in `tob_revenue_oasdi`, which these -outputs read unchanged. Until it lands, treat `oasdi_benefit_taxation_revenue`, -and so `oasdi_income`, as overstated by the NIIT that benefit inclusion adds. +### Benefit taxation and the NIIT transfer + +`tob_revenue_oasdi` takes the difference in `income_tax_chapter_1`, which leaves +out `net_investment_income_tax`. Including benefits raises adjusted gross income +and so `niit_magi`, so benefit inclusion can trigger NIIT. The trust fund +transfer covers only chapter 1 income tax liabilities, and the NIIT sits in +chapter 2A, so that NIIT stays out of the benefit taxation term. With an S.770 +section 8 option on, `oasdi_niit_revenue` transfers shares of the section 8 +add-on and active taxes, and in bill or combined-rate mode of the existing-rate +NIIT. Benefit inclusion can raise those taxes too. `oasdi_income` counts the +transferred share of that increase once, in the NIIT transfer term. ## Entity mapping and weights @@ -125,9 +127,10 @@ describes the repairs. It checks the artifact's hash and base year and the four benefit components' sources. It also rejects stored columns that would bypass these formulas: the six outputs above, aggregate `social_security`, the worker and employer OASDI - tax leaves, the TOB revenue leaves, and the NIIT liability and transfer - leaves, including the S.770 section 8 and owners' draw tax and OASDI - transfer leaves. Keep the receipt with the score. + tax leaves, the TOB revenue leaves and the `income_tax_chapter_1` they + difference, and the NIIT liability and transfer leaves, including the S.770 + section 8 and owners' draw tax and OASDI transfer leaves. Keep the receipt + with the score. 2. **Score in household chunks.** On main, `tob_revenue_oasdi` recalculates income tax in two branches of the whole simulation, so its working memory diff --git a/policyengine_us/tests/code_health/test_social_security_preflight.py b/policyengine_us/tests/code_health/test_social_security_preflight.py index a982a5089d3..2cb1ccbf118 100644 --- a/policyengine_us/tests/code_health/test_social_security_preflight.py +++ b/policyengine_us/tests/code_health/test_social_security_preflight.py @@ -99,6 +99,7 @@ def test_reported_columns_are_not_migrated_again(tmp_path): "employer_social_security_tax", "self_employment_social_security_tax", "employer_total_social_security_tax", + "income_tax_chapter_1", "net_investment_income_tax", "niit_liability", "niit_revenue_oasdi", @@ -168,6 +169,7 @@ def test_missing_component_cannot_be_invented_from_aggregate(tmp_path): "social_security", "social_security_retirement", "tob_revenue_oasdi", + "income_tax_chapter_1", "social_security_payable_factor", ], ) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/income_tax_chapter_1.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/income_tax_chapter_1.yaml new file mode 100644 index 00000000000..6860c8b36f2 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/income_tax_chapter_1.yaml @@ -0,0 +1,59 @@ +- name: Chapter 1 retains its additions and excludes NIIT and unreported payroll tax + period: 2026 + absolute_error_margin: 0.01 + input: + filing_status: SINGLE + income_tax_before_credits: 20_000 + income_tax_capped_non_refundable_credits: 500 + recapture_of_investment_credit: 200 + qualified_retirement_penalty: 300 + niit_magi: 224_000 + net_investment_income: 190_000 + unreported_payroll_tax: 250 + income_tax_refundable_credits: 0 + output: + # NIIT = 3.8% * min(190,000, 224,000 - 200,000). + net_investment_income_tax: 912 + income_tax: 21_162 + # 20,000 + 200 + 300 - 500 = 21,162 - 912 - 250. + income_tax_chapter_1: 20_000 + +- name: Refundable EITC is a payment and does not reduce chapter 1 liability + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + employment_income: 10_000 + filing_status: SINGLE + state_code: TX + meets_eitc_identification_requirements: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + output: + income_tax_before_credits: 0 + income_tax_capped_non_refundable_credits: 0 + # Earnings exceed the childless phase-in cap and precede phase-out. + eitc: 664 + income_tax_refundable_credits: 664 + income_tax: -664 + income_tax_chapter_1: 0 + +- name: Abolishing federal income tax also abolishes chapter 1 liability + period: 2026 + absolute_error_margin: 0.01 + input: + gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true + filing_status: SINGLE + income_tax_before_credits: 20_000 + income_tax_capped_non_refundable_credits: 500 + recapture_of_investment_credit: 200 + qualified_retirement_penalty: 300 + niit_magi: 224_000 + net_investment_income: 190_000 + unreported_payroll_tax: 250 + output: + # NIIT remains nonzero under this switch, so it cannot simply be + # subtracted from the already abolished income tax measure. + net_investment_income_tax: 912 + income_tax_before_refundable_credits: 0 + income_tax_chapter_1: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/revenue/test_tob_revenue.py b/policyengine_us/tests/policy/baseline/gov/ssa/revenue/test_tob_revenue.py index 2022aa15a1d..c94c7fd7b84 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/revenue/test_tob_revenue.py +++ b/policyengine_us/tests/policy/baseline/gov/ssa/revenue/test_tob_revenue.py @@ -1,8 +1,14 @@ -"""Generated TOB split invariants and branch cache isolation. +"""Generated chapter 1 TOB differentials, branch cache isolation and reforms. Policy examples and independent tax calculations live in the variable YAML -files. Python is needed to quantify the split invariants over generated -inputs and to exercise calculation order on an already populated cache. +files, except the three parameter reforms that test actual inclusion's cap +on the old-law inclusion. Core's YAML runner keeps a cloned policy system +for each distinct parameter reform until its process ends, and in CI the +YAML file holding those three peaked at 8,244 MB. The last test here runs +them in turn on one private copy of the policy and restores its parameters +between them. Python is also +needed to quantify the split invariants over generated inputs and to +exercise calculation order on an already populated cache. Each generated batch combines eight income/donation draws with fixed boundary and signed-allocation witnesses. Five filing profiles cover single, joint, separate living together, separate living apart, and an @@ -12,16 +18,26 @@ expenses (including Medicare premiums) and state/local taxes are excluded. One vectorized simulation shares the expensive counterfactual setup across the batch. Joint benefits and interest are divided between spouses to -exercise tax-unit aggregation. +exercise tax-unit aggregation. Fresh, unbranched simulations independently +override taxable benefits at zero, pre-1993 and current-law inclusion. +They share unchanged policy, with separate populations, inputs and caches. +Elderly single/joint NIIT threshold crossings and working families with +children ensure that excluding NIIT and refundable credits is observable. """ +from copy import deepcopy +from functools import reduce +import gc from itertools import product import numpy as np from hypothesis import given, settings from hypothesis import strategies as st +from policyengine_core.reforms import set_parameter from policyengine_us import Simulation +from policyengine_us.spm import clone_spm_system, copy_parameter_nodes +from policyengine_us.system import system PROFILES = ( @@ -49,7 +65,15 @@ ) -def simulation(cases, age, year, force_itemization=True): +def simulation( + cases, + age, + year, + force_itemization=True, + niit_gap=None, + credit_cases=(), + payroll_tax=None, +): situation = { entity: {} for entity in ( @@ -61,14 +85,27 @@ def simulation(cases, age, year, force_itemization=True): "families", ) } - for i, (case, profile) in enumerate(product(cases, PROFILES)): + profiles = [(case, profile, age) for case, profile in product(cases, PROFILES)] + if niit_gap is not None: + for filing_status, threshold in (("SINGLE", 200_000), ("JOINT", 250_000)): + profile = (filing_status, False, False) + # No-inclusion MAGI is below the threshold; $34,000 of current + # inclusion crosses it for every generated gap. Keep a second + # elderly unit comfortably below the threshold as a control. + profiles.extend( + ( + ((40_000, threshold - niit_gap, 0, 0), profile, 70), + ((40_000, 50_000, 0, 0), profile, 70), + ) + ) + for i, (case, profile, case_age) in enumerate(profiles): benefits, interest, exempt_interest, donations = case filing_status, cohabitating, itemizes = profile joint = filing_status == "JOINT" members = [f"head{i}"] + ([f"spouse{i}"] if joint else []) for j, member in enumerate(members): situation["people"][member] = { - "age": {year: 67 if itemizes else age}, + "age": {year: 67 if itemizes else case_age}, "tax_unit_role_input": {year: "HEAD" if j == 0 else "SPOUSE"}, "social_security": {year: benefits / len(members)}, "taxable_interest_income": {year: interest / len(members)}, @@ -97,9 +134,143 @@ def simulation(cases, age, year, force_itemization=True): # These childless units do not participate in TANF. Supplying this # avoids unrelated monthly benefit calculations in tax branches. situation["spm_units"][f"unit{i}"]["takes_up_tanf_if_eligible"] = {year: False} + for i, (benefits, earnings, children) in enumerate(credit_cases): + head = f"credit_head{i}" + dependents = [f"credit_child{i}_{j}" for j in range(children)] + members = [head, *dependents] + situation["people"][head] = { + "age": {year: 40}, + "is_disabled": {year: True}, + "tax_unit_role_input": {year: "HEAD"}, + "social_security": {year: benefits}, + "employment_income": {year: earnings}, + } + for child in dependents: + situation["people"][child] = { + "age": {year: 8}, + "tax_unit_role_input": {year: "DEPENDENT"}, + } + for entity in ("tax_units", "households", "spm_units", "families"): + situation[entity][f"credit_unit{i}"] = {"members": members} + situation["tax_units"][f"credit_unit{i}"].update( + { + "filing_status": {year: "HEAD_OF_HOUSEHOLD"}, + "tax_unit_itemizes": {year: False}, + "itemized_medical_expenses": {year: 0}, + "salt_deduction": {year: 0}, + } + ) + situation["households"][f"credit_unit{i}"]["state_name"] = {year: "TX"} + situation["spm_units"][f"credit_unit{i}"]["takes_up_tanf_if_eligible"] = { + year: False + } + # A child has a separate marital unit, rather than joining the parent. + for member in members: + situation["marital_units"][member] = {"members": [member]} + if payroll_tax is not None: + # Pair every input with an otherwise identical payroll-tax case in + # the same vectorized batch. This tests all three TOB variables with + # unchanged production branch setup and separate household inputs. + for entity, records in situation.items(): + for name, inputs in tuple(records.items()): + duplicate = deepcopy(inputs) + if "members" in duplicate: + duplicate["members"] = [ + f"{member}_with_payroll" for member in duplicate["members"] + ] + if entity == "tax_units": + duplicate["unreported_payroll_tax"] = {year: payroll_tax} + records[f"{name}_with_payroll"] = duplicate return Simulation(situation=situation) +def fresh_simulation(sim, year, taxable_ss): + """Share only policy: never borrow branches or calculated arrays.""" + situation = deepcopy(sim.situation_input) + for i, tax_unit in enumerate(situation["tax_units"].values()): + tax_unit["tax_unit_taxable_social_security"] = {year: float(taxable_ss[i])} + return Simulation(tax_benefit_system=sim.tax_benefit_system, situation=situation) + + +def independent_tax_states(sim, year): + """Read tax components at explicit inclusion inputs, without TOB formulas.""" + current_ss = sim.calculate("tax_unit_taxable_social_security", year) + pre_1993_ss = sim.calculate("tax_unit_taxable_social_security_pre_1993", year) + states = [] + for inclusion in (np.zeros_like(current_ss), pre_1993_ss, current_ss): + independent = fresh_simulation(sim, year, taxable_ss=inclusion) + components = { + name: np.asarray(independent.calculate(name, year), dtype=np.float64) + for name in ( + "income_tax_chapter_1", + "income_tax_before_refundable_credits", + "income_tax", + "net_investment_income_tax", + "income_tax_refundable_credits", + "unreported_payroll_tax", + "eitc", + "refundable_ctc", + ) + } + assert not independent.branches + np.testing.assert_allclose( + components["income_tax_chapter_1"], + components["income_tax_before_refundable_credits"] + - components["net_investment_income_tax"] + - components["unreported_payroll_tax"], + rtol=0, + atol=0.01, + ) + states.append(components) + return states + + +def check_independent_differentials(sim, year, result): + zero, pre_1993, current = independent_tax_states(sim, year) + zero_payroll = zero["unreported_payroll_tax"] == 0 + for state, name in ( + (pre_1993, "tob_revenue_oasdi"), + (current, "tob_revenue_total"), + ): + delta = {component: state[component] - zero[component] for component in state} + # The oracle never runs a production TOB branch or neutralizes a + # variable. It calculates chapter 1 tax with explicit inclusion inputs. + np.testing.assert_allclose( + result[name], delta["income_tax_chapter_1"], rtol=0, atol=0.01 + ) + # Adding payroll tax before differencing float32 totals can amplify + # rounding. Use the zero-payroll partners for these corrected totals; + # every profile also has payroll invariance and all-unit chapter 1 + # differentials/component identities checked independently. + np.testing.assert_allclose( + result[name][zero_payroll], + ( + delta["income_tax_before_refundable_credits"] + - delta["net_investment_income_tax"] + )[zero_payroll], + rtol=0, + atol=0.01, + ) + np.testing.assert_allclose( + result[name][zero_payroll], + ( + delta["income_tax"] + - delta["net_investment_income_tax"] + + delta["income_tax_refundable_credits"] + )[zero_payroll], + rtol=0, + atol=0.01, + ) + # Guard against a generator that silently stops reaching the exclusions. + assert np.any( + (zero["net_investment_income_tax"] == 0) + & (current["net_investment_income_tax"] > 0) + ) + assert np.any(current["net_investment_income_tax"] == 0) + assert np.any(current["eitc"] < zero["eitc"]) + assert np.any(current["refundable_ctc"] < zero["refundable_ctc"]) + + def check_invariants(sim, year, order=REVENUE_VARIABLES): taxable_ss = sim.calculate("tax_unit_taxable_social_security", year).copy() old_law_ss = sim.calculate("tax_unit_taxable_social_security_pre_1993", year) @@ -115,7 +286,11 @@ def check_invariants(sim, year, order=REVENUE_VARIABLES): np.testing.assert_allclose(oasdi[old_law_ss == 0], 0, rtol=0, atol=tolerance) np.testing.assert_allclose(hi[old_law_ss == taxable_ss], 0, rtol=0, atol=tolerance) assert np.all(old_law_ss >= -tolerance) - assert np.all(old_law_ss <= taxable_ss + tolerance) + # OASDI caps old-law inclusion at actual inclusion. At current law the + # pre-1993 amount never exceeds actual inclusion, with no tolerance, so + # the cap returns the pre-1993 amount exactly and changes nothing. The + # independent pre-1993 differentials confirm the OASDI values. + assert np.all(old_law_ss <= taxable_ss) np.testing.assert_array_equal( sim.calculate("tax_unit_taxable_social_security", year), taxable_ss ) @@ -131,7 +306,7 @@ def check_invariants(sim, year, order=REVENUE_VARIABLES): return result -# Each generated draw exercises 65 tax units in three tax counterfactuals. +# Each generated draw exercises 73 pairs of tax units in three counterfactuals. # Batching increases generated household coverage while reducing full-model # branch clones; each draw still owns an independent simulation and caches. @settings(max_examples=2, derandomize=True, deadline=None) @@ -147,13 +322,38 @@ def check_invariants(sim, year, order=REVENUE_VARIABLES): max_size=8, ), age=st.sampled_from([60, 70]), + niit_gap=st.integers(min_value=1, max_value=30_000), + credit_cases=st.lists( + st.tuples( + st.integers(min_value=10_000, max_value=40_000), + st.integers(min_value=25_000, max_value=40_000), + st.integers(min_value=1, max_value=3), + ), + min_size=2, + max_size=2, + ), + payroll_tax=st.integers(min_value=1, max_value=25_000), ) -def test_generated_tob_split_invariants(cases, age): +def test_generated_tob_split_invariants( + cases, age, niit_gap, credit_cases, payroll_tax +): # The elderly-credit witnesses remain taxable in 2024. The 2026 senior # deduction can eliminate their tax; that year's counterfactual/cache # behavior is covered independently by the test below. year = 2024 - result = check_invariants(simulation([*cases, *FIXED_CASES], age, year), year) + sim = simulation( + [*cases, *FIXED_CASES], + age, + year, + niit_gap=niit_gap, + credit_cases=[*credit_cases, (40_000, 30_000, 1), (20_000, 30_000, 2)], + payroll_tax=payroll_tax, + ) + result = check_invariants(sim, year) + for name in REVENUE_VARIABLES: + without_payroll, with_payroll = np.split(result[name], 2) + np.testing.assert_array_equal(with_payroll, without_payroll) + check_independent_differentials(sim, year, result) # Verify that the population actually exercises both kinds of signed # allocation rather than passing only on non-negative households. assert np.any(result["tob_revenue_oasdi"] < 0) @@ -193,3 +393,212 @@ def test_tob_split_preserves_inputs_and_cached_current_law_tax_in_either_order() np.testing.assert_array_equal(reverse[name], first[name]) for name in names: np.testing.assert_array_equal(sim.calculate(name, year), original[name]) + + +# Reform scoring: actual inclusion caps the old-law inclusion in the +# OASDI/HI split (ruling us:tob-oasdi-old-law-bound). +# +# Every case reuses the 2026 single retiree from tob_revenue_oasdi.yaml: +# age 70, $20,000 benefits and $30,000 taxable interest. At current law that +# unit includes $9,600 against a $7,500 pre-1993 amount, with OASDI $769, +# HI $252 and total $1,021. The pre-1993 amount reads its own +# gov.ssa.revenue.pre_1993 parameters, so it stays $7,500 under each reform +# below. +# +# Deductions are $16,100 + $2,050 + $6,000 = $24,150 in every counterfactual, +# and taxable income is $30,000 + inclusion - $24,150. Tax is 10% up to +# $12,400, then 12%. Tax with no inclusion is 10% * $5,850 = $585. Nontaxable +# benefits stay above the $5,000 elderly-credit base, or AGI phases the +# credit out, so the credit is zero in every counterfactual. +# +# OASDI + HI = total in each case. +REFORM_YEAR = 2026 +RETIREE = { + "people": { + "retiree": { + "age": {REFORM_YEAR: 70}, + "social_security": {REFORM_YEAR: 20_000}, + "taxable_interest_income": {REFORM_YEAR: 30_000}, + } + }, + "tax_units": { + "tax_unit": { + "members": ["retiree"], + "filing_status": {REFORM_YEAR: "SINGLE"}, + } + }, + "spm_units": { + "spm_unit": { + "members": ["retiree"], + "takes_up_tanf_if_eligible": {REFORM_YEAR: False}, + } + }, + "households": {"household": {"members": ["retiree"]}}, +} +# Every parameter leaf that any case below reforms. +REFORMED_LEAVES = ( + "gov.irs.social_security.taxability.rate.base.benefit_cap", + "gov.irs.social_security.taxability.rate.base.excess", + "gov.irs.social_security.taxability.rate.additional.benefit_cap", + "gov.irs.social_security.taxability.rate.additional.bracket", + "gov.irs.social_security.taxability.rate.additional.excess", + "gov.irs.social_security.taxability.threshold.base.main.SINGLE", + "gov.irs.social_security.taxability.threshold.adjusted_base.main.SINGLE", + "gov.irs.social_security.taxability.combined_income_ss_fraction", +) +# Each case: its name, its parameter inputs and its expected outputs. +REFORM_CASES = ( + ( + "Repealing benefit taxation leaves OASDI, HI and the total at zero", + { + "gov.irs.social_security.taxability.rate.base.benefit_cap": 0, + "gov.irs.social_security.taxability.rate.base.excess": 0, + "gov.irs.social_security.taxability.rate.additional.benefit_cap": 0, + "gov.irs.social_security.taxability.rate.additional.bracket": 0, + "gov.irs.social_security.taxability.rate.additional.excess": 0, + }, + { + "tax_unit_combined_income_for_social_security_taxability": 40_000, + # Old law: min(50% * $20,000, 50% * ($40,000 - $25,000)) = $7,500. + "tax_unit_taxable_social_security_pre_1993": 7_500, + # Every section 86 rate is zero, so the unit includes nothing. + "tax_unit_taxable_social_security": 0, + # The cap gives old-law inclusion min($7,500, $0) = $0, so all three + # branches tax $0 of benefits. Without the cap OASDI would be + # $1,354 - $585 = $769 and HI -$769 on a $0 total. + "tob_revenue_oasdi": 0, + "tob_revenue_medicare_hi": 0, + "tob_revenue_total": 0, + }, + ), + ( + "Raised thresholds put inclusion below the old-law amount, so OASDI " + "takes it all", + { + "gov.irs.social_security.taxability.threshold.base.main.SINGLE": 30_000, + "gov.irs.social_security.taxability.threshold.adjusted_base.main.SINGLE": 44_000, + }, + { + "tax_unit_combined_income_for_social_security_taxability": 40_000, + "tax_unit_taxable_social_security_pre_1993": 7_500, + # Provisional income of $40,000 falls between the $30,000 base and + # the $44,000 adjusted base, so only the first tier applies: + # min(50% * $20,000, 50% * ($40,000 - $30,000)) = $5,000. + # That is positive and below the $7,500 old-law amount. + "tax_unit_taxable_social_security": 5_000, + # Old-law inclusion = min($7,500, $5,000) = $5,000, the actual + # inclusion. Taxable income with $5,000 included = + # $35,000 - $24,150 = $10,850, all in the 10% bracket, so + # tax = $1,085. + # Total = OASDI = $1,085 - $585 = $500; HI = $500 - $500 = $0. + # Without the cap OASDI would be $1,354 - $585 = $769 and HI -$269. + "tob_revenue_oasdi": 500, + "tob_revenue_medicare_hi": 0, + "tob_revenue_total": 500, + }, + ), + ( + "A reform that raises inclusion keeps the pre-1993 OASDI share and " + "gives HI the rest", + # The same override as tax_unit_taxable_social_security_pre_1993.yaml. + {"gov.irs.social_security.taxability.combined_income_ss_fraction": 0.85}, + { + # Current-law provisional income = $30,000 + 85% * $20,000 + # = $47,000. + "tax_unit_combined_income_for_social_security_taxability": 47_000, + "tax_unit_taxable_social_security_pre_1993": 7_500, + # min(85% * ($47,000 - $34,000) + min($10,000, $4,500), + # 85% * $20,000) = min($11,050 + $4,500, $17,000) = $15,550, + # above the $9,600 at current law. + "tax_unit_taxable_social_security": 15_550, + # Old-law inclusion = min($7,500, $15,550) = $7,500, as at current + # law. Taxable income $13,350 / $21,400 with old-law / reform + # inclusion; tax $1,240 + 12% * $950 = $1,354 and + # $1,240 + 12% * $9,000 = $2,320. + # OASDI = $1,354 - $585 = $769, its current-law value. + # HI = $2,320 - $1,354 = $966; total = $2,320 - $585 = $1,735. + "tob_revenue_oasdi": 769, + "tob_revenue_medicare_hi": 966, + "tob_revenue_total": 1_735, + }, + ), +) + + +def parameter_history(parameter): + return tuple((entry.instant_str, entry.value) for entry in parameter.values_list) + + +def test_reforms_cap_old_law_inclusion_at_actual_inclusion(): + """Run each reform case in turn on one private copy of the shared policy. + + As YAML, each case's parameter inputs built and kept one more cloned + system. Here every case shares one private copy: it starts from current + law on all eight reformed leaves and applies its inputs exactly as Core's + YAML runner does. The shared system's leaves must be unchanged afterwards, + whether or not a case failed. + """ + instant = f"{REFORM_YEAR}-01-01" + shared = {path: system.parameters.get_child(path) for path in REFORMED_LEAVES} + shared_lists = {path: leaf.values_list for path, leaf in shared.items()} + shared_histories = {path: parameter_history(leaf) for path, leaf in shared.items()} + current_law = {path: leaf(instant) for path, leaf in shared.items()} + # The copy owns every node and each leaf's list of dated values, and + # borrows the entries in those lists rather than rebuilding them as a + # full clone does. Parameter.update replaces a leaf's list and never + # edits an entry, so no write here reaches the shared system. + policy = clone_spm_system( + system, parameters=copy_parameter_nodes(system.parameters) + ) + try: + leaves = {path: policy.parameters.get_child(path) for path in REFORMED_LEAVES} + for path, leaf in leaves.items(): + assert leaf is not shared[path] + assert leaf.values_list is not shared_lists[path] + assert parameter_history(leaf) == shared_histories[path] + # The copy's original lists hold current law, so they restore it. + current_law_lists = {path: leaf.values_list for path, leaf in leaves.items()} + for name, reform, expected in REFORM_CASES: + for path, leaf in leaves.items(): + leaf.values_list = list(current_law_lists[path]) + # Assigning the list skips the cache clearing update does. + leaf.parent.clear_parent_cache() + for path, value in reform.items(): + # The modifier Core's YAML runner applies for a parameter input. + set_parameter(path, value, period="year:2000:40", return_modifier=True)( + policy.parameters + ) + # Updates clear each node's cache from the leaf up to the root, but + # not the system's own cache of parameters at each instant. + policy._parameters_at_instant_cache.clear() + # A new simulation of the same situation, built after the reform, + # holds no calculated value from an earlier case. + simulation = Simulation( + tax_benefit_system=policy, situation=deepcopy(RETIREE) + ) + try: + parameters = simulation.tax_benefit_system.parameters(instant) + for path in REFORMED_LEAVES: + assert reduce(getattr, path.split("."), parameters) == ( + reform.get(path, current_law[path]) + ), f"{name}: {path}" + for variable, value in expected.items(): + np.testing.assert_allclose( + simulation.calculate(variable, REFORM_YEAR), + value, + rtol=0, + atol=0.01, + err_msg=f"{name}: {variable}", + ) + finally: + # Free this case's simulation and the branch systems its TOB + # formulas discarded. + del simulation + gc.collect() + finally: + del policy + gc.collect() + for path, leaf in shared.items(): + assert system.parameters.get_child(path) is leaf + assert leaf.values_list is shared_lists[path] + assert parameter_history(leaf) == shared_histories[path] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_revenue_total.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_revenue_total.yaml index 7e64fe9758f..e26c7e4dc29 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_revenue_total.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_revenue_total.yaml @@ -23,3 +23,148 @@ # Tax without SS = $1,160 + 12% * $21,850 = $3,782. # Tax with $25,500 taxable SS = $1,160 + $4,266 + 22% * $11,800 = $8,022. tob_revenue_total: 4_240 + +- name: Chapter 1 TOB excludes benefit-triggered net investment income tax + period: 2026 + absolute_error_margin: 0.01 + input: + people: + retiree: + age: 70 + social_security: 40_000 + taxable_interest_income: 190_000 + tax_units: + tax_unit: + members: [retiree] + filing_status: SINGLE + tax_unit_itemizes: false + spm_units: + spm_unit: + members: [retiree] + takes_up_tanf_if_eligible: false + households: + household: + members: [retiree] + state_name: TX + output: + tax_unit_taxable_social_security_pre_1993: 20_000 + tax_unit_taxable_social_security: 34_000 + # With zero / pre-1993 / current inclusion: + # AGI = $190,000 / $210,000 / $224,000; deduction = $18,150. + # Chapter 1 tax = $33,842 / $38,642 / $42,328. + # NIIT = 3.8% * max(AGI - $200,000, 0) = $0 / $380 / $912. + # No refundable credits. The former income_tax differences were + # $5,180 OASDI, $4,218 HI and $9,398 total, including $912 NIIT. + income_tax_refundable_credits: 0 + net_investment_income_tax: 912 + income_tax_chapter_1: 42_328 + income_tax: 43_240 + tob_revenue_oasdi: 4_800 + tob_revenue_medicare_hi: 3_686 + tob_revenue_total: 8_486 + +- name: Chapter 1 TOB excludes the EITC loss from benefit inclusion + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_disabled: true + tax_unit_role_input: HEAD + employment_income: 35_000 + social_security_disability: 20_000 + child: + age: 17 + tax_unit_role_input: DEPENDENT + tax_units: + tax_unit: + members: [parent, child] + filing_status: HEAD_OF_HOUSEHOLD + tax_unit_itemizes: false + marital_units: + parent_unit: + members: [parent] + child_unit: + members: [child] + spm_units: + spm_unit: + members: [parent, child] + takes_up_tanf_if_eligible: false + households: + household: + members: [parent, child] + state_name: TX + output: + # Provisional income = $45,000; inclusions = $0 / $10,000 / $13,850. + # HOH deduction = $21,900; tax before credits = $1,310 / $2,441 / $2,903. + # The 17-year-old receives a $500 other-dependent credit, not ACTC. + # Chapter 1 liability = $810 / $1,941 / $2,403. + # EITC = $4,213 - 15.98% * (AGI - $22,720): + # $2,250.656 / $652.656 / $37.426. + # Total = $2,403 - $810 = $1,593, whereas the old income_tax + # differential was $1,593 + ($2,250.656 - $37.426) = $3,806.23. + tax_unit_taxable_social_security_pre_1993: 10_000 + tax_unit_taxable_social_security: 13_850 + income_tax_before_credits: 2_903 + income_tax_capped_non_refundable_credits: 500 + income_tax_chapter_1: 2_403 + eitc: 37.426 + refundable_ctc: 0 + income_tax: 2_365.574 + tob_revenue_oasdi: 1_131 + tob_revenue_medicare_hi: 462 + tob_revenue_total: 1_593 + +- name: Chapter 1 TOB excludes an ACTC shift into nonrefundable CTC + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_disabled: true + tax_unit_role_input: HEAD + employment_income: 20_000 + social_security_disability: 20_000 + tax_exempt_interest_income: 12_000 + child: + age: 8 + tax_unit_role_input: DEPENDENT + tax_units: + tax_unit: + members: [parent, child] + filing_status: HEAD_OF_HOUSEHOLD + tax_unit_itemizes: false + marital_units: + parent_unit: + members: [parent] + child_unit: + members: [child] + spm_units: + spm_unit: + members: [parent, child] + takes_up_tanf_if_eligible: false + households: + household: + members: [parent, child] + state_name: TX + output: + # Provisional income = $42,000; inclusions = $0 / $8,500 / $11,300. + # HOH deduction = $21,900; tax before credits = $0 / $660 / $940. + # CTC offsets all of that tax; chapter 1 liability is always zero. + # ACTC = min($1,700, $2,000 - tax) = $1,700 / $1,340 / $1,060. + # EITC is zero: $12,000 investment income exceeds its $11,600 limit. + # Total = $0, whereas the old income_tax differential was + # -$1,060 - (-$1,700) = $640 ($360 OASDI and $280 HI). + tax_unit_taxable_social_security_pre_1993: 8_500 + tax_unit_taxable_social_security: 11_300 + income_tax_before_credits: 940 + income_tax_capped_non_refundable_credits: 940 + income_tax_chapter_1: 0 + refundable_ctc: 1_060 + eitc: 0 + income_tax: -1_060 + tob_revenue_oasdi: 0 + tob_revenue_medicare_hi: 0 + tob_revenue_total: 0 diff --git a/policyengine_us/tools/social_security_preflight.py b/policyengine_us/tools/social_security_preflight.py index 361318c84df..77aee68b5c4 100644 --- a/policyengine_us/tools/social_security_preflight.py +++ b/policyengine_us/tools/social_security_preflight.py @@ -50,6 +50,9 @@ "employer_social_security_tax", "self_employment_social_security_tax", "employer_total_social_security_tax", + # The TOB leaves difference it, so a stored value freezes every tax + # state they measure and zeroes TOB. + "income_tax_chapter_1", "net_investment_income_tax", "niit", "oasdi_payroll_tax_revenue", diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/income_tax_chapter_1.py b/policyengine_us/variables/gov/irs/tax/federal_income/income_tax_chapter_1.py new file mode 100644 index 00000000000..237030d0c45 --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/income_tax_chapter_1.py @@ -0,0 +1,43 @@ +from policyengine_us.model_api import * + + +class income_tax_chapter_1(Variable): + value_type = float + entity = TaxUnit + definition_period = YEAR + unit = USD + label = "Chapter 1 federal income tax after nonrefundable credits" + documentation = ( + "Federal income tax liability under chapter 1 after nonrefundable " + "credits and before refundable credits, including investment credit " + "recapture and the additional tax on early retirement distributions. " + "Excludes chapter 2A net investment income tax and chapter 21 " + "unreported payroll tax. Refundable credits are payments applied to " + "the liability, not reductions of the liability." + ) + reference = ( + "https://www.law.cornell.edu/uscode/text/26/1", + "https://www.law.cornell.edu/uscode/text/26/1411", + "https://www.law.cornell.edu/uscode/text/26/6401#b", + "https://www.law.cornell.edu/uscode/text/42/401", + ) + + def formula(tax_unit, period, parameters): + if parameters( + period + ).gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: + return 0 + + added_components = add( + tax_unit, + period, + [ + "income_tax_before_credits", + "recapture_of_investment_credit", + "qualified_retirement_penalty", + ], + ) + nonrefundable_credits = tax_unit( + "income_tax_capped_non_refundable_credits", period + ) + return added_components - nonrefundable_credits diff --git a/policyengine_us/variables/gov/ssa/oasdi/oasdi_benefit_taxation_revenue.py b/policyengine_us/variables/gov/ssa/oasdi/oasdi_benefit_taxation_revenue.py index f3aff396fbc..7d7fc480fae 100644 --- a/policyengine_us/variables/gov/ssa/oasdi/oasdi_benefit_taxation_revenue.py +++ b/policyengine_us/variables/gov/ssa/oasdi/oasdi_benefit_taxation_revenue.py @@ -7,12 +7,12 @@ class oasdi_benefit_taxation_revenue(Variable): definition_period = YEAR label = "OASDI modeled benefit taxation liability" documentation = ( - "The OASDI share of income tax on Social Security benefits: " + "The OASDI share of chapter 1 income tax on Social Security benefits: " "tob_revenue_oasdi, the tax attributable to section 86 inclusion " - "before the 1993 amendments. It leaves out the HI share " - "(tob_revenue_medicare_hi). The share carries a sign: a unit whose tax " - "falls when its benefits enter income contributes a negative amount, " - "so sum units without a floor." + "before the 1993 amendments, capped at actual inclusion. It leaves " + "out the HI share (tob_revenue_medicare_hi). The share carries a " + "sign: a unit whose tax falls when its benefits enter income " + "contributes a negative amount, so sum units without a floor." ) unit = USD reference = "https://www.ssa.gov/OP_Home/comp2/F098-021.html" diff --git a/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py b/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py index 3fb3165e718..c5614f21081 100644 --- a/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py +++ b/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py @@ -12,9 +12,10 @@ class oasdi_income(Variable): "oasdi_niit_revenue, each counted once. Excludes reserve interest, " "Railroad Retirement interchange and general-fund reimbursements, " "which the model does not represent. tob_revenue_oasdi differences " - "income tax including the net investment income tax, so it carries " - "the NIIT that benefit inclusion adds; with an S.770 section 8 option " - "on, oasdi_niit_revenue's transfer also counts part of that increase." + "chapter 1 income tax, which excludes the chapter 2A net investment " + "income tax. NIIT that benefit inclusion adds enters this sum only " + "through the share oasdi_niit_revenue transfers when an S.770 section " + "8 option is on." ) unit = USD diff --git a/policyengine_us/variables/gov/ssa/revenue/tob_revenue_medicare_hi.py b/policyengine_us/variables/gov/ssa/revenue/tob_revenue_medicare_hi.py index 7262c2b540e..74372fdab5d 100644 --- a/policyengine_us/variables/gov/ssa/revenue/tob_revenue_medicare_hi.py +++ b/policyengine_us/variables/gov/ssa/revenue/tob_revenue_medicare_hi.py @@ -9,13 +9,23 @@ class tob_revenue_medicare_hi(Variable): documentation = ( "Tax revenue credited to Medicare HI under section 121(e)(1)(B), as " "amended by OBRA 1993 section 13215(c). HI receives the increase in " - "tax liability attributable to the 1993 amendments: total taxation-of-" + "chapter 1 income tax liability after nonrefundable credits and " + "before refundable credits attributable to the 1993 amendments: " + "total taxation-of-" "benefits revenue less the pre-1993 OASDI allocation. Both amounts " - "use branches to calculate signed marginal income tax liabilities, " - "retaining negative unit differences for aggregation." + "exclude chapter 2A net investment income tax under section 1411 and " + "use branches to calculate signed marginal chapter 1 liabilities, " + "retaining negative unit differences for aggregation. The OASDI " + "allocation caps old-law inclusion at actual inclusion, so HI gets " + "nothing from a unit whose inclusion does not exceed the pre-1993 " + "amount." ) unit = USD - reference = "https://www.ssa.gov/OP_Home/comp2/F098-021.html" + reference = ( + "https://www.law.cornell.edu/uscode/text/42/401", + "https://www.law.cornell.edu/uscode/text/26/1411", + "https://www.ssa.gov/OP_Home/comp2/F098-021.html", + ) def formula(tax_unit, period, parameters): total = tax_unit("tob_revenue_total", period) diff --git a/policyengine_us/variables/gov/ssa/revenue/tob_revenue_oasdi.py b/policyengine_us/variables/gov/ssa/revenue/tob_revenue_oasdi.py index d4f6cd4692b..db8df732912 100644 --- a/policyengine_us/variables/gov/ssa/revenue/tob_revenue_oasdi.py +++ b/policyengine_us/variables/gov/ssa/revenue/tob_revenue_oasdi.py @@ -7,15 +7,24 @@ class tob_revenue_oasdi(Variable): definition_period = YEAR label = "OASDI trust fund revenue from SS benefit taxation" documentation = ( - "Tax revenue credited to OASDI trust funds per 42 U.S.C. 401 note. " + "Increase in chapter 1 income tax liability after nonrefundable credits and " + "before refundable credits credited to OASDI trust funds per " + "42 U.S.C. 401 note. Excludes chapter 2A net investment income tax " + "under section 1411. " "Per Section 121(e) of PL 98-21, OASDI receives the 'increase in tax " "liabilities attributable to' section 86 LESS the 1993 amendments. " "Calculated using branching to isolate the signed marginal tax " "impact. Negative unit differences are retained when aggregating " - "tax liabilities." + "tax liabilities. Actual inclusion caps the old-law inclusion. When " + "a reform cuts inclusion below the pre-1993 amount, the 1993 " + "amendments add nothing, and OASDI takes the whole increase." ) unit = USD - reference = "https://www.ssa.gov/OP_Home/comp2/F098-021.html" + reference = ( + "https://www.law.cornell.edu/uscode/text/42/401", + "https://www.law.cornell.edu/uscode/text/26/1411", + "https://www.ssa.gov/OP_Home/comp2/F098-021.html", + ) def formula(tax_unit, period, parameters): """ @@ -23,15 +32,23 @@ def formula(tax_unit, period, parameters): OASDI gets the tax attributable to the pre-1993 section 86 inclusion, including its provisional-income limit, under current income tax rules. + Actual inclusion caps the old-law inclusion. At current law the cap + changes nothing, because both laws share the first tier. When a reform + cuts inclusion below the pre-1993 amount, the 1993 amendments add + nothing, so OASDI takes the whole increase and HI none. Method: 1. Calculate tax with NO taxable SS - 2. Calculate tax with the pre-1993 section 86 includible amount + 2. Calculate tax with the lesser of the pre-1993 section 86 includible + amount and actual inclusion 3. Signed OASDI revenue = (2) - (1), without a per-unit floor """ sim = tax_unit.simulation - old_law_taxable_ss = tax_unit( - "tax_unit_taxable_social_security_pre_1993", period + # Read actual inclusion here, before any branch neutralizes it. + taxable_ss = tax_unit("tax_unit_taxable_social_security", period) + old_law_taxable_ss = min_( + tax_unit("tax_unit_taxable_social_security_pre_1993", period), + taxable_ss, ) # === Branch 1: Tax with NO taxable SS === @@ -49,10 +66,10 @@ def formula(tax_unit, period, parameters): except: pass - tax_no_ss = branch_no_ss.tax_unit("income_tax", period) + tax_no_ss = branch_no_ss.tax_unit("income_tax_chapter_1", period) del sim.branches["oasdi_no_ss"] - # === Branch 2: Tax with the pre-1993 includible amount === + # === Branch 2: Tax with the capped pre-1993 includible amount === branch_old_law = sim.get_branch("oasdi_old_law", clone_system=True) # Clear only non-input cached calculations before setting new value @@ -69,7 +86,7 @@ def formula(tax_unit, period, parameters): holder = tax_unit_pop.get_holder("tax_unit_taxable_social_security") holder.set_input(period, old_law_taxable_ss) - tax_old_law_ss = branch_old_law.tax_unit("income_tax", period) + tax_old_law_ss = branch_old_law.tax_unit("income_tax_chapter_1", period) del sim.branches["oasdi_old_law"] return tax_old_law_ss - tax_no_ss diff --git a/policyengine_us/variables/gov/ssa/revenue/tob_revenue_total.py b/policyengine_us/variables/gov/ssa/revenue/tob_revenue_total.py index 8fa143dba94..cc85d335016 100644 --- a/policyengine_us/variables/gov/ssa/revenue/tob_revenue_total.py +++ b/policyengine_us/variables/gov/ssa/revenue/tob_revenue_total.py @@ -7,13 +7,18 @@ class tob_revenue_total(Variable): definition_period = YEAR label = "Total trust fund revenue from SS benefit taxation" documentation = ( - "Tax revenue from taxation of Social Security benefits using " - "branching methodology per 42 U.S.C. 401 note. The total and its " + "Increase in chapter 1 income tax liability after nonrefundable " + "credits and before refundable credits from taxing Social Security " + "benefits, per 42 U.S.C. 401 note. Excludes chapter 2A net investment " + "income tax under section 1411. The total and its " "OASDI and Medicare HI shares are signed: negative unit differences " "are retained for aggregation." ) unit = USD - reference = "https://www.law.cornell.edu/uscode/text/42/401" + reference = ( + "https://www.law.cornell.edu/uscode/text/42/401", + "https://www.law.cornell.edu/uscode/text/26/1411", + ) def formula(tax_unit, period, parameters): """ @@ -24,8 +29,8 @@ def formula(tax_unit, period, parameters): """ sim = tax_unit.simulation - # Calculate income tax WITH taxable SS - income_tax_with_ss = tax_unit("income_tax", period) + # Calculate chapter 1 liability WITH taxable SS + income_tax_with_ss = tax_unit("income_tax_chapter_1", period) # Create branch and neutralize taxable SS branch = sim.get_branch("tob_calc", clone_system=True) @@ -42,8 +47,8 @@ def formula(tax_unit, period, parameters): except: pass - # Recalculate income tax without taxable SS - income_tax_without_ss = branch.tax_unit("income_tax", period) + # Recalculate chapter 1 liability without taxable SS + income_tax_without_ss = branch.tax_unit("income_tax_chapter_1", period) # Clean up branch del sim.branches["tob_calc"]