Skip to content

Add configurable Social Security COLA means test - #10486

Open
MaxGhenis wants to merge 75 commits into
mainfrom
ss-cola-means-test-e3
Open

MaxGhenis wants to merge 75 commits into
mainfrom
ss-cola-means-test-e3

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Both prerequisites, #10400 and #10401, are merged. This branch was stacked on their fetched heads.

Adds an opt-in COLA means test shared by reported Social Security benefits and the supplied-record household calculator. Implements design v3 E3 as replaced by M6, the A5.1 timing/high-water rules and A5.2 joint numerator, A1's calculated-source composition, and P2's static-cross-section convention. Defaults retain current law.

Stack and merge history

The branch was built from then-main d92649c6dab5f49f788441cd60179a578d549ab9, which included #10401 head 04c2b04848e93abff41c113b11433358924cd86c, and merged #10400 head a6dcc92217ae60127edd588295684f332713ff1a while that PR was open. That original stack merge needed no manual conflict resolution; identical shared files remain once.

After both prerequisites merged, this branch merged main 4c65c821919d3db651eb79603225491a04b7a087, retaining its history. Ten overlapping files retained the E3 versions: the benefit-lever guide and helper, four public benefit variables and four withholding variables. The incoming versions were verified byte-identical to original stack base a498bb4da70b657c7770bbc967ab5432ae777a30. The released A5 changelog fragment was removed. No prerequisite merge remains outstanding. The continuation started at 481091a5dcbb8737b771ece7adddb6c0c26e39b8 and fetched upstream main 8b17892d2cba99a82961ea32fbaf9d69c6ba73d6. A final fetch reached main 1976c4b60076cd2d133faf12f61b242d9ca67020; it changes none of E3's paths and a trial merge is clean, so no further main merge or conflict resolution was needed. Continuation commit 4f336b78e2cc687b46f83d16e0067d7108df82eb aligns the legacy calculated-base fixture with A1's complete payment amount. Commit 2c142b89d48ead3c29786a150b0ec570e1d65e08 restores the disabled-E3 legacy COLA path and updates the affected synthetic/source-validation fixtures.

Policy mechanics and choices

OACT A9, 2026 Trustees Report basis, retrieved live during implementation: “Use prior tax year income data for this determination.” A9 withholds COLAs above CPI-U-indexed lines, pays chained CPI-U below them, and uses chained CPI before receipt. The dedicated annual TaxUnit variable social_security_cola_means_test_magi excludes taxable Social Security and retains applicable deductions and IRMAA addbacks, following the linked memorandum, provision 5 and H.R. 6489 section 103(d). January payment year y reads income and filing status from tax year y−1.

The configurable lever differs from A9 in two intended ways: below-line COLAs default to statutory, and scope defaults to first-eligible cohorts from 2026. Chained CPI-U and all beneficiaries are selectable. A separate pre-receipt index defaults to following the below-line choice; pre-receipt events use that index without an income test. The a9 preset selects all beneficiaries from the December 2028 COLA, chained CPI-U below the line and before receipt, and $85,000/$170,000 thresholds in 2018 dollars indexed by CPI-U. The first affected checks are January 2029. Its indexed dollar amounts use PE-US's CPI-U path; the preset reproduces the provision's mechanics rather than OACT's projected dollar estimates or solvency results.

The policy defaults off. Effective year/month, cohort start, filing groups, threshold lines, dollar base year, CPI-U lag and rounding are parameters. An active A5 alternative-rate path overrides the selected index; the applicable percentage-point change is then added with a zero floor. Withholding overrides the entire resulting increase.

means_test.enabled activates the income decision and selected index without a second switch. When E3 is disabled, cola.withholding_enabled retains A5's externally resolved withholding channel. Explicit component decisions and ss_calculator_withhold_cola retain override precedence. Automatic decisions retain timing, cohort and receipt restrictions; manual component inputs retain their prior carry-forward behavior when the automatic policy is inactive, while calculator-specific decisions apply only in their supplied year.

