From 9b0729d1e35dd24622667a06b2b07b4a04d02f00 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 17:25:14 -0400 Subject: [PATCH 1/8] Measure benefit-taxation revenue as chapter 1 liability --- changelog.d/tob-chapter-1-liability.fixed.md | 1 + .../federal_income/income_tax_chapter_1.yaml | 59 ++++++++ .../gov/ssa/revenue/tob_revenue_total.yaml | 142 ++++++++++++++++++ .../federal_income/income_tax_chapter_1.py | 43 ++++++ .../ssa/revenue/tob_revenue_medicare_hi.py | 13 +- .../gov/ssa/revenue/tob_revenue_oasdi.py | 15 +- .../gov/ssa/revenue/tob_revenue_total.py | 19 ++- 7 files changed, 278 insertions(+), 14 deletions(-) create mode 100644 changelog.d/tob-chapter-1-liability.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/income_tax_chapter_1.yaml create mode 100644 policyengine_us/variables/gov/irs/tax/federal_income/income_tax_chapter_1.py 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/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/tob_revenue_total.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_revenue_total.yaml index 7e64fe9758f..6e912137027 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,145 @@ # 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 + 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 + 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 + 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/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/revenue/tob_revenue_medicare_hi.py b/policyengine_us/variables/gov/ssa/revenue/tob_revenue_medicare_hi.py index 7262c2b540e..e91ad32cafb 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,20 @@ 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, " + "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." ) 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..597ac6ec00b 100644 --- a/policyengine_us/variables/gov/ssa/revenue/tob_revenue_oasdi.py +++ b/policyengine_us/variables/gov/ssa/revenue/tob_revenue_oasdi.py @@ -7,7 +7,10 @@ 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 " @@ -15,7 +18,11 @@ class tob_revenue_oasdi(Variable): "tax liabilities." ) 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): """ @@ -49,7 +56,7 @@ 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 === @@ -69,7 +76,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"] From 4da8c8abcfb5d8bb037ca647198369d03a07ae3f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 18:17:06 -0400 Subject: [PATCH 2/8] Check chapter 1 benefit-taxation differentials independently --- .../gov/ssa/revenue/test_tob_revenue.py | 201 +++++++++++++++++- .../gov/ssa/revenue/tob_revenue_total.yaml | 3 + 2 files changed, 196 insertions(+), 8 deletions(-) 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..5613b778e65 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,4 +1,4 @@ -"""Generated TOB split invariants and branch cache isolation. +"""Generated chapter 1 TOB differentials and branch cache isolation. Policy examples and independent tax calculations live in the variable YAML files. Python is needed to quantify the split invariants over generated @@ -12,9 +12,14 @@ 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 itertools import product import numpy as np @@ -49,7 +54,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 +74,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 +123,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) @@ -131,7 +291,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 +307,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) 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 6e912137027..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 @@ -37,6 +37,7 @@ tax_unit: members: [retiree] filing_status: SINGLE + tax_unit_itemizes: false spm_units: spm_unit: members: [retiree] @@ -80,6 +81,7 @@ tax_unit: members: [parent, child] filing_status: HEAD_OF_HOUSEHOLD + tax_unit_itemizes: false marital_units: parent_unit: members: [parent] @@ -133,6 +135,7 @@ tax_unit: members: [parent, child] filing_status: HEAD_OF_HOUSEHOLD + tax_unit_itemizes: false marital_units: parent_unit: members: [parent] From e0e49ca3ea2a7657ae612bf980bca4acdde729bb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 23:39:20 -0400 Subject: [PATCH 3/8] Cap the OASDI old-law inclusion at actual inclusion tob_revenue_oasdi taxed the pre-1993 section 86 amount in its old-law branch whatever a reform did to current inclusion. A reform that cut inclusion below that amount credited OASDI with inclusion the unit never reached: under repeal the total was $0, OASDI positive and HI the same amount negative. The old-law branch now taxes min(pre-1993 amount, actual inclusion), read from the outer simulation before any branch exists. At current law the cap changes nothing, because both laws share the first tier. HI stays total minus OASDI, with signed shares and no per-unit floor. Reform scoring cases in tob_old_law_bound.yaml cover repeal, raised thresholds and higher inclusion. The generated property test now requires pre-1993 inclusion never to exceed actual inclusion at current law, with no tolerance. Ruling: us:tob-oasdi-old-law-bound. Co-Authored-By: Claude Opus 5.5 --- .../gov/ssa/revenue/test_tob_revenue.py | 6 +- .../gov/ssa/revenue/tob_old_law_bound.yaml | 133 ++++++++++++++++++ .../ssa/revenue/tob_revenue_medicare_hi.py | 5 +- .../gov/ssa/revenue/tob_revenue_oasdi.py | 20 ++- 4 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_old_law_bound.yaml 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 5613b778e65..d9ab3c7eb30 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 @@ -275,7 +275,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 ) diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_old_law_bound.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_old_law_bound.yaml new file mode 100644 index 00000000000..08f159b1e87 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_old_law_bound.yaml @@ -0,0 +1,133 @@ +# 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 in Texas. 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 state, 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 state. +# +# OASDI + HI = total in each case. + +- name: Repealing benefit taxation leaves OASDI, HI and the total at zero + period: 2026 + absolute_error_margin: 0.01 + input: + 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 + people: + retiree: + age: 70 + social_security: 20_000 + taxable_interest_income: 30_000 + tax_units: + tax_unit: + members: [retiree] + filing_status: SINGLE + spm_units: + spm_unit: + members: [retiree] + takes_up_tanf_if_eligible: false + households: + household: + members: [retiree] + output: + 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 + +- name: Raised thresholds put inclusion below the old-law amount, so OASDI takes it all + period: 2026 + absolute_error_margin: 0.01 + input: + gov.irs.social_security.taxability.threshold.base.main.SINGLE: 30_000 + gov.irs.social_security.taxability.threshold.adjusted_base.main.SINGLE: 44_000 + people: + retiree: + age: 70 + social_security: 20_000 + taxable_interest_income: 30_000 + tax_units: + tax_unit: + members: [retiree] + filing_status: SINGLE + spm_units: + spm_unit: + members: [retiree] + takes_up_tanf_if_eligible: false + households: + household: + members: [retiree] + output: + 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 + +- name: A reform that raises inclusion keeps the pre-1993 OASDI share and gives HI the rest + period: 2026 + absolute_error_margin: 0.01 + input: + # The same override as tax_unit_taxable_social_security_pre_1993.yaml. + gov.irs.social_security.taxability.combined_income_ss_fraction: 0.85 + people: + retiree: + age: 70 + social_security: 20_000 + taxable_interest_income: 30_000 + tax_units: + tax_unit: + members: [retiree] + filing_status: SINGLE + spm_units: + spm_unit: + members: [retiree] + takes_up_tanf_if_eligible: false + households: + household: + members: [retiree] + output: + # 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 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 e91ad32cafb..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 @@ -15,7 +15,10 @@ class tob_revenue_medicare_hi(Variable): "benefits revenue less the pre-1993 OASDI allocation. Both amounts " "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." + "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 = ( 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 597ac6ec00b..db8df732912 100644 --- a/policyengine_us/variables/gov/ssa/revenue/tob_revenue_oasdi.py +++ b/policyengine_us/variables/gov/ssa/revenue/tob_revenue_oasdi.py @@ -15,7 +15,9 @@ class tob_revenue_oasdi(Variable): "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 = ( @@ -30,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 === @@ -59,7 +69,7 @@ def formula(tax_unit, period, parameters): 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 From f21fd89a6422d9fc43d1bf12238bcade82469c6f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:34:36 -0400 Subject: [PATCH 4/8] Drop a state the reform scoring witness never sets The witness gives no state, so the header no longer says Texas. Say "counterfactual" where "state" meant the inclusion scenario. Co-Authored-By: Claude Opus 5.5 --- .../gov/ssa/revenue/tob_old_law_bound.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_old_law_bound.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_old_law_bound.yaml index 08f159b1e87..f856623c3b6 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_old_law_bound.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_old_law_bound.yaml @@ -2,17 +2,17 @@ # 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 in Texas. 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 +# 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 state, 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 +# 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 state. +# credit out, so the credit is zero in every counterfactual. # # OASDI + HI = total in each case. From 4af11ce91f90992bf031571b866c414b102017e7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:32:49 -0500 Subject: [PATCH 5/8] Run the old-law cap reform cases on one private policy system The three reform cases in tob_old_law_bound.yaml made Core's YAML runner build and keep a cloned policy system for each parameter override set. In CI that file alone peaked at 8,244 MB, above the batch runner's 8,000 MB budget, and took its batch process to 13,540 MB. One Python test now runs the same three cases in turn on a single clone of the shared system and the same retiree situation. Before each case it restores all eight reformed leaves, applies the case's parameter inputs with the modifier the YAML runner uses, clears the parameter caches and builds a new simulation, then asserts the same six outputs to a cent. The shared system's leaves are checked unchanged in a finally block. Co-Authored-By: Claude Opus 5.5 --- .../gov/ssa/revenue/test_tob_revenue.py | 218 +++++++++++++++++- .../gov/ssa/revenue/tob_old_law_bound.yaml | 133 ----------- 2 files changed, 215 insertions(+), 136 deletions(-) delete mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_old_law_bound.yaml 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 d9ab3c7eb30..6d8527b8dae 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 chapter 1 TOB differentials 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 clone 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 @@ -20,13 +26,17 @@ """ 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.system import system PROFILES = ( @@ -382,3 +392,205 @@ 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 clone: 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()} + policy = system.clone() + 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 parameter_history(leaf) == shared_histories[path] + # Parameter.update replaces a leaf's list of dated values and never + # edits an entry, so the clone's original lists restore current law. + 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: + # Release this case's simulation and its branch systems. + 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_old_law_bound.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_old_law_bound.yaml deleted file mode 100644 index f856623c3b6..00000000000 --- a/policyengine_us/tests/policy/baseline/gov/ssa/revenue/tob_old_law_bound.yaml +++ /dev/null @@ -1,133 +0,0 @@ -# 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. - -- name: Repealing benefit taxation leaves OASDI, HI and the total at zero - period: 2026 - absolute_error_margin: 0.01 - input: - 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 - people: - retiree: - age: 70 - social_security: 20_000 - taxable_interest_income: 30_000 - tax_units: - tax_unit: - members: [retiree] - filing_status: SINGLE - spm_units: - spm_unit: - members: [retiree] - takes_up_tanf_if_eligible: false - households: - household: - members: [retiree] - output: - 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 - -- name: Raised thresholds put inclusion below the old-law amount, so OASDI takes it all - period: 2026 - absolute_error_margin: 0.01 - input: - gov.irs.social_security.taxability.threshold.base.main.SINGLE: 30_000 - gov.irs.social_security.taxability.threshold.adjusted_base.main.SINGLE: 44_000 - people: - retiree: - age: 70 - social_security: 20_000 - taxable_interest_income: 30_000 - tax_units: - tax_unit: - members: [retiree] - filing_status: SINGLE - spm_units: - spm_unit: - members: [retiree] - takes_up_tanf_if_eligible: false - households: - household: - members: [retiree] - output: - 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 - -- name: A reform that raises inclusion keeps the pre-1993 OASDI share and gives HI the rest - period: 2026 - absolute_error_margin: 0.01 - input: - # The same override as tax_unit_taxable_social_security_pre_1993.yaml. - gov.irs.social_security.taxability.combined_income_ss_fraction: 0.85 - people: - retiree: - age: 70 - social_security: 20_000 - taxable_interest_income: 30_000 - tax_units: - tax_unit: - members: [retiree] - filing_status: SINGLE - spm_units: - spm_unit: - members: [retiree] - takes_up_tanf_if_eligible: false - households: - household: - members: [retiree] - output: - # 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 From 81df9e43c664daab758b79aa34fe0da6b4b248c1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:32:51 -0500 Subject: [PATCH 6/8] Correct the OASDI accounting text for the chapter 1 measure #10563 described tob_revenue_oasdi as differencing income_tax, so the benefit-taxation term carried benefit-triggered NIIT and overlapped the S.770 NIIT transfer. The TOB outputs now difference income_tax_chapter_1, which leaves NIIT out, so that overlap no longer exists. State the actual-inclusion cap wherever the text defines the pre-1993 share, and list the chapter 1 tax among the columns the preflight rejects. Co-Authored-By: Claude Opus 5.5 --- .../usage/social-security-oasdi-accounting.md | 47 ++++++++++--------- .../oasdi/oasdi_benefit_taxation_revenue.py | 10 ++-- .../variables/gov/ssa/oasdi/oasdi_income.py | 7 +-- 3 files changed, 34 insertions(+), 30 deletions(-) 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/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 From aeb11a0385505fd3fa5647b1e176d3373edcfb14 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:32:52 -0500 Subject: [PATCH 7/8] Reject a stored chapter 1 tax in the Social Security preflight The TOB outputs difference income_tax_chapter_1. A stored value is an input that every counterfactual branch keeps, so it freezes each measured tax state and zeroes TOB. Co-Authored-By: Claude Opus 5.5 --- .../tests/code_health/test_social_security_preflight.py | 2 ++ policyengine_us/tools/social_security_preflight.py | 3 +++ 2 files changed, 5 insertions(+) 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/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", From 1cf200db906aee4cfe7c7d63e982656135da7b4f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 09:24:33 -0500 Subject: [PATCH 8/8] Copy the policy's nodes for the reform cases instead of cloning it A full clone rebuilds every dated parameter entry. Together with the TOB branches' own clones, that took the test's process past a 4 GB cap locally. Copy the nodes and each leaf's list with copy_parameter_nodes, borrowing the entries, which Parameter.update replaces rather than edits; the process then peaked at 3.53 GiB. The shared system's tree is still never written, and the test checks that each copied leaf holds its own list. Co-Authored-By: Claude Opus 5.5 --- .../gov/ssa/revenue/test_tob_revenue.py | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) 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 6d8527b8dae..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 @@ -5,8 +5,8 @@ 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 clone and restores its parameters between them. -Python is also +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 @@ -36,6 +36,7 @@ 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 @@ -532,9 +533,9 @@ 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 clone: 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, + 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" @@ -542,14 +543,20 @@ def test_reforms_cap_old_law_inclusion_at_actual_inclusion(): 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()} - policy = system.clone() + # 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] - # Parameter.update replaces a leaf's list of dated values and never - # edits an entry, so the clone's original lists restore current law. + # 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(): @@ -584,7 +591,8 @@ def test_reforms_cap_old_law_inclusion_at_actual_inclusion(): err_msg=f"{name}: {variable}", ) finally: - # Release this case's simulation and its branch systems. + # Free this case's simulation and the branch systems its TOB + # formulas discarded. del simulation gc.collect() finally: