From 71139d5f0c31b79ff29b6a50e9c502a38dcba020 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 13:03:55 -0400 Subject: [PATCH 1/5] Add modeled OASDI liabilities, benefits and balance Six new outputs for design v3 section B (lever 5): Person payroll liability, TaxUnit benefit-taxation and NIIT-transfer revenue, and Household modeled liabilities, benefits and their modeled balance. No existing variable changes. Co-Authored-By: Claude Opus 5.5 --- changelog.d/ss-oasdi-accounting-b.added.md | 1 + .../oasdi/oasdi_benefit_taxation_revenue.py | 18 +++++++++++ .../variables/gov/ssa/oasdi/oasdi_cost.py | 16 ++++++++++ .../variables/gov/ssa/oasdi/oasdi_income.py | 29 +++++++++++++++++ .../gov/ssa/oasdi/oasdi_modeled_balance.py | 18 +++++++++++ .../gov/ssa/oasdi/oasdi_niit_revenue.py | 17 ++++++++++ .../ssa/oasdi/oasdi_payroll_tax_revenue.py | 31 +++++++++++++++++++ 7 files changed, 130 insertions(+) create mode 100644 changelog.d/ss-oasdi-accounting-b.added.md create mode 100644 policyengine_us/variables/gov/ssa/oasdi/oasdi_benefit_taxation_revenue.py create mode 100644 policyengine_us/variables/gov/ssa/oasdi/oasdi_cost.py create mode 100644 policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py create mode 100644 policyengine_us/variables/gov/ssa/oasdi/oasdi_modeled_balance.py create mode 100644 policyengine_us/variables/gov/ssa/oasdi/oasdi_niit_revenue.py create mode 100644 policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py diff --git a/changelog.d/ss-oasdi-accounting-b.added.md b/changelog.d/ss-oasdi-accounting-b.added.md new file mode 100644 index 00000000000..a76b81a693f --- /dev/null +++ b/changelog.d/ss-oasdi-accounting-b.added.md @@ -0,0 +1 @@ +Add modeled OASDI liabilities, benefits and their modeled balance (oasdi_payroll_tax_revenue, oasdi_benefit_taxation_revenue, oasdi_niit_revenue, oasdi_income, oasdi_cost and oasdi_modeled_balance). 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 new file mode 100644 index 00000000000..06a9c77a7d4 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/oasdi/oasdi_benefit_taxation_revenue.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * + + +class oasdi_benefit_taxation_revenue(Variable): + value_type = float + entity = TaxUnit + definition_period = YEAR + label = "OASDI modeled benefit taxation liability" + documentation = ( + "The OASDI share of income tax on Social Security benefits: " + "tob_revenue_oasdi, the tax attributable to section 86 inclusion " + "before the 1993 amendments. The HI share (tob_revenue_medicare_hi) " + "is excluded. Signed: a unit whose tax falls when benefits are " + "included contributes a negative amount, so sum units without a floor." + ) + unit = USD + reference = "https://www.ssa.gov/OP_Home/comp2/F098-021.html" + adds = ["tob_revenue_oasdi"] diff --git a/policyengine_us/variables/gov/ssa/oasdi/oasdi_cost.py b/policyengine_us/variables/gov/ssa/oasdi/oasdi_cost.py new file mode 100644 index 00000000000..58dd3bd6b8b --- /dev/null +++ b/policyengine_us/variables/gov/ssa/oasdi/oasdi_cost.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * + + +class oasdi_cost(Variable): + value_type = float + entity = Household + definition_period = YEAR + label = "OASDI modeled benefits" + documentation = ( + "Modeled OASDI benefits: members' public social_security, the four " + "OASDI components after any enabled benefit policies. Excludes " + "administrative expenses and Railroad Retirement interchange, and " + "covers only recipients in the household frame." + ) + unit = USD + adds = ["social_security"] diff --git a/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py b/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py new file mode 100644 index 00000000000..8e117aa2a5b --- /dev/null +++ b/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py @@ -0,0 +1,29 @@ +from policyengine_us.model_api import * + + +class oasdi_income(Variable): + value_type = float + entity = Household + definition_period = YEAR + label = "OASDI modeled liabilities" + documentation = ( + "Modeled OASDI liabilities: each member's oasdi_payroll_tax_revenue " + "plus each tax unit's oasdi_benefit_taxation_revenue and " + "oasdi_niit_revenue, each counted once. Excludes reserve interest, " + "Railroad Retirement interchange and general-fund reimbursements, " + "which the model does not represent." + ) + unit = USD + + def formula(household, period, parameters): + person = household.members + payroll = household.sum(person("oasdi_payroll_tax_revenue", period)) + tax_unit = person.tax_unit + tax_unit_revenue = tax_unit( + "oasdi_benefit_taxation_revenue", period + ) + tax_unit("oasdi_niit_revenue", period) + # Credit each tax unit's amount once, to its first member's household, + # whose weight the tax unit carries. Group-to-group adds would split + # it by member count instead. + first_member = tax_unit.reference_entity.members_position == 0 + return payroll + household.sum(where(first_member, tax_unit_revenue, 0)) diff --git a/policyengine_us/variables/gov/ssa/oasdi/oasdi_modeled_balance.py b/policyengine_us/variables/gov/ssa/oasdi/oasdi_modeled_balance.py new file mode 100644 index 00000000000..7601c275aaa --- /dev/null +++ b/policyengine_us/variables/gov/ssa/oasdi/oasdi_modeled_balance.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * + + +class oasdi_modeled_balance(Variable): + value_type = float + entity = Household + definition_period = YEAR + label = "OASDI modeled balance" + documentation = ( + "Modeled OASDI liabilities (oasdi_income) less modeled benefits " + "(oasdi_cost). A modeled balance of liabilities and benefits, not a " + "trust-fund cash balance: it omits reserve interest, Railroad " + "Retirement interchange, administrative expenses and general-fund " + "reimbursements." + ) + unit = USD + adds = ["oasdi_income"] + subtracts = ["oasdi_cost"] diff --git a/policyengine_us/variables/gov/ssa/oasdi/oasdi_niit_revenue.py b/policyengine_us/variables/gov/ssa/oasdi/oasdi_niit_revenue.py new file mode 100644 index 00000000000..751b546a593 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/oasdi/oasdi_niit_revenue.py @@ -0,0 +1,17 @@ +from policyengine_us.model_api import * + + +class oasdi_niit_revenue(Variable): + value_type = float + entity = TaxUnit + definition_period = YEAR + label = "OASDI modeled net investment income tax transfer" + documentation = ( + "The OASDI transfer selected by the S.770 section 8 options " + "(s770_revenue_oasdi): zero unless an option is enabled. This is the " + "transfer under the selected mode, not the full net investment " + "income tax liability." + ) + unit = USD + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" + adds = ["s770_revenue_oasdi"] diff --git a/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py b/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py new file mode 100644 index 00000000000..b5c02ad5490 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * + + +class oasdi_payroll_tax_revenue(Variable): + value_type = float + entity = Person + definition_period = YEAR + label = "OASDI modeled payroll tax liability" + documentation = ( + "Modeled OASDI liability on this person's covered earnings: the " + "employee and employer FICA shares on wages up to the wage base and " + "the SECA OASDI tax on self-employment earnings in the remaining room. " + "A reform that replaces one of these leaves arrives through that leaf; " + "the owners' draw reform adds its OASDI tax inside the SECA leaf, so " + "it is counted once. employer_total_social_security_tax is excluded: " + "it applies the employer rate to a separate employer-aggregate " + "earnings input, another measure of the employer share already " + "counted here, not a fourth stream. A modeled liability, not a " + "trust-fund receipt." + ) + unit = USD + reference = ( + "https://www.law.cornell.edu/uscode/text/26/3101#a", + "https://www.law.cornell.edu/uscode/text/26/3111#a", + "https://www.law.cornell.edu/uscode/text/26/1401#a", + ) + adds = [ + "employee_social_security_tax", + "employer_social_security_tax", + "self_employment_social_security_tax", + ] From 8222d384985a889cdd87020dc42206cec12e61dc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 13:46:28 -0400 Subject: [PATCH 2/5] Test OASDI accounting, extend the preflight, document scoring Hand-computed YAML for payroll, benefit taxation, the S.770 section 8 transfer, owners' draw and household identities; Hypothesis identities with a differential against existing outputs; a weighted Microsimulation fixture. The stored-column preflight now also rejects the modeled balance and the S.770 and owners' draw tax leaves. Co-Authored-By: Claude Opus 5.5 --- docs/_toc.yml | 1 + .../usage/social-security-oasdi-accounting.md | 144 +++++++++ .../social-security-scoring-preflight.md | 7 +- .../test_social_security_preflight.py | 31 ++ .../test_oasdi_accounting_weights.py | 282 ++++++++++++++++++ .../oasdi/oasdi_benefit_taxation_revenue.yaml | 60 ++++ .../baseline/gov/ssa/oasdi/oasdi_cost.yaml | 44 +++ .../baseline/gov/ssa/oasdi/oasdi_income.yaml | 70 +++++ .../gov/ssa/oasdi/oasdi_modeled_balance.yaml | 41 +++ .../gov/ssa/oasdi/oasdi_niit_revenue.yaml | 42 +++ .../ssa/oasdi/oasdi_payroll_tax_revenue.yaml | 112 +++++++ .../gov/ssa/oasdi/test_oasdi_accounting.py | 252 ++++++++++++++++ .../tools/social_security_preflight.py | 7 + .../oasdi/oasdi_benefit_taxation_revenue.py | 7 +- .../gov/ssa/oasdi/oasdi_niit_revenue.py | 2 +- .../ssa/oasdi/oasdi_payroll_tax_revenue.py | 10 +- 16 files changed, 1101 insertions(+), 11 deletions(-) create mode 100644 docs/usage/social-security-oasdi-accounting.md create mode 100644 policyengine_us/tests/microsimulation/test_oasdi_accounting_weights.py create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_benefit_taxation_revenue.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_cost.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_modeled_balance.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_niit_revenue.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_payroll_tax_revenue.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/oasdi/test_oasdi_accounting.py diff --git a/docs/_toc.yml b/docs/_toc.yml index 0cb748b3196..aef7dfab381 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -68,6 +68,7 @@ parts: - file: usage/social-security-calculator sections: - file: usage/social-security/RULINGS + - file: usage/social-security-oasdi-accounting - file: usage/speed - caption: Examples chapters: diff --git a/docs/usage/social-security-oasdi-accounting.md b/docs/usage/social-security-oasdi-accounting.md new file mode 100644 index 00000000000..f1320489372 --- /dev/null +++ b/docs/usage/social-security-oasdi-accounting.md @@ -0,0 +1,144 @@ +# OASDI modeled liabilities and benefits + +Six outputs report modeled OASDI liabilities, modeled OASDI benefits and their +difference, the modeled balance. They add up existing variables and change no +other output. The balance compares modeled liabilities with modeled benefits; +it is not a trust-fund cash balance. + +## Definitions + +| Output | Entity | Definition | +| --- | --- | --- | +| `oasdi_payroll_tax_revenue` | Person | `employee_social_security_tax + employer_social_security_tax + self_employment_social_security_tax` | +| `oasdi_benefit_taxation_revenue` | TaxUnit | `tob_revenue_oasdi` | +| `oasdi_niit_revenue` | TaxUnit | `s770_revenue_oasdi` | +| `oasdi_income` | Household | Each member's `oasdi_payroll_tax_revenue`, plus each tax unit's `oasdi_benefit_taxation_revenue` and `oasdi_niit_revenue`, each counted once | +| `oasdi_cost` | Household | Members' public `social_security`, after any enabled benefit policies | +| `oasdi_modeled_balance` | Household | `oasdi_income - oasdi_cost` | + +The payroll leaves read their rates and the wage base from parameters: +`gov.irs.payroll.social_security.rate.employee` and `.employer` apply to wages +up to `gov.irs.payroll.social_security.cap`, and +`gov.irs.self_employment.rate.social_security` applies to self-employment net +earnings in the room that wages leave under the base. A reform that replaces a +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_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 +transfer, not the whole `net_investment_income_tax` liability. See the +[S.770 section 8 README](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/reforms/congress/s770/README.md) +for the transfer modes. + +### Flows left out + +* `employer_total_social_security_tax` applies the employer rate to + `employer_total_taxable_earnings_for_social_security`, a separate + employer-aggregate input that defaults to zero. It measures the employer share + that `employer_social_security_tax` already counts per worker, so it is not a + fourth stream. +* `unreported_payroll_tax`, the tax on unreported tips and wages from Forms 4137 + and 8919, is a TaxUnit input that doesn't separate OASDI from HI. +* `employer_ss_tax_income_tax_revenue`, the income tax on employer OASDI + contributions under the `gov.contrib.crfb.tax_employer_payroll_tax` reform, + isn't part of `oasdi_income`. A score that enables that reform adds it + separately. + +## Entity mapping and weights + +`oasdi_income` credits each tax unit's amount once, to the household of its +first member. `Microsimulation` weights a tax unit by that same household's +weight, so household-weighted totals equal the Person and TaxUnit weighted +totals, even for a tax unit whose members live in different households. Core's +group-to-group `adds` would instead split a tax unit's amount equally among its +members' households. + +When you aggregate, sum each output at its own entity with that entity's +weights, once. Don't project a tax-unit amount to its members and sum it again: +`calculate("oasdi_benefit_taxation_revenue", map_to="person").sum()` counts a +married couple's amount twice. + +## Stated limitations + +This page states these limitations without measuring them. No residual from +another model closes them. + +**Missing cash flows.** The modeled balance leaves out interest on trust fund +reserves, the Railroad Retirement financial interchange, administrative +expenses and general-fund reimbursements. None of these flows is a model +variable. + +**Recipients outside the frame.** `oasdi_cost` sums benefits for the people in +the household dataset. Benefits paid to recipients outside its frame, such as +beneficiaries living abroad, are missing or incompletely represented. + +**Noncovered employment.** `payroll_tax_gross_wages` is employment income less +FICA pre-tax contributions, with no general OASDI-coverage flag. The model +therefore taxes wages from employment that OASDI doesn't cover, such as some +state and local government jobs, as covered wages. This wage-coverage +assumption differs from the missing cash flows. + +## Scoring a population + +1. **Audit the stored data first.** Run the + [scoring-data preflight](social-security-scoring-preflight.md) on the exact + H5 you'll score: + + ```bash + python -m policyengine_us.tools.social_security_preflight /path/to/populace_us_2024.h5 > receipt.json + ``` + + 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. + +2. **Score in household chunks.** `tob_revenue_oasdi` recalculates income tax in + two branches of the whole simulation, so its working memory grows with the + population. Household-chunked calculation, + [PolicyEngine/policyengine-us#10561](https://github.com/PolicyEngine/policyengine-us/pull/10561), + is the supported route for full-population scoring once it merges. Review + may still change its interface, so check its documentation when it lands. In + the draft, a call looks like this: + + ```python + from policyengine_us import Microsimulation + from policyengine_us.tools.calculate_chunked import calculate_chunked + + result = calculate_chunked( + lambda dataset: Microsimulation(dataset=dataset, dataset_end_year=2035), + [ + "oasdi_payroll_tax_revenue", + "oasdi_benefit_taxation_revenue", + "oasdi_niit_revenue", + "oasdi_income", + "oasdi_cost", + "oasdi_modeled_balance", + ], + 2030, + 8, + dataset=Microsimulation.default_dataset, + ) + totals = result.weighted_sums + ``` + + Chunks keep households whole, and each tax unit's amount sits in one + household, so each output's chunk totals add to its population total. + +3. **Check the identities on the totals.** The `oasdi_income` total equals the + payroll, benefit-taxation and NIIT-transfer totals added together, and the + `oasdi_modeled_balance` total equals the `oasdi_income` total less the + `oasdi_cost` total. + +4. **Treat projected years as independent cross-sections.** The preflight guide + describes how `extend_single_year_dataset` projects each year from the base + year. Report the limitations above with every score. diff --git a/docs/usage/social-security-scoring-preflight.md b/docs/usage/social-security-scoring-preflight.md index e949b622c26..6aa2408ac25 100644 --- a/docs/usage/social-security-scoring-preflight.md +++ b/docs/usage/social-security-scoring-preflight.md @@ -20,8 +20,11 @@ The receipt records the source mapping that the PE-US constructor must implement Two names for one component would retain a final public override and are rejected. Stored aggregate `social_security`, OASDI payroll-tax outputs, federal NIIT liabilities/revenues and TOB revenue outputs are also rejected: these would bypass -the formulas being scored. This population-data contract permits household -situations to continue using explicit final benefit overrides. +the formulas being scored. The preflight also rejects the S.770 section 8 and +owners' draw tax and OASDI transfer leaves and the +[OASDI accounting outputs](social-security-oasdi-accounting.md); their +classification inputs remain valid sources. This population-data contract permits +household situations to continue using explicit final benefit overrides. The JSON receipt records the artifact, hash, base year, stored H5 keys, complete column inventory by entity, row counts, source mapping and any declared final 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 7944574f04d..f5a3d5ad7dc 100644 --- a/policyengine_us/tests/code_health/test_social_security_preflight.py +++ b/policyengine_us/tests/code_health/test_social_security_preflight.py @@ -106,6 +106,16 @@ def test_reported_columns_are_not_migrated_again(tmp_path): "tob_revenue_oasdi", "tob_revenue_medicare_hi", "tob_revenue_total", + "s770_revenue_oasdi", + "s770_existing_niit_revenue_oasdi", + "s770_nii_addon_tax", + "s770_active_business_income_tax", + "owners_draw_oasdi_tax", + "oasdi_payroll_tax_revenue", + "oasdi_benefit_taxation_revenue", + "oasdi_income", + "oasdi_cost", + "oasdi_modeled_balance", ], ) def test_stored_outputs_rejected_even_when_zero(tmp_path, column): @@ -114,6 +124,27 @@ def test_stored_outputs_rejected_even_when_zero(tmp_path, column): audit(path) +@pytest.mark.parametrize( + "column", + [ + "s770_passive_se_income", + "s770_active_fica_income", + "s770_nii_fica_seca_income", + "s770_niit_nol_deduction", + "s770_draw_gross", + "s770_draw_oasdi_base", + "s770_draw_hi_in_effect", + "owners_draw_subsets_are_disjoint", + "owners_draw_passive_se_overlap", + ], +) +def test_section_8_and_owners_draw_classification_inputs_remain_sources( + tmp_path, column +): + receipt = audit(make_dataset(tmp_path, tax_unit_extra={column: [0]})) + assert column in receipt["stored_columns"] + + def test_both_component_names_would_preserve_a_bypassing_override(tmp_path): path = make_dataset( tmp_path, person_extra={"social_security_retirement_reported": [0]} diff --git a/policyengine_us/tests/microsimulation/test_oasdi_accounting_weights.py b/policyengine_us/tests/microsimulation/test_oasdi_accounting_weights.py new file mode 100644 index 00000000000..a2bdff94f8f --- /dev/null +++ b/policyengine_us/tests/microsimulation/test_oasdi_accounting_weights.py @@ -0,0 +1,282 @@ +"""Weighted OASDI accounting totals must count each entity's amount once. + +``Microsimulation`` weights a tax unit by its first member's household weight. +``oasdi_income`` credits each tax unit's amount to that same household, so +household-weighted totals equal the independent Person and TaxUnit weighted +totals. This needs Python: YAML has no dataset loading or weights. + +The fixture has five households with distinct, non-binary weights: households +with two tax units, a beneficiary couple who owe tax on benefits, wages above +the wage base, self-employment earnings, and one tax unit whose members live in +two households. That last unit separates the first-member credit from core's +group-to-group mapping, which splits a tax unit's amount by member count. +""" + +import numpy as np +import pandas as pd +import pytest + +from policyengine_us import Microsimulation +from policyengine_us.data.dataset_schema import USMultiYearDataset, USSingleYearDataset + +YEAR = 2026 +# Household ID, weight and members as (person ID, tax unit ID, inputs). +HOUSEHOLDS = ( + ( + 11, + 1_130.5, + ( + ( + 101, + 21, + dict( + age=72, + social_security_retirement=18_000.0, + taxable_interest_income=45_000.0, + ), + ), + (102, 21, dict(age=70, social_security_retirement=14_000.0)), + (103, 22, dict(age=40, employment_income=60_000.0)), + ), + ), + ( + 12, + 2_457.25, + ( + (104, 23, dict(age=45, employment_income=260_000.0)), + (105, 23, dict(age=44, self_employment_income=80_000.0)), + ( + 106, + 24, + dict( + age=50, + employment_income=250_000.0, + taxable_interest_income=120_000.0, + ), + ), + ), + ), + ( + 13, + 397.75, + ( + ( + 107, + 25, + dict( + age=68, + social_security_retirement=24_000.0, + taxable_interest_income=30_000.0, + ), + ), + ( + 108, + 26, + dict( + age=40, employment_income=166_030.0, self_employment_income=20_000.0 + ), + ), + ), + ), + # Tax unit 27's first member lives in household 14, its second in 15. + ( + 14, + 1_750.125, + ( + ( + 109, + 27, + dict( + age=69, + social_security_retirement=20_000.0, + taxable_interest_income=40_000.0, + ), + ), + ), + ), + ( + 15, + 3_003.5, + ( + (110, 28, dict(age=40, employment_income=90_000.0)), + (111, 27, dict(age=67, social_security_retirement=16_000.0)), + ), + ), +) +# Married couples share a marital unit; everyone else has their own. +MARITAL_UNIT = {102: 101, 105: 104} +INPUTS = ( + "age", + "employment_income", + "self_employment_income", + "social_security_retirement", + "taxable_interest_income", +) + + +def build_dataset(): + people, households, tax_unit_ids = [], [], [] + for household_id, weight, members in HOUSEHOLDS: + households.append( + { + "household_id": household_id, + "household_weight": weight, + "state_fips": 48, + } + ) + for person_id, tax_unit_id, inputs in members: + person = {name: 0.0 for name in INPUTS} + person.update(inputs) + person.update( + person_id=person_id, + person_household_id=household_id, + person_tax_unit_id=tax_unit_id, + person_spm_unit_id=household_id, + person_family_id=household_id, + person_marital_unit_id=MARITAL_UNIT.get(person_id, person_id), + ) + people.append(person) + if tax_unit_id not in tax_unit_ids: + tax_unit_ids.append(tax_unit_id) + people = pd.DataFrame(people) + household_ids = [household["household_id"] for household in households] + dataset = USSingleYearDataset( + person=people, + household=pd.DataFrame(households), + tax_unit=pd.DataFrame( + { + "tax_unit_id": tax_unit_ids, + # Keep optional itemized deductions out of the counterfactuals. + "itemized_medical_expenses": 0.0, + "salt_deduction": 0.0, + } + ), + spm_unit=pd.DataFrame( + {"spm_unit_id": household_ids, "takes_up_tanf_if_eligible": False} + ), + family=pd.DataFrame({"family_id": household_ids}), + marital_unit=pd.DataFrame( + {"marital_unit_id": people.person_marital_unit_id.unique()} + ), + time_period=YEAR, + ) + return dataset, people, dict(zip(household_ids, households)) + + +@pytest.fixture(scope="module") +def results(): + dataset, people, households = build_dataset() + # Bypass automatic multi-year projection for this fixed-year fixture. + sim = Microsimulation(dataset=USMultiYearDataset(datasets=[dataset])) + out = {} + for name in ( + "oasdi_payroll_tax_revenue", + "oasdi_benefit_taxation_revenue", + "oasdi_niit_revenue", + "oasdi_income", + "oasdi_cost", + "oasdi_modeled_balance", + "social_security", + "person_id", + "tax_unit_id", + "household_id", + ): + calculated = sim.calculate(name, YEAR) + out[name] = (np.asarray(calculated).copy(), np.asarray(calculated.weights)) + tob = out["oasdi_benefit_taxation_revenue"][0] + out["projected_tob"] = sim.calculate( + "oasdi_benefit_taxation_revenue", YEAR, map_to="person" + ) + out["split_tax_unit_revenue"] = sim.map_result( + tob + out["oasdi_niit_revenue"][0], "tax_unit", "household" + ) + return out, people, households + + +def weighted(out, name): + values, weights = out[name] + return float(np.sum(values.astype(float) * weights)) + + +def test_tax_units_carry_their_first_members_household_weight(results): + out, people, households = results + # Persons appear in dataset order; a tax unit's first member is its first + # person in that order. + first_household = people.groupby("person_tax_unit_id")[ + "person_household_id" + ].first() + tax_unit_ids = out["tax_unit_id"][0].astype(int) + expected = [ + households[first_household[tax_unit_id]]["household_weight"] + for tax_unit_id in tax_unit_ids + ] + np.testing.assert_array_equal(out["oasdi_niit_revenue"][1], expected) + + +def test_household_totals_match_independent_entity_totals(results): + out, people, households = results + payroll = weighted(out, "oasdi_payroll_tax_revenue") + tob = weighted(out, "oasdi_benefit_taxation_revenue") + niit = weighted(out, "oasdi_niit_revenue") + income = weighted(out, "oasdi_income") + cost = weighted(out, "oasdi_cost") + assert income == pytest.approx(payroll + tob + niit, rel=1e-6) + assert cost == pytest.approx(weighted(out, "social_security"), rel=1e-6) + assert weighted(out, "oasdi_modeled_balance") == pytest.approx( + income - cost, rel=1e-6 + ) + # The fixture exercises every term it checks. + assert payroll > 0 and tob > 0 and cost > 0 + # Projecting tax-unit revenue to every member counts couples twice; the + # fixture would detect that error. + assert float(out["projected_tob"].sum()) > tob + 1 + + +def test_each_household_holds_its_members_and_first_member_tax_units(results): + out, people, households = results + household_ids = out["household_id"][0].astype(int) + person_household = dict(zip(people.person_id, people.person_household_id)) + first_household = people.groupby("person_tax_unit_id")[ + "person_household_id" + ].first() + expected = dict.fromkeys(household_ids, 0.0) + payroll = out["oasdi_payroll_tax_revenue"][0] + for person_id, value in zip(out["person_id"][0].astype(int), payroll): + expected[person_household[person_id]] += float(value) + tax_unit_revenue = ( + out["oasdi_benefit_taxation_revenue"][0] + out["oasdi_niit_revenue"][0] + ) + for tax_unit_id, value in zip(out["tax_unit_id"][0].astype(int), tax_unit_revenue): + expected[first_household[tax_unit_id]] += float(value) + np.testing.assert_allclose( + out["oasdi_income"][0], + [expected[household_id] for household_id in household_ids], + rtol=0, + atol=0.01, + ) + + +def test_split_mapping_agrees_only_where_tax_units_nest_in_households(results): + out, people, households = results + household_ids = out["household_id"][0].astype(int) + person_household = dict(zip(people.person_id, people.person_household_id)) + household_payroll = dict.fromkeys(household_ids, 0.0) + for person_id, value in zip( + out["person_id"][0].astype(int), out["oasdi_payroll_tax_revenue"][0] + ): + household_payroll[person_household[person_id]] += float(value) + credited = out["oasdi_income"][0] - [ + household_payroll[household_id] for household_id in household_ids + ] + split = out["split_tax_unit_revenue"] + nested = ~np.isin(household_ids, [14, 15]) + np.testing.assert_allclose(credited[nested], split[nested], rtol=0, atol=0.01) + # Tax unit 27 spans households 14 and 15: splitting it by member count + # moves half of its revenue to a household with another weight. + assert not np.allclose(credited[~nested], split[~nested], rtol=0, atol=0.01) + weights = out["oasdi_income"][1] + tax_unit_total = weighted(out, "oasdi_benefit_taxation_revenue") + weighted( + out, "oasdi_niit_revenue" + ) + assert float(np.sum(credited * weights)) == pytest.approx(tax_unit_total, rel=1e-6) + assert float(np.sum(split * weights)) != pytest.approx(tax_unit_total, rel=1e-6) diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_benefit_taxation_revenue.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_benefit_taxation_revenue.yaml new file mode 100644 index 00000000000..b9140a79453 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_benefit_taxation_revenue.yaml @@ -0,0 +1,60 @@ +- name: Income tax on a beneficiary couple's benefits sends OASDI its pre-1993 share + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + social_security_retirement: 15_000 + taxable_interest_income: 50_000 + spouse: + age: 70 + social_security_retirement: 15_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + spm_units: + spm_unit: + members: [head, spouse] + takes_up_tanf_if_eligible: false + households: + household: + members: [head, spouse] + state_code: TX + output: + # Provisional income $50,000 + 50% * $30,000 = $65,000. + tax_unit_combined_income_for_social_security_taxability: 65_000 + # Pre-1993: min(50% * $30,000, 50% * ($65,000 - $32,000)) = $15,000. + tax_unit_taxable_social_security_pre_1993: 15_000 + # Current: min(85% * $30,000, 85% * ($65,000 - $44,000) + $6,000) = $23,850. + tax_unit_taxable_social_security: 23_850 + # Deductions $32,200 + 2 * $1,650 + 2 * $6,000 = $47,500. Taxable income + # $2,500 / $17,500 / $26,350 without, with pre-1993 and with current + # inclusion. Tax $250 / $1,750 / $2,480 + 12% * $1,550 = $2,666. + income_tax: 2_666 + tob_revenue_oasdi: 1_500 + tob_revenue_medicare_hi: 916 + # OASDI gets $1,750 - $250 = $1,500; the sum leaves out the $916 HI share. + oasdi_benefit_taxation_revenue: 1_500 + +- name: A negative OASDI share is kept, not floored + period: 2024 + absolute_error_margin: 0.01 + input: + age: 67 + filing_status: SINGLE + tax_unit_itemizes: true + social_security: 1_000 + taxable_interest_income: 11_500 + tax_exempt_interest_income: 23_000 + charitable_cash_donations: 20_000 + itemized_medical_expenses: 0 + salt_deduction: 0 + takes_up_tanf_if_eligible: false + state_code: TX + output: + # The tob_revenue_oasdi witness: inclusion releases charitable deductions + # and raises the elderly credit, so tax falls from $160 to $142.50. + tob_revenue_oasdi: -17.50 + oasdi_benefit_taxation_revenue: -17.50 diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_cost.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_cost.yaml new file mode 100644 index 00000000000..faaa08512e4 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_cost.yaml @@ -0,0 +1,44 @@ +- name: Modeled benefits sum all four OASDI components and exclude SSI + period: 2026 + absolute_error_margin: 0.01 + input: + people: + retiree: + age: 70 + social_security_retirement: 12_000 + social_security_survivors: 6_000 + ssi: 2_000 + dependent: + age: 16 + social_security_dependents: 2_400 + disabled_worker: + age: 50 + social_security_disability: 3_600 + tax_units: + tax_unit: + members: [retiree, dependent, disabled_worker] + spm_units: + spm_unit: + members: [retiree, dependent, disabled_worker] + takes_up_tanf_if_eligible: false + households: + household: + members: [retiree, dependent, disabled_worker] + state_code: TX + output: + social_security: [18_000, 2_400, 3_600] + # $18,000 + $2,400 + $3,600; the $2,000 of SSI is not an OASDI benefit. + oasdi_cost: 24_000 + +- name: Modeled benefits follow enabled benefit levers + period: 2027 + absolute_error_margin: 0.01 + input: + gov.contrib.ssa.benefit_levers.across_the_board.start_year: 2027 + gov.contrib.ssa.benefit_levers.across_the_board.rate: 0.08 + social_security_retirement_reported: 12_000 + social_security_survivors_reported: 6_000 + output: + # 1.08 * ($12,000 + $6,000) = $19,440. + social_security: 19_440 + oasdi_cost: 19_440 diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_income.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_income.yaml new file mode 100644 index 00000000000..65f012796ea --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_income.yaml @@ -0,0 +1,70 @@ +- name: Each person's payroll liability and each tax unit's revenue count once + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + social_security_retirement: 15_000 + taxable_interest_income: 50_000 + spouse: + age: 70 + social_security_retirement: 15_000 + worker: + age: 40 + employment_income: 50_000 + tax_units: + couple: + members: [head, spouse] + filing_status: JOINT + worker_unit: + members: [worker] + filing_status: SINGLE + spm_units: + spm_unit: + members: [head, spouse, worker] + takes_up_tanf_if_eligible: false + households: + household: + members: [head, spouse, worker] + state_code: TX + output: + # The worker's 12.4% * $50,000 = $6,200 is the only payroll liability. + oasdi_payroll_tax_revenue: [0, 0, 6_200] + # The couple's OASDI benefit-taxation share, as in the two-person case. + oasdi_benefit_taxation_revenue: [1_500, 0] + oasdi_niit_revenue: [0, 0] + # $6,200 + $1,500. Projecting the couple's $1,500 to both members and + # summing would give $9,200. + oasdi_income: 7_700 + +- name: Households keep their own liabilities + period: 2026 + absolute_error_margin: 0.01 + input: + people: + wage_earner: + age: 40 + employment_income: 250_000 + self_employed: + age: 40 + self_employment_income: 100_000 + tax_units: + tax_unit_1: + members: [wage_earner] + tax_unit_2: + members: [self_employed] + spm_units: + spm_unit_1: + members: [wage_earner] + spm_unit_2: + members: [self_employed] + households: + household_1: + members: [wage_earner] + state_code: TX + household_2: + members: [self_employed] + state_code: TX + output: + oasdi_income: [22_878, 11_451.40] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_modeled_balance.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_modeled_balance.yaml new file mode 100644 index 00000000000..6a743c59435 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_modeled_balance.yaml @@ -0,0 +1,41 @@ +- name: Modeled balance is modeled liabilities less modeled benefits + period: 2026 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 40 + employment_income: 50_000 + head: + age: 70 + social_security_retirement: 15_000 + taxable_interest_income: 50_000 + spouse: + age: 70 + social_security_retirement: 15_000 + tax_units: + worker_unit: + members: [worker] + filing_status: SINGLE + couple: + members: [head, spouse] + filing_status: JOINT + spm_units: + worker_spm_unit: + members: [worker] + couple_spm_unit: + members: [head, spouse] + takes_up_tanf_if_eligible: false + households: + worker_household: + members: [worker] + state_code: TX + couple_household: + members: [head, spouse] + state_code: TX + output: + # Worker: $6,200 of payroll liability and no benefits. + # Couple: $1,500 of OASDI benefit taxation and $30,000 of benefits. + oasdi_income: [6_200, 1_500] + oasdi_cost: [0, 30_000] + oasdi_modeled_balance: [6_200, -28_500] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_niit_revenue.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_niit_revenue.yaml new file mode 100644 index 00000000000..7d07dbd8124 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_niit_revenue.yaml @@ -0,0 +1,42 @@ +- name: Without a section 8 option the transfer is zero, whatever the NIIT + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 300_000 + taxable_interest_income: 100_000 + filing_status: SINGLE + state_code: TX + output: + # Existing NIIT: 3.8% * min($100,000, $400,000 - $200,000) = $3,800. + net_investment_income_tax: 3_800 + s770_revenue_oasdi: 0 + oasdi_niit_revenue: 0 + +- name: With section 8 on, OASDI gets the selected transfer and the liabilities add once + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.contrib.congress.s770.section_8.transfer.mode: 3 + age: 40 + employment_income: 100_000 + taxable_interest_income: 100_000 + s_corp_income: 200_000 + niit_magi: 400_000 + filing_status: SINGLE + state_code: TX + output: + # Limit X = $400,000 - $200,000 = $200,000; NII n = $100,000 and active + # a = $100,000. Liability (3.8% + 12.4%) * n + 16.2% * a = $32,400. + net_investment_income_tax: 32_400 + # Bill mode transfers 62% of all of it: $2,356 + $7,688 + $10,044. + s770_revenue_oasdi: 20_088 + oasdi_niit_revenue: 20_088 + oasdi_payroll_tax_revenue: 12_400 + oasdi_benefit_taxation_revenue: 0 + # $12,400 of payroll liability plus the $20,088 transfer. + oasdi_income: 32_488 + oasdi_modeled_balance: 32_488 diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_payroll_tax_revenue.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_payroll_tax_revenue.yaml new file mode 100644 index 00000000000..1a9ef441a66 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_payroll_tax_revenue.yaml @@ -0,0 +1,112 @@ +# 2026 parameters: wage base $184,500; employee and employer OASDI rates +# 6.2% each; SECA OASDI rate 12.4%; SE net earnings factor +# 1 - 0.5 * (0.124 + 0.029) = 0.9235. +- name: No earnings, no OASDI payroll liability + period: 2026 + input: + age: 40 + output: + oasdi_payroll_tax_revenue: 0 + +- name: Wage earner under the wage base pays both FICA shares on all wages + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + output: + # 6.2% * $50,000 = $3,100 for each share. + employee_social_security_tax: 3_100 + employer_social_security_tax: 3_100 + self_employment_social_security_tax: 0 + oasdi_payroll_tax_revenue: 6_200 + +- name: Wage earner above the wage base pays OASDI only on the base + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 250_000 + output: + # 6.2% * $184,500 = $11,439 for each share; 12.4% * $184,500 = $22,878. + taxable_earnings_for_social_security: 184_500 + employee_social_security_tax: 11_439 + employer_social_security_tax: 11_439 + oasdi_payroll_tax_revenue: 22_878 + +- name: Self-employed earner pays SECA OASDI on net earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + self_employment_income: 100_000 + output: + # Net earnings 0.9235 * $100,000 = $92,350; 12.4% * $92,350 = $11,451.40. + social_security_taxable_self_employment_income: 92_350 + employee_social_security_tax: 0 + employer_social_security_tax: 0 + self_employment_social_security_tax: 11_451.40 + oasdi_payroll_tax_revenue: 11_451.40 + +- name: Wages plus self-employment earnings at the wage base boundary + period: 2026 + absolute_error_margin: 0.01 + input: + age: [40, 40, 40] + employment_income: [166_030, 166_030, 166_030] + self_employment_income: [10_000, 20_000, 30_000] + output: + # Room after wages: $184,500 - $166,030 = $18,470. + # Net earnings 0.9235 * SE income: $9,235 / $18,470 / $27,705. + # The middle earner fills the room exactly; the third hits the cap. + social_security_taxable_self_employment_income: [9_235, 18_470, 18_470] + # 6.2% * $166,030 = $10,293.86 for each share. + employee_social_security_tax: [10_293.86, 10_293.86, 10_293.86] + employer_social_security_tax: [10_293.86, 10_293.86, 10_293.86] + # 12.4% * $9,235 = $1,145.14; 12.4% * $18,470 = $2,290.28. + self_employment_social_security_tax: [1_145.14, 2_290.28, 2_290.28] + # At and above the boundary the total is 12.4% * $184,500 = $22,878. + oasdi_payroll_tax_revenue: [21_732.86, 22_878, 22_878] + +- name: The employer-aggregate liability is not a fourth stream + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + employer_total_taxable_earnings_for_social_security: 1_000_000 + output: + # 6.2% * $1,000,000 on the separate employer-aggregate input. + employer_total_social_security_tax: 62_000 + # Unchanged from the wage earner under the base. + oasdi_payroll_tax_revenue: 6_200 + +- name: The owners' draw OASDI tax arrives once, through the SECA leaf + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + # Fix the wage base and the draw's current-law room cap, as the owners' + # draw tests do, so the case does not depend on wage base projections. + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + age: [40, 40] + employment_income: [100_000, 0] + self_employment_income: [0, 50_000] + s_corp_income: [200_000, 100_000] + output: + # Owner 1: room $200,000 - $100,000 = $100,000 for a $184,700 adjusted + # draw, so 12.4% * $100,000 = $12,400. + # Owner 2: existing SE net earnings 0.9235 * $50,000 = $46,175 pay + # $5,725.70; the $92,350 adjusted draw fits in the $153,825 room and pays + # 12.4% * $92,350 = $11,451.40. + owners_draw_oasdi_tax: [12_400, 11_451.40] + self_employment_social_security_tax: [12_400, 17_177.10] + employee_social_security_tax: [6_200, 0] + employer_social_security_tax: [6_200, 0] + # Owner 1 reaches 12.4% * $200,000 = $24,800. Adding the draw tax again + # would give $37,200 and $28,628.50. + oasdi_payroll_tax_revenue: [24_800, 17_177.10] + oasdi_income: [24_800, 17_177.10] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/test_oasdi_accounting.py b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/test_oasdi_accounting.py new file mode 100644 index 00000000000..81b93dfbe71 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/test_oasdi_accounting.py @@ -0,0 +1,252 @@ +"""Generated OASDI accounting identities and a differential with existing outputs. + +The variable YAML files hold the hand-computed examples. Python quantifies the +accounting identities over generated multi-person households and compares +existing outputs with and without the new variables in the calculation. +Each draw builds households of one to three adults in one or two tax units, +plus fixed boundary witnesses, in one vectorized simulation that shares the +benefit-taxation counterfactuals across the batch. + +Invariants, for every generated household: + +1. ``oasdi_payroll_tax_revenue`` is the sum of the three worker-level OASDI tax + leaves, is nonnegative, and never exceeds the combined OASDI rate times the + capped base ``min(wage base, covered wages + SE net earnings)``. +2. ``oasdi_income`` equals the members' payroll liabilities plus each tax + unit's ``tob_revenue_oasdi`` and ``s770_revenue_oasdi``, each counted once. + The reference uses the generated memberships, not the model's mapping. +3. ``oasdi_cost`` equals the members' ``social_security``, and + ``oasdi_modeled_balance`` equals ``oasdi_income - oasdi_cost``. +4. With no section 8 option enabled, ``oasdi_niit_revenue`` is zero, including + for units that owe net investment income tax. +5. Differential: calculating the new outputs leaves the existing outputs equal + to those of a fresh simulation that never calculates them. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation +from policyengine_us.system import system + +YEAR = 2026 +PAYROLL = system.parameters(YEAR).gov.irs.payroll.social_security +SE_RATE = system.parameters(YEAR).gov.irs.self_employment.rate.social_security +CAP = PAYROLL.cap +# The largest OASDI rate on any dollar of covered earnings. +COMBINED_RATE = max(PAYROLL.rate.employee + PAYROLL.rate.employer, SE_RATE) +# Float32 tax arithmetic can lose a few mills at the upper generated bound. +TOLERANCE = 0.01 + +LEAVES = ( + "employee_social_security_tax", + "employer_social_security_tax", + "self_employment_social_security_tax", +) +NEW = ( + "oasdi_payroll_tax_revenue", + "oasdi_benefit_taxation_revenue", + "oasdi_niit_revenue", + "oasdi_income", + "oasdi_cost", + "oasdi_modeled_balance", +) +# Existing outputs the new variables read, or that share their branches. +EXISTING = ( + *LEAVES, + "employer_total_social_security_tax", + "taxable_earnings_for_social_security", + "taxable_self_employment_income", + "payroll_tax_gross_wages", + "social_security", + "tax_unit_taxable_social_security", + "income_tax", + "net_investment_income_tax", + "tob_revenue_oasdi", + "tob_revenue_medicare_hi", + "s770_revenue_oasdi", +) + + +def adult(wages=0, se=0, benefits=0, interest=0, age=40): + return dict( + employment_income=wages, + self_employment_income=se, + social_security_retirement=benefits, + taxable_interest_income=interest, + age=age, + ) + + +# Each household is (members, whether the first two file jointly). +FIXED_HOUSEHOLDS = ( + # Wages exactly at the base, and wages plus SE earnings filling it. + ([adult(wages=CAP), adult(wages=CAP - 18_470, se=20_000)], False), + # A self-employment loss pays no SECA. + ([adult(se=-20_000)], False), + # A beneficiary couple who owe tax on benefits, with a worker who files + # separately in the same household. + ( + [ + adult(benefits=15_000, interest=50_000, age=70), + adult(benefits=15_000, age=70), + adult(wages=50_000), + ], + True, + ), + # The unit owes net investment income tax, but no section 8 option applies. + ([adult(wages=300_000, interest=100_000)], False), +) + +PERSON = st.builds( + adult, + wages=st.one_of(st.just(0), st.integers(0, 300_000)), + se=st.one_of(st.just(0), st.integers(-20_000, 150_000)), + benefits=st.one_of(st.just(0), st.integers(0, 40_000)), + interest=st.one_of(st.just(0), st.integers(0, 150_000)), + age=st.sampled_from([40, 70]), +) +HOUSEHOLD = st.tuples(st.lists(PERSON, min_size=1, max_size=3), st.booleans()) + + +def build(households): + """Return a situation and each person's and tax unit's household.""" + situation = { + entity: {} + for entity in ( + "people", + "tax_units", + "households", + "marital_units", + "spm_units", + "families", + ) + } + person_household, tax_unit_household = {}, {} + for h, (members, joint) in enumerate(households): + names = [f"person_{h}_{i}" for i in range(len(members))] + for name, inputs in zip(names, members): + situation["people"][name] = { + key: {YEAR: value} for key, value in inputs.items() + } + person_household[name] = h + if joint and len(names) >= 2: + groups = [names[:2], *([name] for name in names[2:])] + else: + groups = [[name] for name in names] + for t, group in enumerate(groups): + tax_unit = f"tax_unit_{h}_{t}" + filing_status = "JOINT" if len(group) == 2 else "SINGLE" + for j, name in enumerate(group): + situation["people"][name]["tax_unit_role_input"] = { + YEAR: "HEAD" if j == 0 else "SPOUSE" + } + situation["tax_units"][tax_unit] = { + "members": group, + "filing_status": {YEAR: filing_status}, + # Keep optional itemized deductions out of the counterfactuals. + "itemized_medical_expenses": {YEAR: 0}, + "salt_deduction": {YEAR: 0}, + } + situation["marital_units"][f"marital_unit_{h}_{t}"] = {"members": group} + tax_unit_household[tax_unit] = h + situation["households"][f"household_{h}"] = { + "members": names, + "state_code": {YEAR: "TX"}, + } + # Missing group entities default to one group containing everyone, + # so define each explicitly to keep households independent. + for entity in ("spm_units", "families"): + situation[entity][f"unit_{h}"] = {"members": names} + situation["spm_units"][f"unit_{h}"]["takes_up_tanf_if_eligible"] = {YEAR: False} + return situation, person_household, tax_unit_household + + +def positions(sim, entity, names): + index = {name: i for i, name in enumerate(sim.populations[entity].ids)} + return np.array([index[name] for name in names]) + + +@settings( + max_examples=3, + derandomize=True, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.lists(HOUSEHOLD, min_size=4, max_size=6)) +def test_oasdi_accounting_identities(generated): + households = (*FIXED_HOUSEHOLDS, *generated) + situation, person_household, tax_unit_household = build(households) + sim = Simulation(situation=situation) + new = {name: np.asarray(sim.calculate(name, YEAR)).copy() for name in NEW} + existing = {name: np.asarray(sim.calculate(name, YEAR)).copy() for name in EXISTING} + + # 1. Payroll liability: three leaves, nonnegative, within the capped base. + payroll = new["oasdi_payroll_tax_revenue"] + np.testing.assert_allclose( + payroll, sum(existing[leaf] for leaf in LEAVES), rtol=0, atol=TOLERANCE + ) + covered = ( + existing["payroll_tax_gross_wages"] + existing["taxable_self_employment_income"] + ) + assert np.all(payroll >= 0) + assert np.all(payroll <= COMBINED_RATE * np.minimum(CAP, covered) + TOLERANCE) + # The employer-aggregate liability stays outside the sum. + assert np.all(existing["employer_total_social_security_tax"] == 0) + + # 2. Household liabilities count each person and tax unit once. + people = positions(sim, "person", list(person_household)) + tax_units = positions(sim, "tax_unit", list(tax_unit_household)) + n_households = len(households) + np.testing.assert_array_equal( + new["oasdi_benefit_taxation_revenue"], existing["tob_revenue_oasdi"] + ) + np.testing.assert_array_equal( + new["oasdi_niit_revenue"], existing["s770_revenue_oasdi"] + ) + tax_unit_revenue = new["oasdi_benefit_taxation_revenue"] + new["oasdi_niit_revenue"] + reference_income = np.bincount( + list(person_household.values()), + weights=payroll[people].astype(float), + minlength=n_households, + ) + np.bincount( + list(tax_unit_household.values()), + weights=tax_unit_revenue[tax_units].astype(float), + minlength=n_households, + ) + household_positions = positions( + sim, "household", [f"household_{h}" for h in range(n_households)] + ) + income = new["oasdi_income"][household_positions] + np.testing.assert_allclose(income, reference_income, rtol=0, atol=TOLERANCE) + + # 3. Modeled benefits and the modeled balance. + reference_cost = np.bincount( + list(person_household.values()), + weights=existing["social_security"][people].astype(float), + minlength=n_households, + ) + cost = new["oasdi_cost"][household_positions] + np.testing.assert_allclose(cost, reference_cost, rtol=0, atol=TOLERANCE) + np.testing.assert_allclose( + new["oasdi_modeled_balance"][household_positions], + income - cost, + rtol=0, + atol=TOLERANCE, + ) + + # 4. No section 8 option: no transfer, though some units owe NIIT. + assert np.all(new["oasdi_niit_revenue"] == 0) + assert np.any(existing["net_investment_income_tax"] > 0) + # The fixed witnesses keep the checks above from passing vacuously. + assert np.any(existing["tob_revenue_oasdi"] > 0) + assert np.any(np.isclose(payroll, COMBINED_RATE * CAP, rtol=0, atol=TOLERANCE)) + + # 5. Differential: a fresh simulation that never calculates the new + # outputs gives the same existing outputs. + fresh = Simulation(situation=situation) + for name in EXISTING: + np.testing.assert_array_equal( + np.asarray(fresh.calculate(name, YEAR)), existing[name], err_msg=name + ) diff --git a/policyengine_us/tools/social_security_preflight.py b/policyengine_us/tools/social_security_preflight.py index 4a6f4f311f6..361318c84df 100644 --- a/policyengine_us/tools/social_security_preflight.py +++ b/policyengine_us/tools/social_security_preflight.py @@ -57,6 +57,7 @@ "oasdi_niit_revenue", "oasdi_income", "oasdi_cost", + "oasdi_modeled_balance", ) ) @@ -64,6 +65,8 @@ def _is_calculated_tax_leaf(name: str) -> bool: # Include later NIIT and TOB revenue variants without rejecting source # inputs such as net investment income or a supplied NIIT income basis. + # The S.770 section 8 and owners' draw tax and OASDI transfer leaves feed + # the OASDI accounting outputs; their classification hooks remain inputs. return ( name in CALCULATED_TAX_LEAVES or name.startswith("tob_revenue_") @@ -71,6 +74,10 @@ def _is_calculated_tax_leaf(name: str) -> bool: or name.startswith("niit_liability") or name.startswith("niit_revenue") or name.endswith("_niit_revenue") + or ( + name.startswith(("s770_", "owners_draw_")) + and name.endswith(("_tax", "_revenue_oasdi")) + ) ) 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 06a9c77a7d4..f3aff396fbc 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 @@ -9,9 +9,10 @@ class oasdi_benefit_taxation_revenue(Variable): documentation = ( "The OASDI share of income tax on Social Security benefits: " "tob_revenue_oasdi, the tax attributable to section 86 inclusion " - "before the 1993 amendments. The HI share (tob_revenue_medicare_hi) " - "is excluded. Signed: a unit whose tax falls when benefits are " - "included contributes a negative amount, so sum units without a floor." + "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." ) unit = USD reference = "https://www.ssa.gov/OP_Home/comp2/F098-021.html" diff --git a/policyengine_us/variables/gov/ssa/oasdi/oasdi_niit_revenue.py b/policyengine_us/variables/gov/ssa/oasdi/oasdi_niit_revenue.py index 751b546a593..84312e5128e 100644 --- a/policyengine_us/variables/gov/ssa/oasdi/oasdi_niit_revenue.py +++ b/policyengine_us/variables/gov/ssa/oasdi/oasdi_niit_revenue.py @@ -8,7 +8,7 @@ class oasdi_niit_revenue(Variable): label = "OASDI modeled net investment income tax transfer" documentation = ( "The OASDI transfer selected by the S.770 section 8 options " - "(s770_revenue_oasdi): zero unless an option is enabled. This is the " + "(s770_revenue_oasdi): zero unless an option applies. This is the " "transfer under the selected mode, not the full net investment " "income tax liability." ) diff --git a/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py b/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py index b5c02ad5490..c3b91bab82d 100644 --- a/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py +++ b/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py @@ -12,11 +12,11 @@ class oasdi_payroll_tax_revenue(Variable): "the SECA OASDI tax on self-employment earnings in the remaining room. " "A reform that replaces one of these leaves arrives through that leaf; " "the owners' draw reform adds its OASDI tax inside the SECA leaf, so " - "it is counted once. employer_total_social_security_tax is excluded: " - "it applies the employer rate to a separate employer-aggregate " - "earnings input, another measure of the employer share already " - "counted here, not a fourth stream. A modeled liability, not a " - "trust-fund receipt." + "the sum counts it once. The sum leaves out " + "employer_total_social_security_tax: it applies the employer rate to a " + "separate employer-aggregate earnings input, another measure of the " + "employer share this sum already counts, not a fourth stream. A " + "modeled liability, not a trust-fund receipt." ) unit = USD reference = ( From aea8180146264500809d027b90c1a3ee3af61ca7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 15:38:39 -0400 Subject: [PATCH 3/5] Address review: E1xE2 case, TOB/NIIT overlap note, sharper tests Add a case with the owners' draw and S.770 section 8 both on, showing each liability counted once. Document that tob_revenue_oasdi includes NIIT, which overlaps the section 8 transfer, and the scoring data's missing passive subset. Score the file the preflight hashed. Make the simplest Hypothesis draw a boundary witness, place preflight inputs on their own entities, and drop a partner namespace from the docs. Co-Authored-By: Claude Opus 5.5 --- .../usage/social-security-oasdi-accounting.md | 67 +++++++++++++------ .../test_social_security_preflight.py | 26 +++---- .../baseline/gov/ssa/oasdi/oasdi_income.yaml | 38 +++++++++++ .../ssa/oasdi/oasdi_payroll_tax_revenue.yaml | 4 +- .../gov/ssa/oasdi/test_oasdi_accounting.py | 34 ++++++---- .../variables/gov/ssa/oasdi/oasdi_income.py | 5 +- .../ssa/oasdi/oasdi_payroll_tax_revenue.py | 9 +-- 7 files changed, 129 insertions(+), 54 deletions(-) diff --git a/docs/usage/social-security-oasdi-accounting.md b/docs/usage/social-security-oasdi-accounting.md index f1320489372..332e20d3780 100644 --- a/docs/usage/social-security-oasdi-accounting.md +++ b/docs/usage/social-security-oasdi-accounting.md @@ -40,25 +40,42 @@ for the transfer modes. ### Flows left out * `employer_total_social_security_tax` applies the employer rate to - `employer_total_taxable_earnings_for_social_security`, a separate - employer-aggregate input that defaults to zero. It measures the employer share - that `employer_social_security_tax` already counts per worker, so it is not a - fourth stream. + `employer_total_taxable_earnings_for_social_security`, a separate input for + employer-only payroll tax calculations that defaults to zero, rather than to a + worker's own wages. Where both are populated they describe the same employer + liability, so the sum keeps only the worker-level `employer_social_security_tax` + and the aggregate is not a fourth stream. * `unreported_payroll_tax`, the tax on unreported tips and wages from Forms 4137 and 8919, is a TaxUnit input that doesn't separate OASDI from HI. * `employer_ss_tax_income_tax_revenue`, the income tax on employer OASDI - contributions under the `gov.contrib.crfb.tax_employer_payroll_tax` reform, - isn't part of `oasdi_income`. A score that enables that reform adds it + contributions under the contributed reform that counts employer payroll tax as + 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 a share of the whole resulting NIIT, so `oasdi_income` counts the +NIIT that benefit inclusion triggers in both terms. The fix belongs in +`tob_revenue_oasdi`, which these outputs read unchanged. Until it lands, treat +`oasdi_income` as overstated for beneficiaries whose inclusion pushes them past +the NIIT threshold under section 8. + ## Entity mapping and weights `oasdi_income` credits each tax unit's amount once, to the household of its first member. `Microsimulation` weights a tax unit by that same household's weight, so household-weighted totals equal the Person and TaxUnit weighted -totals, even for a tax unit whose members live in different households. Core's -group-to-group `adds` would instead split a tax unit's amount equally among its -members' households. +totals. PE-US declares that tax units sit inside households, and the chunked +helper below requires it. If data ever breaks that nesting, the first-member +credit still keeps the weighted totals equal. Core's group-to-group `adds` would +instead split a tax unit's amount equally among its members and credit each +member's share to that member's household. When you aggregate, sum each output at its own entity with that entity's weights, once. Don't project a tax-unit amount to its members and sum it again: @@ -76,8 +93,8 @@ expenses and general-fund reimbursements. None of these flows is a model variable. **Recipients outside the frame.** `oasdi_cost` sums benefits for the people in -the household dataset. Benefits paid to recipients outside its frame, such as -beneficiaries living abroad, are missing or incompletely represented. +the household dataset. The dataset misses or only partly represents +beneficiaries outside its frame, such as those living abroad. **Noncovered employment.** `payroll_tax_gross_wages` is employment income less FICA pre-tax contributions, with no general OASDI-coverage flag. The model @@ -85,6 +102,15 @@ therefore taxes wages from employment that OASDI doesn't cover, such as some state and local government jobs, as covered wages. This wage-coverage assumption differs from the missing cash flows. +**Pass-through classification in the scoring data.** The owners' draw term in +payroll liability and the active-business part of `oasdi_niit_revenue` depend on +pass-through classification fields. The certified artifact's stored columns +don't include `passive_partnership_s_corp_income`, which therefore defaults to +zero. Population values for those two terms stay provisional until a scoring +artifact carries the repaired fields and the preflight receipt confirms them; +the [S.770 section 8 README](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/reforms/congress/s770/README.md#scoring-data-limitation) +describes the repairs. + ## Scoring a population 1. **Audit the stored data first.** Run the @@ -92,7 +118,7 @@ assumption differs from the missing cash flows. H5 you'll score: ```bash - python -m policyengine_us.tools.social_security_preflight /path/to/populace_us_2024.h5 > receipt.json + python -m policyengine_us.tools.social_security_preflight /data/populace_us_2024.h5 > receipt.json ``` It checks the artifact's hash and base year and the four benefit components' @@ -102,13 +128,14 @@ assumption differs from the missing cash flows. 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.** `tob_revenue_oasdi` recalculates income tax in - two branches of the whole simulation, so its working memory grows with the - population. Household-chunked calculation, +2. **Score in household chunks.** On main, `tob_revenue_oasdi` recalculates + income tax in two branches of the whole simulation, so its working memory + grows with the population. Household-chunked calculation, [PolicyEngine/policyengine-us#10561](https://github.com/PolicyEngine/policyengine-us/pull/10561), is the supported route for full-population scoring once it merges. Review - may still change its interface, so check its documentation when it lands. In - the draft, a call looks like this: + may still change its interface, so check its documentation when it lands. + Score the same file the preflight hashed. With the interface under review, a + call looks like this: ```python from policyengine_us import Microsimulation @@ -126,13 +153,13 @@ assumption differs from the missing cash flows. ], 2030, 8, - dataset=Microsimulation.default_dataset, + dataset="/data/populace_us_2024.h5", ) totals = result.weighted_sums ``` - Chunks keep households whole, and each tax unit's amount sits in one - household, so each output's chunk totals add to its population total. + The helper keeps households whole and requires each tax unit to sit inside + one household, so each output's chunk totals add to its population total. 3. **Check the identities on the totals.** The `oasdi_income` total equals the payroll, benefit-taxation and NIIT-transfer totals added together, and the 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 f5a3d5ad7dc..a982a5089d3 100644 --- a/policyengine_us/tests/code_health/test_social_security_preflight.py +++ b/policyengine_us/tests/code_health/test_social_security_preflight.py @@ -125,24 +125,24 @@ def test_stored_outputs_rejected_even_when_zero(tmp_path, column): @pytest.mark.parametrize( - "column", + "entity, column", [ - "s770_passive_se_income", - "s770_active_fica_income", - "s770_nii_fica_seca_income", - "s770_niit_nol_deduction", - "s770_draw_gross", - "s770_draw_oasdi_base", - "s770_draw_hi_in_effect", - "owners_draw_subsets_are_disjoint", - "owners_draw_passive_se_overlap", + ("person", "s770_passive_se_income"), + ("person", "s770_active_fica_income"), + ("tax_unit", "s770_nii_fica_seca_income"), + ("tax_unit", "s770_niit_nol_deduction"), + ("person", "s770_draw_gross"), + ("person", "s770_draw_oasdi_base"), + ("person", "s770_draw_hi_in_effect"), + ("person", "owners_draw_subsets_are_disjoint"), + ("person", "owners_draw_passive_se_overlap"), ], ) def test_section_8_and_owners_draw_classification_inputs_remain_sources( - tmp_path, column + tmp_path, entity, column ): - receipt = audit(make_dataset(tmp_path, tax_unit_extra={column: [0]})) - assert column in receipt["stored_columns"] + path = make_dataset(tmp_path, **{f"{entity}_extra": {column: [0]}}) + assert column in audit(path)["stored_columns"] def test_both_component_names_would_preserve_a_bypassing_override(tmp_path): diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_income.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_income.yaml index 65f012796ea..37d5b4a7e61 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_income.yaml @@ -68,3 +68,41 @@ state_code: TX output: oasdi_income: [22_878, 11_451.40] + +- name: With the owners' draw and section 8 both on, each liability counts once + period: 2027 + absolute_error_margin: 0.02 + reforms: + - policyengine_us.reforms.ssa.owners_draw.owners_draw + - policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.contrib.congress.s770.section_8.transfer.mode: 3 + age: 40 + employment_income: 100_000 + s_corp_income: 200_000 + taxable_interest_income: 100_000 + niit_magi: 400_000 + filing_status: SINGLE + state_code: TX + output: + # Payroll: 6.2% * $100,000 twice, plus 12.4% of the draw's $100,000 of + # room = $24,800, with the draw's OASDI tax inside the SECA leaf once. + owners_draw_oasdi_tax: 12_400 + oasdi_payroll_tax_revenue: 24_800 + # M7: only the profits the draw's OASDI tax reaches leave the active base: + # $100,000 / 0.9235 = $108,283.70, leaving A = $91,716.30. + owners_draw_niit_exclusion: 108_283.70 + s770_active_business_income: 91_716.30 + # X = $200,000; n = $100,000 and a = $91,716.30. Liability $3,800 + + # $12,400 + 16.2% * $91,716.30 = $31,058.04; bill mode transfers 62%. + net_investment_income_tax: 31_058.04 + oasdi_niit_revenue: 19_255.98 + # $24,800 + $19,255.98. Leaving the taxed draw in the active base would + # give $24,800 + $20,088 = $44,888. + oasdi_income: 44_055.98 diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_payroll_tax_revenue.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_payroll_tax_revenue.yaml index 1a9ef441a66..916e60193e5 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_payroll_tax_revenue.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_payroll_tax_revenue.yaml @@ -86,8 +86,8 @@ absolute_error_margin: 0.02 reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw input: - # Fix the wage base and the draw's current-law room cap, as the owners' - # draw tests do, so the case does not depend on wage base projections. + # Fix the wage base and the draw's current-law room cap at $200,000, as + # the owners' draw tests do. gov.contrib.ssa.owners_draw.current_law_cap: 200_000 gov.contrib.ssa.owners_draw.in_effect: true gov.contrib.ssa.owners_draw.phase_in_share: 1 diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/test_oasdi_accounting.py b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/test_oasdi_accounting.py index 81b93dfbe71..c325919606a 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/test_oasdi_accounting.py +++ b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/test_oasdi_accounting.py @@ -3,11 +3,17 @@ The variable YAML files hold the hand-computed examples. Python quantifies the accounting identities over generated multi-person households and compares existing outputs with and without the new variables in the calculation. -Each draw builds households of one to three adults in one or two tax units, -plus fixed boundary witnesses, in one vectorized simulation that shares the -benefit-taxation counterfactuals across the batch. +Each draw builds four to six households of one to three adults, filing as +singles or as a joint couple plus singles, alongside fixed boundary witnesses, +in one vectorized simulation that shares the benefit-taxation counterfactuals +across the batch. Hypothesis tries the simplest draw first, so each strategy's +first choice is a boundary value: that draw is a batch of beneficiaries aged 70 +with wages above the base, self-employment earnings and taxable benefits. The +second draw is random. -Invariants, for every generated household: +Invariants, for every generated household under current-law payroll +parameters (reforms that tax earnings above the wage base lift invariant 1's +bound by design): 1. ``oasdi_payroll_tax_revenue`` is the sum of the three worker-level OASDI tax leaves, is nonnegative, and never exceeds the combined OASDI rate times the @@ -55,7 +61,6 @@ # Existing outputs the new variables read, or that share their branches. EXISTING = ( *LEAVES, - "employer_total_social_security_tax", "taxable_earnings_for_social_security", "taxable_self_employment_income", "payroll_tax_gross_wages", @@ -99,15 +104,18 @@ def adult(wages=0, se=0, benefits=0, interest=0, age=40): ([adult(wages=300_000, interest=100_000)], False), ) +# Each first choice is a boundary value, so the simplest draw is informative. PERSON = st.builds( adult, - wages=st.one_of(st.just(0), st.integers(0, 300_000)), - se=st.one_of(st.just(0), st.integers(-20_000, 150_000)), - benefits=st.one_of(st.just(0), st.integers(0, 40_000)), - interest=st.one_of(st.just(0), st.integers(0, 150_000)), - age=st.sampled_from([40, 70]), + wages=st.one_of(st.sampled_from([CAP + 1_000, CAP, 0]), st.integers(0, 300_000)), + se=st.one_of(st.sampled_from([60_000, 0, -20_000]), st.integers(-20_000, 150_000)), + benefits=st.one_of(st.sampled_from([24_000, 0]), st.integers(0, 40_000)), + interest=st.one_of(st.sampled_from([60_000, 0]), st.integers(0, 150_000)), + age=st.sampled_from([70, 40]), +) +HOUSEHOLD = st.tuples( + st.lists(PERSON, min_size=1, max_size=3), st.sampled_from([True, False]) ) -HOUSEHOLD = st.tuples(st.lists(PERSON, min_size=1, max_size=3), st.booleans()) def build(households): @@ -169,7 +177,7 @@ def positions(sim, entity, names): @settings( - max_examples=3, + max_examples=2, derandomize=True, deadline=None, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], @@ -192,8 +200,6 @@ def test_oasdi_accounting_identities(generated): ) assert np.all(payroll >= 0) assert np.all(payroll <= COMBINED_RATE * np.minimum(CAP, covered) + TOLERANCE) - # The employer-aggregate liability stays outside the sum. - assert np.all(existing["employer_total_social_security_tax"] == 0) # 2. Household liabilities count each person and tax unit once. people = positions(sim, "person", list(person_household)) diff --git a/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py b/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py index 8e117aa2a5b..7e8394c3cb0 100644 --- a/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py +++ b/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py @@ -11,7 +11,10 @@ class oasdi_income(Variable): "plus each tax unit's oasdi_benefit_taxation_revenue and " "oasdi_niit_revenue, each counted once. Excludes reserve interest, " "Railroad Retirement interchange and general-fund reimbursements, " - "which the model does not represent." + "which the model does not represent. tob_revenue_oasdi differences " + "income tax including the net investment income tax, so with an S.770 " + "section 8 option on, NIIT that benefit inclusion triggers also " + "enters oasdi_niit_revenue's transfer." ) unit = USD diff --git a/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py b/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py index c3b91bab82d..d60711ed300 100644 --- a/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py +++ b/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py @@ -13,10 +13,11 @@ class oasdi_payroll_tax_revenue(Variable): "A reform that replaces one of these leaves arrives through that leaf; " "the owners' draw reform adds its OASDI tax inside the SECA leaf, so " "the sum counts it once. The sum leaves out " - "employer_total_social_security_tax: it applies the employer rate to a " - "separate employer-aggregate earnings input, another measure of the " - "employer share this sum already counts, not a fourth stream. A " - "modeled liability, not a trust-fund receipt." + "employer_total_social_security_tax, which applies the employer rate " + "to a separate input for employer-only payroll tax calculations rather " + "than to a worker's wages; where both are populated they describe the " + "same employer liability, so it is not a fourth stream. A modeled " + "liability, not a trust-fund receipt." ) unit = USD reference = ( From 69c4178a6316df76393fec47fd8173284698d6c8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 16:55:26 -0400 Subject: [PATCH 4/5] Use active voice for the employer-aggregate note Co-Authored-By: Claude Opus 5.5 --- docs/usage/social-security-oasdi-accounting.md | 2 +- .../variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/usage/social-security-oasdi-accounting.md b/docs/usage/social-security-oasdi-accounting.md index 332e20d3780..a106dea93b8 100644 --- a/docs/usage/social-security-oasdi-accounting.md +++ b/docs/usage/social-security-oasdi-accounting.md @@ -42,7 +42,7 @@ for the transfer modes. * `employer_total_social_security_tax` applies the employer rate to `employer_total_taxable_earnings_for_social_security`, a separate input for employer-only payroll tax calculations that defaults to zero, rather than to a - worker's own wages. Where both are populated they describe the same employer + worker's own wages. Where data populate both, they describe the same employer liability, so the sum keeps only the worker-level `employer_social_security_tax` and the aggregate is not a fourth stream. * `unreported_payroll_tax`, the tax on unreported tips and wages from Forms 4137 diff --git a/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py b/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py index d60711ed300..8c476c4d88d 100644 --- a/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py +++ b/policyengine_us/variables/gov/ssa/oasdi/oasdi_payroll_tax_revenue.py @@ -15,7 +15,7 @@ class oasdi_payroll_tax_revenue(Variable): "the sum counts it once. The sum leaves out " "employer_total_social_security_tax, which applies the employer rate " "to a separate input for employer-only payroll tax calculations rather " - "than to a worker's wages; where both are populated they describe the " + "than to a worker's wages; where data populate both, they describe the " "same employer liability, so it is not a fourth stream. A modeled " "liability, not a trust-fund receipt." ) From b1804233d6e7f819fd35d87090f316faf4d9903a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 17:57:31 -0400 Subject: [PATCH 5/5] Correct the NIIT overlap and data-limitation notes The section 8 transfer shares the existing-rate NIIT only in bill and combined-rate modes, and the TOB term overstates even at baseline. The missing passive subset affects both parts of the NIIT transfer. Co-Authored-By: Claude Opus 5.5 --- docs/usage/social-security-oasdi-accounting.md | 17 +++++++++-------- .../baseline/gov/ssa/oasdi/oasdi_income.yaml | 5 +++-- .../gov/ssa/oasdi/test_oasdi_accounting.py | 5 +++-- .../variables/gov/ssa/oasdi/oasdi_income.py | 6 +++--- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/usage/social-security-oasdi-accounting.md b/docs/usage/social-security-oasdi-accounting.md index a106dea93b8..4b3f3044242 100644 --- a/docs/usage/social-security-oasdi-accounting.md +++ b/docs/usage/social-security-oasdi-accounting.md @@ -59,12 +59,13 @@ for the transfer modes. 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 a share of the whole resulting NIIT, so `oasdi_income` counts the -NIIT that benefit inclusion triggers in both terms. The fix belongs in -`tob_revenue_oasdi`, which these outputs read unchanged. Until it lands, treat -`oasdi_income` as overstated for beneficiaries whose inclusion pushes them past -the NIIT threshold under section 8. +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. ## Entity mapping and weights @@ -103,8 +104,8 @@ state and local government jobs, as covered wages. This wage-coverage assumption differs from the missing cash flows. **Pass-through classification in the scoring data.** The owners' draw term in -payroll liability and the active-business part of `oasdi_niit_revenue` depend on -pass-through classification fields. The certified artifact's stored columns +payroll liability and `oasdi_niit_revenue`, in both its NII and active parts, +depend on pass-through classification fields. The certified artifact's stored columns don't include `passive_partnership_s_corp_income`, which therefore defaults to zero. Population values for those two terms stay provisional until a scoring artifact carries the repaired fields and the preflight receipt confirms them; diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_income.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_income.yaml index 37d5b4a7e61..3995ef6e53b 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/oasdi_income.yaml @@ -99,8 +99,9 @@ # $100,000 / 0.9235 = $108,283.70, leaving A = $91,716.30. owners_draw_niit_exclusion: 108_283.70 s770_active_business_income: 91_716.30 - # X = $200,000; n = $100,000 and a = $91,716.30. Liability $3,800 + - # $12,400 + 16.2% * $91,716.30 = $31,058.04; bill mode transfers 62%. + # MAGI excess $200,000; NII base $100,000; active base $91,716.30. + # Liability $3,800 + $12,400 + 16.2% * $91,716.30 = $31,058.04; bill + # mode transfers 62%. net_investment_income_tax: 31_058.04 oasdi_niit_revenue: 19_255.98 # $24,800 + $19,255.98. Leaving the taxed draw in the active base would diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/test_oasdi_accounting.py b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/test_oasdi_accounting.py index c325919606a..e534e6e5e78 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/test_oasdi_accounting.py +++ b/policyengine_us/tests/policy/baseline/gov/ssa/oasdi/test_oasdi_accounting.py @@ -7,7 +7,7 @@ singles or as a joint couple plus singles, alongside fixed boundary witnesses, in one vectorized simulation that shares the benefit-taxation counterfactuals across the batch. Hypothesis tries the simplest draw first, so each strategy's -first choice is a boundary value: that draw is a batch of beneficiaries aged 70 +first choice is an informative nonzero value: that draw is a batch of beneficiaries aged 70 with wages above the base, self-employment earnings and taxable benefits. The second draw is random. @@ -104,7 +104,8 @@ def adult(wages=0, se=0, benefits=0, interest=0, age=40): ([adult(wages=300_000, interest=100_000)], False), ) -# Each first choice is a boundary value, so the simplest draw is informative. +# Each first choice is an informative nonzero value, so the simplest draw is +# informative. PERSON = st.builds( adult, wages=st.one_of(st.sampled_from([CAP + 1_000, CAP, 0]), st.integers(0, 300_000)), diff --git a/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py b/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py index 7e8394c3cb0..3fb3165e718 100644 --- a/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py +++ b/policyengine_us/variables/gov/ssa/oasdi/oasdi_income.py @@ -12,9 +12,9 @@ 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 with an S.770 " - "section 8 option on, NIIT that benefit inclusion triggers also " - "enters oasdi_niit_revenue's transfer." + "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." ) unit = USD