Divergences and documented conventions

  • Income definition: M6 replaces v3's current-year positive gross-source measure with prior-year MAGI excluding Social Security, including applicable deductions and IRMAA addbacks. The decreasing-year dependency chain replaces the earlier requirement that every income-source dependency avoid benefits entirely.
  • Scenario values: M4 leaves the single/joint thresholds at neutral zero placeholders rather than encoding the approximate package lines in v3/M6. Evaluated prior-year income decisions require positive scenario lines in the stated dollar year; never/always sensitivities and pre-receipt index-only events do not read those lines. Public A9 example values are supplied by the preset.
  • Comparison and filing groups: the build brief fixes withholding at strictly above the line rather than exposing v3's proposed comparison-operator choice. Equality receives the COLA. Single, head-of-household and separate filers share the single line; joint filers use joint. Surviving spouses default to joint through a configurable mapping, a modeling choice because A9 does not name them.
  • Index conventions: December CPI-U observations with payment-year lag 1 and nearest-$100 half-up threshold rounding fill details A9 does not specify. Chained CPI-U uses Q3 averages, half-up rounding to 0.1 percentage point and the high-water/no-decrease rules. Projected index snapshots persist until the next authored observation; scoring must pin the paths and conventions.
  • Numeric selectors: Core rejects string-valued parameters. scope uses 0=first_eligible_cohorts, 1=all_beneficiaries; the below-line index uses 0=statutory, 1=chained_cpi_u; the pre-receipt index adds -1=follow_below_threshold. income_sensitivity uses 0=prior_year, 1=never_withheld, 2=always_withheld. Filing-group mappings use 0=single, 1=joint. The semantic choices are unchanged.
  • History arrays: Core stores complete arrays per variable/year. Supplied TaxUnit MAGI/source and filing-status histories must provide a value for every TaxUnit for every supplied variable/year. Partial arrays are unsupported because omitted units receive defaults rather than an automatic per-unit proxy fallback.

Shared decisions, invariants and tests

Each exposed event uses one joint numerator: 1 if withheld, otherwise 1 + adjusted_COLA, divided by 1 + statutory_COLA. Withholding does not reset the index high-water base or create a later catch-up increase. Neither withholding nor a downward adjustment reduces the nominal benefit level. Switching the policy off later preserves earlier effects.

The calculator resolves receipt by the first check month and applies these same decisions inside its COLA schedule. Payment outputs avoid unnecessary next-January income reads. Calculated public routes bypass population factors already applied by the calculator's ledger; reported routes retain the stable multiplication order. Across-the-board and payable factors also apply once, before the calculator's earnings test.

The following tests are in policyengine_us/tests/unit/test_ss_cola_means_test.py:

Invariant Tests
Neutral defaults and exact disabled-policy equality test_neutral_defaults_require_explicit_threshold_package; test_property_means_test_off_preserves_stacked_base_outputs; test_means_test_off_preserves_downstream_dependency_closure
All index × scope combinations; pre-2026 and boundary cohorts test_each_index_scope_combination_and_first_eligible_boundary
Eligibility reconstructed within each static score year test_static_cohort_proxy_is_reconstructed_within_each_score_year
Filing statuses, equality and configurable surviving-spouse mapping test_equality_is_paid_and_each_filing_status_has_an_explicit_group; test_surviving_spouse_mapping_is_a_parameter
Prior-year income/status and MAGI addbacks excluding benefits test_only_prior_tax_year_income_and_filing_status_make_the_decision; test_magi_excludes_social_security_and_retains_irmaa_addbacks
Supplied histories override the proxy; never/always sensitivities test_supplied_income_history_overrides_the_extended_record_proxy; test_static_cross_section_proxy_sensitivities
CPI-U rounding, Q3 averaging, high-water and no-decrease test_cpi_u_threshold_indexing_and_decimal_half_up; test_chained_index_uses_q3_high_water_and_half_up; test_chained_index_reads_all_three_q3_months; test_zero_cola_and_downward_adjustment_floor
Joint numerator with simultaneous CPI-E and withholding test_two_year_cpi_e_and_withholding_use_one_joint_numerator; test_property_joint_factor_equals_product_of_per_year_ratios
No duplicate calculator/public factors, including other payment factors test_calculated_source_applies_withholding_and_cpi_e_once
Pre-receipt choice and first-check timing test_pre_receipt_index_is_separate_and_has_no_income_withholding; test_midyear_first_check_retains_the_pre_receipt_index; test_named_withholding_uses_selected_calculator_cohort_and_receipt
Explicit decisions, legacy carry-over and persistent earlier effects test_explicit_withholding_input_remains_a_final_decision; test_disabled_means_test_preserves_manual_input_carry_over; test_disabled_later_policy_retains_an_earlier_withheld_event
A9 preset binding and mechanics test_a9_preset_reproduces_captured_description
No multi-year dependency cycle on either reported or calculator route test_multi_year_income_dependency_graph_is_acyclic
Withholding never raises the benefit; increasing income never un-withholds test_property_a_withheld_year_never_raises_the_benefit; test_property_withholding_is_monotone_in_income

