Skip to content

Add modeled OASDI liabilities, benefits and balance - #10563

Merged
MaxGhenis merged 5 commits into
mainfrom
ss-oasdi-accounting-b
Nov 1, 2026
Merged

MaxGhenis merged 5 commits into
mainfrom
ss-oasdi-accounting-b

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

PolicyEngine-US #10566 later moved the benefit-taxation measure to chapter 1 liability, capped historical inclusion at actual inclusion, and removed the NIIT overlap this description reports. The description below records this PR as merged.

Design v3 section B (lever 5): modeled OASDI liabilities, modeled OASDI benefits and their difference, the modeled balance. Six new outputs over existing variables; no existing variable changes. There is no stack: this branches from main, where everything B reads has merged (the signed TOB split #10381, s770_revenue_oasdi from #10402, the owners' draw reform #10483 and the scoring preflight #10400).

Definitions

Output Entity Definition
oasdi_payroll_tax_revenue Person adds: employee_social_security_tax + employer_social_security_tax + self_employment_social_security_tax
oasdi_benefit_taxation_revenue TaxUnit adds: tob_revenue_oasdi (no HI share)
oasdi_niit_revenue TaxUnit adds: s770_revenue_oasdi (zero unless a section 8 option applies)
oasdi_income Household members' payroll liabilities, plus each tax unit's two revenue terms credited once to its first member's household
oasdi_cost Household adds: public social_security, after any enabled benefit policies
oasdi_modeled_balance Household adds oasdi_income, subtracts oasdi_cost
  • Rates come from parameters. The leaves read gov.irs.payroll.social_security.rate.employee and .employer, the wage base gov.irs.payroll.social_security.cap, and gov.irs.self_employment.rate.social_security. B adds no rate.
  • Owners' draw counts once. Add contributed owners draw SECA reform #10483 replaces self_employment_social_security_tax with a subclass that adds owners_draw_oasdi_tax, so the draw's OASDI tax reaches oasdi_payroll_tax_revenue through that leaf, and B doesn't add it again. The YAML case "The owners' draw OASDI tax arrives once, through the SECA leaf" turns the reform on and confirms it: an owner with $100,000 of wages and $200,000 of S-corporation profits under a $200,000 base gets $24,800 (12.4% of the base), not the $37,200 a second copy would give.
  • E1 and E2 together. A case with the owners' draw and section 8 both on gives oasdi_income $44,055.98. The draw's OASDI tax sits once in payroll liability, and the active base excludes only the $108,283.70 of profits that tax reaches (M7). Leaving those profits in the active base would give $44,888.
  • The employer aggregate is not a fourth stream. 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 (default zero), not to a worker's own wages. A YAML case sets that input to $1,000,000 and leaves payroll liability unchanged.
  • The TOB share carries a sign. tob_revenue_oasdi is Fix OASDI/HI split of Social Security benefit tax revenue #10381's statutory split: tax with section 86 inclusion as before 1993, less tax with none, with no per-unit floor. A YAML witness keeps a −$17.50 unit negative. The vendored source-conflict register has no us:tob-oasdi-hi-signed-allocation entry at its current pin, so B cites none. B adds no reading of its own, and any later correction to tob_revenue_oasdi flows through unchanged.
  • NIIT: the transfer only. oasdi_niit_revenue is the selected section 8 transfer, never the whole net_investment_income_tax liability. A baseline case owes $3,800 of NIIT and gets a zero transfer.
  • Why oasdi_income has a formula, not adds. Core's group-to-group adds divides a tax unit's amount by its member count and credits each member's share to that member's household. Microsimulation weights a tax unit by its first member's household weight (_project_household_weights → value_from_first_person), so the formula credits each tax unit's amount to that same household. Household-weighted totals then equal the Person and TaxUnit weighted totals. PE-US declares that tax units nest in households; if data ever breaks that, the first-member credit still keeps the totals equal.
  • The preflight now covers B. Add composable Social Security benefit levers #10400's social_security_preflight already rejected stored oasdi_payroll_tax_revenue, oasdi_benefit_taxation_revenue, oasdi_niit_revenue, oasdi_income and oasdi_cost. This PR adds oasdi_modeled_balance, plus the S.770 section 8 and owners' draw tax and OASDI-transfer leaves that B reads: names starting s770_ or owners_draw_ and ending _tax or _revenue_oasdi. A test keeps their nine classification inputs valid sources. None of the five artifact inventories in Microcosm's pinned stored-column fixture holds any newly rejected name; the certified artifact has 377 columns.

Stated limitations

We state these without measuring them (design amendment M5). No residual from another model closes them.

  • Missing cash flows: reserve interest, the Railroad Retirement financial interchange, administrative expenses and general-fund reimbursements. The balance compares modeled liabilities with modeled benefits. It isn't a trust-fund cash balance.
  • Recipients outside the frame: oasdi_cost sums benefits for the people in 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 therefore taxes wages from noncovered employment (for example, some state and local government jobs) as covered wages. This wage-coverage assumption differs from the missing cash flows.
  • Benefit taxation overlaps the NIIT transfer. tob_revenue_oasdi differences income_tax, which includes net_investment_income_tax, and benefit inclusion raises niit_magi. So NIIT that inclusion adds lands in the TOB term, although the 42 U.S.C. 401 note transfers only chapter 1 liabilities and §1411 sits in chapter 2A; oasdi_benefit_taxation_revenue overstates by that amount even at baseline. With a section 8 option on, oasdi_niit_revenue also transfers shares of the add-on and active taxes (and, in bill or combined-rate mode, of the existing-rate NIIT), so oasdi_income counts that part of the increase twice. The fix belongs in tob_revenue_oasdi; it's out of scope here because B changes no existing variable (follow-up below).
  • Pass-through classification in the scoring data: the certified artifact stores no passive_partnership_s_corp_income, so the owners' draw term and both the NII and active parts of the section 8 transfer stay provisional on it until a repaired artifact passes the preflight.
  • Flows outside B's table: unreported_payroll_tax (Forms 4137 and 8919) is a TaxUnit input with no OASDI/HI split. employer_ss_tax_income_tax_revenue, the income tax on employer contributions under the contributed reform that counts employer payroll tax as income, isn't in oasdi_income. A score that enables that reform adds it separately.

Usage

docs/usage/social-security-oasdi-accounting.md gives the definitions, the limitations and the population route:

  1. Run the P1 stored-column preflight (python -m policyengine_us.tools.social_security_preflight <h5>) on the exact file you'll score.
  2. Score full populations in household chunks with calculate_chunked (Add household-chunked microsimulation calculations #10561, in review) once it merges, passing the same file. On main, tob_revenue_oasdi recalculates income tax in two branches of the whole simulation, so its working memory grows with the population. This PR builds no chunking.
  3. Check the identities on the totals.

Invariants

Each holds for every input under current-law payroll parameters. Two kinds of reform lift invariant 1's bound by design: those that tax earnings above the base, and the owners' draw, whose covered earnings exceed wages plus SE net earnings. With the draw on at 2026 parameters, $100,000 of wages and $200,000 of profits give $22,878 of payroll against a $12,400 bound.

  1. oasdi_payroll_tax_revenue equals the three leaves, is ≥ 0, and is ≤ the combined OASDI rate (max(employee + employer, SECA), from parameters) × min(wage base, covered wages + SE net earnings).
  2. oasdi_income equals the members' payroll liabilities plus each tax unit's tob_revenue_oasdi + s770_revenue_oasdi, once. The test builds its reference from the generated memberships, not the model's mapping.
  3. oasdi_cost equals the members' social_security, and oasdi_modeled_balance = oasdi_income − oasdi_cost.
  4. With section 8 off, oasdi_niit_revenue = 0, including for units that owe NIIT.
  5. Weighted totals: Σ_h w_h·oasdi_income = Σ_p w_p·payroll + Σ_tu w_tu·(TOB + NIIT transfer). Cost and balance follow the same pattern. Tax-unit weights equal the first member's household weight. Projecting a tax-unit amount to members double counts, and the fixture detects it.
  6. Differential between two mappings: the first-member credit and core's split mapping (map_result) agree for every tax unit inside one household. They differ only for a tax unit that spans households, where only the first-member credit matches the weights.
  7. Differential with existing outputs: calculating the new outputs leaves 13 existing outputs (the three OASDI leaves and their bases, social_security, taxable benefits, income_tax, NIIT, the OASDI and HI TOB shares, s770_revenue_oasdi) identical to a fresh simulation's. Statically, the diff modifies no existing variable, parameter or reform file.

No intended violations.

Tests

Local runs on a loaded shared macOS host, one file per process, each under a 4 GiB RSS cap and a 1800 s timeout with four numeric threads. Peak RSS is the largest single process; wall time includes model import. CPU time shows the load: wall time ran 7–20 times CPU.

File How it ran Result Wall / CPU Peak RSS
oasdi_payroll_tax_revenue.yaml core YAML runner 6 of 7 passed, then the cap stopped it at the owners' draw case 1265 s / 84 s 4.00 GiB (cap)
↳ owners' draw case Simulation(reform=…), same inputs and expectations 6/6 outputs passed 684 s / 70 s 3.40 GiB
oasdi_benefit_taxation_revenue.yaml Simulation per case (the runner hit the cap in case 1) 2/2 1429 s / 94 s 3.28 GiB
oasdi_income.yaml Simulation per case 3/3 (2 baseline, owners' draw + section 8) 1022 s + 442 s 3.36 GiB
oasdi_modeled_balance.yaml Simulation per case 1/1 831 s / 60 s 2.71 GiB
oasdi_niit_revenue.yaml runner for the baseline case; Simulation(reform=…) for section 8 2/2 1525 s + 1068 s 3.36 GiB
oasdi_cost.yaml core YAML runner 2/2 1066 s / 87 s 3.38 GiB
test_oasdi_accounting.py pytest 1 passed (two Hypothesis draws plus four fixed witnesses) 1754 s / 184 s 3.59 GiB
test_oasdi_accounting_weights.py pytest 4/4 254 s / 46 s 2.76 GiB
test_social_security_preflight.py pytest on a copy that loads the tool by file path (no model import) 53/53 57 s / 3 s 0.10 GiB
employee_social_security_tax.yaml (existing) core YAML runner 2/2 1622 s 2.15 GiB
self_employment_social_security_tax.yaml (existing) core YAML runner 2/2 1386 s 2.16 GiB

Not run locally: the other existing payroll and SECA files, the TOB files (tob_revenue_*.yaml, tob_tier_split.yaml, test_tob_revenue.py) and the section 8 files (four s770_*.yaml files, test_s770_section_8.py). Each file took 15–27 minutes of wall time on this host, and under the cap the section 8 YAML would need one process for each of its 17 structural combos. CI runs them all. B modifies none of their variables, and every run above built the full model with the six new variables registered.

Why some cases ran outside the YAML runner. Under the 4 GiB cap, the core YAML runner holds its own system beside the module-level one. A reform clone, or a TOB branch's system clone, then pushes past the cap. For those cases I evaluated the same inputs and expected outputs through Simulation, letting the structural reforms install from their in_effect parameters as in production. The committed YAML files stay as written, and CI runs them through the runner on 16 GB machines.

CI routing and cost

  • The YAML lives in a new folder, tests/policy/baseline/gov/ssa/oasdi/. test-yaml-no-structural-other-ssa's per-subdir pass (gov/ssa --exclude revenue) runs it as its own subprocess, with combo weight 3.25: three structural combos (owners' draw, section 8, both) and one parameter-only combo, under the batcher's 5.0. No file holds more than one structural combo, and the heavy gov/ssa/revenue batches don't change.
  • test_oasdi_accounting.py runs in the Rest Python "policy" group, test_oasdi_accounting_weights.py in test-microsimulation-base, and the preflight cases in code_health.
  • This PR adds no jobs or runners. Linux CI measurements aren't available yet; the local figures above come from a loaded macOS host.

Follow-ups proposed (not opened)

  • TOB: difference chapter 1 liability only. tob_revenue_oasdi and tob_revenue_total difference income_tax, which adds NIIT (chapter 2A); difference income tax less NIIT in both branches. (unreported_payroll_tax is an input, so it cancels between branches.) Coordinate with Add household-chunked microsimulation calculations #10561, which rewrites both files.
  • Preflight: reject stored intermediate leaves. For example owners_draw_oasdi_base, owners_draw_niit_exclusion, s770_nii_taxable_base and s770_active_business_income, and a stored income_tax, which would freeze both TOB branches.

impact: none; new accounting outputs only (no existing variable changes)

axiom: n/a: modeled accounting aggregates over existing variables, not a rule

🤖 Generated with Claude Code

MaxGhenis and others added 5 commits October 31, 2026 13:03
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 31, 2026 22:02
@MaxGhenis
MaxGhenis merged commit adabf9b into main Nov 1, 2026
43 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

US + core hub merge audit, us#10563 at b1804233d6e7f819fd35d87090f316faf4d9903a (squash): add modeled OASDI liabilities, benefits and balance.

  • Reviews: GPT-6.1 Sol on a Subfleet hard-tier lane approved this head in round 1. It reproduced all 17 YAML cases and 91 expected values from the parameters and the statute. It also confirmed each tax unit's revenue counts once on a split-household fixture. Its one P3 asked to qualify invariant 1 for the owners' draw, and the body now does.
  • Gates checked live at merge:
    • gh pr checks exits 0;
    • the latest Pull request run at the head (41246382219) concluded success;
    • MERGEABLE;
    • not a draft;
    • no reviewer's latest review requests changes;
    • head pinned.
  • Methodology: Sol marks a methodology choice. The body discloses the first-member allocation, the owners' draw exclusion and each limitation, and every output is new. The chapter 1 benefit-taxation fix changes this PR's benefit-taxation sentences and carries those edits itself.
  • Impact: none. The diff changes no existing variable, parameter or reform. A main/head comparison on a synthetic case matched all 13 existing outputs exactly. No full-file pair ran, because the base has none of the new outputs and the diff touches no existing formula.
  • Axiom: n/a, accounting aggregates over existing variables and not a rule.

@MaxGhenis
MaxGhenis deleted the ss-oasdi-accounting-b branch November 1, 2026 09:50
MaxGhenis added a commit that referenced this pull request Nov 1, 2026
Brings in #10563, whose OASDI accounting text this branch's chapter 1
measure makes stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Nov 1, 2026
#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 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Nov 1, 2026
* Measure benefit-taxation revenue as chapter 1 liability

* Check chapter 1 benefit-taxation differentials independently

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant