Skip to content

Add an opt-in 2026 Trustees wage and price path assumption - #10565

Open
MaxGhenis wants to merge 5 commits into
mainfrom
ss-trustees-2026-assumptions
Open

MaxGhenis wants to merge 5 commits into
mainfrom
ss-trustees-2026-assumptions

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds trustees_2026_economic_assumptions_reform, an opt-in reform that puts Social Security's wage and price paths on the intermediate assumptions of the 2026 OASDI Trustees Report, for long-range reform scoring. Long-range reform scoring has to match the Trustees' taxable payroll, and the taxable maximum and bend points run on gov.ssa.nawi. Main loads CBO's February 2026 NAWI through 2034 and then extends it at 3.2335% a year; the Trustees' AWI ends 2100 32% above that path, and chained onto the published NAWI(2025) it ends 34% above (1,076,751.82 against 804,773.88). Moving wages alone would leave real wage growth near 1.3% a year against the Trustees' 1.13%, so the reform moves prices too.

The reform stays off by default, and nothing registers it in a default list, so current-law output doesn't change. The 2025 Trustees functions keep their behaviour, and their tests pass unedited. extend_social_security_payroll_cap gains a keyword-only cap=None, which defaults to gov.irs.payroll.social_security.cap, so its other callers keep their behaviour.

Source: 2026 OASDI Trustees Report, single-year tables, Table VI.G1, intermediate assumptions, columns "Adjusted CPI" (2026 = 100) and "AWI", 2025 to 2100 (https://www.ssa.gov/oact/TR/2026/SingleYearTRTables_TR2026.xlsx, workbook sha256 38d8c5fe…ba361). The module carries both series as Python dicts, as trustees_2025.py does, and a test pins their sha256.

Chain rule

The module chains the Trustees' growth onto published values. It never writes the Trustees' levels.

  • NAWI. NAWI(2025) stays the published 72,800.33. For each year Y from 2026, NAWI(Y) = NAWI(Y − 1) × AWI(Y) / AWI(Y − 1).
  • COLA index. gov.ssa.uprating at Y-01-01 holds the third-quarter CPI-W of Y − 1, and 2027-01-01 (328.746, the 2026 third quarter) is the last actual. For each Y from 2028, U(Y) = U(Y − 1) × ACPI(Y − 1) / ACPI(Y − 2).
  • Both hold their end_year value (default 2100) after end_year.

Then, in order: the SOI income projection (apply_trustees_2025_soi_income_projection, reused; it reads the active NAWI), only when end_year exceeds 2036; extend_social_security_payroll_cap(last_projected_year=2027), on the live cap and then on its owners' draw copy; extend_social_security_wage_indexed_parameters; and the readers below.

Values read from a built reformed system and from main:

Path Year Reform Main
gov.ssa.nawi 2026 76,056.64 75,428.91
gov.ssa.nawi 2036 114,329.99 104,988.75
gov.ssa.nawi 2100 1,076,751.82 804,773.88
gov.ssa.uprating 2028 336.833 336.945
gov.ssa.uprating 2050 567.580 551.224
gov.ssa.uprating 2100 1,857.875 1,682.750
Taxable maximum 2028 201,000 199,200
Taxable maximum 2060 654,900 558,600
Taxable maximum 2100 2,652,900 1,995,300

Real wage growth from 2050 to 2100 comes to 1.127% a year under the reform; the Trustees' wages over main's prices would give 1.27%.

Reach

Core's uprate_parameters writes each reader's values into the parameter when the system builds, so a later change to a path doesn't reach them. The module finds every parameter whose uprating metadata names gov.ssa.nawi or gov.ssa.uprating by walking the tree (with _iter_updatable_parameters, as the core-thresholds reform does) and derives each again as core would from the new path: its last value stated in YAML, times path(Y) / path(that date), with its own rounding, for every year after the path's last unchanged year (2025 for NAWI, 2027 for the COLA index). Values the YAML states stay as stated. It raises for a reader that states a value after that year or uprates on a cadence, so a new reader that doesn't fit stops the build instead of staying on CBO's path. A test pins the list below.

The assumption also extends gov.contrib.ssa.owners_draw.current_law_cap from 2028 through end_year. It runs extend_social_security_payroll_cap(..., cap=<the copy>) on the copy itself, so the rule reads the copy's own values through 2027 as its anchor and no-decrease floor. A policy reform that sets the payroll cap changes only the live cap, so reform scoring keeps the assumption's cap path in the copy when a policy raises or removes the payroll cap, from 2027 or later, in either tuple order. Before this fix the assumption copied the live cap's path into the copy. On a built system's second pass, the live cap's extension read a policy's 2027 cap as its floor: removing the cap from 2027 left the copy at infinity in 2060, and a $10 million cap from 2027 left it at $10 million, in either order.

Parameter Path Rounding Last YAML value Moves from Why it moves
gov.ssa.nawi — — 2025 2026 The Trustees' AWI growth
gov.ssa.uprating — — 2027 2028 The Trustees' adjusted CPI growth, a year behind
calibration.gov.irs.soi.* (17 income totals) NAWI — 2036 2037 Wage growth times population growth (2025 Trustees function), when end_year exceeds 2036
gov.irs.payroll.social_security.cap NAWI $300 2036 (2028–2036 CBO-derived) 2028 42 U.S.C. 430(b) on NAWI(Y − 2); SSA last set it for 2027
gov.contrib.ssa.owners_draw.current_law_cap NAWI $300 — 2028 The cap's rule, run on the copy from its own values through 2027
gov.ssa.social_security.wage_base cap — — 2028 Equals the cap
QC threshold, both earnings-test exempt amounts, both PIA bend points, both SGA amounts NAWI $10 / $1 — 2028 Automatic determinations on NAWI(Y − 2)
gov.ssa.ssi.amount.individual, .couple COLA index none 2027 2028 Federal benefit rate COLA
gov.ssa.ssi.income.exclusions.blind_or_disabled_working_student.amount, .cap COLA index $10 2027 2028 Indexed exclusion
gov.states.ia.hhs.ssa.rcf_personal_needs COLA index $1 2026 2028 Indexed to the COLA in YAML
gov.states.md.tax.income.agi.subtractions.pension.max_amount COLA index none 2026 2028 Indexed to the COLA in YAML
gov.states.nc.ncdhhs.sa.rate.basic, .enhanced COLA index $1 2027 2028 Indexed to the COLA in YAML
gov.states.ok.dhs.ssp.child_allocation, .ineligible_spouse_standard COLA index $1 2026 2028 Indexed to the COLA in YAML
gov.states.sd.dss.ssp.assisted_living_daily_rate COLA index $0.01 2026 2028 Indexed to the COLA in YAML
gov.irs.income.exemption.overtime.hce_salary_threshold, .salary_basis_threshold NAWI none 2024-11-15 2026 Indexed to NAWI in YAML

Readers that read a path when they run need nothing: ss_aime indexes earnings by gov.ssa.nawi, the COLA levers in tools/social_security_benefit_levers.py read gov.ssa.uprating, and the per-capita SOI series rebuild after every reform.

Anchor. For the four rounded readers whose YAML stops at 2026 (Iowa, both Oklahoma amounts, South Dakota), the module anchors at the 2026 value, as core does, rather than at the rounded 2027 value. That keeps core's arithmetic: where the new path equals the old one, the reader keeps its default value. Anchoring at 2027 would move these by one rounding step ($1, or $0.01 for South Dakota) in 5 to 59 of the years from 2028 to 2100. For the other readers the two anchors agree.

veterans_benefits

veterans_benefits names gov.ssa.uprating as its variable uprating. Core reads that index from the simulation's own system when a later year asks for an input given for an earlier one, so in a household Simulation under the reform the benefit follows the Trustees' path: $30,000 entered for 2026 reads $68,034 in 2060 under the reform and $65,158 without it (a test checks both against the two paths). Nothing needed changing. A Microsimulation built from a single-year dataset is different. Microsimulation.__init__ builds the reformed system first (_prepare_spm_system, system.py line 478), but then calls extend_single_year_dataset without system= (lines 515 and 527), so that function falls back to the module-level default system. With no dataset_end_year, the extension stops at the last year in the CPI-U YAML: _get_default_end_year reads gov.bls.cpi.cpi_u, whose last entry falls on 2036-02-01. So inputs age on the default paths through 2036. For a later year, Core uprates each input whose variable names an uprating parameter from its latest stored year, 2036, on the simulation's own system, the reformed one (Simulation._calculate in policyengine-core 3.33.2). So a later year carries only the gap accumulated by 2036. Under this reform the SOI totals keep their CBO values through 2036, so that gap falls on the one input variable that reads gov.ssa.uprating directly, veterans_benefits: the index reads 403.287 on the default path against 407.218 on the reform's at 2036, a ratio of 0.990347. For a dataset year of 2027 or earlier, every later year of veterans_benefits in such a Microsimulation reads 0.990347 times its value on a dataset that extend_single_year_dataset extended with the reformed system. No other input variable names a path the assumption changes before 2037. To age microdata on these paths, extend the dataset with the reformed system (extend_single_year_dataset(dataset, system=CountryTaxBenefitSystem(reform=...))) and pass the result. This applies to the 2025 Trustees scenario too.

Composition with trustees_core_thresholds_reform

In a built system either order gives the same tree: the IRS thresholds after 2034 follow the 2026 Trustees NAWI under both (trustees_2026, trustees_core_thresholds) and (trustees_core_thresholds, trustees_2026). CountryTaxBenefitSystem.__init__ applies the reform set twice, so on the second pass the core-thresholds reform reads the Trustees' NAWI whichever comes first. One test per order builds that system and checks every IRS parameter the core-thresholds reform wage-indexes, for 2027 to 2100, against the wage-indexing formula on the built tree's NAWI, which in turn matches the Trustees' path; two of them also differ from the formula on main's NAWI.

Policy reforms that set a parameter this assumption derives should come after it in the reform tuple; on the second pass the later reform wins. That order keeps the values a later reform sets directly, but nothing re-derives their downstream readers after it: an override of gov.ssa.uprating placed after the assumption moves veterans_benefits, which reads the index when it runs, and leaves the SSI amounts the assumption materialized from the index alone. The assumption extends the owners' draw copy from the copy's own values, so the copy holds the assumption's path in either order.

Invariants

Each holds for every input the tests draw; the Hypothesis tests use at most 50 examples, with years drawn over one built tree.

  1. Chain identities. NAWI(Y) / NAWI(Y − 1) = AWI(Y) / AWI(Y − 1) for 2026–2100, and U(Y) / U(Y − 1) = ACPI(Y − 1) / ACPI(Y − 2) for 2028–2100; over any window, the ratios telescope the same way. Both paths hold their 2100 value after 2100.

  2. Published values hold. NAWI through 2025, the cap, its owners' draw copy and the COLA index through 2027, the wage-indexed amounts for 2027, every reader's YAML values and the SOI totals through 2036 match main entry for entry.

  3. Statute. The cap, wage base, both bend points, the QC threshold, both earnings-test amounts and both SGA amounts equal a formula written in the test on the Trustees' NAWI for every year 2028–2100, and differ from main at 2028, 2036, 2037, 2060 and 2100.

  4. Monotone. The cap, QC threshold and bend points never fall.

  5. Readers. Each reader equals core's formula on the new path; the same formula on the default path reproduces main (a differential check on the test itself). Exact rounding ties, which the two-decimal CPI makes possible, accept either neighbor.

  6. Idempotent. The function applied once to a copy of the default tree equals the built system, which applies the reform twice, for every touched parameter, entry for entry.

  7. Neutral. Building the reform or applying the function leaves system.parameters unchanged, and main still reads NAWI 2036 104,988.75, the cap 2100 1,995,300, its owners' draw copy 2060 558,600 and gov.ssa.uprating 2036 403.287.

  8. Real wages. Real wage growth from 2050 to 2100 is 1.127% a year, within 0.02 points of the Trustees' 1.13%.

  9. Owners' draw cap. The current-law cap copy equals the assumption's payroll cap for every year from 2028 to 2100 (654,900 in 2060 against main's 558,600), and keeps that path after one pass of a policy that raises the payroll cap from 2028.

  10. Owners' draw cap across both passes. With a policy that removes the payroll cap or sets it to $10 million from 2027, replaying both passes of the reform set on the default-tree copy (assumption, policy, assumption, policy, and the reverse tuple order) leaves the copy on the statutory path for every year 2028–2100, 654,900 in 2060, with its entries through 2027 equal to main's cap; the live cap reads the policy's value for every year 2027–2100.

  11. Short horizons keep the CBO income window. With end_year 2028, 2030 or 2036, all 17 SOI totals match main entry for entry through 2036, and at 2040.

Limits

  • The Trustees give annual CPI averages in Table VI.G1, so annual change stands in for third-quarter change. SSA also publishes a projected COLA of 2.4% for January 2028 (https://www.ssa.gov/OACT/TR/TRassum.html); the annual proxy gives 2.46% there (102.46 / 100). The module keeps the annual proxy.
  • Table VI.G1 labels the series "Adjusted CPI" and Table V.B1 labels it "Consumer Price Index"; neither table names the CPI-W. The module treats it as the path behind benefit COLAs.
  • Other price paths stay on their CBO extensions: CPI-U, chained CPI-U, gov.irs.uprating, SNAP and HHS uprating. Inputs uprated by CPI-U keep the default path too.
  • SOI income totals keep their CBO values through 2036.
  • Inputs uprated by calibration.gov.cbo.income_by_source.adjusted_gross_income or its per-capita series keep the default path: 348 variables, including 16 stored in the default dataset. From 2036 to 2100, that path grows 10.393 times per capita, versus 9.418 for wages.
  • A Microsimulation built from a single-year dataset ages its inputs on the default paths through 2036, where the default extension ends, because system.py calls extend_single_year_dataset without system=. Core uprates later years on the reform's paths from 2036, so they carry the gap accumulated by then: for veterans_benefits, a ratio of 0.990347 (see above).
  • With end_year=2060, reading COLA events through 2061 raises Requested COLA year 2061 exceeds the frozen Q3 horizon 2060 (read_parameter_events in tools/social_security/cola.py), because the annual calculator (tools/social_security/engine.py) asks for the following January to price December entitlement. The scalar that gov.ssa.uprating carries after end_year doesn't extend that horizon.
  • An exact rounding tie (for example the working-student exclusion in 2039, $2,500 × 136.20 / 100 = $3,405) rounds whichever way core's float arithmetic lands, as core's own uprating does.

Mutation checks

At 60b99ee, the reviewer ran each of the six mutants in its own process under the 2 GB and 900 s cap, against a small stub tree with Core's parameter operations and the real extension functions. Assertion equivalents of the listed tests killed all six; the mutants didn't run the named tests on the full model. Each row names the assertion that killed its mutant.

Mutant Observed killing assertion (stub-tree equivalent)
Start the NAWI chain from the Trustees' 2025 level test_pins (NAWI 2026)
last_projected_year=2036 for the cap test_pins (cap 2028)
Skip the wage-indexed step test_wage_base_equals_the_cap
Skip the reader step test_readers_follow_the_new_path
Shift the price lag: U(Y) uses ACPI(Y) / ACPI(Y − 1) test_pins (gov.ssa.uprating 2028)
Apply to the shared default tree in place of the reform's own test_reform_leaves_the_default_tree_alone

The two fixes' new tests haven't run against 60b99ee as mutants. The local run included an in-process replay of two of them on the reviewed head's module, after the new tests, and its time guard skipped the replay because startup had pushed the run to 1,381 s. On 60b99ee the reviewer read the values these tests reject: the owners' draw copy at infinity and at $10 million in 2060, and the 2036 employment-income total at $12.862 trillion with end_year=2028, against main's $17.326 trillion.

Tests

policyengine_us/tests/core/test_trustees_2026_economic_assumptions.py, 106 tests, in the Rest Python core group. It builds one reformed system and copies the default tree once (both cached, freed at module teardown); the two composition tests run last, free those, and build one system each.

This update, after the first review, adds seven parameter-tree cases and renames one test.

  • test_owners_draw_current_law_cap_survives_both_passes_of_a_2027_cap_policy replays both passes of the reform set with a policy that removes the payroll cap or sets it to $10 million from 2027, each in both tuple orders (four cases).
  • test_a_short_end_year_keeps_the_cbo_income_totals applies the assumption with end_year 2028, 2030 and 2036 (three cases).
  • test_owners_draw_current_law_cap_survives_later_cap_policy becomes test_owners_draw_current_law_cap_holds_after_one_cap_policy_from_2028, since it covers one pass from 2028.

The new cases reuse the file's cached default-tree copy. A context manager puts the copy's 42 touched parameters on the default tree's values, the case applies its steps, and the copy then gets its own values back; the context manager checks the copy against a snapshot and the default tree against its import-time snapshot. They add no model build or tree copy; they apply the assumption to the copy eleven times.

The local run selected those eight with -k in one process, on a host at load 80 to 125 on 18 CPUs: all passed (98 deselected). Pytest reported 455.92 s after collection; the whole process took 1,491.6 s wall and 48.8 s user CPU, with a peak RSS of 1.63 GB, or 1.52 GiB (/usr/bin/time -l). In CI run 41299903587 at 608c43b, Rest Python shard 1 ran all 106 tests in the file and they passed; the seven new cases took 3.79 s of call time together, against 1.74 s for the renamed one-pass test.

The owners' draw update (60b99ee) added three parameter-tree regression checks for the owners' draw cap copy. They reuse the file's cached reformed system and default-tree copy, adding no model builds or tree copies to the full-file run. The local filtered run selected only those three checks: all passed (96 deselected), with 750.42 s reported by pytest and 1,349.32 s total process wall time including startup. Peak RSS was unavailable because the sandbox denied the resource reporter's sysctl call after the tests passed; the process remained under the 4 GB monitor. Nobody has measured before/after runtime or Linux CI memory for those three.

Local runs, on a host at load 50 to 160 on 18 CPUs, so wall times are inflated several-fold (CPU time is the steadier figure):

Process Tests Result Wall CPU (user) Peak RSS
Reformed system only 90 passed 537 s 59 s 3.26 GB
Default-tree copy 3 passed 1,396 s 67 s 2.26 GB
Composition, assumption first 1 passed 860 s — —
Composition, core thresholds first 1 not run locally (host load); CI — — —

The first row's peak predates trimming the COLA-lever case from 2060 to 2040 on the reformed tree alone, which cut the full-tree at-instant builds it triggers from 70 to 15. CI's Rest Python shards and Quick Feedback passed at 85f851c (run 41266782916), and the Pull request workflow passed at 60b99ee (run 41276700022); per-file timings come with each run's reports.

impact: none under current law (opt-in, off by default); the hub will measure
axiom: n/a: economic assumption paths, not enacted law

🤖 Generated with Claude Code

MaxGhenis and others added 3 commits October 31, 2026 19:02
Chain the 2026 Trustees intermediate AWI and adjusted CPI growth onto the
published NAWI(2025) and the 2027 COLA index, then re-derive the SOI income
totals, the taxable maximum from 2028, the wage-indexed amounts and the
parameters whose uprating names either path. Off by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Pins from separate arithmetic on the Trustees' workbook, the statutory
re-derivation of the wage-indexed amounts, the readers core uprated at
build, published years, neutrality of the default tree, composition with
the core-thresholds reform in either order, household checks on the cap,
PIA, veterans' benefits and the COLA lever, and Hypothesis properties.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Each order now builds one system and checks every IRS parameter the
core-thresholds reform wage-indexes against the formula on the Trustees'
NAWI, so each fits in its own process; the COLA lever case reads through
2040 to keep the at-instant trees it builds few.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@MaxGhenis
MaxGhenis marked this pull request as ready for review November 1, 2026 02:50
MaxGhenis and others added 2 commits November 1, 2026 01:21
A built system applies its reform set twice. On the second pass the
payroll cap extension read a policy's 2027 cap as its no-decrease floor,
and the assumption copied that path into the owners' draw copy: removing
the cap from 2027 left the copy at infinity in 2060. The cap extension
now takes a keyword-only cap= (default: the live cap, so its other
callers keep their behaviour), and the assumption runs it on the copy
itself, from the copy's own values through 2027.

The reused SOI helper holds each total at its end_year value from
end_year on, so an end_year of 2036 or earlier overwrote CBO's later
values. The assumption now calls it only when end_year exceeds 2036.

The module docstring now says why a Microsimulation ages inputs on the
default paths (system.py omits system=), through which year (2036), what
a later year carries, and what the order rule does and doesn't
re-derive.

Tests: both passes replayed with a cap removal and a $10 million cap from
2027 in both tuple orders, and end_year 2028, 2030 and 2036, on the
file's cached default-tree copy; the one-pass cap-policy test is renamed
to what it covers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

This branch has not been deployed

No deployments
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