Skip to content

Add annual Social Security earnings histories and per-year reforms - #10560

Merged
MaxGhenis merged 18 commits into
mainfrom
ss-household-calculator-4a2
Nov 5, 2026
Merged

MaxGhenis merged 18 commits into
mainfrom
ss-household-calculator-4a2

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

This PR adds an opt-in annual covered-earnings history source to the household Social Security calculator. A complete supplied career now produces conventional AIME, annual owners' draw credit, the mini-PIA blend, separate above-cap AIME+ credit and a reportable annual ledger, and then enters the existing claiming, COLA, payment-rounding and earnings-test pipeline.

Design

  • Scope: complete own-record histories for future retirement cohorts, including eligibility in 2036, 2038 and 2054 and claims through 2059. The route takes insured status as given and adds no credits algorithm or disability, family or minimum-benefit computation.
  • Shared services: the route uses the merged eligibility-year compute_pia service and household payment pipeline. Equivalent supplied AIME and history-derived AIME consume the same formula path, and applicable payment policies and COLAs run once.
  • Conventional history: initial earnings stop at Q−1, including earnings after 62, and later payment requests add no post-claim earnings. The route takes these steps:
    • require observed or explicitly certified-zero years;
    • cap each earnings year before indexing;
    • index only years before E−2 and round indexed annual amounts to cents;
    • select the cohort's computation years, min(35, E − 1956) (28 for E=1984, 34 for E=1990, 35 from E=1991);
    • divide by that count × 12 and floor AIME.
  • Earliest eligibility: the route starts at eligibility in 1984. For workers with pre-1979 earnings who become eligible before 1984, 42 U.S.C. 415(a)(4)(B)(i) keeps the December 1978 computation when larger, and this route can't compute it. The route assumes no covered wages before 1951, which the 415(a)(4)(B)(ii) subsection (d) guarantee counts. Earnings can begin earlier than eligibility, from 1951 or birth if later, and inactive annual donut defaults cover those early earnings years independently of the eligibility benchmark.
  • Owners' draw (E1): one shared pure function now holds the existing owners' draw arithmetic, and both the reform formulas and the history route call it. Staged evaluation preserves public intermediate overrides and avoids a second kernel and repeated full-model evaluation for every earnings year. The credit takes N_D before the ordinary cap, apart from the room-limited tax base.
  • Mini-PIA: the route applies E's PIA formula to each indexed ordinary year / 12, selects the highest 40 annual mini-PIAs, divides by 37 and blends by the parameterized eligibility-cohort share.
  • Donut: the route keeps separate named E2.13, E2.14 and custom parameter sets. Tax and optional credit use only selected covered earnings above max(ordinary_cap, threshold). AIME+ has its own indexing and top-35/420 calculation for every cohort, and its PIA component follows the mini/conventional blend.
  • Ledger: the route exposes annual ordinary, E1 and donut amounts, indexing, mini-PIAs with exact fractions, selection flags and aggregate diagnostics, as typed Python results and ss_calculated_history_* outputs.
  • Limits and settings: the route rejects history benchmarks before 1984 under 415(a)(4)(B)(i), and supplied AIME and PIA keep their 1983 start. It adds no package-specific settings, keeps independent donut variants and E1's room_cap_basis, and preserves the existing OASDI-only versus HI-on gross-profit NIIT exclusion.

Policy sources

B3.12, OACT 2025 Trustees Report edition: “Sum these individual PIAs for the 40 highest years of indexed earnings and divide that total amount by 37”. That edition raises the retired-worker mini share 20 percentage points per cohort during 2032–2036 and leaves disability benefits alone.

E2.13 and E2.14, OACT 2025 Trustees Report edition: E2.13 reads “Apply OASDI 12.4 percent payroll tax rate on earnings above $400,000”, and E2.14 reads “Apply OASDI 12.4 percent payroll tax rate on earnings above $250,000”. In the 2025 edition both begin with 2027 earnings, hold their thresholds fixed and provide a 2% secondary AIME+ factor for newly taxed above-cap earnings. The 2026 edition moved them to 2028, and this PR keeps the 2025 years and citations.

The conventional calculation follows POMS indexing and cent rounding, base-year rules and AIME/PIA rounding. Tests freeze the complete nominal histories from SSA's published 2027 examples and the corresponding initial PIAs:

  • Case A: AIME of $6,072, where SSA prints the formula result $2,720.24 and then the PIA $2,720.20.
  • Case B: E=2022 AIME of $11,843, including earnings through 2026, where SSA prints the formula result $3,419.61 and the calculator floors the initial PIA to $3,419.60.

Conventions that move results

The annual ledger JSON names these two conventions.

  • Combined dime rounding. The PIA floors to a dime once, after the mini-PIA and the donut credit combine: 42 U.S.C. 415(a)(1)(A) rounds the summed formula result down to a multiple of $0.10, and the provision descriptions don't address rounding. For example, a mini of $399.99 plus a $0.02 credit gives a $400 PIA and payment, and truncating the mini first would give $399.90 and $399. The ledger reports the January diagnostic PIA's period as diagnostic_pia_entitlement_month and diagnostic_pia_formula_year.
  • Cohort-selected donut coefficient. A custom path uses the coefficient of eligibility year E, as benefit formulas select by cohort. The named E2.13 and E2.14 variants keep a constant 2%, so this moves only a custom path that varies. For example, AIME+ of $240 at 2% against 3% gives $4.80 against $7.20, and with a $2,180 baseline PIA and claiming at 65, the payment reads $1,893 against $1,895. The ledger reports donut_credit_rate (with donut_credit_rate_exact) and donut_credit_rate_year.

Cohort computation years

The ordinary history AIME selects min(35, E − 1956) years and divides by that count × 12 (42 U.S.C. 415(b)(2): elapsed years from 1951 through the year before age-62 eligibility, less five; POMS RS 00605.016 and RS 00605.017).

For example, take a worker born 1928-01-02 who becomes eligible and claims in 1990, with $3,000 of covered wages in 1951–1989. The highest 34 indexed years sum to $372,486.21, which gives AIME floor(/408) = $912, PIA $498.30 and an age-62 payment of $398. A fixed 35/420 would give $375,934.10, $895, $492.80 and $394.

AIME+ and the mini-PIA keep their own selection (35/420 and 40/37). The ledger adds computation_years, the ordinary_top35 flags follow it, and donut_top35 still marks 35 years. Legacy ss_aime and the supplied-AIME and supplied-PIA routes give main's results.

Other design conventions

  1. Annual mini-AIMEs and mini-PIAs keep their precision. The route aggregates /37 and blends before rounding, adds PIA+ afterward and truncates the combined eligibility PIA to dimes. Conventional AIME and PIA keep statutory rounding. OACT's summary doesn't specify these intermediate rounding and composition choices.
  2. For a custom changing donut path, annual policy selects the taxable and credited stream, and the eligibility-year parameter selects its PIA coefficient.
  3. Main routes the public benefit through the calculator for supplied_aime, supplied_pia and supplied_ratio_survivor sources. This PR doesn't add history to that routing: a public source of history raises the existing source-mismatch error, so callers read history results from the ss_calculated_* outputs. The calculator guide says how to place such a check in a public component with a post-construction set_input.
  4. The route keeps main's distinction between entitlement months and checks paid one month later. The synthetic fixture's $21,720 covers 12 months of entitlement, and January 2039 commencement pays 11 checks totaling $19,910 in 2039, with $21,720 for a subsequent full payment year.
  5. The route reuses main's historical bend points and processed future wage-indexed parameters. Legacy ss_aime keeps its lookback, age filters and untruncated result. Both paths share applicable indexing and selection arithmetic and have independent differentials where their assumptions match.
  6. The source stores the complete career as one structural JSON string, which preserves exact supplied money and all source years through export and reload. Explicit certificates distinguish missing years from zeros without Core carrying observations into gaps. Later supplied records stay in the source and don't enter initial benefits.
  7. The route keeps the existing E1 floating-point and NumPy kernel arithmetic, restores its monetary outputs through Decimal(str(...)) and then applies annual history cent rounding. The E1 reform keeps its independent $400 draw floor, signed residual and subset conventions, parameter-derived netting and room priority.
  8. The annual report uses January entitlement's PIA formula for diagnostics, and its earnings ledger stays frozen at Q−1. Earlier earnings years break ties in selection flags. Before eligibility, the report shows the ledger and leaves PIA diagnostics unset.

Interaction with main