The new YAML case, policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_cola_means_test.yaml, covers prior-year income, equality, an excluded older cohort and the CPI-E/withholding numerator through the policy test runner. The separate disabled-policy differential against archived main 4c65c821919d3db651eb79603225491a04b7a087 matched five legacy public-input reported situations, five calculator situations, five pure published examples and all 149,182 cached entries exactly.

Unmodeled cases and sensitivity flags

Population scoring uses the static-cross-section prior-income proxy: the same record's dataset-extended y−1 copy, not an observed longitudinal income history. Supplied complete histories override it. Use income_sensitivity=1 and 2 for the never-withheld and always-withheld envelope; these retain automatic timing, cohort and receipt restrictions. The engine does not carry a person's income, cohort or composition across projected rows.

Missing entitlement and worker information retains A5's declared exposure proxies. Identified records can supply component _worker_eligibility_year, _worker_id, _entitlement_year/month and _first_cola_year/month. Integer-age uncertainty calls for adjacent-cohort sensitivities; disability/young-auxiliary pre-start exposure calls for supplied current-year entitlement or first-COLA alternatives. These assumptions are not observed histories. Auxiliary PIA inversion without worker/share information remains unidentified.

The supplied-record calculator retains its existing scope: own retirement and restricted survivors. Disability, spouse, child, minimum and dual-entitlement computations are outside that route. Its scope assertions include ss_calculator_no_special_monthly_test, ss_calculator_no_prior_withholding_adjustment, ss_calculator_earnings_test_no_trigger, explicit prior-disability information and an authoritative basis when required, plus ordinary non-WINDEX and nonbinding survivor-limit assertions. Unsupported historical computation/rounding routes fail; chained CPI-U rates require payment year 2002 or later. See the calculator guide and benefit-lever guide.

No population impact or OACT solvency result is inferred from these mechanics. Population scoring requires the hub's pinned artifact, policy settings and sensitivity receipt.

Overlap with #10484

Rechecked #10486 at 2c142b89d48ead3c29786a150b0ec570e1d65e08 and #10484 (ss-pia-ratio-lever-c) at fea0d26ef98a6771e946f621e8edd2bff11a046c, with shared merge base 4c65c821919d3db651eb79603225491a04b7a087. The intersection of changed files is empty, both from the actual fork-base comparisons and the PR diffs. A trial merge is clean.

The policies interact semantically: #10484 supplies the eligibility-year PIA ratio; E3 supplies COLA index/withholding decisions. Reported routes compose their respective population factors, while calculated routes already apply applicable PIA/COLA changes inside the calculator and bypass those population factors. Combined runtime validation of both PRs together has not been run. #10484 was not changed.

Validation

Initial required local validation passed at 481091a5dcbb8737b771ece7adddb6c0c26e39b8. CI then exposed the two compatibility issues described below. The new Python file was rerun after the model fix; the completed counts and final-head CI results are recorded here:

  • Formatting: all 7,513 files passed ruff format --check .; ruff check . passed, using the locked Ruff 0.15.5.
  • env -u UV_FROZEN uv lock --check passed without relocking; whitespace checks passed. Nothing under .github/ was changed.
  • policyengine_us/tests/unit/test_ss_cola_means_test.py: 42 passed in 779.06 seconds, in one clean full-file run, exit 0; no split was needed.
  • New contributed YAML: 1 passed in 9.88 seconds, exit 0, from the prior build at initial head 481091a5dcbb8737b771ece7adddb6c0c26e39b8; retained rather than repeated.
  • social_security/ss_calculated_benefit.yaml: all 8 cases covered. Prepared splits 1–3: 3 passed in 25.64 seconds (413.55 seconds command wall time); 4–6: 3 passed in 22.28 seconds (355.92 seconds wall time). Cases 7–8: 2 passed in 135.49 seconds, exit 0, from the prior build at initial head 481091a5dcbb8737b771ece7adddb6c0c26e39b8. Splitting retains the prior full-file 4 GB limit finding; no cap was raised.
  • Focused existing baseline batch: 35 passed in 29.35 seconds, exit 0, across six files (268.68 seconds command wall time). Per-file case time below excludes interpreter startup and suite overhead.

The first ready CI run exposed one legacy fixture in policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_retirement.yaml (9 passed, 1 failed). Its reserved-route comment and $8,800 expectation predated the implemented calculator ledger. A1 requires across-the-board and payable changes once, before the earnings test. The injected $10,000 base now represents complete calculated checks: the public route must return 10000 * 1 = 10000; the old 10000 * 1.1 * 0.8 = 8800 repeated those changes. The first continuation commit corrects that expectation and its explanation, preserving the calculator ledger and its independently passing composition test. This file is outside the allowed local regression scope; its full 10-case rerun passed in final-head CI.

The next run exposed three legacy-helper failures in policyengine_us/tests/core/test_social_security_lever_properties.py and four outdated exception assertions in policyengine_us/tests/core/test_social_security_inputs.py. The model now avoids E3 calculator/history inputs when the means test has never been enabled, and uses the original annual manual-withholding channel for disabled E3 events. This preserves the original hand calculations: historical COLA 1.1135 / 1.112, persistent withheld COLA 1 / 1.036, and mixed reported/calculated factors [1.0375 / 1.036, 1]. The synthetic parameter fixture gains only the neutral means_test.enabled=false leaf. Source-validation tests still reject every unmatched source/calculator pair, expecting the implemented route's ValueError rather than the old reserved-route exception. No numerical expectation in either core file changed. Both full files were delegated to CI under the local scope restriction. make format, Ruff and env -u UV_FROZEN uv lock --check passed before each continuation commit, without relocking.

Paths in the following tables are relative to policyengine_us/tests/policy/baseline/gov/ssa/:

Locally run file Passed Case seconds Why chosen
social_security/social_security_retirement.yaml 3 19.221 Changed public retirement formula, default zero, final override and separation from calculator internals.
ssi/income/ssi_unearned_income.yaml 4 0.495 Immediate unearned-income consumer of aggregate Social Security and all four component formulas.
ssi/ssi_countable_income.yaml 10 1.635 Countable-income and household aggregation through derived unearned income.
ssi/eligibility/income/deemed/from_ineligible_parent.yaml 4 0.598 Parental deeming through derived unearned income.
ssi/eligibility/income/deemed/from_ineligible_spouse.yaml 4 0.549 Spousal deeming/countable-income route.
ssi/integration_tests.yaml 10 5.023 End-to-end eligibility, income, deeming and payment consumers in mixed households.

No existing baseline SSA YAML activates the new means-test parameters, which default disabled; the new Python/YAML cases test those decisions and MAGI. The calculator splits cover the changed engine. Upstream AIME/PIA/FRA and earnings-history files, revenue cases that generally supply aggregate benefits, and farther SSI resource/arrangement consumers were validated by CI. This follows the assigned focused local scope and avoids repeating the long folder run under the lane's I/O clamp. The local prohibition leaves revenue/test_tob_revenue.py to CI. No file outside the required new Python file and the selected SSA regressions was run locally; no microsimulation was run.

The remaining 48 files are listed below: 47 YAML files / 346 enumerated cases, routed through the existing Full Suite - Baseline (ssa-usda) job, and the TOB Python file through the existing Rest policy group. The E3 Python file remains in the existing Rest remaining group. No CI routing was added.