Main's COLA means test feeds the calculator engine, and a history record counts as a retirement component there, with the same eligibility, receipt and ss_calculator_cola_override inputs as a supplied-AIME record. For example, take matching history and supplied-AIME records under the means test, in separate tax units, with prior-year MAGI of $150,000 and $50,000 against a $100,000 line, plus a 0 override at $150,000. Both routes give the same January 2027 checks: $256 withheld, $265 paid and $265 overridden. With the means test disabled, all six read $265.

test_ss_calculator_differential.py classes gov.contrib.ssa.history as calculator-only, and only history_parameters_at (via donut.py and mini_pia.py) reads it.

Tests

  • Pure-service files cover arithmetic, source validation, Hypothesis invariants, frozen SSA histories, legacy AIME and E1 differentials, and serialization.
  • test_ss_history_engine.py runs real Simulation ingress, complete JSON export and reload, future cohort policies, a fixture with all three reforms, and earnings before 1983.
  • test_ss_calculator_differential.py runs the existing calculator, SSA and downstream outputs with history unselected, including every cached reference intermediate.
  • The hand-computed fixture covers all eight reform combinations: baseline AIME $5,000 and PIA $2,180.00; E1 AIME $5,131 and PIA $2,221.90; AIME+ $240 and PIA+ $4.80; combined PIA $2,088.50; age-65 factor 13/15; payment $1,810 and full-year entitlement $21,720.
  • The boundary case at E=1984 uses $3,000 a year in 1951–1983 and NAWI(1982) of 14,531.34. Its highest-28 sum of $260,949.26 gives AIME floor(/336) = $776 and PIA .9 × 267 + .32 × 509 = 403.18, which floors to $403.10.
  • Other invariants cover missing versus certified-zero years, cap and gap boundaries, 35 versus 40 selection and fixed /37, phase boundaries, signed and overlapping E1 inputs, monotonic AIME, once-only credit, inactive-source isolation and zero benefit before entitlement.
  • The E1 kernel releases its Person accessors on return or error, and three weakref checks with cyclic GC disabled pin that.
  • These tests kill the mutants we ran:
    • mini-PIA divided by 40: test_hand_computed_mini_pias_and_fixed_divisor;
    • donut gap: test_cap_gap_and_threshold_boundaries;
    • E1 after the cap: test_e1_credit_enters_before_ordinary_cap;
    • indexing to E−1: test_cap_before_indexing_half_up_cents_and_face_value_from_e_minus_two;
    • earliest year back to 1983: test_computation_years_follow_the_elapsed_years_rule and test_benchmarks_before1984_rejected_at_each_history_entry_point[1983].
  • CI: the latest Pull request run, at 308e100b9e (https://github.com/PolicyEngine/policyengine-us/actions/runs/41984891263), concluded success: all 36 jobs passed.

impact: none. ss_calculator_source defaults to off, the default dataset doesn't hold it, and calculator_output skips every off record, so each new history output reads its default on every record. ss_aime now calls shared helpers that run main's arithmetic in main's order, and only the owners' draw reform and the calculator read the other new code.

axiom: n/a: household calculator and contributed reform paths; current-law results stay the same

🤖 Generated with Claude Code

MaxGhenis and others added 8 commits October 31, 2026 08:54
Add explicit complete-history inputs, annual cap/indexing ledger and claim-year freeze, reusing the supplied-record PIA/payment pipeline. Share legacy-compatible indexing and highest-years helpers and add conventional, SSA-example and legacy differential tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Extract one staged per-year kernel shared by reform Variables and history records, preserving intermediate overrides and lazy parameter reads. Add adjusted draw before each ordinary benefit cap and retain tax-room and NIIT-exclusion diagnostics separately. Freeze the pre-extraction arithmetic in differential and property tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Apply the shared eligibility-year formula to annual indexed earnings divided by 12, select the highest 40 mini PIAs and divide by 37. Blend with conventional PIA using a neutral-by-default 2032–2036 parameter path, retaining exact rational intermediates until final dime truncation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep separate fixed-threshold OACT 2025 variants and a configurable neutral custom path. Tax and optionally credit only the covered stream above max(ordinary cap, threshold), index and select its own highest 35 years, and add the eligibility-cohort AIME-plus component after the mini blend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Expose exact annual earnings, E1, donut, indexing and mini-PIA diagnostics through the household report. Preserve complete JSON sources through export, document archived OACT rules and modeling conventions, and isolate new engine/differential files on existing Rest runners. Add future-cohort, source-isolation, reporting and full SSA payment examples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Break the recursive local-function cycle after each selected-stage or complete annual calculation so Person accessors do not retain entire simulations until cyclic collection. Preserve all arithmetic and lazy dependency behavior; add three GC-disabled weakref regressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 31, 2026 18:05
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Picking this up for the US hub. I'll post a hand-off comment here when it's ready.


Generated by Claude Code

Cohorts first eligible before 1991 have fewer than 35 computation years
under 42 U.S.C. 415(b)(2), so a fixed 35/420 understated their AIME (a 1990
witness gave $895 instead of $912). The ordinary history AIME now selects
min(35, E - 1956) years and divides by that count times 12; AIME+ and the
mini-PIA keep their own rules. The ledger JSON also names the donut credit
coefficient and the year that selected it, the January diagnostic PIA's
period, and the computation-year count the ordinary flags follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
State the cohort selection rule and its 1990 witness, the combined dime
rounding and cohort-selected donut coefficient conventions, Case B's dime
floor of SSA's formula result, and that the E2.13/E2.14/B3.12 dates come
from the 2025 Trustees Report edition. No parameter value changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review

Head: 076a1101cc39453d0fb2e686d105336c9873139f (fast-forward from f28674251980bb6453e804401916e8597c08a4ad; no new commits from others; main not merged).

What the item asked and how it's resolved

  1. Cohort computation years. computation_years(E) = min(35, E − 1956), rejecting E < 1983 like compute_history (policyengine_us/tools/social_security/history.py:188-199). aime_from_indexed(indexed, years=35) now selects years and divides by 12 × years, keeping whole-dollar flooring (history.py:202-210). compute_history passes the cohort count for the ordinary AIME only; AIME+ keeps 35/420 (history.py:409-425).
    • Callers read: aime_from_indexed (ordinary AIME and AIME+ in compute_history; tests). highest_earnings (aime_from_indexed, and ss_aime.py:103, left unchanged). compute_mini_pia keeps its 40/37 rule. The report's _selected(..., 35) for ordinary_top35 now uses history.computation_years (history_report.py:83-88), so those flags mark the same years the AIME selected; donut_top35 and mini_top40 are unchanged. ss_aime.py and the supplied-AIME/PIA services are untouched.
  2. Report visibility (new keys only; no existing key renamed, and values change only where pre-1991 ordinary_top35 flags were wrong):
    • donut_credit_rate, donut_credit_rate_exact, donut_credit_rate_year (history_report.py:148-150). history_parameters_at records the eligibility year that selected the rate (history.py:525); a pure caller that infers a single annual rate gets the first credited year; an explicit rate with no year gives null.
    • diagnostic_pia_entitlement_month and diagnostic_pia_formula_year (history_report.py:151-155), stamped where the household service captures the January diagnostic (household.py:593-595, the one change outside history.py/history_report.py; HistoryPIACalculation gained two optional fields, history.py:174-176).
    • Also computation_years, so the reader can see the selection count the ordinary_top35 flags follow.
  3. Docs (docs/contributed_reforms/social_security_history.md): step 4 and a new paragraph state the cohort rule and the 1990 witness; both conventions are now named, with the brief's examples; the report section names the new keys; Case A/B wording follows the brief; the edition is named for B3.12 and E2.13/E2.14, with one statement that the 2026 edition moved E2.13/E2.14 to 2028. docs/usage/social-security-calculator.md doesn't state 35 or 420, so it's unchanged.
  4. Edition elsewhere: reference titles of the 26 gov.contrib.ssa.history parameter files now read "…, 2025 Trustees Report edition" (no value or label changed), and the donut.py and mini_pia.py module docstrings name it.
  5. Body: new Conventions and Cohort computation years sections, edition and Case A/B wording, and a validation line. impact: and axiom: lines kept. Readback matches my file, except that the GitHub proxy replaces the closing "🤖 Generated with Claude Code" line with its own attribution footer on every write. I tried twice and couldn't keep that line.

Hand computations (branch's YAML values: wage base, NAWI, bend points)

All cases: $3,000 of covered wages every year from 1951 to E−1, with eligibility and claim in E. The 1951 base ($3,600) is already above $3,000.

  • E = 1990 (witness): NAWI(1988) = 19,334.04. Highest 34 = $372,486.21 → 372,486.21 / 408 = 912.95 → AIME $912. The highest 35 would be $375,934.10 / 420 = 895.08 → $895. Bends 356/2,145: .9×356 + .32×(912−356) = 320.40 + 177.92 = 498.32 → PIA $498.30 (the old result was 320.40 + .32×539 = 492.88 → $492.80). FRA is 65, so a January 1990 claim at 62 has factor 0.8: 398.64 → $398 (old: 394.24 → $394).
  • E = 1983: 27 years. NAWI(1981) = 13,773.10. Highest 27 = $243,418.77 / 324 = 751.29 → AIME $751. Bends 254/1,528: 228.60 + .32×497 = 387.64 → PIA $387.60. The old 35/420 rule gave $619.
  • E = 1991: 35 years. NAWI(1989) = 20,099.55. Highest 35 = $390,818.79 / 420 = 930.52 → AIME $930. Bends 370/2,230: 333.00 + .32×560 = 512.20 → PIA $512.20. Unchanged from the old rule.
  • Zero extra year: E = 1990 with 1951–1955 certified zero. The 34 nonzero years are selected either way, so AIME is floor(sum / 408) instead of floor(sum / 420).
  • AIME+ stays at 35: E = 1990, flat NAWI, one $404,080 year above a $400,000 threshold. AIME+ = floor(4,080 / 420) = 9; a 34-year count would give floor(4,080 / 408) = 10.
  • Convention examples:
    • $399.99 + $0.02 = $400.01 → $400.0 → $400. Truncating the mini first gives $399.90 + $0.02 → $399.9 → $399.
    • 240 × 0.02 = 4.80 and 240 × 0.03 = 7.20. Then (2,180 + 4.80) × 13/15 = 1,893.49 → $1,893, and 2,187.20 × 13/15 = 1,895.57 → $1,895.

Tests (one file per process)

File Result
test_ss_earnings_history.py 27 passed (8 new: rule, witness, 3 boundaries, 35-year /420, zero extra year, AIME+ at 35)
test_ss_history_report.py 11 passed (3 new: donut on, donut off, before-eligibility nulls and cohort flags)
test_ss_history_engine.py 5 passed, 86 s (real Simulation: new keys asserted on the E2.13 case and on the inactive custom-donut case)
test_ss_history_ssa_examples.py 2 passed (comment only)
test_ss_history_reforms.py 34 passed (unchanged)
test_ss_mini_pia.py 23 passed (unchanged)
test_ss_donut.py 36 passed (unchanged)

No YAML case was added: the path is a JSON history input plus Python-only tools, and the YAML parameter paths can't express a 1990 career. make format (ruff format) and ruff check pass.

Mutants (each in its own process; code restored and checked with cmp)

  1. Fixed 35/420: killed. Five failures, including test_1990_witness_selects_34_years_and_divides_by_408, the 1983 and 1990 boundaries, and test_zero_extra_year_moves_aime_through_the_divisor_alone.
  2. E − 1955: killed. Six failures, including test_computation_years_follow_the_elapsed_years_rule and the witness.
  3. Short count on AIME+: killed by test_aime_plus_keeps_35_years_for_a_short_cohort.

CI

No Pull request run at this head. The PR's mergeable_state is dirty: git merge-tree shows content conflicts with current main in policyengine_us/tools/social_security/engine.py and docs/usage/social-security-calculator.md. The conflicts were already there at f286742519, so they come from main moving, not from this round. Per the brief I didn't merge main. The last run was 41227650561, success at f286742519.

Judgment calls and limits

  • I couldn't fetch any external source: ssa.gov, web.archive.org and law.cornell.edu are blocked from this session. These facts come from the brief and are unverified by me:
    • SSA prints $3,419.61 and $2,720.24. The formula arithmetic does reproduce both: 921.60 + 1,647.36 + 850.65 = 3,419.61, and 1,206.00 + 1,514.24 = 2,720.24.
    • The 2026 edition moved E2.13/E2.14 to 2028.
    • The POMS section numbers.
  • Because I couldn't fetch 42 U.S.C. 415(a)(1)(A), the docs and body paraphrase it ("rounds the summed formula result down to a multiple of $0.10") instead of quoting it.
  • Recording the January diagnostic's period needed a 3-line change in household.py, where the January details are captured. Deriving it inside the report would have repeated the household service's January rule in a second place.
  • The parameter metadata edits are limited to reference titles, as the edition item asked. The brief's constraints list didn't name parameter files; no value or label changed.

Proposed follow-up


Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Picking this up for the US hub. I'll post a hand-off comment here when it's ready.


Generated by Claude Code

Main shipped the public calculator source selectors and the COLA means
test. Keep both import sets in engine.py, and take main's selector text in
the calculator guide; this branch's "modes remain reserved" text is now
false, so only the history-specific guidance survives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6HPC2jULDq8hPQKD71
Main's namespace-classification check fails on any unlisted child of
gov.contrib.ssa. Only the household calculator's history route reads
this node (history_parameters_at via donut.py and mini_pia.py), so the
reference model drops it and the differential can still catch a legacy
formula that reads it by accident.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6HPC2jULDq8hPQKD71
@MaxGhenis MaxGhenis changed the title [WIP] Add annual Social Security earnings histories and per-year reforms Add annual Social Security earnings histories and per-year reforms Nov 2, 2026
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review

Head: 84eaa12954699e5e27ca34d4a0aacb31ae35ceff. That is two commits on top of 076a1101cc (the expected head; nobody else had pushed): merge commit d9a375deed (main at 97b6d5135c) and 84eaa12954. One normal merge, with no rebase and no force-push.

Steps

  1. Merge. git merge origin/main hit two conflicts. Both sides changed three files: the two conflicted ones and calculator/inputs.py.
  2. engine.py imports (policyengine_us/tools/social_security/engine.py:20-22): I kept both sides, this PR's history / history_report imports and main's means_test import.
  3. docs/usage/social-security-calculator.md (:233-256): I kept main's selector paragraph unchanged. I deleted this PR's statements that the public calculated-source modes "remain reserved", the "adds no _reported twins / changes no public formula" sentence (it describes the change, not the model), and the sentence restating what the ss_calculated_* checks include (main's paragraph already covers that). I kept a rewritten version of this PR's second paragraph, limited to history:
    • The public selectors do not accept history. A public source of history raises "Public Social Security source must match the supported ss_calculator_source" (selected_reported_benefit in tools/social_security_benefit_levers.py). History checks are read from ss_calculated_*.
    • A public component set with set_input after construction is a final override, so no factor is applied again. A public benefit supplied at construction is treated as a reported source, and the factors apply.
    • I checked all three by running them under an across-the-board −10% reform. Public history raised ValueError. ss_calculated_retirement was 2,530, and after a post-construction set_input the public value was still 2,530. Supplied at construction, the same value came out as 2,277 (= 2,530 × 0.9).
  4. Silent merge check. calculator/inputs.py merged cleanly: main added ss_calculator_cola_override at the end of the file, and this PR added history/donut inputs near the top. In engine.py, a history record is a retirement component for cola_override, component_withholding and indexed_cola_rate. It gets the same explicit eligibility, receipt and override inputs as a supplied-AIME record. A history run under a means-tested COLA reform behaves correctly. Witness: matching history and aime records (birth 1964-01-02, E=2026, claimed 2026-01), each in its own tax unit, using the reform package from test_ss_cola_means_test._reform ($100,000 single line). January 2027 ss_calculated_payment came out the same on both routes: $256 at prior-year MAGI $150,000 (withheld), $265 at $50,000 (paid), $265 at $150,000 with ss_calculator_cola_override = 0. With the means test disabled, all six were $265. No code fix was needed.
  5. Classification (policyengine_us/tests/unit/test_ss_calculator_differential.py:33-35): main has test_every_contributed_calculator_namespace_child_is_classified. Its only readers of gov.contrib.ssa.history are donut.py:96 and mini_pia.py:115, both reached only through history_parameters_at, which only calculator_output calls. So I added it to NEW_PARAMETER_NODES.
  6. PR body and title. I rewrote design divergence 3: main now routes supplied_aime / supplied_pia / supplied_ratio_survivor through the calculator, and history is not routed. I added a "Merge with main" section with the witness, the classification and the test counts, and noted that the old Validation section predates the merge. I found no unfinished plan items, so I dropped [WIP] from the title. impact: and axiom: are unchanged. I read the body back after writing it and it matches.

Tests (local, one process per file, uv sync --extra dev)

All 25 policyengine_us/tests/unit/test_ss_*.py files plus test_owners_draw_kernel.py and code_health/test_rest_python_groups.py pass: 27 files, 784 passed, 0 failed. Notable counts: differential 9, engine 20, COLA means test 49, household calculator 114, PIA service 75, history engine 5, history reforms 34. This PR adds no YAML under tests/policy/contrib/ssa/. make format reported no changes, and ruff passes.

CI

Pull request run 41398777566 at this head was still running at 07:15Z, 60 minutes after the 06:15Z push. 34 jobs had succeeded with no failure or cancellation. One job, Contrib tests / Full Suite - Contrib (other-shard-1), was still in progress. The Documentation run succeeded. The hub should confirm that the last job finishes green.

Judgment calls

  • I pushed the two commits together, while the last local files were still running, so CI could start sooner. Every local file then passed.
  • The docs paragraph on post-construction set_input is kept as user guidance for history checks only, because history has no public selector.
  • No partner test, .github/ file or public formula was touched.

Follow-up

Route history through the public Social Security source selector. The history value is in SocialSecurityBenefitSource, but selected_reported_benefit and calculator_component_basis reject it. Wiring it would let the public benefit read history checks the same way as supplied_aime.


Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review
VERDICT: APPROVE
84eaa12
METHODOLOGY_CHOICE: yes

This is a round-2 delta review. The PR head hasn't moved past 84eaa12954. Main has since moved 14 commits, to 4bfc548de3. None of them touch a file in this diff, and git merge-tree --write-tree pr-10560 origin/main merges with no conflict. No earlier **Hub review in progress** comment exists on the PR, so I started from the beginning.

I found no P1 or P2. The r1 P2 is fixed, and the merge with main broke nothing I could find. Two P3s follow.

Findings

P3-1. The PR body's CI line still points at the pre-fix head

## Validation, last bullet: "Pull request CI run 41227650561 succeeded at head f286742, all 34 jobs." That run predates both the cohort fix and the merge. A reader checking CI from the body lands on the wrong commit. The head has its own green run:

$ gh api "repos/PolicyEngine/policyengine-us/actions/runs?head_sha=84eaa129...&per_page=20" --jq '.workflow_runs[]|[.id,.name,.status,.conclusion]|@tsv'
41398777566  Pull request   completed  success
41398777582  Documentation  completed  success
$ gh api repos/PolicyEngine/policyengine-us/actions/runs/41398777566/jobs?per_page=100 --jq '[.jobs[].conclusion]|group_by(.)|map({(.[0]//"null"):length})'
[{"success":34}]

Replacement for that bullet:

  • Pull request CI run 41398777566 succeeded at head 84eaa12, all 34 jobs. The earlier run 41227650561 at f286742 was the first to finish the owners'-draw files and SSA baseline YAML that the capped local runs couldn't complete.

P3-2. Two mentions of E2.13/E2.14 lack the edition

The r1-fix brief asked for the edition wherever the PR names these provisions without it. grep -rn "E2\.1[34]\|B3\.12" policyengine_us docs | grep -v "2025\|2026" returns four lines. Lines 196–197 of the doc sit in a paragraph that opens with "OACT's 2025 Trustees Report edition", so they are covered. Two are not:

  • docs/contributed_reforms/social_security_history.md:222, in the cohort-coefficient convention: "The named E2.13 and E2.14 variants carry a constant 2%". The body's copy of this sentence names the edition; the doc's doesn't.
  • policyengine_us/tests/unit/test_ss_history_engine.py:280: # E2.13's coefficient, selected at eligibility 2036; January 2039 formula.

Neither changes a result. Replacements:

formulas select by cohort. The named E2.13 and E2.14 variants (2025 Trustees
Report edition) carry a constant 2%, so this only moves a custom path that varies.
    # E2.13's coefficient (2025 Trustees Report edition), selected at
    # eligibility 2036; January 2039 formula.

The r1 P2: cohort computation years

Hand computation, done before reading the code. I read the raw YAML values on the branch (gov/ssa/nawi.yaml, social_security/wage_base.yaml, pia/bend_points/{first,second}.yaml). I wrote a separate script that uses no package code: it applies the year's cap, indexes years before E−2 by NAWI(E−2)/NAWI(y), rounds to the cent half up, takes the top min(35, E−1956) years, floors the sum over 12·n, and applies .90/.32/.15 with a dime floor. $3,000 a year from 1951 to E−1:

E n NAWI(E−2) bends top-n sum AIME PIA old 35/420 AIME / PIA
1983 27 13,773.10 254 / 1,528 243,418.77 751 387.60 619 / 345.40
1990 34 19,334.04 356 / 2,145 372,486.21 912 498.30 895 / 492.80
1991 35 20,099.55 370 / 2,230 390,818.79 930 512.20 930 / 512.20
1990, 1951–55 zero 34 293,777.06 720 436.80 699 / 430.10

At 62 with FRA 65 the factor is 1 − 36 × 5/900 = 0.8, and 498.30 × 0.8 = 398.64 → $398.

Production at this head. I ran the pure service with the same parameter files:

1928-01-02 E=1990 Q=1990  years 34  aime 912  pia 498.3
1928-01-01 E=1989 Q=1990  years 33  aime 887  pia 480.4   (attains 62 on 1989-12-31)
1929-01-02 E=1991 Q=1991  years 35  aime 930  pia 512.2
1921-01-02 E=1983 Q=1983  years 27  aime 751  pia 387.6
1920-12-30 E=1982         ValueError: Computation benchmarks before 1983 are unsupported
computation_years(1982) -> ValueError

Through a real Simulation (ss_calculator_source: history, birth 1928-01-02, entitlement 1990-01, no supplied claiming factor), the outputs were ss_calculated_history_aime 912, ss_calculated_history_conventional_pia 498.3 and ss_calculated_payment for 1990-02 of 398. The ledger JSON reported computation_years 34, with 34 ordinary_top35 flags set.

The same witness at the r1 head f286742519 gave AIME 895 and PIA 492.8 for 1990, and 619 / 345.4 for 1983. That reproduces the defect, and the fix removes it. Both sides of the boundary behave as they should: E=1982 is rejected in computation_years, compute_history and SuppliedRecord.benchmark, and E=1983 is accepted with 27 years. AIME+ still uses 35/420, and the mini-PIA still uses 40/37.

Docs. docs/contributed_reforms/social_security_history.md step 4 (lines 87–91) and lines 94–103 state the rule, the 324/408/420 divisors and the witness. Each value matches the runs above. history.py:1-8 and the computation_years docstring match the code.

Mutants

I ran each mutant in its own process on a separate worktree, with pytest -x. I restored the code afterwards, and git status --porcelain showed only the untracked venv link.

Mutant First failing test
1. computation_years returns 35 (fixed 35/420) test_ss_earnings_history.py::test_computation_years_follow_the_elapsed_years_rule
2. E − 1955 same test
3. AIME+ gets the cohort count test_aime_plus_keeps_35_years_for_a_short_cohort
4 (mine). Report flags fixed at 35 (history_report.py:85) test_ss_history_report.py::test_report_period_fields_are_null_before_eligibility_and_flags_follow_cohort
5 (mine). Select n years but divide by 420 (history.py:210) test_1990_witness_selects_34_years_and_divides_by_408

All five were killed.

Tests

I ran each changed test file in its own process with four threads:

File Result
test_ss_earnings_history 27 passed
test_ss_history_report 11 passed
test_ss_history_ssa_examples 2 passed
test_ss_mini_pia 23 passed
test_ss_donut 36 passed
test_ss_history_reforms 34 passed
test_owners_draw_kernel 44 passed
test_ss_earnings_legacy 76 passed
code_health/test_rest_python_groups 64 passed
test_ss_history_engine 5 passed (86 s wall)
test_ss_history_differential 1 passed (93 s)
test_ss_calculator_differential 9 passed (207 s)

That makes 380 passed and none failed. The new cohort tests assert the claim itself, not a weaker one. The 1990 witness asserts the 34- and 35-year sums, AIME, the bends, PIA, the 0.8 factor and the $398 payment. The boundary cases assert n, the sum, AIME (cross-checked against sum // (12n)) and PIA for 1983, 1990 and 1991. The 35-year case asserts that /420 holds and /408 does not. The zero-year case isolates the divisor. The parametrized boundaries reach both ends of the changed range (27 and 35), and computation_years(1982) covers the rejected side.

Main merge

  • engine.py imports (:20-22): both sides are kept, and the module imports cleanly under every test above.

  • calculator/inputs.py: main's ss_calculator_cola_override and this PR's history inputs sit in separate regions. test_ss_calculator_differential.py (9 passed) covers the classification of every input and parameter.

  • Means-tested COLA witness, rerun. I built six people, each in their own tax unit (E=2026, claim 2026-01): history and aime routes × {MAGI $150,000, MAGI $50,000, MAGI $150,000 with ss_calculator_cola_override = 0}. I used test_ss_cola_means_test._reform with its $100,000 single line. January 2027 ss_calculated_payment came out:

    • enabled: history 256 / 265 / 265 and aime 256 / 265 / 265;
    • disabled: all six 265.

    This matches the hand-off.

  • Readers of gov.contrib.ssa.history. Outside tests, grep -rn "contrib.ssa.history" policyengine_us docs finds only tools/social_security/donut.py:96 and mini_pia.py:115. donut_parameters_at and mini_pia_share_at are called only from history_parameters_at (history.py:516-524), and that function is called only from engine.py:125. No production code walks gov.contrib.ssa generically; the only generic walks are the two differential tests. The NEW_PARAMETER_NODES entry is therefore correct.

Docs: docs/usage/social-security-calculator.md:233-256

I checked each sentence under an across-the-board −10% reform (benefit_levers.across_the_board.rate −0.1 from 2026), using a history record with a single 2025 year of $120,000.00 wages plus $0.01 SE (AIME 285, PIA 256.50):

  • ss_calculated_retirement 2026 = 2,530 (11 checks of 230).
  • Public social_security_retirement_source = history raises ValueError: Public Social Security source must match the supported ss_calculator_source. This confirms "do not yet accept history … raises an error".
  • set_input("social_security_retirement", 2026, [2530]) after construction returns 2,530. This confirms "a later public input is an explicit final override".
  • social_security_retirement: 2530 supplied at construction returns 2,277, which equals 2,530 × 0.9. Supplying social_security_retirement_reported at construction also gives 2,277. This confirms "treated as a reported source, and the benefit factors apply".
  • The selector paragraph kept from main matches selected_reported_benefit (tools/social_security_benefit_levers.py:127-154): only supplied_aime/aime, supplied_pia/pia and supplied_ratio_survivor/survivor pass.

The owners_draw.md claim that wage-indexed paths extend through 2100 also holds. Under the default tree, wage base and NAWI are 192,300 / 78,286.07 in 2027, 558,600 / 225,341.89 in 2060, and 1,995,300 / 804,773.88 in 2100.

Baseline

The PR body's impact: line says "none under current law". The diff touches these files that a current-law calculation reads:

  • variables/gov/ssa/social_security/ss_aime.py now calls earnings.highest_earnings and earnings.index_covered_earnings. I read both. They keep the same operation order: amount * (idx_nawi / year_nawi), the same sort/reverse/slice, and float64 zero padding. So the arithmetic, dtype and summation order are unchanged. ss_aime's only production readers are ss_pia and the owners'-draw reform's subclass. ss_pia feeds ss_retirement_benefit_before_earnings_test and then ss_earnings_test_reduction. Nothing outside tests reads that last variable (grep -rln over variables and parameters finds only its own file), so these values don't reach social_security or net income.
  • tools/social_security/household.py and pia.py are imported by population.py, which feeds the baseline PIA-ratio variables. Its imports (attainment_month, claiming_factor, full_retirement_age_months, month_string, has_active_reform) are unchanged. The new exact_result defaults to False, and the new SuppliedRecord fields default to None. Importing household.py now also imports history, owners_draw, donut and mini_pia. That costs import time but changes no value.
  • calculator/inputs.py and results.py add structural inputs and ss_calculated_history_* outputs. All of them are zero or empty unless ss_calculator_source is history.
  • reforms/ssa/owners_draw.py is rewritten onto the shared kernel. It isn't current law, but it is a contributed reform whose outputs feed payroll and income tax when on.

Beyond social_security, income_tax, state_income_tax, household_net_income and household_benefits, the hub's impact pair should add:

  • ss_aime and ss_pia at baseline, expecting exact equality;
  • an owners'-draw reform pair (gov.contrib.ssa.owners_draw.in_effect true), main against head, on self_employment_social_security_tax, self_employment_medicare_tax, self_employment_tax_ald_person, owners_draw_net_earnings, owners_draw_oasdi_base, owners_draw_niit_exclusion, owners_draw_credited_earnings, ss_aime, employee_payroll_tax/self_employment_tax, and income_tax.

Nothing in the diff should move a baseline output, so the population check is equality on those variables, not an impact estimate. The axiom: line exists, and its "n/a" reason fits a household calculator path with no current-law change.

Interaction with #10571

git merge-tree --write-tree pr-10560 pr-10571 merges with no conflict. It auto-merges owners_draw.py and docs/contributed_reforms/owners_draw.md. In the composed tree, #10571's new self_employed_health_insurance_earned_income_limit subclass still finds _enabled, owners_draw_niit_exclusion (now kernel-backed, owners_draw.py:175) and update_variable. There, #10571's new owners_draw_self_employed_health_insurance_earned_income_limit.yaml passes 7 of 7 cases (Core CLI, one process, 95 s).

I'd land #10571 first. It is a small fix with its own YAML, while #10560 is large and needs its owners'-draw CI either way. #10560 then merges main and reruns tests/reforms/test_owners_draw.py and every tests/policy/contrib/ssa/owners_draw*.yaml, including #10571's new SEHI file, against the kernel-backed reform. In the other order, #10571 must merge main and rerun the same files. Neither order needs hand edits.

Other open work: #10527 also edits the Makefile's REST_PYTHON_GROUPS line, so whichever lands second must keep both edits to that line. #10561 shares no file. No changed file is under .github/ or tests/policy/baseline/partners/.

Methodology

The PR picks one reading where the sources leave a choice, so METHODOLOGY_CHOICE is yes. The choices that move results:

  1. Combined dime rounding. The mini blend and the AIME+ credit are summed before a single dime floor.
  2. Cohort-selected donut coefficient. For a custom varying path, E's rate applies.
  3. Initial earnings frozen at Q−1, with no recomputation.

The docs state all three: social_security_history.md:177-185, 219-227 and 108-112. The body's Conventions section repeats the first two with the brief's examples. The ledger now exposes donut_credit_rate, donut_credit_rate_exact, donut_credit_rate_year, diagnostic_pia_entitlement_month, diagnostic_pia_formula_year and computation_years, and test_ss_history_report.py asserts each of them with the donut on and with it off.

Checked and sound

  • The r1-fix brief's items. Cohort rule, report fields, tests, mutants, docs, the body's Conventions section, the Case A/B wording, and edition naming are all done, apart from P3-2. Two changes go beyond the brief's file scope, and the hand-off discloses both. One is a 3-line household.py stamp of the January diagnostic period. The other is reference titles in the 26 gov.contrib.ssa.history parameter files: git diff f286742 076a110 -- policyengine_us/parameters shows only title: lines changed, with no value or label touched. No new input or stored column was added.
  • Internal consistency of the branch's historical parameters. All bend points from 1979 to 2026 equal round(180 or 1085 × NAWI(E−2)/NAWI(1977)) computed on the branch's own NAWI. Every wage base from 1995 to 2026 equals the 1992-indexed formula rounded to $300, except 2010, 2011 and 2016, which hold the prior year's value. The 2027 SSA-example bends (1,340 / 8,077) also follow from the branch's NAWI(2025).
  • Docs on 1951/birth-year earliest base years, missing years and certified zeros match EarningsHistory.through_claim.

What I couldn't check

  • Primary sources. ssa.gov and govinfo.gov don't resolve from this session, and the brief's saved OACT pages weren't available to me. So I couldn't check, against OACT or the U.S. Code:

    • the historical caps, NAWI and bend points (only their internal consistency, above);
    • the quoted B3.12, E2.13 and E2.14 text, and the 2025 versus 2026 edition dates;
    • SSA's printed Case A and Case B figures;
    • the text of 42 U.S.C. 415(a)(1)(A) and 415(b)(2).

    The PR's cohort rule matches its own description of 415(b)(2), and the witness arithmetic is mine. The quotations remain unverified.

  • E=1983 and earlier-law guarantees. Because I couldn't read § 415, I couldn't check whether any transitional or old-law computation also applies to people who reach 62 in 1983. If one applies, the earliest accepted cohort could need a "larger of" comparison. The hub should check this against the fetched usc42-415.html. If such a rule reaches 1983, either start the history route at 1984 or document the omission.

  • Population data. I had no population data and ran no microsimulation, so baseline equality of ss_aime/ss_pia and the owners'-draw reform pair on population data is unmeasured.

  • Other runs I didn't make. I didn't run the full owners'-draw YAML or Python suites, or the composed Add annual Social Security earnings histories and per-year reforms #10560 + Add an opt-in 2026 Trustees wage and price path assumption #10565 (2026 Trustees path) tree. The history route reads the same gov.ssa.nawi and wage_base paths that Add an opt-in 2026 Trustees wage and price path assumption #10565 re-derives, but I didn't exercise that combination.


Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Picking this up for the US hub. I'll post a hand-off comment here when it's ready.


Generated by Claude Code

Brings the branch up to date with main (18 commits behind) before the
1984 benchmark fix round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
42 U.S.C. 415(a)(4)(B)(i) keeps the December 1978 computation, when larger,
for workers with pre-1979 earnings who become eligible before 1984. The
history route computes only the wage-indexed 415(a)(1) formula, so it now
refuses E = 1983 at computation_years, compute_history, history_pia_details
and SuppliedRecord.benchmark (history source only; supplied AIME and PIA
keep their 1983 start). The boundary case moves to E = 1984 (28 years,
divisor 336), and the docs note that the route assumes no covered wages
before 1951, which the 415(a)(4)(B)(ii) subsection (d) guarantee counts.

Also names the 2025 Trustees Report edition beside two E2.13/E2.14 mentions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review

Head: 2166886f482c1999a2a7c4763e1de98cb1a6afc4

Merge

Fast-forwarded to 84eaa12, then merged origin/main with a merge commit (05a19b0). By then main was at e75028d, not 4bfc548, but the branch was still 18 commits behind. No conflicts.

Finding 1: the supported cohorts start at E = 1984

  • policyengine_us/tools/social_security/history.py:188-202: new EARLIEST_HISTORY_ELIGIBILITY_YEAR = 1984, EARLY_BENCHMARK_MESSAGE ("Computation benchmarks before 1984 are unsupported: 42 U.S.C. 415(a)(4)(B)(i) keeps the December 1978 computation, when larger, for workers eligible before 1984") and _check_supported_eligibility.
  • Entry points that now refuse E ≤ 1983: computation_years (history.py:214, docstring at 207-209 now reads 28 years in 1984, 34 in 1990, 35 from 1991), compute_history (history.py:288), history_pia_details (history.py:449, also used by compute_history_pia; this one is my addition, see judgment calls), and SuppliedRecord.benchmark for source == "history" (household.py:267-268).
  • Docs: docs/contributed_reforms/social_security_history.md:73 now says the benchmark "begins in 1984". Lines 77-87 are a new paragraph on 415(a)(4)(B)(i), and on (B)(ii)/subsection (d) with the assumption of no covered wages before 1951 (no new input or assertion). Step 4 at lines 99-103 now reads 28 years / E = 1984 and 336, 408 or 420 months. docs/usage/social-security-calculator.md:185-187 adds one sentence saying the history route requires E >= 1984.
  • Tests (policyengine_us/tests/unit/test_ss_earnings_history.py):
    • The comment at lines 121-125 is updated.
    • computation_years(1984) == 28 at line 175, with 1983 and 1982 both refused and the message checked for "before 1984 … 415(a)(4)(B)(i)".
    • The boundary case moved from E=1983 to E=1984 ((1984, 28, "260949.26", 776, "403.1"), line 235). The test is renamed test_cohort_boundaries_select_28_34_and_35_years, and the 1990 and 1991 cases are kept.
    • New test_benchmarks_before1984_rejected_at_each_history_entry_point[1983|1982] (line 412) covers computation_years, compute_history, SuppliedRecord.benchmark and history_pia_details. For 1982 the record meets the shared 1983 refusal first. The 1983 case also asserts that a supplied-AIME record still benchmarks at 1983.
    • The uninsured check is split out into test_uninsured_history_rejected.
  • test_ss_donut.py:46-47: the comment and test name now say history benchmarks start in 1984.

E = 1984 hand computation, done in a standalone script that reads only the branch's YAML (gov/ssa/nawi.yaml, social_security/wage_base.yaml, pia/bend_points/{first,second}.yaml) before I ran the code:

  • The career is $3,000 a year for 1951–1983, birth 1922. Every cap is ≥ $3,600, so nothing is capped.
  • Each year 1951–1981 is indexed by NAWI(1982) 14,531.34 / NAWI(y), rounded to the cent with half cents up. 1982 and 1983 stay at face value.
  • Highest 28 = 1951–1978 (1979, 1980, 1981, 1982 and 1983 drop out). Their sum is $260,949.26.
  • AIME = floor(260,949.26 / 336 = 776.63) = $776. A 35/420 window would give $660.
  • 1984 bends are 267 / 1,612. PIA = 0.9 × 267 + 0.32 × (776 − 267) = 240.30 + 162.88 = 403.18, which truncates to $403.10.
  • The code's output matched.

Mutant, run in its own process and restored afterwards: setting EARLIEST_HISTORY_ELIGIBILITY_YEAR = 1983 was killed by test_computation_years_follow_the_elapsed_years_rule and test_benchmarks_before1984_rejected_at_each_history_entry_point[1983] (2 failed, 27 passed).

Finding 2: E2.13/E2.14 edition

  • docs/contributed_reforms/social_security_history.md:234 now reads "The named E2.13 and E2.14 variants (2025 Trustees Report edition) carry a constant 2%".
  • policyengine_us/tests/unit/test_ss_history_engine.py:280-281 is now the two-line comment as specified.

Finding 3: PR body

  • The Validation CI bullet now cites run 41519611528 at 2166886 (34/34 jobs, success). It keeps the clause saying that run 41227650561 at f286742 was the first to finish the owners'-draw files and SSA baseline YAML.
  • The body now states the 1984 start, with 415(a)(4)(B)(i), in the 4a-2 bullet (28 for E=1984), the eligibility bullet (which adds the no-wages-before-1951 assumption) and the M2 bullet. I also added a Validation bullet for this round, and reworded "predates the merge" to exclude it.
  • The impact: and axiom: lines are unchanged. The readback matches my file except for one thing: the GitHub proxy strips the 🤖 Generated with [Claude Code] closing line and replaces it with its own attribution footer. I tried twice and couldn't keep that line.

Tests (one file per process)

  • test_ss_earnings_history.py: 29 passed
  • test_ss_history_engine.py: 5 passed
  • test_ss_history_report.py: 11 passed
  • test_ss_donut.py: 36 passed
  • test_ss_household_calculator.py: 114 passed
  • test_ss_mini_pia.py: 23 passed
  • code_health/test_source_conflicts.py: 103 passed
  • ruff format and ruff check are clean on the changed files.
  • No full suites or microsimulations were run.

CI

Pull request run 41519611528 at 2166886: completed, success, 34/34 jobs (it finished about 70 minutes after the push).

Judgment calls

  1. History route only. SuppliedRecord.benchmark still refuses E < 1983 for every source. Only source == "history" adds the 1984 refusal, so the supplied-AIME, supplied-PIA and survivor routes and the shared pia.py/cola.py/household.py 1983 checks on main are unchanged. That keeps within the brief's scope, which is the history service.
  2. history_pia_details guard. I added the refusal here too, because it computes the 415(a)(1) PIA from any HistoryCalculation, including one built by hand.
  3. compute_mini_pia left alone. It still inherits compute_pia's 1983 check, and its test (test_ss_mini_pia.py:80) still expects "before 1983". It is reached through the history route only after compute_history has refused E ≤ 1983.
  4. Statute not fetched. law.cornell.edu, ssa.gov, web.archive.org and uscode.house.gov were all blocked from this session, so I couldn't read 415(a)(4) or 415(d). The docs paraphrase the hub ruling and quote nothing from the statute. Please check the wording at social_security_history.md:77-87 against the text.

Proposed follow-up

Supplied AIME/PIA routes accept E = 1983 although 415(a)(4)(B)(i) can keep the December 1978 computation. The supplied-AIME route computes the 415(a)(1) PIA for E = 1983 through compute_pia/SuppliedRecord.benchmark, which still accept 1983. Decide whether those routes should also start at 1984, or should require an assertion that the worker has no pre-1979 earnings.


Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Picking this up for the US hub. I'll post a hand-off comment here when it's ready.


Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Picking this up for the US hub. I'll post a hand-off comment here when it's ready.


Generated by Claude Code

Main moved 59 commits past the branch. Three files changed on both sides
and merged without a conflict. owners_draw.py keeps #10571's
self_employed_health_insurance_earned_income_limit override in
reform.apply. That override reads owners_draw_niit_exclusion, which this
branch now computes through the shared kernel, and the kernel takes the
same disabled, HI-on and OASDI-only branches as main's formula. The
owners' draw docs keep #10571's line 5 section next to this branch's
annual-history text. The differential test keeps main's leaf-level
classification and adds gov.contrib.ssa.history to NEW_PARAMETER_NODES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SuppliedRecord.benchmark ran the shared 1983 check before the history
route's own 1984 check, so a history record at E = 1982 got "Computation
benchmarks before 1983 are unsupported", which implies 1983 works. The
history check now runs first, so calculate_year and the engine give the
415(a)(4)(B)(i) reason at E <= 1983, as computation_years, compute_history
and history_pia_details already did. Supplied AIME, PIA and survivor
records keep the shared 1983 start.

The tests now pin both sides of this boundary at the main door: a
January 1, 1922 birth attains 62 on December 31, 1983 and is refused, and
a January 2 birth benchmarks at 1984, so changing the history check to
<= fails. They also check that a supplied PIA, not only a supplied AIME,
keeps 1983, and that annual records cannot begin before 1951, the limit
the docs rely on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The paragraph on why the history route starts at 1984 said "before 1984"
without (B)'s condition that the worker first becomes eligible after
1978, could be read as if (B)(ii) also stopped at 1984, and did not name
the subsection. It also left out that 415(d)(2)(A) limits subsection (d)
to a worker with a quarter of coverage before 1951, which is what makes
the no-pre-1951-wages assumption sufficient. It now also states the
route's other assumption: no disability-benefit eligibility before
January 1979, the case 415(a)(4)(A) takes outside paragraph (1). Own
retirement records have no prior-disability input. The wording follows
the delta review's reading of the statute; the build session could not
reach a copy of 42 U.S.C. 415 to recheck it.

Also rewraps the 100-column donut-coefficient line. Only whitespace
changes there, so the rendered page is the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

MaxGhenis commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor Author

Ready for hub review

Head: 0761d7b2bfed593aab01ce48d0d944b34d5cbdb9

Three commits on top of 2166886f48 (the expected head; nobody else had pushed): merge commit 09207a9bf1, then 8d80d184ab (findings 1 and 3) and 0761d7b2bf (finding 2). One normal merge: no rebase and no force-push. I pushed once, at 12:56Z, as a fast-forward.

Item What I did Where
Merge main Merged 2b39b88e2f with no conflict. Checked the three files changed on both sides. Ran the named tests: all pass. Section 1
Finding 1 Applied as written. household.py:265-268, test_ss_earnings_history.py:424-425
Finding 2 Applied as written, including the (a)(4)(A) sentence. I could not reach any copy of 42 U.S.C. 415, so the statutory statements are unverified by me. social_security_history.md:77-90
Finding 2, optional Rewrapped; only whitespace changed. social_security_history.md:234-239
Finding 3 Applied as written: 31 passed. The <= mutant is killed. test_ss_earnings_history.py:426-429, :438-449, :452-455
Findings 4, 5 Left alone; they are already in the body. With finding 1 applied, the body's "refuse E ≤ 1983, citing 415(a)(4)(B)(i)" now holds at every entry point. n/a
Finding 6 Left for the hub's follow-up. n/a

1. Merge with main

  • git merge origin/main merged main at 2b39b88e2f with no conflict. Main had moved past the brief's c3ae23111c: 59 commits past the branch instead of 26. The extra 33 commits touch none of this PR's files. The same three files changed on both sides as at c3ae23111c.
  • The merge adds no hand edits. git merge-tree --write-tree 2166886f48 2b39b88e2f gives tree e488a52bb2, the same tree as merge commit 09207a9bf1.
  • policyengine_us/reforms/ssa/owners_draw.py
    • Count the owners' draw in the SEHI limit's line 5 under the owners' draw reform #10571's self_employed_health_insurance_earned_income_limit override is kept and registered in reform.apply (:310). It reads _enabled (:31, used at :234) and owners_draw_niit_exclusion (:247).
    • owners_draw_niit_exclusion (:188) now calls _draw(..., "niit_exclusion"), which evaluates tools/social_security/owners_draw.py:264-270. That code takes the same three branches as main's formula (main's reforms/ssa/owners_draw.py:237-244):
      • disabled gives 0;
      • with include_hi, the result is owners_draw_gross;
      • otherwise it is owners_draw_oasdi_base / owners_draw_net_earnings_factor.
    • Each operand is read through the same owners_draw_* Person variables. The kernel's enabled uses in_effect and year >= start_year, the same test as _enabled.
    • Numeric differential, main 2b39b88e2f against merge 09207a9bf1. Each checkout ran in its own process with its own package.
      • Inputs: 216 single-person households, crossing 9 draw sources (including negatives, overlapping passive and already-SE subsets, and a source just below the $400 floor) with wages of $0, $60,000, $190,000 and $260,000, Schedule C income of $0, $40,000 and −$5,000, and farm income of $0 and $12,000.
      • Parameter variants (8): defaults, HI on, half_seca_deduction off, room_cap_basis 1, full share, passive-inclusive, factor 0.9, and a combined variant.
      • Years: 2026, 2027, 2028 and 2036.
      • Outputs (15): every owners_draw_* stage, self_employment_tax, self_employment_tax_ald_person, self_employed_health_insurance_earned_income_limit and self_employed_health_insurance_ald_person.
      • Result: 103,680 values compared, 0 mismatches, 0 errors. owners_draw_niit_exclusion is nonzero in 94 to 168 rows per active year and zero everywhere in 2026, before the start year.
  • docs/contributed_reforms/owners_draw.md, read section by section. Both sides' text stands, and both agree with the merged code:
  • test_ss_calculator_differential.py keeps main's leaf-level classification test (Fix docs helper import and tighten docs and test hygiene #10642) and this PR's gov.contrib.ssa.history entry in NEW_PARAMETER_NODES. All of that node's leaves sit under a listed path, so the new walk classifies them.

Post-merge tests at 09207a9bf1 (one file per process, four numeric threads):

File Result Wall time Peak RSS
tests/reforms/test_owners_draw.py 38 passed 213 s 3.3 GB
tests/policy/contrib/ssa/owners_draw_self_employed_health_insurance_earned_income_limit.yaml 11 passed 371 s 9.6 GB
tests/policy/contrib/ssa/owners_draw.yaml 17 passed 369 s 9.7 GB
tests/unit/test_ss_calculator_differential.py 9 passed 181 s 2.6 GB
tests/unit/test_ss_history_engine.py 5 passed 70 s 1.9 GB
tests/unit/test_ss_history_differential.py 1 passed 87 s 2.3 GB
test_ss_donut.py / test_ss_earnings_history.py / test_ss_earnings_legacy.py 36 / 29 / 76 passed <1 GB
test_ss_history_reforms.py / test_ss_history_report.py / test_ss_history_ssa_examples.py 34 / 11 / 2 passed <1 GB
test_ss_mini_pia.py / test_owners_draw_kernel.py / test_ss_household_calculator.py 23 / 44 / 114 passed <1 GB
code_health/test_rest_python_groups.py / code_health/test_source_conflicts.py 64 / 103 passed <1 GB

All 17 files passed: 617 tests, none failed. test_owners_draw.py now has 38 cases, up from 36, because main's #10571 added two generated SEHI tests (test_generated_disabled_sehi_limit_equals_baseline and test_generated_sehi_limit_counts_seca_taxed_draw_in_line_5). Both pass against the kernel-backed reform. The YAML files' peak RSS covers the Core CLI's whole process. These are local measurements, not CI's.

2. Finding 1: the history reason comes first

policyengine_us/tools/social_security/household.py:265-268 now runs the history-source check before the shared 1983 check, as the review wrote it. policyengine_us/tests/unit/test_ss_earnings_history.py:424-425 drops the comment and the conditional match, so both parametrized years expect EARLY.

Nothing relied on the old order:

  • test_ss_household_calculator.py:566 uses retirement(), whose source defaults to "pia".
  • :630 goes through SurvivorBasis.benchmark (household.py:208).
  • test_ss_mini_pia.py:81 tests compute_mini_pia, test_ss_pia_service.py:79-81 tests compute_pia and inverse_pia, and test_ss_calculator_parameters.py:93 tests bend_points_at. None of them goes through SuppliedRecord.

test_ss_household_calculator.py passes 114 of 114 at the head.

3. Finding 2: docs paragraph

docs/contributed_reforms/social_security_history.md:77-90 is now the review's replacement paragraph, including its last sentence on 415(a)(4)(A).

:234-239 is rewrapped. The first line breaks where round 2 suggested ("…(2025 Trustees"). The rest of the paragraph is reflowed to at most 77 columns, because round 2's second line would run to 82. A whitespace-normalized comparison against 2166886f48 shows that only whitespace changed, so the rendered paragraph is the same.

Statute check: I could not reach any copy of 42 U.S.C. 415.

  • From the container, direct requests to uscode.house.gov, govinfo.gov, law.cornell.edu, ssa.gov, secure.ssa.gov and web.archive.org all got a 403 from the proxy.
  • Through the fetch tool, every host failed DNS resolution, outside the session's allowed network domains:
  • The fetch tool refuses web.archive.org.

So every statutory statement in the paragraph is unverified by me:

  • (B)(i)'s 1979-1983 scope;
  • the "larger" comparison;
  • (B)(ii) having no year limit;
  • the 1939 Act benefit under (d);
  • (d)(2)(A) and the pre-1951 quarter of coverage;
  • (a)(4)(A).

I applied the paragraph unchanged. Search-engine summaries came back, but they are not fetched text and they contradicted each other. One summary described (a)(4)(A) as pairing January 1979 with old-age eligibility, not disability. Please check (a)(4)(A)'s wording, eligibility against entitlement, against the hub's fetched copy before landing.

I read the code behind the paragraph's statements about the route:

  • The 1984 refusal: history.py:200-202, :214, :288, :449 and household.py:265.
  • Records from 1951: EarningsHistory.through_claim, history.py:76-80.
  • No prior-disability input on an own-retirement record: engine.py:61-62 reads prior_disability_eligibility only when the source is survivor, and SuppliedRecord has no such field of its own.

4. Finding 3: tests and mutant

The replacement for lines 428-430 is at test_ss_earnings_history.py:426-429, and it now asserts both AIME and PIA. The two new tests are at :438 and :452, as the review wrote them.

Traced by hand before running:

  • attainment_month(1922-01-01, 744): (1921 × 12 + 11) + 744 = 23,807, and 23,807 // 12 = 1983, which is December 1983. The history check refuses it with the 415(a)(4)(B)(i) message.
  • attainment_month(1922-01-02, 744): (1922 × 12 + 0) + 744 = 23,808, which gives 1984. The earliest entitlement month is January 1984, so 1984-02 qualifies and benchmark() returns 1984.
  • EarningsHistory(1950, …).through_claim(1984): the earliest base year is max(1951, birth year) = 1951, and 1950 < 1951 raises "History first_year must be within the worker's base years".

Results:

  • test_ss_earnings_history.py: 31 passed (29 before, plus the two new tests). Both new tests passed on the first run, so there was nothing to diagnose.
  • Mutant (< → <= in the history check, now household.py:265), run in its own process: killed by test_history_benchmark_boundary_follows_age_62_attainment (1 failed, 30 passed). The file was restored and checked with cmp.

Tests at the head (one file per process)

File Result
test_ss_earnings_history.py 31 passed
test_ss_household_calculator.py 114 passed
test_ss_donut.py 36 passed
test_ss_history_report.py 11 passed
test_ss_history_reforms.py 34 passed
test_ss_mini_pia.py 23 passed
test_ss_history_ssa_examples.py 2 passed
test_ss_history_engine.py (real Simulation) 5 passed
code_health/test_source_conflicts.py 103 passed

All 9 files passed: 359 tests, none failed. The head differs from the merge commit only in household.py, test_ss_earnings_history.py and the history docs page, so the owners'-draw and differential results above still apply.

ruff format . and ruff check . (the make format commands) changed nothing and pass. No full suites, microsimulations or partner tests were run. Nothing under .github/ or tests/policy/baseline/partners/ differs between 2166886f48 and the head, or between main 2b39b88e2f and the head.

CI

Pull request run 41639987378 at 0761d7b2bf: completed, success, 35 of 35 jobs. It finished at 14:02Z, about 65 minutes after the 12:56Z push.

Judgment calls

  1. I merged current main (2b39b88e2f), not c3ae23111c. git merge origin/main, as written, takes the current main. The extra 33 commits touch no file in this PR.

  2. Finding 2 went in unverified, for the reasons in section 3. The brief allows "or that you couldn't reach one". The review's text came from the hub's own reading of the statute. I flag (a)(4)(A) above.

  3. Rewrap. Section 3 explains why the first line follows round 2's break and the rest of the paragraph is reflowed.

  4. I didn't edit the PR body. Findings 4 and 5, impact: and axiom: stay as they are. With finding 1 applied, the body's "refuse E ≤ 1983, citing 415(a)(4)(B)(i)" is now true for every entry point. Three parts of the body now trail the branch:

    • its Validation bullets and CI line still describe 2166886f48, and the test_ss_earnings_history.py count is now 31;
    • its "Eligibility begins in 1984" bullet still says "who become eligible before 1984";
    • it doesn't mention the newly documented assumption of no disability-benefit eligibility before January 1979.

    The hub may want to update these after this run.

  5. The extra checks are mine, not the brief's:

    • the merge-tree equality check;
    • the numeric NIIT differential;
    • test_source_conflicts.py, test_rest_python_groups.py, test_owners_draw_kernel.py and the history engine and differential files after the merge.
  6. The refusal message's reason is imprecise for E ≤ 1978. I had two independent read-only reviews of my commits, and both raised this. Their other points are in 4, 7 and the follow-ups.

    • After finding 1, SuppliedRecord.benchmark gives EARLY_BENCHMARK_MESSAGE for every history record at E ≤ 1983, E ≤ 1978 included. So do computation_years, compute_history and history_pia_details, as they did before this round.
    • The message cites 415(a)(4)(B)(i) "for workers eligible before 1984". The new docs paragraph limits (B)(i) to first eligibility from 1979 through 1983.
    • So for E ≤ 1978, the stated reason is not the one the docs give. The refusal itself is right.
    • I left the message unchanged. Finding 1 specified this order, and I can't check a reworded statutory reason against the statute. The follow-up below covers it.
  7. Not changed, outside this round: four prose lines in the history doc still run past 80 columns (:188, :198, :214, :217). They are cosmetic only.

Proposed follow-ups

Align the 1984 refusal's stated reason with the docs' (B)(i) scope.
EARLY_BENCHMARK_MESSAGE and its comment (history.py:188-197), docs/usage/social-security-calculator.md:185-187 and the test comment at test_ss_earnings_history.py:121-125 say (B)(i) covers workers "eligible before 1984". The docs now say first eligibility from 1979 through 1983, so the reason is wrong for E ≤ 1978.
Either reword them to the docs' scope (before 1984, 415(a)(4) can keep a December 1978 computation that the route cannot compute) or give E ≤ 1978 its own reason. This is wording only, with no change to any refusal, and the tests' EARLY regex would follow.

State that history records carry no period of disability.
Own-retirement history records have no disability input at any date, and computation_years takes only E (history.py:205-215).
If a period of disability changes elapsed years under 415(b)(2), the 1984 paragraph should say that this case is also outside the route. Neither the review nor I checked that statutory point.


Generated by Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis merged commit be457a1 into main Nov 5, 2026
38 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

US + core hub merge audit, us#10560 at 308e100b9e2fa285cc88e8e19e0b86f17ba3eb83 (squash): Add annual Social Security earnings histories and per-year reforms.

  • Review: Round 2 approved 84eaa12954, with METHODOLOGY_CHOICE yes. A delta review of 2166886f48 found no P1 or P2. Its first three findings landed as written at 0761d7b2bf, and the hub compared each diff with the review's replacement text.
  • Gates checked live at merge:
    • gh pr checks exits 0;
    • the latest Pull request run at the head concluded success (41984891263, 36 of 36 jobs), on main merged in at 2b61205a18;
    • MERGEABLE;
    • not a draft;
    • no reviewer's latest review requests changes;
    • the semantic-overlap guard;
    • head pinned.
  • Impact: none, by a dataset read and a code read. ss_calculator_source defaults to off, the default dataset doesn't hold it, and calculator_output skips every off record. ss_aime calls shared helpers that run main's arithmetic in main's order.
  • Axiom: n/a, since the PR changes the household calculator and contributed reform paths.

🤖 Generated with Claude Code

@MaxGhenis
MaxGhenis deleted the ss-household-calculator-4a2 branch November 5, 2026 11:33
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.

2 participants