File delegated to CI Cases CI result
revenue/employer_medicare_tax_income_tax_revenue.yaml 4 passed
revenue/employer_ss_tax_income_tax_revenue.yaml 4 passed
revenue/tax_unit_taxable_social_security_pre_1993.yaml 14 passed
revenue/test_tob_revenue.py 2 passed
revenue/tob_revenue_medicare_hi.yaml 6 passed
revenue/tob_revenue_oasdi.yaml 13 passed
revenue/tob_revenue_total.yaml 1 passed
revenue/tob_tier_split.yaml 3 passed
social_security/integration.yaml 16 passed
social_security/multiyear_integration.yaml 4 passed
social_security/ss_aime.yaml 4 passed
social_security/ss_aime_input.yaml 2 passed
social_security/ss_claiming_age.yaml 3 passed
social_security/ss_covered_earnings_this_year.yaml 9 passed
social_security/ss_earnings_test_reduction.yaml 8 passed
social_security/ss_full_retirement_age_months.yaml 12 passed
social_security/ss_pia.yaml 10 passed
social_security/ss_quarters_of_coverage.yaml 12 passed
social_security/ss_retirement_age_adjustment_factor.yaml 7 passed
ss/never_eligible_for_social_security_benefits.yaml 3 passed
ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.yaml 9 passed
ssi/eligibility/income/deemed/ssi_income_deemed_from_ineligible_spouse.yaml 6 passed
ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml 2 passed
ssi/eligibility/income/is_ssi_blind_or_disabled_working_student_exclusion_eligible.yaml 6 passed
ssi/eligibility/income/ism/ssi_in_kind_support_and_maintenance.yaml 5 passed
ssi/eligibility/income/ssi_blind_or_disabled_working_student_exclusion.yaml 10 passed
ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml 13 passed
ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.yaml 39 passed
ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_spouse.yaml 4 passed
ssi/eligibility/resources/meets_ssi_resource_test.yaml 8 passed
ssi/eligibility/ssi_marital_both_eligible.yaml 4 passed
ssi/eligibility/status/ssi_federal_living_arrangement.yaml 12 passed
ssi/income/ssi_earned_income.yaml 9 passed
ssi/income/ssi_engaged_in_sga.yaml 6 passed
ssi/income/ssi_marital_earned_income.yaml 4 passed
ssi/income/ssi_marital_unearned_income.yaml 4 passed
ssi/is_ssi_aged_blind_disabled.yaml 4 passed
ssi/is_ssi_disabled.yaml 9 passed
ssi/is_ssi_eligible.yaml 3 passed
ssi/is_ssi_qualified_noncitizen.yaml 1 passed
ssi/meets_ssi_disability_criteria.yaml 5 passed
ssi/ssi.yaml 3 passed
ssi/ssi_amount_if_eligible.yaml 10 passed
ssi/ssi_claim_is_joint.yaml 5 passed
ssi/ssi_couple_computation_applies.yaml 5 passed
ssi/ssi_if_takes_up.yaml 4 passed
ssi/ssi_living_arrangement_integration.yaml 15 passed
ssi/uncapped_ssi.yaml 6 passed

At final head 2c142b89d48ead3c29786a150b0ec570e1d65e08, SSA/USDA CI passed all 54 SSA YAML files / 389 cases. All 48 delegated files passed: 47 YAML files / 346 cases plus both TOB Python cases (183.756 case-seconds). The six Rest Python shards all passed; their JUnit reports confirm 42 E3 cases (244.005 case-seconds), 126 source-validation cases (290.014 case-seconds), and 24 lever-property cases (0.988 case-seconds), all passing. These CI case-time sums exclude collection and process overhead. Quick Feedback also passed: the new YAML case passed in 18.43 seconds, the corrected retirement YAML passed all 10 cases in 96.09 seconds, and the E3 Python file passed a clean 42-case full-file run in 233.22 seconds. The microsimulation suite and all Contrib shards passed. The complete CI run concluded success at this head: 34 checks passed and 1 was skipped, with no failures. The final-head watch completed within the requested 100-minute limit, with polls at least five minutes apart.
At the final head, the clean full-file attempt reached the unchanged 4 GB RSS cap after 761.9 seconds, with 40 passing results printed and no assertion failure. The allowed -k split separates the 40 earlier cases from the two downstream dependency-closure cases.

  • -k 'not means_test_off_preserves_downstream_dependency_closure': 40 passed in 277.78 seconds, exit 0.
  • -k 'means_test_off_preserves_downstream_dependency_closure': 2 passed in 59.66 seconds, exit 0.

Local country-importing processes used the supplied Python 3.13.9 environment and locked Core 3.33.2, serially, only starting while load1 was at or below 100. Each process retained 4 GB RSS, 1,800 seconds and four numeric threads. No local test environment was created. The earlier exact current-law disabled-policy differential is retained; the compatibility fix leaves its neutral current-law path unchanged.

impact: none under current law (contributed reform); the hub will measure

axiom: n/a: contributed reform, not enacted law

# Conflicts:
#	policyengine_us/tests/code_health/test_rest_python_groups.py
MaxGhenis and others added 25 commits October 25, 2026 23:00
Run the unchanged input checks in a dedicated Rest group and preserve three isolated processes, distinct reports, and failure propagation. Avoid the differential parent's redundant country import and allow slower probes within a bounded hang guard.
Main's #10401 (ee6c1c5) added the ss-calculator-engine and
ss-calculator-differential Rest groups beside this branch's
social-security-inputs group. The Makefile keeps all three groups, their
file lists and recipes, and lists all three files in REST_LISTED_TESTS so
the core and remaining groups don't run them twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Retain E3 changes in ten overlapping files; upstream versions match the original stacked base. Remove the released benefit-lever changelog fragment.
@MaxGhenis MaxGhenis changed the title WIP: Add configurable Social Security COLA means test Add configurable Social Security COLA means test Oct 29, 2026
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 29, 2026 11:33

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