From 6219e40b8f4434f42003ce34b83f497d0cc2b340 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 08:54:09 -0400 Subject: [PATCH 01/14] Add annual earnings histories and conventional Social Security AIME 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 --- .../ss-household-calculator-4a2.added.md | 1 + .../tests/unit/test_ss_earnings_history.py | 272 ++++++++++++++++++ .../tests/unit/test_ss_earnings_legacy.py | 187 ++++++++++++ .../unit/test_ss_history_ssa_examples.py | 115 ++++++++ .../tools/social_security/earnings.py | 42 +++ .../tools/social_security/engine.py | 18 ++ .../tools/social_security/history.py | 207 +++++++++++++ .../tools/social_security/household.py | 43 ++- .../ssa/social_security/calculator/inputs.py | 18 +- .../gov/ssa/social_security/ss_aime.py | 18 +- 10 files changed, 905 insertions(+), 16 deletions(-) create mode 100644 changelog.d/ss-household-calculator-4a2.added.md create mode 100644 policyengine_us/tests/unit/test_ss_earnings_history.py create mode 100644 policyengine_us/tests/unit/test_ss_earnings_legacy.py create mode 100644 policyengine_us/tests/unit/test_ss_history_ssa_examples.py create mode 100644 policyengine_us/tools/social_security/earnings.py create mode 100644 policyengine_us/tools/social_security/history.py diff --git a/changelog.d/ss-household-calculator-4a2.added.md b/changelog.d/ss-household-calculator-4a2.added.md new file mode 100644 index 00000000000..d953a2ba06e --- /dev/null +++ b/changelog.d/ss-household-calculator-4a2.added.md @@ -0,0 +1 @@ +Add an opt-in annual earnings-history path for the Social Security household calculator. diff --git a/policyengine_us/tests/unit/test_ss_earnings_history.py b/policyengine_us/tests/unit/test_ss_earnings_history.py new file mode 100644 index 00000000000..85271704503 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_earnings_history.py @@ -0,0 +1,272 @@ +"""Pure career arithmetic, certification and compatibility with the legacy AIME. + +These tests exercise supplied records and pure services without constructing a +country model. API/export behavior has a separate small integration witness. +""" + +from dataclasses import replace +from datetime import date +from decimal import Decimal +from types import SimpleNamespace as Node + +import numpy as np +import pytest +from hypothesis import given, strategies as st +from policyengine_core.periods import period + +from policyengine_us.tools.social_security.history import ( + EarningsHistory, + EarningsRecord, + HistoryParameters, + aime_from_indexed, + compute_history, + compute_history_pia, +) +from policyengine_us.tools.social_security.household import ( + SuppliedRecord, + calculate_year, +) +from policyengine_us.tools.social_security.pia import FormulaPath +from policyengine_us.variables.gov.ssa.social_security.ss_aime import _compute_aime + + +def history(records, first=1999, zeros=()): + return EarningsHistory(first, tuple(records), tuple(zeros), True) + + +def parameters(first=1999, last=2038, **overrides): + return HistoryParameters( + wage_base={year: 120_000 for year in range(first, last + 1)}, + nawi={year: 100 for year in range(first, last + 1)}, + **overrides, + ) + + +def fixture(): + return history( + [EarningsRecord(year, 60_000, 0) for year in range(2004, 2037)] + + [EarningsRecord(2037, 0, 0), EarningsRecord(2038, 500_800, 0)], + zeros=range(1999, 2004), + ) + + +def worker(source="history", **kwargs): + return SuppliedRecord( + source=source, + worker_id="worker", + birth_date=date(1974, 1, 2), + entitlement_month="2039-01", + insured=True, + eligibility_year=2036, + no_special_monthly_test=True, + no_prior_withholding_adjustment=True, + earnings_test_no_trigger=True, + **kwargs, + ) + + +def test_hand_computed_conventional_rows_and_payment_pipeline(): + career = compute_history(fixture(), 2036, 2039, parameters(), birth_year=1974) + assert career.aime == 5000 + assert [row.ordinary_covered for row in career.ledger[:5]] == [0] * 5 + assert [row.indexed_ordinary for row in career.ledger[5:38]] == [60_000] * 33 + assert career.ledger[-2].ordinary_covered == 0 + assert career.ledger[-1].ordinary_covered == 120_000 + assert compute_history_pia(career, bend_points=(1000, 6000)) == Decimal("2180") + annual = calculate_year( + worker(history=fixture(), history_parameters=parameters()), + 2039, + bend_points=(1000, 6000), + ) + january = annual.ledger[1] + assert january.claiming_factor == Decimal(13) / 15 + assert january.payable_benefit == 1889 + assert annual.entitlement_total == 1889 * 12 + # A January entitlement is first paid in February (merged 4a-1 contract). + assert annual.payment_total == 1889 * 11 + + +def test_cap_before_indexing_half_up_cents_and_face_value_from_e_minus_two(): + supplied = history( + [EarningsRecord(y, 150_000, 0) for y in range(2033, 2039)], first=2033 + ) + p = parameters() + p = replace(p, nawi={2033: 96, 2034: 100, 2035: 80, 2036: 75, 2037: 70, 2038: 60}) + result = compute_history(supplied, 2036, 2039, p) + assert result.ledger[0].indexed_ordinary == 125_000 + assert [row.indexed_ordinary for row in result.ledger[1:]] == [120_000] * 5 + cents = compute_history( + history([EarningsRecord(2033, ".005", 0)], first=2033, zeros=range(2034, 2039)), + 2036, + 2039, + replace(p, nawi={2033: 100, 2034: 100}), + ) + assert cents.ledger[0].indexed_ordinary == Decimal(".01") + + +def test_top35_padding_and_whole_dollar_truncation(): + assert aime_from_indexed([Decimal(420)] * 40) == 35 + assert aime_from_indexed([Decimal("419.99")]) == 0 + assert aime_from_indexed([Decimal(420)]) == 1 + assert aime_from_indexed([]) == 0 + + +def test_after62_through_previous_claim_year_and_no_post_claim_recomputation(): + records = [EarningsRecord(y, 0, 0) for y in range(2036, 2041)] + records[2] = EarningsRecord(2038, 100_800, 0) + records[3] = EarningsRecord(2039, 120_000, 0) + supplied = history(records, first=2036) + result = compute_history(supplied, 2036, 2039, parameters()) + assert result.aime == 240 + record = worker(history=supplied, history_parameters=parameters()) + assert calculate_year(record, 2040, bend_points=(1000, 6000)).history == result + + +@pytest.mark.parametrize( + "reform", + [ + FormulaPath(), + FormulaPath(factor_values=(".95", ".32", ".05")), + FormulaPath(bend_point_multipliers=("1.18", 1)), + ], +) +def test_4d_identical_supplied_aime_and_history_pia(reform): + record = worker(history=fixture(), history_parameters=parameters()) + supplied = worker("aime", aime=5000) + actual = calculate_year(record, 2040, bend_points=(1000, 6000), formula_path=reform) + expected = calculate_year( + supplied, 2040, bend_points=(1000, 6000), formula_path=reform + ) + assert actual.ledger == expected.ledger + assert actual.entitlement_total == expected.entitlement_total + + +def test_missing_versus_zero_certification_and_json(): + supplied = EarningsHistory.from_json( + '{"first_year":2037,"certified_zero_before_first_year":true,"records":[{"year":2038,"covered_wages":"420.00","covered_self_employment":"0"}]}' + ) + with pytest.raises(ValueError, match="Missing required.*2037"): + compute_history(supplied, 2036, 2039, parameters()) + assert ( + compute_history( + replace(supplied, certified_zero_years=(2037,)), 2036, 2039, parameters() + ).aime + == 1 + ) + with pytest.raises(ValueError, match="Certify zero"): + compute_history( + replace(supplied, certified_zero_before_first_year=False), + 2036, + 2039, + parameters(), + ) + with pytest.raises(ValueError, match="conflicting"): + compute_history( + replace(supplied, certified_zero_years=(2037, 2038)), + 2036, + 2039, + parameters(), + ) + + +@pytest.mark.parametrize("bad", [None, True, "NaN", "Infinity", -1]) +def test_invalid_covered_earnings_rejected(bad): + with pytest.raises(ValueError, match="finite nonnegative"): + compute_history( + history([EarningsRecord(2038, bad, 0)], first=2038), + 2036, + 2039, + parameters(), + ) + + +def test_duplicate_year_unknown_field_and_missing_se_rejected(): + with pytest.raises(ValueError, match="Duplicate"): + compute_history( + history([EarningsRecord(2038, 0, 0)] * 2, first=2038), + 2036, + 2039, + parameters(), + ) + for payload in ( + '{"first_year":2038,"unknown":0}', + '{"first_year":2038,"records":[{"year":2038,"covered_wages":0}]}', + ): + with pytest.raises(ValueError, match="Invalid annual"): + EarningsHistory.from_json(payload) + + +def test_benchmarks_before1983_and_uninsured_history_rejected(): + with pytest.raises(ValueError, match="before 1983"): + compute_history(fixture(), 1982, 2039, parameters()) + with pytest.raises(ValueError, match="insured-status"): + replace(worker(), insured=False).benchmark() + + +def test_benefit_zero_before_entitlement(): + result = calculate_year( + worker(history=fixture(), history_parameters=parameters()), + 2038, + bend_points=(1000, 6000), + ) + assert result.entitlement_total == result.payment_total == 0 + + +@given( + st.lists(st.integers(min_value=0, max_value=1_000_000), min_size=1, max_size=45), + st.integers(min_value=0, max_value=1_000_000), +) +def test_aime_nondecreasing_in_any_earnings_year(earnings, extra): + # Cycle the changed position to cover both selected and unselected years. + position = extra % len(earnings) + bumped = list(earnings) + bumped[position] += extra + assert aime_from_indexed(bumped) >= aime_from_indexed(earnings) + + +def test_legacy_aime_differential_on_shared_year_window_and_exact_rounding(): + """Independent old NumPy formula agrees where its window/rounding match.""" + wages = {year: 50_400 for year in range(1986, 2026)} + p = Node( + indexing_age=60, + minimum_age_for_earnings=22, + max_earnings_age=62, + years_of_earnings=35, + months_per_year=12, + ) + + class Person: + count = 1 + + def __call__(self, name, when): + return np.array( + [ + 62 + if name == "age" + else wages.get(when.start.year, 0) + if name == "employment_income" + else 0 + ] + ) + + def policy(when): + year = when.start.year + nawi = 50 if year < 2024 else 100 + return Node( + gov=Node( + ssa=Node(nawi=nawi, social_security=Node(aime=p, wage_base=120_000)), + simulation=Node(aime_lookback_years=40), + ) + ) + + years = range(1986, 2026) + career = compute_history( + history([EarningsRecord(y, wages[y], 0) for y in years], first=1986), + 2026, + 2026, + HistoryParameters( + {y: 120_000 for y in years}, {y: 50 if y < 2024 else 100 for y in years} + ), + ) + legacy = _compute_aime(Person(), period("2026"), policy) + assert legacy[0] == career.aime == 8400 diff --git a/policyengine_us/tests/unit/test_ss_earnings_legacy.py b/policyengine_us/tests/unit/test_ss_earnings_legacy.py new file mode 100644 index 00000000000..9d4bf4b03db --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_earnings_legacy.py @@ -0,0 +1,187 @@ +"""Exact current-law compatibility for the shared AIME indexing helpers. + +These tests use small synthetic Person arrays and annual parameter snapshots, +without constructing a model. The reference preserves the pre-extraction +algorithm, including its historical dtype promotion and absence of rounding. +""" + +from types import SimpleNamespace + +import numpy as np +import pytest +from policyengine_core.periods import period + +from policyengine_us.tools.social_security.earnings import highest_earnings +from policyengine_us.variables.gov.ssa.social_security.ss_aime import _compute_aime + + +CURRENT_YEAR = 2039 +AGES = (0, 21, 22, 23, 59, 60, 61, 62, 64, 65, 69, 70, 71, 95) + + +class AnnualPerson: + """Return deterministic arrays with cap, loss and precision edge cases.""" + + def __init__(self, ages, dtype): + self.ages = np.asarray(ages, dtype=np.int32) + self.count = len(ages) + self.dtype = dtype + self.calls = [] + + def __call__(self, name, instant): + year = instant.start.year + self.calls.append((name, year)) + if name == "age": + return self.ages + index = np.arange(self.count) + if name == "employment_income": + amount = 19_687.33 + (year % 17) * 1_234.567 + index * 217.039 + amount *= np.where((year + index) % 11 == 0, 7, 1) + elif name == "total_self_employment_income": + amount = np.where((year + index) % 5 == 0, -3_456.789, 6_789.123) + elif name == "owners_draw_credited_earnings": + amount = np.where((year + index) % 3 == 0, 55_410.17, 0) + else: + raise AssertionError(name) + return np.asarray(amount, dtype=self.dtype) + + +def annual_parameters( + lookback, dtype, *, minimum_age=22, maximum_age=69, indexing_age=60, years=35 +): + aime = SimpleNamespace( + minimum_age_for_earnings=minimum_age, + max_earnings_age=maximum_age, + indexing_age=indexing_age, + years_of_earnings=years, + months_per_year=12, + ) + + def parameters(instant): + year = instant.start.year + cap = 50_000 + (year - 1950) * 3_432.17 + # Integer annual inputs must retain the old integer assignment/truncation + # behavior when the annual cap is also integral. + if np.issubdtype(dtype, np.integer): + cap = int(cap) + return SimpleNamespace( + gov=SimpleNamespace( + simulation=SimpleNamespace(aime_lookback_years=lookback), + ssa=SimpleNamespace( + nawi=19_000.123 + (year - 1950) * 837.619, + social_security=SimpleNamespace(wage_base=cap, aime=aime), + ), + ) + ) + + return parameters + + +def legacy_aime(person, instant, parameters, additional_earnings_variable=None): + """Independent pre-extraction window, assignment, selection and summation.""" + ages = person("age", instant) + current_year = instant.start.year + policy = parameters(instant).gov.ssa.social_security.aime + indexing_years = current_year - ages + policy.indexing_age + annual_amounts = [] + lookback = parameters(instant).gov.simulation.aime_lookback_years + for years_ago in range(lookback + 1): + year = current_year - years_ago + year_ages = ages - years_ago + eligible = (year_ages >= policy.minimum_age_for_earnings) & ( + year_ages <= policy.max_earnings_age + ) + if not np.any(eligible): + continue + annual_period = instant.this_year.offset(-int(years_ago)) + total = person("employment_income", annual_period) + person( + "total_self_employment_income", annual_period + ) + if additional_earnings_variable is not None: + total = total + person(additional_earnings_variable, annual_period) + cap = parameters(annual_period).gov.ssa.social_security.wage_base + covered = np.minimum(total, cap) + indexed = np.zeros_like(covered) + indexing = eligible & (year <= indexing_years) + if np.any(indexing): + year_nawi = parameters(annual_period).gov.ssa.nawi + for indexing_year in np.unique(indexing_years[eligible].astype(int)): + members = ( + eligible & indexing & (indexing_years.astype(int) == indexing_year) + ) + if np.any(members) and indexing_year <= current_year: + anchor_period = instant.this_year.offset( + -int(current_year - indexing_year) + ) + anchor_nawi = parameters(anchor_period).gov.ssa.nawi + indexed[members] = covered[members] * (anchor_nawi / year_nawi) + unindexed = eligible & (year > indexing_years) + indexed[unindexed] = covered[unindexed] + indexed[~eligible] = 0 + annual_amounts.append(indexed) + + matrix = ( + np.column_stack(annual_amounts) + if annual_amounts + else np.zeros((person.count, 1)) + ) + ordered = np.sort(matrix, axis=1)[:, ::-1] + count = min(ordered.shape[1], policy.years_of_earnings) + selected = ordered[:, :count] + if count < policy.years_of_earnings: + selected = np.hstack( + [selected, np.zeros((person.count, policy.years_of_earnings - count))] + ) + return np.sum(selected, axis=1) / ( + policy.years_of_earnings * policy.months_per_year + ) + + +def assert_exact_legacy_match(ages, dtype, lookback, additional=None, **policy): + instant = period(str(CURRENT_YEAR)) + parameters = annual_parameters(lookback, dtype, **policy) + old_person = AnnualPerson(ages, dtype) + new_person = AnnualPerson(ages, dtype) + expected = legacy_aime(old_person, instant, parameters, additional) + actual = _compute_aime(new_person, instant, parameters, additional) + assert actual.dtype == expected.dtype + np.testing.assert_array_equal(actual, expected) + # Sharing helpers must not expand the legacy earnings-input window. + assert new_person.calls == old_person.calls + + +@pytest.mark.parametrize("dtype", [np.float32, np.float64, np.int32, np.int64]) +@pytest.mark.parametrize("lookback", [0, 1, 10, 34, 35, 36, 50, 75]) +@pytest.mark.parametrize("additional", [None, "owners_draw_credited_earnings"]) +def test_shared_helpers_preserve_legacy_results_and_dtype(dtype, lookback, additional): + assert_exact_legacy_match(AGES, dtype, lookback, additional) + + +@pytest.mark.parametrize( + "ages, lookback, policy", + [ + ((0, 1, 21, 1000), 0, {}), # Entirely empty eligible history. + ((22,), 0, {}), # Minimum earnings age, future indexing anchor. + ((69,), 0, {}), # Maximum earnings age, unindexed earnings. + ((70,), 1, {}), # First included year is just before maximum age. + ((20, 21, 22, 60, 61), 45, {"minimum_age": 21, "maximum_age": 60}), + ((59, 60, 61, 62, 100), 75, {"indexing_age": 59, "years": 40}), + ((65, 66), 12, {"years": 1}), + ((65, 66), 12, {"years": 20}), + ], +) +def test_age_boundaries_empty_careers_and_alternative_selection_windows( + ages, lookback, policy +): + assert_exact_legacy_match( + ages, np.float32, lookback, "owners_draw_credited_earnings", **policy + ) + + +@pytest.mark.parametrize("dtype", [np.float32, np.float64, np.int32, np.int64]) +def test_short_career_padding_retains_legacy_float64_promotion(dtype): + amounts = np.arange(14, dtype=dtype).reshape(2, 7) + expected = np.hstack([np.sort(amounts, axis=1)[:, ::-1], np.zeros((2, 28))]) + actual = highest_earnings(amounts, 35) + assert actual.dtype == expected.dtype == np.dtype("float64") + np.testing.assert_array_equal(actual, expected) diff --git a/policyengine_us/tests/unit/test_ss_history_ssa_examples.py b/policyengine_us/tests/unit/test_ss_history_ssa_examples.py new file mode 100644 index 00000000000..ed3abf9732b --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_history_ssa_examples.py @@ -0,0 +1,115 @@ +"""Full earnings histories from SSA's published 2027 retirement examples. + +Frozen source inputs were retrieved on 2026-10-31. The calculator receives +annual nominal earnings, annual caps, and exact NAWI observations rather than +a supplied AIME or a pre-indexed total. SSA displays its indexed annual amounts +as whole dollars; the history ledger retains the required cents. + +Earnings, caps, and displayed indexed amounts: +https://www.ssa.gov/OACT/ProgData/retirebenefit1.html +Eligibility-year bend points and initial PIAs: +https://www.ssa.gov/OACT/ProgData/retirebenefit2.html +Exact national average wage index observations: +https://www.ssa.gov/OACT/COLA/AWI.html +""" + +from decimal import Decimal + +import pytest + +from policyengine_us.tools.social_security.history import ( + EarningsHistory, + EarningsRecord, + HistoryParameters, + compute_history, + compute_history_pia, +) + + +# year, case-A nominal wages, case-B nominal wages (= annual ordinary cap), +# displayed indexed earnings for A and B, and exact NAWI. No 2026 NAWI is +# needed: both cases retain their age-60 year and all later years at face value. +SSA_ROWS = """ +1987 17229 43800 68069 132230 18426.51 +1988 18134 45000 68282 129476 19334.04 +1989 18912 48000 68499 132847 20099.55 +1990 19847 51300 68712 135712 21027.98 +1991 20651 53400 68927 136192 21811.60 +1992 21783 55500 69142 134612 22935.42 +1993 22038 57600 69355 138514 23132.67 +1994 22700 60600 69571 141920 23753.53 +1995 23683 61200 69787 137801 24705.66 +1996 24917 62700 70000 134596 25913.90 +1997 26452 65400 70215 132652 27426.00 +1998 27922 68400 70431 131837 28861.44 +1999 29567 72600 70643 132545 30469.84 +2000 31297 76200 70858 131828 32154.82 +2001 32141 80400 71073 135853 32921.92 +2002 32561 84900 71287 142032 33252.09 +2003 33458 87000 71503 142072 34064.95 +2004 35118 87900 71717 137166 35648.55 +2005 36512 90000 71932 135485 36952.94 +2006 38305 94200 72148 135576 38651.41 +2007 40162 97500 72362 134234 40405.48 +2008 41208 102000 72577 137272 41334.97 +2009 40706 106800 72790 145932 40711.61 +2010 41791 106800 73005 142563 41673.83 +2011 43228 106800 73221 138231 42979.61 +2012 44708 110100 73435 138188 44321.67 +2013 45412 113700 73650 140905 44888.16 +2014 47161 117000 73865 140024 46481.52 +2015 48943 118500 74078 137051 48098.63 +2016 49640 118500 74294 135520 48642.15 +2017 51503 127200 74509 140614 50321.89 +2018 53523 128400 74723 136976 52145.80 +2019 55688 132900 74937 136655 54099.99 +2020 57426 137700 75153 137700 55628.60 +2021 62711 142800 75367 142800 60575.07 +2022 66232 147000 75581 147000 63795.13 +2023 69364 160200 75797 160200 66621.80 +2024 72927 168600 76011 168600 69846.57 +2025 76226 176100 76226 176100 72800.33 +2026 79009 184500 79009 184500 - +""" + + +@pytest.mark.parametrize( + "case,birth_year,eligibility,bends,expected_aime,expected_pia", + [ + ("A", 1965, 2027, (1340, 8077), 6072, "2720.20"), + ("B", 1960, 2022, (1024, 6172), 11843, "3419.60"), + ], +) +def test_ssa_published_full_earnings_history( + case, birth_year, eligibility, bends, expected_aime, expected_pia +): + rows = [line.split() for line in SSA_ROWS.strip().splitlines()] + wages_column = 1 if case == "A" else 2 + indexed_column = 3 if case == "A" else 4 + history = EarningsHistory( + first_year=1987, + records=tuple( + EarningsRecord(int(row[0]), Decimal(row[wages_column]), 0) for row in rows + ), + certified_zero_before_first_year=True, + ) + frozen_parameters = HistoryParameters( + wage_base={int(row[0]): Decimal(row[2]) for row in rows}, + nawi={int(row[0]): Decimal(row[5]) for row in rows if row[5] != "-"}, + ) + calculation = compute_history( + history, eligibility, 2027, frozen_parameters, birth_year=birth_year + ) + assert calculation.aime == expected_aime + assert compute_history_pia(calculation, bend_points=bends) == Decimal(expected_pia) + assert len(calculation.ledger) == 40 + for annual, source in zip(calculation.ledger, rows, strict=True): + assert annual.year == int(source[0]) + assert annual.ordinary_covered == Decimal(source[wages_column]) + # The source prints whole dollars; preserve the cents in production. + assert abs(annual.indexed_ordinary - Decimal(source[indexed_column])) < 1 + if annual.year >= eligibility - 2: + assert annual.indexed_ordinary == annual.ordinary_covered + # Case B includes five years after its eligibility year; these must remain + # available through Q-1 rather than stopping the history at age 62. + assert calculation.ledger[-1].year == 2026 diff --git a/policyengine_us/tools/social_security/earnings.py b/policyengine_us/tools/social_security/earnings.py new file mode 100644 index 00000000000..ba064de3f0d --- /dev/null +++ b/policyengine_us/tools/social_security/earnings.py @@ -0,0 +1,42 @@ +"""Shared annual indexing and highest-years selection for AIME calculations. + +The legacy variable retains its existing year window and precision. The opt-in +history service supplies a complete career and requests statutory cent rounding. +""" + +from decimal import Decimal +from fractions import Fraction + +import numpy as np + + +def index_covered_earnings(amount, year_nawi, indexing_nawi, *, round_cents=False): + if round_cents: + # Convert first: annual records are exact money, not binary floats. + result = ( + Fraction(str(amount)) + * Fraction(str(indexing_nawi)) + / Fraction(str(year_nawi)) + ) + cents = result * 100 + rounded = (2 * cents.numerator + cents.denominator) // (2 * cents.denominator) + return Decimal(rounded) / 100 + return amount * (indexing_nawi / year_nawi) + + +def highest_earnings(earnings, years=35): + """Select and zero-pad the highest years along the last array dimension. + + Object arrays preserve Decimal cents for the history service. Numeric arrays + preserve the legacy variable's NumPy arithmetic and summation order. + """ + values = np.asarray(earnings) + selected = np.sort(values, axis=-1)[..., ::-1][..., :years] + missing = years - selected.shape[-1] + if missing > 0: + shape = (*selected.shape[:-1], missing) + # The legacy formula's padding is float64, including when annual + # earnings arrived as float32. Preserve that promotion and sum exactly. + dtype = object if selected.dtype == object else float + selected = np.concatenate((selected, np.zeros(shape, dtype=dtype)), axis=-1) + return selected diff --git a/policyengine_us/tools/social_security/engine.py b/policyengine_us/tools/social_security/engine.py index 8c779c480b9..a36f8408513 100644 --- a/policyengine_us/tools/social_security/engine.py +++ b/policyengine_us/tools/social_security/engine.py @@ -17,6 +17,7 @@ ) from .parameters import bend_points_at, formula_path_at from .pia import FormulaPath +from .history import EarningsHistory, history_parameters_at def calculator_output(person, period, parameters, output): @@ -106,6 +107,23 @@ def money(name, when=period): **kwargs, ) benchmark = record.benchmark() + if source == "history": + history = EarningsHistory.from_json(text("earnings_history")) + # The full annual source travels as one structural JSON input; + # Core cannot carry an earlier earnings observation into a gap. + from dataclasses import replace + + record = replace( + record, + history=history, + history_parameters=history_parameters_at( + parameters, + history, + benchmark, + month_number(record.entitlement_month) // 12, + birth_year=record.birth_date.year, + ), + ) # Include the following January to price December entitlement, and any # supplied source valuation used for baseline survivor recovery. through = max( diff --git a/policyengine_us/tools/social_security/history.py b/policyengine_us/tools/social_security/history.py new file mode 100644 index 00000000000..0d14b07c407 --- /dev/null +++ b/policyengine_us/tools/social_security/history.py @@ -0,0 +1,207 @@ +"""Complete, explicitly certified annual earnings for own-record retirement. + +Initial earnings are frozen at claim year Q, through Q-1. This is an opt-in +household service, not a replacement for the population variable's rolling +lookback. Annual caps precede E-2 wage indexing; top 35 / 420 is floored. +""" + +from dataclasses import dataclass +from decimal import Decimal +import json +from typing import Mapping + +from .earnings import highest_earnings, index_covered_earnings +from .pia import compute_pia + + +def _money(value, name): + if isinstance(value, bool): + raise ValueError(f"{name} must be a finite nonnegative amount") + try: + amount = Decimal(str(value)) + except (ValueError, ArithmeticError) as error: + raise ValueError(f"{name} must be a finite nonnegative amount") from error + if not amount.is_finite() or amount < 0: + raise ValueError(f"{name} must be a finite nonnegative amount") + return amount + + +def _year(value): + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError("History years must be integer calendar years") + return value + + +@dataclass(frozen=True) +class EarningsRecord: + year: int + covered_wages: Decimal | int | str + covered_self_employment: Decimal | int | str + + +@dataclass(frozen=True) +class EarningsHistory: + first_year: int + records: tuple[EarningsRecord, ...] = () + certified_zero_years: tuple[int, ...] = () + certified_zero_before_first_year: bool = False + + @classmethod + def from_json(cls, payload): + """A single structural source preserves every year and exact money. + + Explicit zero records contain both wage and adjusted existing-SE zero. + Omitted years never inherit another year's value. A zero certification + cannot also carry an observed record for the same year. + """ + try: + data = json.loads(payload, parse_float=Decimal) + if not isinstance(data, dict): + raise ValueError("History must be a JSON object") + records = tuple(EarningsRecord(**row) for row in data.pop("records", [])) + return cls(records=records, **data) + except (TypeError, KeyError, json.JSONDecodeError) as error: + raise ValueError("Invalid annual earnings history JSON") from error + + def through_claim(self, claim_year, birth_year=None): + first = _year(self.first_year) + claim = _year(claim_year) + earliest = max(1951, birth_year) if birth_year is not None else 1951 + if first < earliest or first >= claim: + raise ValueError( + "History first_year must be within the worker's base years" + ) + if not isinstance(self.certified_zero_before_first_year, bool): + raise ValueError("Earlier-zero certification must be a boolean") + if first > earliest and not self.certified_zero_before_first_year: + raise ValueError("Certify zero earnings before history first_year") + records = {} + for row in self.records: + year = _year(row.year) + if year < first: + raise ValueError("A history record precedes first_year") + if year in records: + raise ValueError(f"Duplicate earnings year {year}") + _money(row.covered_wages, "Covered wages") + _money(row.covered_self_employment, "Existing covered SE earnings") + records[year] = row + zeros = tuple(_year(year) for year in self.certified_zero_years) + if len(set(zeros)) != len(zeros) or set(zeros) & records.keys(): + raise ValueError("Duplicate or conflicting certified-zero earnings year") + if any(year < first for year in zeros): + raise ValueError("A certified-zero year precedes first_year") + missing = set(range(first, claim)) - records.keys() - set(zeros) + if missing: + raise ValueError(f"Missing required earnings years: {sorted(missing)}") + return tuple( + records.get(year, EarningsRecord(year, 0, 0)) + for year in range(first, claim) + ) + + +@dataclass(frozen=True) +class HistoryParameters: + wage_base: Mapping[int, Decimal | int | str] + nawi: Mapping[int, Decimal | int | str] + + +@dataclass(frozen=True) +class AnnualEarnings: + year: int + covered_wages: Decimal + covered_self_employment: Decimal + ordinary_cap: Decimal + ordinary_covered: Decimal + indexing_factor: Decimal + indexed_ordinary: Decimal + + +@dataclass(frozen=True) +class HistoryCalculation: + eligibility_year: int + claim_year: int + ledger: tuple[AnnualEarnings, ...] + aime: int + + +def _parameter(path, year, name, *, positive=False): + if year not in path: + raise ValueError(f"Missing {name} for year {year}") + value = _money(path[year], name) + if positive and not value: + raise ValueError(f"{name} must be positive") + return value + + +def aime_from_indexed(indexed_amounts): + selected = highest_earnings(tuple(indexed_amounts) or (Decimal(0),)) + return int(sum(selected, Decimal(0)) // 420) + + +def compute_history( + history, eligibility_year, claim_year, parameters, *, birth_year=None +): + """Compute a complete career, including earnings after 62 through Q-1.""" + eligibility = _year(eligibility_year) + if eligibility < 1983: + raise ValueError("Computation benchmarks before 1983 are unsupported") + if claim_year < eligibility: + raise ValueError("Claim year cannot precede eligibility") + records = history.through_claim(claim_year, birth_year) + anchor_year = eligibility - 2 + anchor = _parameter(parameters.nawi, anchor_year, "NAWI", positive=True) + ledger = [] + for row in records: + wages = _money(row.covered_wages, "Covered wages") + se = _money(row.covered_self_employment, "Existing covered SE earnings") + cap = _parameter(parameters.wage_base, row.year, "Ordinary cap", positive=True) + covered = min(wages + se, cap) + nawi = ( + _parameter(parameters.nawi, row.year, "NAWI", positive=True) + if row.year < anchor_year + else anchor + ) + indexed = index_covered_earnings(covered, nawi, anchor, round_cents=True) + ledger.append( + AnnualEarnings(row.year, wages, se, cap, covered, anchor / nawi, indexed) + ) + return HistoryCalculation( + eligibility, + claim_year, + tuple(ledger), + aime_from_indexed(row.indexed_ordinary for row in ledger), + ) + + +def compute_history_pia( + calculation, + *, + bend_points, + formula_path=None, + payment_year=None, + recompute_mode="cohort", +): + return compute_pia( + calculation.aime, + calculation.eligibility_year, + formula_path, + payment_year, + recompute_mode, + bend_points=bend_points, + ) + + +def history_parameters_at( + parameters, history, eligibility_year, claim_year, *, birth_year=None +): + years = [row.year for row in history.through_claim(claim_year, birth_year)] + return HistoryParameters( + wage_base={ + year: parameters.gov.ssa.social_security(f"{year}-01-01").wage_base + for year in years + }, + nawi={ + year: parameters.gov.ssa.nawi(f"{year}-01-01") + for year in set(years) | {eligibility_year - 2} + }, + ) diff --git a/policyengine_us/tools/social_security/household.py b/policyengine_us/tools/social_security/household.py index d4865fb6a54..206b564bad2 100644 --- a/policyengine_us/tools/social_security/household.py +++ b/policyengine_us/tools/social_security/household.py @@ -13,6 +13,14 @@ from fractions import Fraction from typing import Mapping +from .history import ( + EarningsHistory, + HistoryCalculation, + HistoryParameters, + compute_history, + compute_history_pia, +) + from policyengine_us.tools.social_security.cola import ( adjusted_events as adjust_cola_events, apply_colas, @@ -223,12 +231,16 @@ class SuppliedRecord: no_special_monthly_test: bool = False no_prior_withholding_adjustment: bool = False earnings_test_no_trigger: bool = False + history: EarningsHistory | None = None + history_parameters: HistoryParameters | None = None def benchmark(self): if not self.worker_id.strip(): raise ValueError("An explicit worker record ID is required") - if self.source not in ("aime", "pia", "survivor"): - raise ValueError("Choose an explicit aime, pia or survivor source") + if self.source not in ("aime", "pia", "survivor", "history"): + raise ValueError("Choose an explicit aime, pia, survivor or history source") + if self.source == "history" and not self.insured: + raise ValueError("History retirement requires an insured-status assertion") if self.source_basis != "unreformed_eligibility": raise ValueError("Only explicitly unreformed source amounts are supported") month_number(self.entitlement_month) @@ -322,6 +334,7 @@ class AnnualBenefit: eligibility_pia_interval: tuple[Decimal, Decimal] | None = None inferred_aime_interval: tuple[Decimal, Decimal] | None = None settlements: tuple[EarningsTestSettlement, ...] = () + history: HistoryCalculation | None = None @property def deferred_earnings_test_payment(self): @@ -475,8 +488,21 @@ def calculate_year( if payment_year == year: settlement_years.add(earnings_year) benchmark = record.benchmark() + history = None + source_record = record + if record.source == "history": + if record.history is None or record.history_parameters is None: + raise ValueError("History source requires annual records and parameters") + history = compute_history( + record.history, + benchmark, + month_number(record.entitlement_month) // 12, + record.history_parameters, + birth_year=record.birth_date.year, + ) + source_record = replace(record, source="aime", aime=history.aime) baseline_pia, aime, pia_interval = _source_pia( - record, benchmark, bend_points, cola_events + source_record, benchmark, bend_points, cola_events ) paths = formula_path policies = policies or BenefitPolicies() @@ -548,7 +574,15 @@ def calculate_year( path = replace(path, s770_increase_first_bend_point=flag) # Only infer AIME if the selected path actually changes the baseline. pia = baseline_pia - if payment_year >= benchmark and has_active_reform( + if history is not None and payment_year >= benchmark: + pia = compute_history_pia( + history, + bend_points=bend_points, + formula_path=path, + payment_year=payment_year, + recompute_mode=mode, + ) + elif payment_year >= benchmark and has_active_reform( path, benchmark, payment_year, mode ): if aime is None and baseline_pia != 0: @@ -768,4 +802,5 @@ def calculate_year( pia_interval, inferred_interval, tuple(settlements), + history, ) diff --git a/policyengine_us/variables/gov/ssa/social_security/calculator/inputs.py b/policyengine_us/variables/gov/ssa/social_security/calculator/inputs.py index 85c11d308f5..7eda9104e53 100644 --- a/policyengine_us/variables/gov/ssa/social_security/calculator/inputs.py +++ b/policyengine_us/variables/gov/ssa/social_security/calculator/inputs.py @@ -13,7 +13,23 @@ class ss_calculator_source(Variable): definition_period = ETERNITY default_value = "off" label = "Social Security calculator source" - documentation = "Explicit source: off, aime, pia or survivor." + documentation = "Explicit source: off, aime, pia, survivor or history." + + +class ss_calculator_earnings_history(Variable): + value_type = str + entity = Person + definition_period = ETERNITY + default_value = "" + label = "Social Security calculator annual earnings history" + documentation = ( + "Complete JSON source with first_year, records (year, covered_wages, " + "covered_self_employment), certified_zero_years and an explicit " + "certified_zero_before_first_year assertion. Existing SE is adjusted " + "covered earnings, not gross profits. Earnings stop at claim year minus " + "one; missing years cannot inherit values from another year." + ) + reference = "https://secure.ssa.gov/poms.nsf/lnx/0300605018" class ss_calculator_worker_id(Variable): diff --git a/policyengine_us/variables/gov/ssa/social_security/ss_aime.py b/policyengine_us/variables/gov/ssa/social_security/ss_aime.py index b0d1bc89c81..e842cc05300 100644 --- a/policyengine_us/variables/gov/ssa/social_security/ss_aime.py +++ b/policyengine_us/variables/gov/ssa/social_security/ss_aime.py @@ -1,5 +1,9 @@ from policyengine_us.model_api import * import numpy as np +from policyengine_us.tools.social_security.earnings import ( + highest_earnings, + index_covered_earnings, +) class ss_aime(Variable): @@ -83,8 +87,8 @@ def _compute_aime(person, period, parameters, additional_earnings_variable=None) if idx_years_ago >= 0: idx_period = period.this_year.offset(-int(idx_years_ago)) idx_nawi = parameters(idx_period).gov.ssa.nawi - indexed_amount[idx_mask] = covered_earnings[idx_mask] * ( - idx_nawi / year_nawi + indexed_amount[idx_mask] = index_covered_earnings( + covered_earnings[idx_mask], year_nawi, idx_nawi ) indexed_amount[no_indexing] = covered_earnings[no_indexing] @@ -96,14 +100,6 @@ def _compute_aime(person, period, parameters, additional_earnings_variable=None) else: earnings_matrix = np.zeros((person.count, 1)) - sorted_earnings = np.sort(earnings_matrix, axis=1)[:, ::-1] - - top_n = min(sorted_earnings.shape[1], p.years_of_earnings) - top_earnings = sorted_earnings[:, :top_n] - - if top_n < p.years_of_earnings: - padding = np.zeros((person.count, p.years_of_earnings - top_n)) - top_earnings = np.hstack([top_earnings, padding]) - + top_earnings = highest_earnings(earnings_matrix, p.years_of_earnings) total_indexed = np.sum(top_earnings, axis=1) return total_indexed / (p.years_of_earnings * p.months_per_year) From 2f5b9b86ab66d03e95dbc1f5574d83f21d4b3f60 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 09:02:36 -0400 Subject: [PATCH 02/14] Reuse owners draw arithmetic for annual history benefit credit 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 --- policyengine_us/reforms/ssa/owners_draw.py | 134 ++----- .../tests/unit/test_owners_draw_kernel.py | 336 ++++++++++++++++++ .../tests/unit/test_ss_earnings_history.py | 49 ++- .../tools/social_security/history.py | 118 +++++- .../tools/social_security/owners_draw.py | 282 +++++++++++++++ 5 files changed, 819 insertions(+), 100 deletions(-) create mode 100644 policyengine_us/tests/unit/test_owners_draw_kernel.py create mode 100644 policyengine_us/tools/social_security/owners_draw.py diff --git a/policyengine_us/reforms/ssa/owners_draw.py b/policyengine_us/reforms/ssa/owners_draw.py index 604f324a271..51c9152e74c 100644 --- a/policyengine_us/reforms/ssa/owners_draw.py +++ b/policyengine_us/reforms/ssa/owners_draw.py @@ -6,6 +6,10 @@ """ from policyengine_us.model_api import * +from policyengine_us.tools.social_security.owners_draw import ( + OwnersDrawParameters, + compute_owners_draw, +) from policyengine_us.variables.gov.irs.tax.self_employment.self_employment_social_security_tax import ( self_employment_social_security_tax as baseline_oasdi_tax, ) @@ -26,17 +30,29 @@ def _enabled(period, parameters): return p.in_effect and period.start.year >= p.start_year -def _share(period, parameters): - p = parameters(period).gov.contrib.ssa.owners_draw - if not _enabled(period, parameters): - return 0 - if p.phase_in_years < 1 or int(p.phase_in_years) != p.phase_in_years: - raise ValueError("Owners' draw phase_in_years must be a positive integer") - if p.phase_in_share == -1: - return min(1, (period.start.year - p.start_year + 1) / p.phase_in_years) - if not np.isfinite(p.phase_in_share) or not 0 <= p.phase_in_share <= 1: - raise ValueError("Owners' draw phase_in_share must be -1 or in [0, 1]") - return p.phase_in_share +_DRAW_INPUT_VARIABLES = { + "s_corp_income": "s_corp_income", + "partnership_income": "partnership_income", + "passive_income": "passive_partnership_s_corp_income", + "already_se_income": "partnership_self_employment_net_earnings", + "subsets_are_disjoint": "owners_draw_subsets_are_disjoint", + "passive_se_overlap": "owners_draw_passive_se_overlap", + "covered_wages": "taxable_earnings_for_social_security", + "existing_covered_se": "social_security_taxable_self_employment_income", +} + + +def _draw(person, period, parameters, output): + """Delegate one stage while honoring directly supplied intermediate inputs.""" + return compute_owners_draw( + period.start.year, + inputs=lambda name: person(_DRAW_INPUT_VARIABLES[name], period), + parameters=OwnersDrawParameters.from_policy_parameters( + parameters(period).gov, year=period.start.year, output=output + ), + output=output, + overrides=lambda name: person(f"owners_draw_{name}", period), + ) def create_owners_draw(): @@ -72,31 +88,7 @@ class owners_draw_active_income(Variable): reference = "https://www.ssa.gov/OACT/solvency/provisions/coverage.html" def formula(person, period, parameters): - if not _enabled(period, parameters): - return 0 - total = add(person, period, ["s_corp_income", "partnership_income"]) - already_se = person("partnership_self_employment_net_earnings", period) - if not parameters( - period - ).gov.contrib.ssa.owners_draw.material_participation_only: - return total - already_se - passive = person("passive_partnership_s_corp_income", period) - disjoint = person("owners_draw_subsets_are_disjoint", period) - overlap = person("owners_draw_passive_se_overlap", period) - unknown_overlap = ~np.isfinite(overlap) - ambiguous = (passive != 0) & (already_se != 0) & ~disjoint & unknown_overlap - if np.any(ambiguous): - raise ValueError( - "Owners' draw needs a validated disjoint-subset assertion " - "or explicit passive/SE overlap when both subsets are nonzero" - ) - if np.any(disjoint & ~unknown_overlap & (overlap != 0)): - raise ValueError( - "Owners' draw disjoint assertion contradicts supplied overlap" - ) - # Net signed entity profits/losses before flooring. The residual - # does not offset wages or the preexisting SE earnings base. - return total - passive - already_se + where(unknown_overlap, 0, overlap) + return _draw(person, period, parameters, "active_income") class owners_draw_planned_gross(Variable): value_type = float @@ -106,9 +98,7 @@ class owners_draw_planned_gross(Variable): label = "Planned gross owners' draw before the separate reporting floor" def formula(person, period, parameters): - return _share(period, parameters) * max_( - 0, person("owners_draw_active_income", period) - ) + return _draw(person, period, parameters, "planned_gross") class owners_draw_net_earnings_factor(Variable): value_type = float @@ -117,18 +107,7 @@ class owners_draw_net_earnings_factor(Variable): label = "Net earnings factor applied to owners' draw" def formula(person, period, parameters): - if not _enabled(period, parameters): - return 0 - p = parameters(period).gov - factor = p.contrib.ssa.owners_draw.net_earnings_factor - if factor == 0: - factor = 1 - p.irs.ald.misc.employer_share * ( - p.irs.self_employment.rate.social_security - + p.irs.self_employment.rate.medicare - ) - if not np.isfinite(factor) or not 0 < factor <= 1: - raise ValueError("Owners' draw net earnings factor must be in (0, 1]") - return factor + return _draw(person, period, parameters, "net_earnings_factor") class owners_draw_gross(Variable): value_type = float @@ -138,12 +117,7 @@ class owners_draw_gross(Variable): label = "Actual gross owners' draw accepted by the reporting floor" def formula(person, period, parameters): - if not _enabled(period, parameters): - return 0 - planned = person("owners_draw_planned_gross", period) - adjusted = planned * person("owners_draw_net_earnings_factor", period) - floor = parameters(period).gov.irs.self_employment.net_earnings_exemption - return where(adjusted >= floor, planned, 0) + return _draw(person, period, parameters, "gross") class owners_draw_net_earnings(Variable): value_type = float @@ -153,9 +127,7 @@ class owners_draw_net_earnings(Variable): label = "Adjusted owners' draw before the OASDI room cap" def formula(person, period, parameters): - return person("owners_draw_gross", period) * person( - "owners_draw_net_earnings_factor", period - ) + return _draw(person, period, parameters, "net_earnings") class owners_draw_oasdi_room(Variable): value_type = float @@ -165,22 +137,7 @@ class owners_draw_oasdi_room(Variable): label = "OASDI room after wages and existing adjusted SE earnings" def formula(person, period, parameters): - if not _enabled(period, parameters): - return 0 - p = parameters(period).gov.contrib.ssa.owners_draw - if p.room_cap_basis == 1: - return np.inf - if p.room_cap_basis != 0: - raise ValueError( - "Owners' draw room_cap_basis must be 0 (current law) or 1 (no ceiling)" - ) - wages = person("taxable_earnings_for_social_security", period) - existing_se = person( - "social_security_taxable_self_employment_income", period - ) - # The existing base is already adjusted and capped; #10376 also - # floors its room when another reform taxes above-cap wages. - return max_(0, p.current_law_cap - wages - existing_se) + return _draw(person, period, parameters, "oasdi_room") class owners_draw_oasdi_base(Variable): value_type = float @@ -190,10 +147,7 @@ class owners_draw_oasdi_base(Variable): label = "Owners' draw subject to SECA OASDI" def formula(person, period, parameters): - return min_( - person("owners_draw_net_earnings", period), - person("owners_draw_oasdi_room", period), - ) + return _draw(person, period, parameters, "oasdi_base") class owners_draw_oasdi_tax(Variable): value_type = float @@ -203,8 +157,7 @@ class owners_draw_oasdi_tax(Variable): label = "Incremental SECA OASDI tax on owners' draw" def formula(person, period, parameters): - rate = parameters(period).gov.irs.self_employment.rate.social_security - return rate * person("owners_draw_oasdi_base", period) + return _draw(person, period, parameters, "oasdi_tax") class owners_draw_hi_tax(Variable): value_type = float @@ -214,10 +167,7 @@ class owners_draw_hi_tax(Variable): label = "Optional incremental SECA HI tax on owners' draw" def formula(person, period, parameters): - if not parameters(period).gov.contrib.ssa.owners_draw.include_hi: - return 0 - rate = parameters(period).gov.irs.self_employment.rate.medicare - return rate * person("owners_draw_net_earnings", period) + return _draw(person, period, parameters, "hi_tax") class owners_draw_niit_exclusion(Variable): value_type = float @@ -232,13 +182,7 @@ class owners_draw_niit_exclusion(Variable): ) def formula(person, period, parameters): - if not _enabled(period, parameters): - return 0 - if parameters(period).gov.contrib.ssa.owners_draw.include_hi: - return person("owners_draw_gross", period) - return person("owners_draw_oasdi_base", period) / person( - "owners_draw_net_earnings_factor", period - ) + return _draw(person, period, parameters, "niit_exclusion") class owners_draw_credited_earnings(Variable): value_type = float @@ -252,9 +196,7 @@ class owners_draw_credited_earnings(Variable): ) def formula(person, period, parameters): - if not parameters(period).gov.contrib.ssa.owners_draw.benefit_credit: - return 0 - return person("owners_draw_net_earnings", period) + return _draw(person, period, parameters, "credited_earnings") class self_employment_social_security_tax(baseline_oasdi_tax): def formula(person, period, parameters): diff --git a/policyengine_us/tests/unit/test_owners_draw_kernel.py b/policyengine_us/tests/unit/test_owners_draw_kernel.py new file mode 100644 index 00000000000..37bfb987bc6 --- /dev/null +++ b/policyengine_us/tests/unit/test_owners_draw_kernel.py @@ -0,0 +1,336 @@ +"""Pure-kernel extraction differential, including public intermediate inputs. + +The independent reference freezes arithmetic from the merged reform before the +extraction. It avoids model construction and tests the compatibility contract +that annual history and installed Variables must share. +""" + +from dataclasses import fields, replace +from types import SimpleNamespace + +import numpy as np +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st +from policyengine_core.periods import period + +from policyengine_us.reforms.ssa.owners_draw import create_owners_draw +from policyengine_us.tools.social_security.owners_draw import ( + OwnersDrawInputs, + OwnersDrawParameters, + OwnersDrawResult, + compute_owners_draw, +) + + +OUTPUTS = tuple(field.name for field in fields(OwnersDrawResult)) +INPUTS = OwnersDrawInputs( + s_corp_income=np.array([-50_000, 433, 434, 60_000, 150_000, 400_000]), + partnership_income=np.array([20_000, 0, 0, 40_000, 100_000, 40_000]), + passive_income=np.array([0, 0, 0, -20_000, 40_000, 20_000]), + already_se_income=np.array([0, 0, 0, 30_000, 50_000, 40_000]), + subsets_are_disjoint=np.array([True, True, True, True, False, False]), + passive_se_overlap=np.array([np.nan, np.nan, np.nan, np.nan, 10_000, 20_000]), + covered_wages=np.array([0, 0, 0, 30_000, 180_000, 250_000]), + existing_covered_se=np.array([50_000, 0, 0, 20_000, 15_000, 0]), +) +PARAMETERS = OwnersDrawParameters( + in_effect=True, phase_in_share=1, current_law_cap=200_000 +) + + +def _old_formula(output, year, inputs, p, overrides=None): + """Arithmetic of the old Variables, independent of the extracted kernel.""" + values = dict(overrides or {}) + enabled = p.in_effect and year >= p.start_year + + def value(name): + if name not in values: + values[name] = formula(name) + return values[name] + + def formula(name): + if name == "active_income": + if not enabled: + return 0 + total = inputs.s_corp_income + inputs.partnership_income + if not p.material_participation_only: + return total - inputs.already_se_income + overlap = np.where( + np.isfinite(inputs.passive_se_overlap), inputs.passive_se_overlap, 0 + ) + return total - inputs.passive_income - inputs.already_se_income + overlap + if name == "phase_in_share": + if not enabled: + return 0 + return ( + min(1, (year - p.start_year + 1) / p.phase_in_years) + if p.phase_in_share == -1 + else p.phase_in_share + ) + if name == "planned_gross": + return formula("phase_in_share") * np.maximum(0, value("active_income")) + if name == "net_earnings_factor": + if not enabled: + return 0 + return p.net_earnings_factor or ( + 1 - p.employer_share * (p.oasdi_rate + p.hi_rate) + ) + if name == "gross": + if not enabled: + return 0 + planned = value("planned_gross") + adjusted = planned * value("net_earnings_factor") + return np.where(adjusted >= p.reporting_floor, planned, 0) + if name == "net_earnings": + return value("gross") * value("net_earnings_factor") + if name == "oasdi_room": + if not enabled: + return 0 + if p.room_cap_basis == 1: + return np.inf + return np.maximum( + 0, p.current_law_cap - inputs.covered_wages - inputs.existing_covered_se + ) + if name == "oasdi_base": + return np.minimum(value("net_earnings"), value("oasdi_room")) + if name == "oasdi_tax": + return p.oasdi_rate * value("oasdi_base") + if name == "hi_tax": + return p.hi_rate * value("net_earnings") if p.include_hi else 0 + if name == "niit_exclusion": + if not enabled: + return 0 + if p.include_hi: + return value("gross") + return value("oasdi_base") / value("net_earnings_factor") + if name == "credited_earnings": + return value("net_earnings") if p.benefit_credit else 0 + raise AssertionError(name) + + # A supplied target would bypass its Variable; overrides apply only to + # dependencies when invoking a formula directly. + return formula(output) + + +@pytest.mark.parametrize( + "year, changes", + [ + (2026, {}), + (2027, {}), + (2029, {"phase_in_share": -1}), + (2036, {"phase_in_share": -1}), + (2054, {"phase_in_share": -1}), + (2027, {"in_effect": False}), + (2027, {"phase_in_share": 0}), + (2027, {"material_participation_only": False}), + (2027, {"net_earnings_factor": 0.91}), + (2027, {"oasdi_rate": 0.2, "hi_rate": 0.04, "employer_share": 0.4}), + (2027, {"reporting_floor": 1_000}), + (2027, {"room_cap_basis": 1}), + (2027, {"benefit_credit": False}), + (2027, {"include_hi": True}), + (2027, {"include_hi": True, "room_cap_basis": 1}), + ], +) +def test_all_annual_outputs_equal_pre_extraction_formulas(year, changes): + p = replace(PARAMETERS, **changes) + result = compute_owners_draw(year, inputs=INPUTS, parameters=p) + for output in OUTPUTS: + np.testing.assert_array_equal( + getattr(result, output), + _old_formula(output, year, INPUTS, p), + err_msg=output, + ) + + +@pytest.mark.parametrize( + "output, overrides, changes", + [ + ("planned_gross", {"active_income": np.array([-1, 10_000])}, {}), + ("gross", {"planned_gross": np.array([400, 500])}, {}), + ("gross", {"net_earnings_factor": 0.8}, {"net_earnings_factor": -1}), + ("gross", {"planned_gross": 500}, {"phase_in_years": 0}), + ("net_earnings", {"gross": 1_000}, {}), + ( + "net_earnings", + {"gross": 1_000, "net_earnings_factor": 0.8}, + {"in_effect": False}, + ), + ("oasdi_base", {"net_earnings": 5_000}, {}), + ("oasdi_base", {"oasdi_room": 123}, {"room_cap_basis": 2}), + ("oasdi_tax", {"oasdi_base": 2_000}, {}), + ("hi_tax", {"net_earnings": 2_000}, {"include_hi": True}), + ("credited_earnings", {"net_earnings": 2_000}, {"in_effect": False}), + ("credited_earnings", {"net_earnings": 2_000}, {"benefit_credit": False}), + ("niit_exclusion", {"oasdi_base": 100, "net_earnings_factor": 0.8}, {}), + ("niit_exclusion", {"gross": 123}, {"include_hi": True}), + ], +) +def test_intermediate_overrides_equal_pre_extraction_formulas( + output, overrides, changes +): + p = replace(PARAMETERS, **changes) + actual = compute_owners_draw( + 2027, inputs=INPUTS, parameters=p, output=output, overrides=overrides + ) + np.testing.assert_array_equal( + actual, _old_formula(output, 2027, INPUTS, p, overrides) + ) + + +def _policy_parameters(p): + draw = SimpleNamespace( + **{ + name: getattr(p, name) + for name in ( + "in_effect", + "start_year", + "phase_in_years", + "phase_in_share", + "material_participation_only", + "net_earnings_factor", + "current_law_cap", + "room_cap_basis", + "include_hi", + "benefit_credit", + ) + } + ) + gov = SimpleNamespace( + contrib=SimpleNamespace(ssa=SimpleNamespace(owners_draw=draw)), + irs=SimpleNamespace( + ald=SimpleNamespace(misc=SimpleNamespace(employer_share=p.employer_share)), + self_employment=SimpleNamespace( + rate=SimpleNamespace(social_security=p.oasdi_rate, medicare=p.hi_rate), + net_earnings_exemption=p.reporting_floor, + ), + ), + ) + return lambda instant: SimpleNamespace(gov=gov) + + +def test_installed_formulas_delegate_to_the_same_kernel_and_honor_inputs(): + installed = [] + create_owners_draw().apply(SimpleNamespace(update_variable=installed.append)) + variables = {variable.__name__: variable for variable in installed} + annual_period = period("2027") + parameters = _policy_parameters(PARAMETERS) + values = { + "s_corp_income": INPUTS.s_corp_income, + "partnership_income": INPUTS.partnership_income, + "passive_partnership_s_corp_income": INPUTS.passive_income, + "partnership_self_employment_net_earnings": INPUTS.already_se_income, + "owners_draw_subsets_are_disjoint": INPUTS.subsets_are_disjoint, + "owners_draw_passive_se_overlap": INPUTS.passive_se_overlap, + "taxable_earnings_for_social_security": INPUTS.covered_wages, + "social_security_taxable_self_employment_income": INPUTS.existing_covered_se, + # Explicit public intermediate inputs must remain authoritative. + "owners_draw_net_earnings_factor": 0.8, + "owners_draw_oasdi_room": np.array([0, 1, 2, 3, 4, 5]), + } + + def person(name, instant): + if name not in values: + values[name] = variables[name].formula(person, instant, parameters) + return values[name] + + overrides = { + "net_earnings_factor": values["owners_draw_net_earnings_factor"], + "oasdi_room": values["owners_draw_oasdi_room"], + } + for output in OUTPUTS: + if output == "phase_in_share" or output in overrides: + continue + np.testing.assert_array_equal( + person(f"owners_draw_{output}", annual_period), + _old_formula(output, 2027, INPUTS, PARAMETERS, overrides), + err_msg=output, + ) + + +@pytest.mark.parametrize( + "output", + [ + "active_income", + "planned_gross", + "net_earnings_factor", + "gross", + "oasdi_room", + "niit_exclusion", + ], +) +def test_disabled_stages_do_not_read_unused_historical_policy_parameters(output): + # Original disabled formulas return before consulting rates, caps, subset + # inputs, or the reporting floor. Such parameters can be absent historically. + gov = SimpleNamespace( + contrib=SimpleNamespace( + ssa=SimpleNamespace( + owners_draw=SimpleNamespace(in_effect=False, start_year=2027) + ) + ) + ) + p = OwnersDrawParameters.from_policy_parameters(gov, year=1983, output=output) + overrides = {"active_income": 1_000} + assert ( + compute_owners_draw(1983, parameters=p, output=output, overrides=overrides) == 0 + ) + + +@pytest.mark.parametrize("include_hi", [False, True]) +def test_m7_excludes_only_gross_profits_whose_net_earnings_face_seca(include_hi): + result = compute_owners_draw( + 2027, + inputs=OwnersDrawInputs(s_corp_income=100_000, covered_wages=190_000), + parameters=replace(PARAMETERS, include_hi=include_hi), + ) + assert result.net_earnings == 92_350 + assert result.oasdi_base == 10_000 + assert result.niit_exclusion == pytest.approx( + 100_000 if include_hi else 10_000 / 0.9235 + ) + # Credit follows the uncapped earnings ledger independently of tax room. + assert result.credited_earnings == 92_350 + + +@pytest.mark.parametrize( + "inputs, message", + [ + ( + OwnersDrawInputs( + partnership_income=100, passive_income=20, already_se_income=10 + ), + "validated disjoint", + ), + ( + OwnersDrawInputs(subsets_are_disjoint=True, passive_se_overlap=1), + "contradicts", + ), + ], +) +def test_invalid_subset_assertions_keep_existing_failures(inputs, message): + with pytest.raises(ValueError, match=message): + compute_owners_draw(2027, inputs=inputs, parameters=PARAMETERS) + + +@given( + total=st.floats(min_value=-1_000_000, max_value=1_000_000, allow_nan=False), + wages=st.floats(min_value=0, max_value=1_000_000, allow_nan=False), + existing_se=st.floats(min_value=0, max_value=200_000, allow_nan=False), + share=st.floats(min_value=0, max_value=1, allow_nan=False), +) +@settings(max_examples=80, deadline=None) +def test_credit_is_the_single_adjusted_residual_before_tax_cap( + total, wages, existing_se, share +): + result = compute_owners_draw( + 2027, + inputs=OwnersDrawInputs( + s_corp_income=total, covered_wages=wages, existing_covered_se=existing_se + ), + parameters=replace(PARAMETERS, phase_in_share=share), + ) + assert 0 <= result.credited_earnings <= max(total, 0) + assert result.credited_earnings == result.net_earnings + assert 0 <= result.oasdi_base <= result.net_earnings + assert result.oasdi_base <= max(0, 200_000 - wages - existing_se) diff --git a/policyengine_us/tests/unit/test_ss_earnings_history.py b/policyengine_us/tests/unit/test_ss_earnings_history.py index 85271704503..532557ecaa0 100644 --- a/policyengine_us/tests/unit/test_ss_earnings_history.py +++ b/policyengine_us/tests/unit/test_ss_earnings_history.py @@ -11,7 +11,7 @@ import numpy as np import pytest -from hypothesis import given, strategies as st +from hypothesis import given, settings, strategies as st from policyengine_core.periods import period from policyengine_us.tools.social_security.history import ( @@ -224,6 +224,53 @@ def test_aime_nondecreasing_in_any_earnings_year(earnings, extra): assert aime_from_indexed(bumped) >= aime_from_indexed(earnings) +@settings(max_examples=50, deadline=None) +@given( + annual=st.lists( + st.tuples( + st.integers(min_value=0, max_value=1_000_000), + st.integers(min_value=0, max_value=50_000), + st.integers(min_value=60_000, max_value=200_000), + st.integers(min_value=50, max_value=500), + ), + min_size=1, + max_size=45, + ), + position=st.integers(min_value=0, max_value=44), + extra=st.integers(min_value=0, max_value=1_000_000), +) +def test_history_aime_nondecreasing_when_any_years_covered_wages_increase( + annual, position, extra +): + """Exercise annual cap, E-2 indexing, cents, top 35, and whole-dollar AIME.""" + first = 2039 - len(annual) + years = tuple(range(first, 2039)) + supplied = history( + [ + EarningsRecord(year, wages, se) + for year, (wages, se, _, _) in zip(years, annual, strict=True) + ], + first=first, + ) + nawi = {year: values[3] for year, values in zip(years, annual, strict=True)} + nawi.setdefault(2034, 100) + policy = HistoryParameters( + wage_base={year: values[2] for year, values in zip(years, annual, strict=True)}, + nawi=nawi, + ) + changed_position = position % len(annual) + bumped_records = list(supplied.records) + original = bumped_records[changed_position] + bumped_records[changed_position] = replace( + original, covered_wages=original.covered_wages + extra + ) + original_aime = compute_history(supplied, 2036, 2039, policy).aime + bumped_aime = compute_history( + replace(supplied, records=tuple(bumped_records)), 2036, 2039, policy + ).aime + assert bumped_aime >= original_aime + + def test_legacy_aime_differential_on_shared_year_window_and_exact_rounding(): """Independent old NumPy formula agrees where its window/rounding match.""" wages = {year: 50_400 for year in range(1986, 2026)} diff --git a/policyengine_us/tools/social_security/history.py b/policyengine_us/tools/social_security/history.py index 0d14b07c407..f8788174655 100644 --- a/policyengine_us/tools/social_security/history.py +++ b/policyengine_us/tools/social_security/history.py @@ -11,6 +11,7 @@ from typing import Mapping from .earnings import highest_earnings, index_covered_earnings +from .owners_draw import OwnersDrawInputs, OwnersDrawParameters, compute_owners_draw from .pia import compute_pia @@ -37,6 +38,7 @@ class EarningsRecord: year: int covered_wages: Decimal | int | str covered_self_employment: Decimal | int | str + owners_draw: OwnersDrawInputs | dict | None = None @dataclass(frozen=True) @@ -94,7 +96,7 @@ def through_claim(self, claim_year, birth_year=None): if missing: raise ValueError(f"Missing required earnings years: {sorted(missing)}") return tuple( - records.get(year, EarningsRecord(year, 0, 0)) + records.get(year, EarningsRecord(year, 0, 0, OwnersDrawInputs())) for year in range(first, claim) ) @@ -103,6 +105,7 @@ def through_claim(self, claim_year, birth_year=None): class HistoryParameters: wage_base: Mapping[int, Decimal | int | str] nawi: Mapping[int, Decimal | int | str] + owners_draw_by_year: Mapping[int, OwnersDrawParameters] | None = None @dataclass(frozen=True) @@ -114,6 +117,11 @@ class AnnualEarnings: ordinary_covered: Decimal indexing_factor: Decimal indexed_ordinary: Decimal + e1_gross: Decimal = Decimal(0) + e1_net_earnings: Decimal = Decimal(0) + e1_credited: Decimal = Decimal(0) + e1_oasdi_base: Decimal = Decimal(0) + e1_niit_exclusion: Decimal = Decimal(0) @dataclass(frozen=True) @@ -138,6 +146,60 @@ def aime_from_indexed(indexed_amounts): return int(sum(selected, Decimal(0)) // 420) +def _owners_inputs(row, wages, se): + supplied = row.owners_draw + if isinstance(supplied, dict): + required = { + "s_corp_income", + "partnership_income", + "passive_income", + "already_se_income", + } + if not required <= supplied.keys(): + raise ValueError(f"Missing owners' draw source fields for year {row.year}") + if supplied.keys() - required - {"subsets_are_disjoint", "passive_se_overlap"}: + raise ValueError("Unknown owners' draw history input") + supplied = OwnersDrawInputs(**supplied) + if not isinstance(supplied, OwnersDrawInputs): + raise ValueError(f"Supply owners' draw source fields for year {row.year}") + values = {} + for name in ( + "s_corp_income", + "partnership_income", + "passive_income", + "already_se_income", + "passive_se_overlap", + ): + raw = getattr(supplied, name) + if name == "passive_se_overlap" and (raw is None or str(raw).lower() == "nan"): + values[name] = float("nan") + continue + try: + amount = Decimal(str(raw)) + converted = float(amount) + except (ValueError, TypeError, ArithmeticError) as error: + raise ValueError( + "Owners' draw source amounts must be finite signed numbers" + ) from error + if ( + isinstance(raw, bool) + or not amount.is_finite() + or not Decimal(str(converted)).is_finite() + ): + raise ValueError( + "Owners' draw source amounts must be finite signed numbers" + ) + values[name] = converted + if not isinstance(supplied.subsets_are_disjoint, bool): + raise ValueError("Owners' draw disjoint-subset assertion must be boolean") + return OwnersDrawInputs( + **values, + subsets_are_disjoint=supplied.subsets_are_disjoint, + covered_wages=float(wages), + existing_covered_se=float(se), + ) + + def compute_history( history, eligibility_year, claim_year, parameters, *, birth_year=None ): @@ -155,7 +217,20 @@ def compute_history( wages = _money(row.covered_wages, "Covered wages") se = _money(row.covered_self_employment, "Existing covered SE earnings") cap = _parameter(parameters.wage_base, row.year, "Ordinary cap", positive=True) - covered = min(wages + se, cap) + draw_parameters = (parameters.owners_draw_by_year or {}).get( + row.year, OwnersDrawParameters() + ) + draw = None + if draw_parameters.in_effect and row.year >= draw_parameters.start_year: + draw = compute_owners_draw( + row.year, + inputs=_owners_inputs(row, wages, se), + parameters=draw_parameters, + ) + # Preserve the existing kernel's arithmetic, then keep decimal earnings + # through annual cent rounding. Credit is N_D before any room cap. + credited = Decimal(str(float(draw.credited_earnings))) if draw else Decimal(0) + covered = min(wages + se + credited, cap) nawi = ( _parameter(parameters.nawi, row.year, "NAWI", positive=True) if row.year < anchor_year @@ -163,7 +238,26 @@ def compute_history( ) indexed = index_covered_earnings(covered, nawi, anchor, round_cents=True) ledger.append( - AnnualEarnings(row.year, wages, se, cap, covered, anchor / nawi, indexed) + AnnualEarnings( + row.year, + wages, + se, + cap, + covered, + anchor / nawi, + indexed, + e1_gross=Decimal(str(float(draw.gross))) if draw else Decimal(0), + e1_net_earnings=Decimal(str(float(draw.net_earnings))) + if draw + else Decimal(0), + e1_credited=credited, + e1_oasdi_base=Decimal(str(float(draw.oasdi_base))) + if draw + else Decimal(0), + e1_niit_exclusion=Decimal(str(float(draw.niit_exclusion))) + if draw + else Decimal(0), + ) ) return HistoryCalculation( eligibility, @@ -195,6 +289,23 @@ def history_parameters_at( parameters, history, eligibility_year, claim_year, *, birth_year=None ): years = [row.year for row in history.through_claim(claim_year, birth_year)] + from types import SimpleNamespace as Node + + owners = {} + for year in years: + when = f"{year}-01-01" + settings = parameters.gov.contrib.ssa.owners_draw(when) + if not settings.in_effect or year < settings.start_year: + continue + # Snapshot only the relevant subtrees, not every US parameter/year. + gov = Node( + contrib=Node(ssa=Node(owners_draw=settings)), + irs=Node( + ald=Node(misc=parameters.gov.irs.ald.misc(when)), + self_employment=parameters.gov.irs.self_employment(when), + ), + ) + owners[year] = OwnersDrawParameters.from_policy_parameters(gov) return HistoryParameters( wage_base={ year: parameters.gov.ssa.social_security(f"{year}-01-01").wage_base @@ -204,4 +315,5 @@ def history_parameters_at( year: parameters.gov.ssa.nawi(f"{year}-01-01") for year in set(years) | {eligibility_year - 2} }, + owners_draw_by_year=owners, ) diff --git a/policyengine_us/tools/social_security/owners_draw.py b/policyengine_us/tools/social_security/owners_draw.py new file mode 100644 index 00000000000..e43c6bd8efd --- /dev/null +++ b/policyengine_us/tools/social_security/owners_draw.py @@ -0,0 +1,282 @@ +"""Shared annual owners' draw arithmetic for the reform and benefit history. + +The staged interface preserves the reform's public intermediate input contract: +an overridden gross draw or netting factor remains authoritative downstream. +Without overrides, one call computes a complete annual ledger from pure inputs. +""" + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any + +import numpy as np + + +@dataclass(frozen=True) +class OwnersDrawInputs: + """Signed subsets are already included in the supplied entity totals.""" + + s_corp_income: Any = 0 + partnership_income: Any = 0 + passive_income: Any = 0 + already_se_income: Any = 0 + subsets_are_disjoint: Any = False + passive_se_overlap: Any = np.nan + covered_wages: Any = 0 + existing_covered_se: Any = 0 + + +@dataclass(frozen=True) +class OwnersDrawParameters: + """Annual parameter values; default activation is deliberately off.""" + + in_effect: bool = False + start_year: int = 2027 + phase_in_years: int = 10 + phase_in_share: float = -1 + material_participation_only: bool = True + net_earnings_factor: float = 0 + employer_share: float = 0.5 + oasdi_rate: float = 0.124 + hi_rate: float = 0.029 + reporting_floor: float = 400 + current_law_cap: float = np.inf + room_cap_basis: int = 0 + include_hi: bool = False + benefit_credit: bool = True + + @classmethod + def from_policy_parameters(cls, gov, *, year=None, output=None): + """Read annual policy values, preserving each Variable's lazy lookup. + + A selected stage uses Person overrides for all earlier stages, so it + reads only its direct parameters. In particular a disabled stage must + not require historical rate parameters it never used before extraction. + History calls without ``output`` request the complete annual snapshot. + """ + draw = gov.contrib.ssa.owners_draw + if output is not None: + values = dict(in_effect=draw.in_effect, start_year=draw.start_year) + enabled = values["in_effect"] and year >= values["start_year"] + if enabled and output == "active_income": + values["material_participation_only"] = draw.material_participation_only + if enabled and output in ("phase_in_share", "planned_gross"): + values.update( + phase_in_years=draw.phase_in_years, + phase_in_share=draw.phase_in_share, + ) + if enabled and output == "net_earnings_factor": + values["net_earnings_factor"] = draw.net_earnings_factor + if values["net_earnings_factor"] == 0: + values.update( + employer_share=gov.irs.ald.misc.employer_share, + oasdi_rate=gov.irs.self_employment.rate.social_security, + hi_rate=gov.irs.self_employment.rate.medicare, + ) + if enabled and output == "gross": + values["reporting_floor"] = ( + gov.irs.self_employment.net_earnings_exemption + ) + if enabled and output == "oasdi_room": + values["room_cap_basis"] = draw.room_cap_basis + if values["room_cap_basis"] == 0: + values["current_law_cap"] = draw.current_law_cap + if output == "oasdi_tax": + values["oasdi_rate"] = gov.irs.self_employment.rate.social_security + if output == "hi_tax": + values["include_hi"] = draw.include_hi + if values["include_hi"]: + values["hi_rate"] = gov.irs.self_employment.rate.medicare + if enabled and output == "niit_exclusion": + values["include_hi"] = draw.include_hi + if output == "credited_earnings": + values["benefit_credit"] = draw.benefit_credit + return cls(**values) + return cls( + in_effect=draw.in_effect, + start_year=draw.start_year, + phase_in_years=draw.phase_in_years, + phase_in_share=draw.phase_in_share, + material_participation_only=draw.material_participation_only, + net_earnings_factor=draw.net_earnings_factor, + employer_share=gov.irs.ald.misc.employer_share, + oasdi_rate=gov.irs.self_employment.rate.social_security, + hi_rate=gov.irs.self_employment.rate.medicare, + reporting_floor=gov.irs.self_employment.net_earnings_exemption, + current_law_cap=draw.current_law_cap, + room_cap_basis=draw.room_cap_basis, + include_hi=draw.include_hi, + benefit_credit=draw.benefit_credit, + ) + + +@dataclass(frozen=True) +class OwnersDrawResult: + active_income: Any + phase_in_share: Any + planned_gross: Any + net_earnings_factor: Any + gross: Any + net_earnings: Any + oasdi_room: Any + oasdi_base: Any + oasdi_tax: Any + hi_tax: Any + niit_exclusion: Any + credited_earnings: Any + + +def compute_owners_draw( + year: int, + *, + inputs: OwnersDrawInputs | Callable[[str], Any] = OwnersDrawInputs(), + parameters: OwnersDrawParameters = OwnersDrawParameters(), + output: str | None = None, + overrides: Mapping[str, Any] | Callable[[str], Any] | None = None, +) -> OwnersDrawResult | Any: + """Compute one year's draw, optionally evaluating only a requested stage. + + ``overrides`` supplies *dependencies* of the requested stage. The reform + uses a lazy Person accessor here, retaining direct inputs to every public + intermediate variable and avoiding validation of unused earlier stages. + History callers leave it unset and receive every annual amount. + + Covered wages and existing covered SE are the already adjusted tax bases + used to order the OASDI room. Benefit credit is N_D before this room cap. + """ + p = parameters + enabled = p.in_effect and year >= p.start_year + cache = {} + + def annual_input(name): + return inputs(name) if callable(inputs) else getattr(inputs, name) + + def dependency(name): + if callable(overrides): + return overrides(name) + if overrides is not None and name in overrides: + return overrides[name] + return evaluate(name) + + def evaluate(name): + if name in cache: + return cache[name] + if name == "active_income": + if not enabled: + value = 0 + else: + total = annual_input("s_corp_income") + annual_input( + "partnership_income" + ) + already_se = annual_input("already_se_income") + if not p.material_participation_only: + value = total - already_se + else: + passive = annual_input("passive_income") + disjoint = annual_input("subsets_are_disjoint") + overlap = annual_input("passive_se_overlap") + unknown_overlap = ~np.isfinite(overlap) + ambiguous = ( + (passive != 0) + & (already_se != 0) + & ~np.asarray(disjoint) + & unknown_overlap + ) + if np.any(ambiguous): + raise ValueError( + "Owners' draw needs a validated disjoint-subset assertion " + "or explicit passive/SE overlap when both subsets are nonzero" + ) + if np.any(disjoint & ~unknown_overlap & (overlap != 0)): + raise ValueError( + "Owners' draw disjoint assertion contradicts supplied overlap" + ) + value = ( + total + - passive + - already_se + + np.where(unknown_overlap, 0, overlap) + ) + elif name == "phase_in_share": + if not enabled: + value = 0 + else: + if p.phase_in_years < 1 or int(p.phase_in_years) != p.phase_in_years: + raise ValueError( + "Owners' draw phase_in_years must be a positive integer" + ) + if p.phase_in_share == -1: + value = min(1, (year - p.start_year + 1) / p.phase_in_years) + elif ( + not np.isfinite(p.phase_in_share) or not 0 <= p.phase_in_share <= 1 + ): + raise ValueError( + "Owners' draw phase_in_share must be -1 or in [0, 1]" + ) + else: + value = p.phase_in_share + elif name == "planned_gross": + value = evaluate("phase_in_share") * np.maximum( + 0, dependency("active_income") + ) + elif name == "net_earnings_factor": + if not enabled: + value = 0 + else: + value = p.net_earnings_factor + if value == 0: + value = 1 - p.employer_share * (p.oasdi_rate + p.hi_rate) + if not np.isfinite(value) or not 0 < value <= 1: + raise ValueError( + "Owners' draw net earnings factor must be in (0, 1]" + ) + elif name == "gross": + if not enabled: + value = 0 + else: + planned = dependency("planned_gross") + adjusted = planned * dependency("net_earnings_factor") + value = np.where(adjusted >= p.reporting_floor, planned, 0) + elif name == "net_earnings": + value = dependency("gross") * dependency("net_earnings_factor") + elif name == "oasdi_room": + if not enabled: + value = 0 + elif p.room_cap_basis == 1: + value = np.inf + elif p.room_cap_basis != 0: + raise ValueError( + "Owners' draw room_cap_basis must be 0 (current law) or 1 (no ceiling)" + ) + else: + value = np.maximum( + 0, + p.current_law_cap + - annual_input("covered_wages") + - annual_input("existing_covered_se"), + ) + elif name == "oasdi_base": + value = np.minimum(dependency("net_earnings"), dependency("oasdi_room")) + elif name == "oasdi_tax": + value = p.oasdi_rate * dependency("oasdi_base") + elif name == "hi_tax": + value = p.hi_rate * dependency("net_earnings") if p.include_hi else 0 + elif name == "niit_exclusion": + if not enabled: + value = 0 + elif p.include_hi: + value = dependency("gross") + else: + value = dependency("oasdi_base") / dependency("net_earnings_factor") + elif name == "credited_earnings": + value = dependency("net_earnings") if p.benefit_credit else 0 + else: + raise ValueError(f"Unknown owners' draw output: {name}") + cache[name] = value + return value + + if output is not None: + return evaluate(output) + return OwnersDrawResult( + **{name: evaluate(name) for name in OwnersDrawResult.__dataclass_fields__} + ) From bb38c78b2ea66e749fa7de7419d6ba6692546d17 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 09:11:02 -0400 Subject: [PATCH 03/14] Compute annual mini PIAs and eligibility-cohort phase-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ssa/history/mini_pia/in_effect.yaml | 10 + .../contrib/ssa/history/mini_pia/share.yaml | 15 ++ .../tests/unit/test_ss_earnings_history.py | 1 + .../tests/unit/test_ss_mini_pia.py | 172 ++++++++++++++++++ .../tools/social_security/history.py | 28 ++- .../tools/social_security/mini_pia.py | 120 ++++++++++++ policyengine_us/tools/social_security/pia.py | 9 +- 7 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/in_effect.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/share.yaml create mode 100644 policyengine_us/tests/unit/test_ss_mini_pia.py create mode 100644 policyengine_us/tools/social_security/mini_pia.py diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/in_effect.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/in_effect.yaml new file mode 100644 index 00000000000..7821e46eab5 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/in_effect.yaml @@ -0,0 +1,10 @@ +description: Whether the annual-history calculator uses the annual mini-PIA formula for retired workers. +values: + 1983-01-01: false +metadata: + unit: bool + label: Annual-history mini-PIA activation + economy: false + reference: + - title: 2025 OACT provision B3.12 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/benefitlevel.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/share.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/share.yaml new file mode 100644 index 00000000000..ee4da28683b --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/share.yaml @@ -0,0 +1,15 @@ +description: The share of a newly eligible retired worker's PIA based on the annual mini-PIA formula, if enabled. +values: + 1983-01-01: 0 + 2032-01-01: 0.2 + 2033-01-01: 0.4 + 2034-01-01: 0.6 + 2035-01-01: 0.8 + 2036-01-01: 1 +metadata: + unit: /1 + label: Mini-PIA eligibility cohort share + economy: false + reference: + - title: 2025 OACT provision B3.12 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/benefitlevel.html diff --git a/policyengine_us/tests/unit/test_ss_earnings_history.py b/policyengine_us/tests/unit/test_ss_earnings_history.py index 532557ecaa0..ceec8e185e3 100644 --- a/policyengine_us/tests/unit/test_ss_earnings_history.py +++ b/policyengine_us/tests/unit/test_ss_earnings_history.py @@ -212,6 +212,7 @@ def test_benefit_zero_before_entitlement(): assert result.entitlement_total == result.payment_total == 0 +@settings(max_examples=50, deadline=None) @given( st.lists(st.integers(min_value=0, max_value=1_000_000), min_size=1, max_size=45), st.integers(min_value=0, max_value=1_000_000), diff --git a/policyengine_us/tests/unit/test_ss_mini_pia.py b/policyengine_us/tests/unit/test_ss_mini_pia.py new file mode 100644 index 00000000000..e9f09d105b3 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_mini_pia.py @@ -0,0 +1,172 @@ +"""Exact annual mini-PIA aggregation and retired-worker cohort selection. + +These exercise a pure history service rather than authored model variables; +YAML cannot assert its rational intermediates or supplied formula paths. +""" + +from decimal import Decimal +from fractions import Fraction +from pathlib import Path + +from hypothesis import given, strategies as st +from policyengine_core.parameters import ParameterNode +import pytest + +import policyengine_us +from policyengine_us.tools.social_security.mini_pia import ( + blend_mini_pia, + compute_mini_pia, + mini_pia_share_at, +) +from policyengine_us.tools.social_security.pia import FormulaPath, compute_pia + + +BENDS = (1000, 6000) + + +def _parameters(): + # Read only this small subtree, without constructing or copying a US model. + root = ParameterNode(data={}) + parent = root + for name in ("gov", "contrib", "ssa", "history"): + child = ParameterNode(name, data={}) + parent.add_child(name, child) + parent = child + path = ( + Path(policyengine_us.__file__).resolve().parent + / "parameters/gov/contrib/ssa/history/mini_pia" + ) + parent.add_child("mini_pia", ParameterNode("mini_pia", directory_path=str(path))) + return root + + +def test_hand_computed_mini_pias_and_fixed_divisor(): + baseline = compute_mini_pia([60000] * 33 + [120000], 2036, bend_points=BENDS) + owners = compute_mini_pia([60000] * 33 + [55410, 120000], 2036, bend_points=BENDS) + assert baseline.annual_mini_pias[:33] == (Fraction(2180),) * 33 + assert baseline.annual_mini_pias[33] == 3100 + assert owners.annual_mini_pias[33] == Fraction("2057.60") + assert baseline.mini_pia == Fraction(75040, 37) + assert owners.mini_pia == Fraction("77097.60") / 37 + assert len(owners.selected_mini_pias) == 40 + assert owners.selected_mini_pias[-5:] == (0,) * 5 + # Combining after the blend, then truncating once, gives the fixture dime. + combined = blend_mini_pia("2221.9", owners.mini_pia, 1) + Fraction("4.8") + assert (combined * 10).__floor__() / 10 == 2088.5 + + +def test_top_40_annual_pias_are_selected_instead_of_top_35(): + result = compute_mini_pia([60000] * 41 + [120000], 2036, bend_points=BENDS) + assert result.selected_mini_pias == (Fraction(3100),) + (Fraction(2180),) * 39 + assert result.mini_pia == Fraction(3100 + 39 * 2180, 37) + + +def test_annual_pia_and_blend_retain_fractional_cents(): + path = FormulaPath(factor_values=(1, 1, 1)) + result = compute_mini_pia([1], 2036, bend_points=BENDS, formula_path=path) + assert result.annual_mini_pias == (Fraction(1, 12),) + assert result.mini_pia == Fraction(1, 444) + assert blend_mini_pia("0.1", result.mini_pia, "0.2") == Fraction(893, 11100) + + +def test_empty_certified_career_pads_40_zeros(): + result = compute_mini_pia([], 2036, bend_points=BENDS) + assert result.annual_mini_pias == () + assert result.selected_mini_pias == (0,) * 40 + assert result.mini_pia == 0 + + +@pytest.mark.parametrize("year", [1982, 1979]) +def test_mini_pia_rejects_benchmarks_before_1983_even_for_empty_career(year): + with pytest.raises(ValueError, match="before 1983"): + compute_mini_pia([], year, bend_points=BENDS) + + +def test_empty_career_still_validates_shared_formula(): + with pytest.raises(ValueError, match="bend points"): + compute_mini_pia([], 2036, bend_points=(0, 6000)) + + +def test_negative_annual_earnings_rejected(): + with pytest.raises(ValueError, match="nonnegative"): + compute_mini_pia([-1], 2036, bend_points=BENDS) + + +@pytest.mark.parametrize("share", ["-0.01", "1.01"]) +def test_invalid_blend_share_rejected(share): + with pytest.raises(ValueError, match="between zero and one"): + blend_mini_pia(2180, 2028, share) + + +@given( + conventional=st.integers(min_value=0, max_value=100000), + annual=st.lists(st.integers(min_value=0, max_value=1000000), max_size=50), +) +def test_share_zero_equals_conventional_pia(conventional, annual): + mini = compute_mini_pia(annual, 2036, bend_points=BENDS) + assert blend_mini_pia(conventional, mini.mini_pia, 0) == conventional + + +def test_disability_pia_is_unchanged_at_full_retirement_share(): + assert blend_mini_pia("2180", "2028.108108", 1, disability=True) == 2180 + + +def test_mini_uses_shared_payment_year_reform_rules(): + path = FormulaPath(factor_values=(1, 1, 1), factor_start_year=2038) + cohort = compute_mini_pia( + [60000], 2036, bend_points=BENDS, formula_path=path, payment_year=2039 + ) + recomputed = compute_mini_pia( + [60000], + 2036, + bend_points=BENDS, + formula_path=path, + payment_year=2039, + recompute_mode="retroactive", + ) + assert cohort.annual_mini_pias == (Fraction(2180),) + assert recomputed.annual_mini_pias == (Fraction(5000),) + + +@pytest.mark.parametrize( + "year,expected", + [ + (2031, 0), + (2032, ".2"), + (2033, ".4"), + (2034, ".6"), + (2035, ".8"), + (2036, 1), + (2038, 1), + (2054, 1), + ], +) +def test_oact_2032_to_2036_phase_is_parameterized(year, expected): + parameters = _parameters() + assert mini_pia_share_at(parameters, year) == 0 + parameters.gov.contrib.ssa.history.mini_pia.in_effect.update( + start="1983-01-01", value=True + ) + assert mini_pia_share_at(parameters, year) == Fraction(expected) + assert mini_pia_share_at(parameters, year, disability=True) == 0 + + +def test_custom_cohort_share_changes_only_selected_years(): + parameters = _parameters() + settings = parameters.gov.contrib.ssa.history.mini_pia + settings.in_effect.update(start="1983-01-01", value=True) + settings.share.update(start="2038-01-01", value=0.5) + assert mini_pia_share_at(parameters, 2036) == 1 + assert mini_pia_share_at(parameters, 2038) == Fraction(1, 2) + assert mini_pia_share_at(parameters, 2054) == Fraction(1, 2) + + +def test_shared_formula_exposes_exact_rational_only_when_requested(): + assert compute_pia(1, 2036, bend_points=BENDS) == Decimal("0.9") + exact = compute_pia( + Fraction(1, 12), 2036, bend_points=BENDS, round_result=False, exact_result=True + ) + assert exact == Fraction(3, 40) + assert isinstance(exact, Fraction) + with pytest.raises(ValueError, match="exact_result requires"): + compute_pia(1, 2036, bend_points=BENDS, exact_result=True) diff --git a/policyengine_us/tools/social_security/history.py b/policyengine_us/tools/social_security/history.py index f8788174655..603c41c59a0 100644 --- a/policyengine_us/tools/social_security/history.py +++ b/policyengine_us/tools/social_security/history.py @@ -7,12 +7,14 @@ from dataclasses import dataclass from decimal import Decimal +from fractions import Fraction import json from typing import Mapping from .earnings import highest_earnings, index_covered_earnings from .owners_draw import OwnersDrawInputs, OwnersDrawParameters, compute_owners_draw -from .pia import compute_pia +from .mini_pia import blend_mini_pia, compute_mini_pia, mini_pia_share_at +from .pia import _number, compute_pia def _money(value, name): @@ -106,6 +108,7 @@ class HistoryParameters: wage_base: Mapping[int, Decimal | int | str] nawi: Mapping[int, Decimal | int | str] owners_draw_by_year: Mapping[int, OwnersDrawParameters] | None = None + mini_share: Decimal | Fraction | int | str = 0 @dataclass(frozen=True) @@ -130,6 +133,7 @@ class HistoryCalculation: claim_year: int ledger: tuple[AnnualEarnings, ...] aime: int + mini_share: Fraction = Fraction(0) def _parameter(path, year, name, *, positive=False): @@ -142,7 +146,10 @@ def _parameter(path, year, name, *, positive=False): def aime_from_indexed(indexed_amounts): - selected = highest_earnings(tuple(indexed_amounts) or (Decimal(0),)) + amounts = tuple( + _money(value, "Indexed annual earnings") for value in indexed_amounts + ) + selected = highest_earnings(amounts or (Decimal(0),)) return int(sum(selected, Decimal(0)) // 420) @@ -210,6 +217,9 @@ def compute_history( if claim_year < eligibility: raise ValueError("Claim year cannot precede eligibility") records = history.through_claim(claim_year, birth_year) + mini_share = _number(parameters.mini_share, "mini-PIA share") + if not 0 <= mini_share <= 1: + raise ValueError("mini-PIA share must be between zero and one") anchor_year = eligibility - 2 anchor = _parameter(parameters.nawi, anchor_year, "NAWI", positive=True) ledger = [] @@ -264,6 +274,7 @@ def compute_history( claim_year, tuple(ledger), aime_from_indexed(row.indexed_ordinary for row in ledger), + mini_share, ) @@ -275,7 +286,7 @@ def compute_history_pia( payment_year=None, recompute_mode="cohort", ): - return compute_pia( + conventional = compute_pia( calculation.aime, calculation.eligibility_year, formula_path, @@ -283,6 +294,16 @@ def compute_history_pia( recompute_mode, bend_points=bend_points, ) + minis = compute_mini_pia( + (row.indexed_ordinary for row in calculation.ledger), + calculation.eligibility_year, + bend_points=bend_points, + formula_path=formula_path, + payment_year=payment_year, + recompute_mode=recompute_mode, + ) + blended = blend_mini_pia(conventional, minis.mini_pia, calculation.mini_share) + return Decimal((blended * 10).__floor__()) / 10 def history_parameters_at( @@ -316,4 +337,5 @@ def history_parameters_at( for year in set(years) | {eligibility_year - 2} }, owners_draw_by_year=owners, + mini_share=mini_pia_share_at(parameters, eligibility_year), ) diff --git a/policyengine_us/tools/social_security/mini_pia.py b/policyengine_us/tools/social_security/mini_pia.py new file mode 100644 index 00000000000..b5d78d21b67 --- /dev/null +++ b/policyengine_us/tools/social_security/mini_pia.py @@ -0,0 +1,120 @@ +"""Annual mini-PIAs for the opt-in covered-earnings history route. + +OACT's archived 2025 B3.12 specifies the highest 40 annual mini-PIAs and the +fixed divisor 37. Keeping annual and blended values exact until the combined +eligibility PIA's final dime truncation is an explicit modeling convention. + +https://www.ssa.gov/OACT/solvency/provisions_tr2025/benefitlevel.html +""" + +from collections.abc import Iterable +from dataclasses import dataclass +from fractions import Fraction + +from .pia import ( + DEFAULT_FACTORS, + FormulaPath, + Number, + _number, + _year, + compute_pia, +) + + +@dataclass(frozen=True) +class MiniPIAResult: + """Unrounded annual values retain the supplied earnings-ledger order.""" + + annual_mini_pias: tuple[Fraction, ...] + selected_mini_pias: tuple[Fraction, ...] + mini_pia: Fraction + + +def compute_mini_pia( + indexed_annual_amounts: Iterable[Number], + eligibility_year: int, + *, + bend_points: tuple[Number, Number], + factors: tuple[Number, Number, Number] = DEFAULT_FACTORS, + formula_path: FormulaPath | None = None, + payment_year: int | None = None, + recompute_mode: str = "cohort", +) -> MiniPIAResult: + """Apply E's shared PIA formula to every indexed annual amount / 12. + + Select annual mini-PIAs themselves, pad the selected list to 40 with + certified zeros, and always divide their exact sum by 37. The history + caller is responsible for refusing uncertified missing earnings years. + """ + _year(eligibility_year, "eligibility year") + annual = tuple( + compute_pia( + _number(amount, "indexed annual earnings") / 12, + eligibility_year, + formula_path, + payment_year, + recompute_mode, + bend_points=bend_points, + factors=factors, + round_result=False, + exact_result=True, + ) + for amount in indexed_annual_amounts + ) + # Validate the shared formula even for an empty, certified-zero career. + if not annual: + compute_pia( + 0, + eligibility_year, + formula_path, + payment_year, + recompute_mode, + bend_points=bend_points, + factors=factors, + ) + selected = tuple(sorted(annual, reverse=True)[:40]) + selected += (Fraction(0),) * (40 - len(selected)) + return MiniPIAResult( + annual_mini_pias=annual, + selected_mini_pias=selected, + mini_pia=sum(selected, Fraction(0)) / 37, + ) + + +def blend_mini_pia( + conventional_pia: Number, + mini_pia: Number, + share: Number, + *, + disability: bool = False, +) -> Fraction: + """Blend unrounded mini and dime-rounded conventional PIA exactly once. + + Disability remains conventional regardless of the retired-worker share. + A separately enabled AIME+ component is added after this blend, before + final eligibility-PIA dime truncation by the household payment pipeline. + """ + conventional = _number(conventional_pia, "conventional PIA") + mini = _number(mini_pia, "mini-PIA") + fraction = _number(share, "mini-PIA share") + if conventional < 0 or mini < 0: + raise ValueError("PIAs must be nonnegative") + if not 0 <= fraction <= 1: + raise ValueError("mini-PIA share must be between zero and one") + return ( + conventional if disability else fraction * mini + (1 - fraction) * conventional + ) + + +def mini_pia_share_at( + parameters, eligibility_year: int, *, disability=False +) -> Fraction: + """Read the retired-worker cohort share; master activation defaults off.""" + year = _year(eligibility_year, "eligibility year") + settings = parameters.gov.contrib.ssa.history.mini_pia(f"{year}-01-01") + if disability or not settings.in_effect: + return Fraction(0) + share = _number(settings.share, "mini-PIA share") + if not 0 <= share <= 1: + raise ValueError("mini-PIA share must be between zero and one") + return share diff --git a/policyengine_us/tools/social_security/pia.py b/policyengine_us/tools/social_security/pia.py index 72e9d3a5348..a0090ebd06b 100644 --- a/policyengine_us/tools/social_security/pia.py +++ b/policyengine_us/tools/social_security/pia.py @@ -260,14 +260,19 @@ def compute_pia( bend_points: tuple[Number, Number], factors: tuple[Number, Number, Number] = DEFAULT_FACTORS, round_result: bool = True, -) -> Decimal: + exact_result: bool = False, +) -> Decimal | Fraction: """Compute a monthly PIA from supplied AIME and eligibility-year inputs. Direct household AIME inputs must be whole dollars (the household adapter validates this). Fractional AIME remains supported here for the documented inverse lower-endpoint convention and continuous reform sensitivities. ``round_result=False`` exposes the formula before statutory dime truncation. + Also requesting ``exact_result=True`` returns its exact rational value for + mini-PIA aggregation; this requires ``round_result=False``. """ + if exact_result and round_result: + raise ValueError("exact_result requires round_result=False") year = _computation_years(eligibility_year, payment_year, recompute_mode) amount = _number(aime, "AIME") if amount < 0: @@ -329,7 +334,7 @@ def compute_pia( # Rational arithmetic keeps exact dime boundaries exact; binary floats # and finite Decimal approximations cannot accidentally lose one dime. return Decimal((result * 10).numerator // (result * 10).denominator) / 10 - return _decimal(result) + return result if exact_result else _decimal(result) def inverse_unrounded( From 02b231dd7222f68280efc77f6643a4b016fd5130 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 09:15:07 -0400 Subject: [PATCH 04/14] Add named annual donut tax and AIME-plus credit paths 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 --- .../history/donut/custom/credit_enabled.yaml | 10 + .../ssa/history/donut/custom/credit_rate.yaml | 10 + .../ssa/history/donut/custom/in_effect.yaml | 10 + .../donut/custom/include_owners_draw.yaml | 10 + .../donut/custom/include_self_employment.yaml | 10 + .../ssa/history/donut/custom/start_year.yaml | 10 + .../ssa/history/donut/custom/tax_rate.yaml | 10 + .../ssa/history/donut/custom/threshold.yaml | 10 + .../donut/oact_2025_e2_13/credit_enabled.yaml | 10 + .../donut/oact_2025_e2_13/credit_rate.yaml | 10 + .../donut/oact_2025_e2_13/in_effect.yaml | 10 + .../oact_2025_e2_13/include_owners_draw.yaml | 10 + .../include_self_employment.yaml | 10 + .../donut/oact_2025_e2_13/start_year.yaml | 10 + .../donut/oact_2025_e2_13/tax_rate.yaml | 10 + .../donut/oact_2025_e2_13/threshold.yaml | 10 + .../donut/oact_2025_e2_14/credit_enabled.yaml | 10 + .../donut/oact_2025_e2_14/credit_rate.yaml | 10 + .../donut/oact_2025_e2_14/in_effect.yaml | 10 + .../oact_2025_e2_14/include_owners_draw.yaml | 10 + .../include_self_employment.yaml | 10 + .../donut/oact_2025_e2_14/start_year.yaml | 10 + .../donut/oact_2025_e2_14/tax_rate.yaml | 10 + .../donut/oact_2025_e2_14/threshold.yaml | 10 + policyengine_us/tests/unit/test_ss_donut.py | 206 ++++++++ .../tests/unit/test_ss_history_reforms.py | 449 ++++++++++++++++++ .../tools/social_security/donut.py | 107 +++++ .../tools/social_security/engine.py | 1 + .../tools/social_security/history.py | 79 ++- .../ssa/social_security/calculator/inputs.py | 13 + 30 files changed, 1093 insertions(+), 2 deletions(-) create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_enabled.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_rate.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/in_effect.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_owners_draw.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_self_employment.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/start_year.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/tax_rate.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/threshold.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_enabled.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_rate.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/in_effect.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_owners_draw.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_self_employment.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/start_year.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/tax_rate.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/threshold.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_enabled.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_rate.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/in_effect.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_owners_draw.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_self_employment.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/start_year.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/tax_rate.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/threshold.yaml create mode 100644 policyengine_us/tests/unit/test_ss_donut.py create mode 100644 policyengine_us/tests/unit/test_ss_history_reforms.py create mode 100644 policyengine_us/tools/social_security/donut.py diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_enabled.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_enabled.yaml new file mode 100644 index 00000000000..2923ea195a4 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_enabled.yaml @@ -0,0 +1,10 @@ +description: Whether this variant's newly taxed above-threshold earnings receive separate AIME+ benefit credit. +values: + 1983-01-01: false +metadata: + unit: bool + label: Custom annual-history donut benefit credit + economy: false + reference: + - title: 2025 OACT fixed-threshold donut provisions + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_rate.yaml new file mode 100644 index 00000000000..0e35fcf6012 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_rate.yaml @@ -0,0 +1,10 @@ +description: The monthly PIA factor applied to this variant's separately computed AIME+. +values: + 1983-01-01: 0 +metadata: + unit: /1 + label: Custom annual-history donut AIME+ factor + economy: false + reference: + - title: 2025 OACT fixed-threshold donut provisions + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/in_effect.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/in_effect.yaml new file mode 100644 index 00000000000..77a0e1440cc --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/in_effect.yaml @@ -0,0 +1,10 @@ +description: Whether this independently named annual-history donut variant is enabled. +values: + 1983-01-01: false +metadata: + unit: bool + label: Custom annual-history donut activation + economy: false + reference: + - title: 2025 OACT fixed-threshold donut provisions + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_owners_draw.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_owners_draw.yaml new file mode 100644 index 00000000000..5b506a6ccb5 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_owners_draw.yaml @@ -0,0 +1,10 @@ +description: Whether this variant includes reclassified net owners' draw consistently for above-threshold tax and credit. +values: + 1983-01-01: true +metadata: + unit: bool + label: Custom annual-history donut owners' draw inclusion + economy: false + reference: + - title: 2025 OACT fixed-threshold donut provisions + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_self_employment.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_self_employment.yaml new file mode 100644 index 00000000000..5894f5a277c --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_self_employment.yaml @@ -0,0 +1,10 @@ +description: Whether this variant includes existing covered net self-employment earnings consistently for tax and credit. +values: + 1983-01-01: true +metadata: + unit: bool + label: Custom annual-history donut covered self-employment inclusion + economy: false + reference: + - title: 2025 OACT fixed-threshold donut provisions + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/start_year.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/start_year.yaml new file mode 100644 index 00000000000..3b045ccb12b --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/start_year.yaml @@ -0,0 +1,10 @@ +description: The first earnings year subject to this annual-history donut variant. +values: + 1983-01-01: 2027 +metadata: + unit: year + label: Custom annual-history donut first earnings year + economy: false + reference: + - title: 2025 OACT fixed-threshold donut provisions + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/tax_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/tax_rate.yaml new file mode 100644 index 00000000000..a3ca17cad7a --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/tax_rate.yaml @@ -0,0 +1,10 @@ +description: The combined OASDI tax rate on this variant's annual earnings above both the ordinary cap and the threshold. +values: + 1983-01-01: 0 +metadata: + unit: /1 + label: Custom annual-history donut above-threshold OASDI rate + economy: false + reference: + - title: 2025 OACT fixed-threshold donut provisions + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/threshold.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/threshold.yaml new file mode 100644 index 00000000000..219c3981250 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/threshold.yaml @@ -0,0 +1,10 @@ +description: The annual earnings threshold for this donut variant; dated values specify the threshold path explicitly. +values: + 1983-01-01: 0 +metadata: + unit: currency-USD + label: Custom annual-history donut earnings threshold + economy: false + reference: + - title: 2025 OACT fixed-threshold donut provisions + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_enabled.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_enabled.yaml new file mode 100644 index 00000000000..0e6f5a83f29 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_enabled.yaml @@ -0,0 +1,10 @@ +description: Whether this variant's newly taxed above-threshold earnings receive separate AIME+ benefit credit. +values: + 1983-01-01: true +metadata: + unit: bool + label: 2025 OACT E2.13 annual-history donut benefit credit + economy: false + reference: + - title: 2025 OACT provision E2.13 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_rate.yaml new file mode 100644 index 00000000000..6a030fb881b --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_rate.yaml @@ -0,0 +1,10 @@ +description: The monthly PIA factor applied to this variant's separately computed AIME+. +values: + 1983-01-01: 0.02 +metadata: + unit: /1 + label: 2025 OACT E2.13 annual-history donut AIME+ factor + economy: false + reference: + - title: 2025 OACT provision E2.13 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/in_effect.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/in_effect.yaml new file mode 100644 index 00000000000..fcfc04281e4 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/in_effect.yaml @@ -0,0 +1,10 @@ +description: Whether this independently named annual-history donut variant is enabled. +values: + 1983-01-01: false +metadata: + unit: bool + label: 2025 OACT E2.13 annual-history donut activation + economy: false + reference: + - title: 2025 OACT provision E2.13 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_owners_draw.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_owners_draw.yaml new file mode 100644 index 00000000000..1e1a00f45db --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_owners_draw.yaml @@ -0,0 +1,10 @@ +description: Whether this variant includes reclassified net owners' draw consistently for above-threshold tax and credit. +values: + 1983-01-01: true +metadata: + unit: bool + label: 2025 OACT E2.13 annual-history donut owners' draw inclusion + economy: false + reference: + - title: 2025 OACT provision E2.13 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_self_employment.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_self_employment.yaml new file mode 100644 index 00000000000..ceb5991e937 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_self_employment.yaml @@ -0,0 +1,10 @@ +description: Whether this variant includes existing covered net self-employment earnings consistently for tax and credit. +values: + 1983-01-01: true +metadata: + unit: bool + label: 2025 OACT E2.13 annual-history donut covered self-employment inclusion + economy: false + reference: + - title: 2025 OACT provision E2.13 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/start_year.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/start_year.yaml new file mode 100644 index 00000000000..c4976f27708 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/start_year.yaml @@ -0,0 +1,10 @@ +description: The first earnings year subject to this annual-history donut variant. +values: + 1983-01-01: 2027 +metadata: + unit: year + label: 2025 OACT E2.13 annual-history donut first earnings year + economy: false + reference: + - title: 2025 OACT provision E2.13 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/tax_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/tax_rate.yaml new file mode 100644 index 00000000000..53c781be02a --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/tax_rate.yaml @@ -0,0 +1,10 @@ +description: The combined OASDI tax rate on this variant's annual earnings above both the ordinary cap and the threshold. +values: + 1983-01-01: 0.124 +metadata: + unit: /1 + label: 2025 OACT E2.13 annual-history donut above-threshold OASDI rate + economy: false + reference: + - title: 2025 OACT provision E2.13 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/threshold.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/threshold.yaml new file mode 100644 index 00000000000..7aa69fe02de --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/threshold.yaml @@ -0,0 +1,10 @@ +description: The annual earnings threshold for this donut variant; dated values specify the threshold path explicitly. +values: + 1983-01-01: 400000 +metadata: + unit: currency-USD + label: 2025 OACT E2.13 annual-history donut earnings threshold + economy: false + reference: + - title: 2025 OACT provision E2.13 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_enabled.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_enabled.yaml new file mode 100644 index 00000000000..6d77e5e26b9 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_enabled.yaml @@ -0,0 +1,10 @@ +description: Whether this variant's newly taxed above-threshold earnings receive separate AIME+ benefit credit. +values: + 1983-01-01: true +metadata: + unit: bool + label: 2025 OACT E2.14 annual-history donut benefit credit + economy: false + reference: + - title: 2025 OACT provision E2.14 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_rate.yaml new file mode 100644 index 00000000000..5d00c4c09c0 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_rate.yaml @@ -0,0 +1,10 @@ +description: The monthly PIA factor applied to this variant's separately computed AIME+. +values: + 1983-01-01: 0.02 +metadata: + unit: /1 + label: 2025 OACT E2.14 annual-history donut AIME+ factor + economy: false + reference: + - title: 2025 OACT provision E2.14 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/in_effect.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/in_effect.yaml new file mode 100644 index 00000000000..721c428dd6e --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/in_effect.yaml @@ -0,0 +1,10 @@ +description: Whether this independently named annual-history donut variant is enabled. +values: + 1983-01-01: false +metadata: + unit: bool + label: 2025 OACT E2.14 annual-history donut activation + economy: false + reference: + - title: 2025 OACT provision E2.14 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_owners_draw.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_owners_draw.yaml new file mode 100644 index 00000000000..d62ece2f611 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_owners_draw.yaml @@ -0,0 +1,10 @@ +description: Whether this variant includes reclassified net owners' draw consistently for above-threshold tax and credit. +values: + 1983-01-01: true +metadata: + unit: bool + label: 2025 OACT E2.14 annual-history donut owners' draw inclusion + economy: false + reference: + - title: 2025 OACT provision E2.14 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_self_employment.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_self_employment.yaml new file mode 100644 index 00000000000..67e97777123 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_self_employment.yaml @@ -0,0 +1,10 @@ +description: Whether this variant includes existing covered net self-employment earnings consistently for tax and credit. +values: + 1983-01-01: true +metadata: + unit: bool + label: 2025 OACT E2.14 annual-history donut covered self-employment inclusion + economy: false + reference: + - title: 2025 OACT provision E2.14 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/start_year.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/start_year.yaml new file mode 100644 index 00000000000..1414a0e2d26 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/start_year.yaml @@ -0,0 +1,10 @@ +description: The first earnings year subject to this annual-history donut variant. +values: + 1983-01-01: 2027 +metadata: + unit: year + label: 2025 OACT E2.14 annual-history donut first earnings year + economy: false + reference: + - title: 2025 OACT provision E2.14 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/tax_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/tax_rate.yaml new file mode 100644 index 00000000000..248727eb595 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/tax_rate.yaml @@ -0,0 +1,10 @@ +description: The combined OASDI tax rate on this variant's annual earnings above both the ordinary cap and the threshold. +values: + 1983-01-01: 0.124 +metadata: + unit: /1 + label: 2025 OACT E2.14 annual-history donut above-threshold OASDI rate + economy: false + reference: + - title: 2025 OACT provision E2.14 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/threshold.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/threshold.yaml new file mode 100644 index 00000000000..216b766be7d --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/threshold.yaml @@ -0,0 +1,10 @@ +description: The annual earnings threshold for this donut variant; dated values specify the threshold path explicitly. +values: + 1983-01-01: 250000 +metadata: + unit: currency-USD + label: 2025 OACT E2.14 annual-history donut earnings threshold + economy: false + reference: + - title: 2025 OACT provision E2.14 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/tests/unit/test_ss_donut.py b/policyengine_us/tests/unit/test_ss_donut.py new file mode 100644 index 00000000000..6c628a7ee3e --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_donut.py @@ -0,0 +1,206 @@ +"""Pure annual donut coverage and independently named parameter variants.""" + +from dataclasses import replace +from fractions import Fraction +from pathlib import Path + +from hypothesis import given, strategies as st +from policyengine_core.parameters import ParameterNode +import pytest + +import policyengine_us +from policyengine_us.tools.social_security.donut import ( + DONUT_VARIANTS, + DonutParameters, + compute_donut_year, + donut_parameters_at, +) + + +P = DonutParameters( + in_effect=True, + threshold=400000, + tax_rate=".124", + credit_enabled=True, + credit_rate=".02", +) + + +def _parameters(): + root = ParameterNode(data={}) + parent = root + for name in ("gov", "contrib", "ssa", "history"): + child = ParameterNode(name, data={}) + parent.add_child(name, child) + parent = child + path = ( + Path(policyengine_us.__file__).resolve().parent + / "parameters/gov/contrib/ssa/history/donut" + ) + parent.add_child("donut", ParameterNode("donut", directory_path=str(path))) + return root + + +@pytest.mark.parametrize( + "wages,expected", + [ + (0, 0), + (120000, 0), + (120001, 0), + (399999, 0), + (400000, 0), + (400001, 1), + (500800, 100800), + ], +) +def test_cap_gap_and_threshold_boundaries(wages, expected): + result = compute_donut_year( + 2038, covered_wages=wages, ordinary_cap=120000, parameters=P + ) + assert result.taxed_earnings == expected + assert result.credited_earnings == expected + assert result.tax == expected * Fraction(".124") + + +def test_cap_overtaking_fixed_threshold_leaves_no_double_counted_tier(): + result = compute_donut_year( + 2059, covered_wages=500800, ordinary_cap=450000, parameters=P + ) + assert result.threshold == 450000 + assert result.taxed_earnings == 50800 + + +@pytest.mark.parametrize( + "settings,year", + [(replace(P, in_effect=False), 2038), (P, 2026), (replace(P, tax_rate=0), 2038)], +) +def test_disabled_pre_activation_and_untaxed_earnings_receive_no_credit(settings, year): + result = compute_donut_year( + year, covered_wages=500800, ordinary_cap=120000, parameters=settings + ) + assert result.taxed_earnings == result.credited_earnings == result.tax == 0 + + +def test_no_credit_variant_keeps_taxed_earnings_separate(): + result = compute_donut_year( + 2038, + covered_wages=500800, + ordinary_cap=120000, + parameters=replace(P, credit_enabled=False), + ) + assert result.taxed_earnings == 100800 + assert result.credited_earnings == 0 + assert result.tax == Fraction("12499.20") + + +@pytest.mark.parametrize( + "include_se,include_draw,expected", + [ + (False, False, 0), + (True, False, 10000), + (False, True, 20000), + (True, True, 40000), + ], +) +def test_coverage_flags_apply_identically_for_tax_and_credit( + include_se, include_draw, expected +): + result = compute_donut_year( + 2038, + covered_wages=390000, + existing_covered_se=20000, + owners_draw_net_earnings=30000, + ordinary_cap=120000, + parameters=replace( + P, include_self_employment=include_se, include_owners_draw=include_draw + ), + ) + assert result.taxed_earnings == result.credited_earnings == expected + + +def test_net_draw_and_existing_se_are_counted_once(): + result = compute_donut_year( + 2038, + covered_wages=350000, + existing_covered_se=20000, + owners_draw_net_earnings=55410, + ordinary_cap=120000, + parameters=P, + ) + assert result.taxed_earnings == result.credited_earnings == 25410 + + +@given(wages=st.integers(min_value=120001, max_value=400000)) +def test_donut_never_credits_the_gap(wages): + result = compute_donut_year( + 2038, covered_wages=wages, ordinary_cap=120000, parameters=P + ) + assert result.taxed_earnings == result.credited_earnings == 0 + + +@pytest.mark.parametrize( + "field", + [ + "covered_wages", + "existing_covered_se", + "owners_draw_net_earnings", + "ordinary_cap", + ], +) +def test_negative_covered_inputs_rejected(field): + arguments = {"covered_wages": 500800, "ordinary_cap": 120000, field: -1} + with pytest.raises(ValueError, match="nonnegative"): + compute_donut_year(2038, parameters=P, **arguments) + + +@pytest.mark.parametrize("year", [2027, 2036, 2038, 2054, 2057, 2059]) +def test_oact_variants_remain_separate_and_fixed_threshold_through_claim_horizon(year): + parameters = _parameters() + high = donut_parameters_at(parameters, year, "oact_2025_e2_13") + low = donut_parameters_at(parameters, year, "oact_2025_e2_14") + assert high.in_effect is low.in_effect is False + assert high.threshold == 400000 + assert low.threshold == 250000 + assert high.start_year == low.start_year == 2027 + assert high.tax_rate == low.tax_rate == 0.124 + assert high.credit_rate == low.credit_rate == 0.02 + assert high.credit_enabled is low.credit_enabled is True + assert high.include_self_employment is low.include_self_employment is True + + +def test_named_credit_and_no_credit_variants_can_coexist(): + parameters = _parameters() + variants = parameters.gov.contrib.ssa.history.donut + variants.oact_2025_e2_13.in_effect.update(start="2027-01-01", value=True) + variants.oact_2025_e2_14.in_effect.update(start="2027-01-01", value=True) + variants.oact_2025_e2_14.credit_enabled.update(start="2027-01-01", value=False) + high = compute_donut_year( + 2038, + covered_wages=500800, + ordinary_cap=120000, + parameters=donut_parameters_at(parameters, 2038, "oact_2025_e2_13"), + ) + low = compute_donut_year( + 2038, + covered_wages=500800, + ordinary_cap=120000, + parameters=donut_parameters_at(parameters, 2038, "oact_2025_e2_14"), + ) + assert high.taxed_earnings == high.credited_earnings == 100800 + assert low.taxed_earnings == 250800 + assert low.credited_earnings == 0 + + +def test_custom_variant_defaults_are_neutral_and_accept_a_dated_threshold(): + parameters = _parameters() + settings = parameters.gov.contrib.ssa.history.donut.custom + settings.in_effect.update(start="2027-01-01", value=True) + settings.tax_rate.update(start="2027-01-01", value=0.1) + settings.threshold.update(start="2027-01-01", value=500000) + settings.threshold.update(start="2030-01-01", value=600000) + assert donut_parameters_at(parameters, 2029, "custom").threshold == 500000 + assert donut_parameters_at(parameters, 2030, "custom").threshold == 600000 + assert donut_parameters_at(parameters, 2038, "custom").credit_enabled is False + with pytest.raises(ValueError, match="Unknown donut variant"): + donut_parameters_at(parameters, 2038, "unknown") + assert len(DONUT_VARIANTS) == 3 diff --git a/policyengine_us/tests/unit/test_ss_history_reforms.py b/policyengine_us/tests/unit/test_ss_history_reforms.py new file mode 100644 index 00000000000..3b39c47f741 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_history_reforms.py @@ -0,0 +1,449 @@ +"""Annual reform composition, synthetic ledger, and exact payment pipeline. + +These pure service tests retain annual and rational intermediates that the +model's scalar YAML outputs cannot expose. The synthetic fixture is arithmetic, +not a forecast: unit NAWI ratios, zero COLAs, and fixed supplied caps/bends. +""" + +from dataclasses import replace +from datetime import date +from decimal import Decimal +from fractions import Fraction +from itertools import product + +from hypothesis import given, settings, strategies as st +import pytest + +from policyengine_us.tools.social_security.donut import DonutParameters +from policyengine_us.tools.social_security.history import ( + EarningsHistory, + EarningsRecord, + HistoryParameters, + compute_history, + compute_history_pia, +) +from policyengine_us.tools.social_security.household import ( + SuppliedRecord, + calculate_year, + claiming_factor, +) +from policyengine_us.tools.social_security.mini_pia import compute_mini_pia +from policyengine_us.tools.social_security.owners_draw import ( + OwnersDrawInputs, + OwnersDrawParameters, +) +from policyengine_us.tools.social_security.pia import compute_pia + + +D = Decimal +BENDS = (1000, 6000) +DRAW = OwnersDrawParameters( + in_effect=True, + start_year=2027, + phase_in_share=1, + current_law_cap=120000, + benefit_credit=True, +) +DONUT = DonutParameters( + in_effect=True, + start_year=2027, + threshold=400000, + tax_rate=".124", + credit_enabled=True, + credit_rate=".02", +) + + +def _fixture(*, e1=False, mini=False, donut=False): + records = [] + for year in range(1999, 2039): + wages = 60000 if 2004 <= year <= 2036 else 500800 if year == 2038 else 0 + owners = OwnersDrawInputs(s_corp_income=60000 if year == 2037 else 0) + records.append(EarningsRecord(year, wages, 0, owners_draw=owners)) + history = EarningsHistory( + first_year=1999, + records=tuple(records), + certified_zero_before_first_year=True, + ) + parameters = HistoryParameters( + wage_base={year: 120000 for year in range(1999, 2039)}, + nawi={year: 1 for year in range(1999, 2039)}, + owners_draw_by_year={year: DRAW for year in range(1999, 2039)} if e1 else None, + mini_share=Fraction(int(mini)), + donut_by_year={year: DONUT for year in range(1999, 2039)} if donut else None, + ) + return history, parameters + + +EXPECTED_PIA = { + (False, False, False): "2180.0", + (True, False, False): "2221.9", + (False, True, False): "2028.1", + (False, False, True): "2184.8", + (True, True, False): "2083.7", + (True, False, True): "2226.7", + (False, True, True): "2032.9", + (True, True, True): "2088.5", +} + + +@pytest.mark.parametrize("e1,mini,donut", list(product((False, True), repeat=3))) +def test_every_hand_fixture_row_and_all_eight_reform_combinations(e1, mini, donut): + history, parameters = _fixture(e1=e1, mini=mini, donut=donut) + calculation = compute_history(history, 2036, 2039, parameters, birth_year=1974) + assert calculation.aime == (5131 if e1 else 5000) + assert calculation.aime_plus == (240 if donut else 0) + assert calculation.donut_credit_pia == (Fraction("4.8") if donut else 0) + assert calculation.mini_share == int(mini) + assert compute_history_pia(calculation, bend_points=BENDS) == D( + EXPECTED_PIA[e1, mini, donut] + ) + assert len(calculation.ledger) == 40 + for row in calculation.ledger: + owners_year = e1 and row.year == 2037 + donut_year = donut and row.year == 2038 + ordinary = ( + 60000 + if 2004 <= row.year <= 2036 + else 55410 + if owners_year + else 120000 + if row.year == 2038 + else 0 + ) + assert row.ordinary_cap == 120000 + assert row.ordinary_covered == ordinary + assert row.indexed_ordinary == ordinary + assert row.indexing_factor == 1 + assert row.e1_gross == (60000 if owners_year else 0) + assert row.e1_net_earnings == (55410 if owners_year else 0) + assert row.e1_credited == (55410 if owners_year else 0) + assert row.e1_oasdi_base == (55410 if owners_year else 0) + assert row.e1_niit_exclusion == (60000 if owners_year else 0) + assert row.donut_taxed == (100800 if donut_year else 0) + assert row.donut_credited == (100800 if donut_year else 0) + assert row.indexed_donut == (100800 if donut_year else 0) + minis = compute_mini_pia( + [row.indexed_ordinary for row in calculation.ledger], 2036, bend_points=BENDS + ) + for row, mini_pia in zip(calculation.ledger, minis.annual_mini_pias, strict=True): + expected_mini = ( + 2180 + if 2004 <= row.year <= 2036 + else Fraction("2057.6") + if e1 and row.year == 2037 + else 3100 + if row.year == 2038 + else 0 + ) + assert mini_pia == expected_mini + assert minis.mini_pia == (Fraction("77097.6") if e1 else Fraction(75040)) / 37 + + +def test_joint_fixture_reuses_claiming_payment_rounding_and_first_year_timing(): + history, parameters = _fixture(e1=True, mini=True, donut=True) + record = SuppliedRecord( + source="history", + worker_id="synthetic-worker", + birth_date=date(1974, 1, 2), + entitlement_month="2039-01", + insured=True, + eligibility_year=2036, + history=history, + history_parameters=parameters, + no_special_monthly_test=True, + no_prior_withholding_adjustment=True, + earnings_test_no_trigger=True, + ) + assert claiming_factor(record.birth_date, record.entitlement_month) == Fraction( + 13, 15 + ) + before = calculate_year(record, 2038, bend_points=BENDS) + assert before.entitlement_total == before.payment_total == 0 + claimed = calculate_year(record, 2039, bend_points=BENDS) + january = next(row for row in claimed.ledger if row.entitlement_month == "2039-01") + assert january.eligibility_pia == D("2088.5") + assert abs(january.unrounded_benefit - D("1810.033333333333333333333333")) < D( + "1e-20" + ) + assert january.gross_benefit == january.payable_benefit == 1810 + assert claimed.entitlement_total == 21720 + assert claimed.payment_total == 1810 * 11 + full_payment_year = calculate_year(record, 2040, bend_points=BENDS) + assert ( + full_payment_year.entitlement_total == full_payment_year.payment_total == 21720 + ) + + +def _single( + *, year=2038, wages=0, se=0, owners=None, draw=DRAW, donut=None, cap=120000 +): + history = EarningsHistory( + first_year=year, + records=( + EarningsRecord(year, wages, se, owners_draw=owners or OwnersDrawInputs()), + ), + certified_zero_before_first_year=True, + ) + parameters = HistoryParameters( + wage_base={year: cap}, + nawi={year - 2: 1, year: 1}, + owners_draw_by_year={year: draw} if draw else None, + donut_by_year={year: donut} if donut else None, + ) + return compute_history(history, year, year + 1, parameters) + + +def test_e1_credit_enters_before_ordinary_cap(): + calculation = _single(wages=119000, owners=OwnersDrawInputs(s_corp_income=60000)) + row = calculation.ledger[0] + assert row.e1_net_earnings == row.e1_credited == 55410 + assert row.e1_oasdi_base == 1000 + assert row.ordinary_covered == 120000 + assert calculation.aime == 285 + + +@pytest.mark.parametrize( + "owners,expected", + [ + (OwnersDrawInputs(s_corp_income=-10000, partnership_income=20000), "9235"), + (OwnersDrawInputs(s_corp_income=-10000), "0"), + (OwnersDrawInputs(s_corp_income=400), "0"), + (OwnersDrawInputs(s_corp_income=500), "461.75"), + ( + OwnersDrawInputs( + s_corp_income=100000, + passive_income=40000, + already_se_income=20000, + passive_se_overlap=10000, + ), + "46175", + ), + ( + OwnersDrawInputs( + s_corp_income=100000, + passive_income=40000, + already_se_income=20000, + subsets_are_disjoint=True, + ), + "36940", + ), + ], +) +def test_e1_losses_floor_and_explicit_subset_overlap(owners, expected): + row = _single(owners=owners).ledger[0] + assert row.e1_credited == D(expected) + assert row.ordinary_covered == D(expected) + + +def test_e1_ambiguous_subsets_are_rejected_by_shared_kernel(): + with pytest.raises(ValueError, match="overlap"): + _single( + owners=OwnersDrawInputs( + s_corp_income=100000, passive_income=40000, already_se_income=20000 + ) + ) + + +@pytest.mark.parametrize("year,expected", [(2026, 0), (2027, 5541), (2036, 55410)]) +def test_e1_linear_phase_is_applied_per_earnings_year(year, expected): + row = _single( + year=year, + owners=OwnersDrawInputs(s_corp_income=60000), + draw=replace(DRAW, phase_in_share=-1, phase_in_years=10), + ).ledger[0] + assert row.e1_credited == expected + + +def test_e1_netting_factor_tracks_tax_rate_parameters(): + row = _single( + owners=OwnersDrawInputs(s_corp_income=60000), + draw=replace(DRAW, oasdi_rate=0.15), + ).ledger[0] + assert row.e1_net_earnings == row.e1_credited == 54630 + + +@pytest.mark.parametrize( + "basis,expected_base,expected_exclusion", [(0, 0, 0), (1, 55410, 60000)] +) +def test_e1_room_cap_basis_is_independent_of_reformed_ordinary_cap( + basis, expected_base, expected_exclusion +): + row = _single( + wages=150000, + owners=OwnersDrawInputs(s_corp_income=60000), + cap=1000000, + draw=replace(DRAW, room_cap_basis=basis), + ).ledger[0] + assert row.e1_oasdi_base == expected_base + assert row.e1_niit_exclusion == expected_exclusion + assert row.e1_credited == 55410 + assert row.ordinary_covered == 205410 + + +def test_e1_hi_mode_changes_gross_exclusion_above_room(): + row = _single( + wages=150000, + owners=OwnersDrawInputs(s_corp_income=60000), + draw=replace(DRAW, include_hi=True), + ).ledger[0] + assert row.e1_oasdi_base == 0 + assert row.e1_niit_exclusion == 60000 + assert row.e1_credited == 55410 + + +def test_e1_credit_switch_does_not_convert_tax_dollars_into_earnings(): + row = _single( + owners=OwnersDrawInputs(s_corp_income=60000), + draw=replace(DRAW, benefit_credit=False), + ).ledger[0] + assert row.e1_net_earnings == row.e1_oasdi_base == 55410 + assert row.e1_credited == row.ordinary_covered == 0 + + +def test_donut_taxed_draw_is_included_once_even_after_e1_ordinary_room_exhausted(): + row = _single( + wages=390000, + owners=OwnersDrawInputs(s_corp_income=60000), + donut=DONUT, + ).ledger[0] + assert row.e1_oasdi_base == 0 + assert row.e1_credited == 55410 + assert row.ordinary_covered == 120000 + assert row.donut_taxed == row.donut_credited == 45410 + + +def test_donut_draw_exclusion_applies_to_tax_and_credit_together(): + row = _single( + wages=390000, + owners=OwnersDrawInputs(s_corp_income=60000), + donut=replace(DONUT, include_owners_draw=False), + ).ledger[0] + assert row.e1_credited == 55410 + assert row.donut_taxed == row.donut_credited == 0 + + +def test_donut_pia_factor_uses_the_supplied_eligibility_cohort_coefficient(): + history, parameters = _fixture(donut=True) + parameters = replace(parameters, donut_credit_rate=Fraction(".03")) + calculation = compute_history(history, 2036, 2039, parameters) + assert calculation.aime_plus == 240 + assert calculation.donut_credit_pia == Fraction("7.2") + assert compute_history_pia(calculation, bend_points=BENDS) == D("2187.2") + + +def test_mixed_annual_donut_rates_require_an_explicit_cohort_coefficient(): + history = EarningsHistory( + first_year=2037, + records=(EarningsRecord(2037, 500800, 0), EarningsRecord(2038, 500800, 0)), + certified_zero_before_first_year=True, + ) + parameters = HistoryParameters( + wage_base={2037: 120000, 2038: 120000}, + nawi={2034: 1, 2037: 1, 2038: 1}, + donut_by_year={2037: DONUT, 2038: replace(DONUT, credit_rate=".03")}, + ) + with pytest.raises(ValueError, match="rate|coefficient"): + compute_history(history, 2036, 2039, parameters) + calculation = compute_history( + history, 2036, 2039, replace(parameters, donut_credit_rate=Fraction(".05")) + ) + assert calculation.aime_plus == 480 + assert calculation.donut_credit_pia == 24 + + +@settings(max_examples=30, deadline=None) +@given( + wages=st.lists( + st.integers(min_value=0, max_value=1000000), min_size=40, max_size=40 + ) +) +def test_all_reforms_off_equals_independent_conventional_computation(wages): + history = EarningsHistory( + first_year=1999, + records=tuple( + EarningsRecord(year, amount, 0) + for year, amount in zip(range(1999, 2039), wages, strict=True) + ), + certified_zero_before_first_year=True, + ) + parameters = HistoryParameters( + wage_base={year: 120000 for year in range(1999, 2039)}, + nawi={year: 1 for year in range(1999, 2039)}, + ) + calculation = compute_history(history, 2036, 2039, parameters) + expected_aime = ( + sum(sorted((min(amount, 120000) for amount in wages), reverse=True)[:35]) // 420 + ) + assert calculation.aime == expected_aime + assert calculation.aime_plus == calculation.donut_credit_pia == 0 + assert compute_history_pia(calculation, bend_points=BENDS) == compute_pia( + expected_aime, 2036, bend_points=BENDS + ) + + +@settings(max_examples=40, deadline=None) +@given(wages=st.integers(min_value=0, max_value=600000)) +def test_donut_never_changes_conventional_aime_and_never_credits_gap(wages): + off = _single(wages=wages, draw=None) + on = _single(wages=wages, draw=None, donut=DONUT) + assert on.aime == off.aime + row = on.ledger[0] + assert row.donut_credited == max(0, wages - 400000) + if 120000 < wages <= 400000: + assert row.donut_taxed == row.donut_credited == 0 + + +@settings(max_examples=40, deadline=None) +@given( + wages=st.integers(min_value=0, max_value=500000), + se=st.integers(min_value=0, max_value=50000), + gross=st.integers(min_value=0, max_value=200000), +) +def test_e1_credit_is_bounded_by_net_draw_and_each_ledger_stream_counts_it_once( + wages, se, gross +): + row = _single( + wages=wages, + se=se, + owners=OwnersDrawInputs(s_corp_income=gross), + donut=DONUT, + ).ledger[0] + assert 0 <= row.e1_credited <= row.e1_net_earnings + assert row.e1_credited == row.e1_net_earnings + assert row.ordinary_covered == min(D(wages + se) + row.e1_credited, 120000) + assert ( + row.donut_taxed + == row.donut_credited + == max(0, D(wages + se) + row.e1_net_earnings - 400000) + ) + + +@settings(max_examples=20, deadline=None) +@given( + payment_year=st.integers(min_value=2030, max_value=2038), + wages=st.integers(min_value=0, max_value=1000000), +) +def test_benefit_is_zero_before_entitlement_for_any_earnings(payment_year, wages): + history, parameters = _fixture(e1=True, mini=True, donut=True) + history = replace( + history, + records=history.records[:-1] + + (replace(history.records[-1], covered_wages=wages),), + ) + record = SuppliedRecord( + source="history", + worker_id="synthetic-worker", + birth_date=date(1974, 1, 2), + entitlement_month="2039-01", + insured=True, + eligibility_year=2036, + history=history, + history_parameters=parameters, + no_special_monthly_test=True, + no_prior_withholding_adjustment=True, + earnings_test_no_trigger=True, + ) + result = calculate_year(record, payment_year, bend_points=BENDS) + assert result.entitlement_total == result.payment_total == 0 + assert all(row.payable_benefit == 0 for row in result.ledger) diff --git a/policyengine_us/tools/social_security/donut.py b/policyengine_us/tools/social_security/donut.py new file mode 100644 index 00000000000..1b187a8933f --- /dev/null +++ b/policyengine_us/tools/social_security/donut.py @@ -0,0 +1,107 @@ +"""Separate annual above-threshold tax and optional benefit-credit streams. + +The fixed-threshold 2025 OACT E2.13/E2.14 variants remain separate parameter +sets. This service supplies a ledger for the household calculator, rather +than changing population payroll-tax variables or ordinary covered earnings. + +https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html +""" + +from dataclasses import dataclass +from fractions import Fraction + +from .pia import Number, _number + + +DONUT_VARIANTS = ("oact_2025_e2_13", "oact_2025_e2_14", "custom") + + +@dataclass(frozen=True) +class DonutParameters: + """One independently named annual provision; activation defaults off. + + Amounts entering covered SE or owners' draw must already use their net + earnings adjustments. Including either stream applies identically on the + tax and benefit sides. The dated threshold is explicit: the archived OACT + variants hold it fixed, while callers may supply a custom annual path. + """ + + name: str = "custom" + in_effect: bool = False + start_year: int = 2027 + threshold: Number = 0 + tax_rate: Number = 0 + credit_enabled: bool = False + credit_rate: Number = 0 + include_self_employment: bool = True + include_owners_draw: bool = True + + +@dataclass(frozen=True) +class DonutYearResult: + taxed_earnings: Fraction + credited_earnings: Fraction + tax: Fraction + threshold: Fraction + + +def compute_donut_year( + year: int, + *, + covered_wages: Number, + ordinary_cap: Number, + existing_covered_se: Number = 0, + owners_draw_net_earnings: Number = 0, + parameters: DonutParameters = DonutParameters(), +) -> DonutYearResult: + """Tax only selected earnings above max(ordinary cap, donut threshold). + + Covered wages, existing covered SE, and newly reclassified net draw are + disjoint annual inputs. Credited earnings equal the taxed stream when + credit is enabled; they are zero otherwise. No tax dollars are converted + into earnings credit. The ordinary AIME ledger is left to the caller. + """ + p = parameters + if isinstance(year, bool) or not isinstance(year, int): + raise ValueError("earnings year must be an integer calendar year") + if isinstance(p.start_year, bool) or not isinstance(p.start_year, int): + raise ValueError("donut start_year must be an integer calendar year") + cap = _number(ordinary_cap, "ordinary annual cap") + threshold = _number(p.threshold, "donut threshold") + tax_rate = _number(p.tax_rate, "donut tax rate") + credit_rate = _number(p.credit_rate, "donut credit rate") + wages = _number(covered_wages, "covered wages") + se = _number(existing_covered_se, "existing covered SE") + draw = _number(owners_draw_net_earnings, "owners' net draw") + if min(cap, threshold, tax_rate, credit_rate, wages, se, draw) < 0: + raise ValueError("donut amounts and rates must be nonnegative") + tier_start = max(cap, threshold) + if not p.in_effect or year < p.start_year or tax_rate == 0: + return DonutYearResult(Fraction(0), Fraction(0), Fraction(0), tier_start) + earnings = wages + if p.include_self_employment: + earnings += se + if p.include_owners_draw: + earnings += draw + taxed = max(Fraction(0), earnings - tier_start) + credited = taxed if p.credit_enabled else Fraction(0) + return DonutYearResult(taxed, credited, taxed * tax_rate, tier_start) + + +def donut_parameters_at(parameters, year: int, variant: str) -> DonutParameters: + """Resolve one named annual parameter set without collapsing variants.""" + if variant not in DONUT_VARIANTS: + raise ValueError(f"Unknown donut variant: {variant}") + node = getattr(parameters.gov.contrib.ssa.history.donut, variant) + settings = node(f"{year}-01-01") + return DonutParameters( + name=variant, + in_effect=bool(settings.in_effect), + start_year=int(settings.start_year), + threshold=settings.threshold, + tax_rate=settings.tax_rate, + credit_enabled=bool(settings.credit_enabled), + credit_rate=settings.credit_rate, + include_self_employment=bool(settings.include_self_employment), + include_owners_draw=bool(settings.include_owners_draw), + ) diff --git a/policyengine_us/tools/social_security/engine.py b/policyengine_us/tools/social_security/engine.py index a36f8408513..60fff865208 100644 --- a/policyengine_us/tools/social_security/engine.py +++ b/policyengine_us/tools/social_security/engine.py @@ -122,6 +122,7 @@ def money(name, when=period): benchmark, month_number(record.entitlement_month) // 12, birth_year=record.birth_date.year, + donut_variant=text("donut_variant"), ), ) # Include the following January to price December entitlement, and any diff --git a/policyengine_us/tools/social_security/history.py b/policyengine_us/tools/social_security/history.py index 603c41c59a0..cfc1fa1cda6 100644 --- a/policyengine_us/tools/social_security/history.py +++ b/policyengine_us/tools/social_security/history.py @@ -12,6 +12,7 @@ from typing import Mapping from .earnings import highest_earnings, index_covered_earnings +from .donut import DonutParameters, compute_donut_year, donut_parameters_at from .owners_draw import OwnersDrawInputs, OwnersDrawParameters, compute_owners_draw from .mini_pia import blend_mini_pia, compute_mini_pia, mini_pia_share_at from .pia import _number, compute_pia @@ -109,6 +110,8 @@ class HistoryParameters: nawi: Mapping[int, Decimal | int | str] owners_draw_by_year: Mapping[int, OwnersDrawParameters] | None = None mini_share: Decimal | Fraction | int | str = 0 + donut_by_year: Mapping[int, DonutParameters] | None = None + donut_credit_rate: Decimal | Fraction | int | str | None = None @dataclass(frozen=True) @@ -125,6 +128,10 @@ class AnnualEarnings: e1_credited: Decimal = Decimal(0) e1_oasdi_base: Decimal = Decimal(0) e1_niit_exclusion: Decimal = Decimal(0) + donut_taxed: Decimal = Decimal(0) + donut_credited: Decimal = Decimal(0) + indexed_donut: Decimal = Decimal(0) + donut_tax: Decimal = Decimal(0) @dataclass(frozen=True) @@ -134,6 +141,8 @@ class HistoryCalculation: ledger: tuple[AnnualEarnings, ...] aime: int mini_share: Fraction = Fraction(0) + aime_plus: int = 0 + donut_credit_pia: Fraction = Fraction(0) def _parameter(path, year, name, *, positive=False): @@ -223,6 +232,7 @@ def compute_history( anchor_year = eligibility - 2 anchor = _parameter(parameters.nawi, anchor_year, "NAWI", positive=True) ledger = [] + credit_rates = set() for row in records: wages = _money(row.covered_wages, "Covered wages") se = _money(row.covered_self_employment, "Existing covered SE earnings") @@ -247,6 +257,37 @@ def compute_history( else anchor ) indexed = index_covered_earnings(covered, nawi, anchor, round_cents=True) + donut_parameters = (parameters.donut_by_year or {}).get(row.year) + donut = None + if donut_parameters is not None: + donut = compute_donut_year( + row.year, + covered_wages=wages, + existing_covered_se=se, + owners_draw_net_earnings=Decimal(str(float(draw.net_earnings))) + if draw + else 0, + ordinary_cap=cap, + parameters=donut_parameters, + ) + if donut.credited_earnings: + credit_rates.add( + _number(donut_parameters.credit_rate, "donut credit rate") + ) + donut_taxed = ( + Decimal(donut.taxed_earnings.numerator) / donut.taxed_earnings.denominator + if donut + else Decimal(0) + ) + donut_credited = ( + Decimal(donut.credited_earnings.numerator) + / donut.credited_earnings.denominator + if donut + else Decimal(0) + ) + indexed_donut = index_covered_earnings( + donut_credited, nawi, anchor, round_cents=True + ) ledger.append( AnnualEarnings( row.year, @@ -267,14 +308,33 @@ def compute_history( e1_niit_exclusion=Decimal(str(float(draw.niit_exclusion))) if draw else Decimal(0), + donut_taxed=donut_taxed, + donut_credited=donut_credited, + indexed_donut=indexed_donut, + donut_tax=Decimal(donut.tax.numerator) / donut.tax.denominator + if donut + else Decimal(0), ) ) + if parameters.donut_credit_rate is None: + if len(credit_rates) > 1: + raise ValueError( + "Mixed annual donut rates require an explicit eligibility-cohort coefficient" + ) + credit_rate = next(iter(credit_rates), Fraction(0)) + else: + credit_rate = _number(parameters.donut_credit_rate, "donut credit rate") + if credit_rate < 0: + raise ValueError("Donut credit rate must be nonnegative") + aime_plus = aime_from_indexed(row.indexed_donut for row in ledger) return HistoryCalculation( eligibility, claim_year, tuple(ledger), aime_from_indexed(row.indexed_ordinary for row in ledger), mini_share, + aime_plus, + credit_rate * aime_plus, ) @@ -303,11 +363,18 @@ def compute_history_pia( recompute_mode=recompute_mode, ) blended = blend_mini_pia(conventional, minis.mini_pia, calculation.mini_share) - return Decimal((blended * 10).__floor__()) / 10 + combined = blended + calculation.donut_credit_pia + return Decimal((combined * 10).__floor__()) / 10 def history_parameters_at( - parameters, history, eligibility_year, claim_year, *, birth_year=None + parameters, + history, + eligibility_year, + claim_year, + *, + birth_year=None, + donut_variant="custom", ): years = [row.year for row in history.through_claim(claim_year, birth_year)] from types import SimpleNamespace as Node @@ -338,4 +405,12 @@ def history_parameters_at( }, owners_draw_by_year=owners, mini_share=mini_pia_share_at(parameters, eligibility_year), + donut_by_year={ + year: donut_parameters_at(parameters, year, donut_variant) for year in years + }, + # Annual parameters define the taxed stream. The PIA coefficient is + # selected at initial eligibility, like the ordinary formula factors. + donut_credit_rate=donut_parameters_at( + parameters, eligibility_year, donut_variant + ).credit_rate, ) diff --git a/policyengine_us/variables/gov/ssa/social_security/calculator/inputs.py b/policyengine_us/variables/gov/ssa/social_security/calculator/inputs.py index 7eda9104e53..34a19d19d9d 100644 --- a/policyengine_us/variables/gov/ssa/social_security/calculator/inputs.py +++ b/policyengine_us/variables/gov/ssa/social_security/calculator/inputs.py @@ -32,6 +32,19 @@ class ss_calculator_earnings_history(Variable): reference = "https://secure.ssa.gov/poms.nsf/lnx/0300605018" +class ss_calculator_donut_variant(Variable): + value_type = str + entity = Person + definition_period = ETERNITY + default_value = "custom" + label = "Social Security calculator donut provision" + documentation = ( + "Named annual parameter set: custom, oact_2025_e2_13 or oact_2025_e2_14. " + "Several variants may be configured independently; only this selected " + "set applies to a worker record. All sets are inactive by default." + ) + + class ss_calculator_worker_id(Variable): value_type = str entity = Person From 77513b24a39e0a6adcd24a7ef0a3d48615bfa6c7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 09:32:36 -0400 Subject: [PATCH 05/14] Expose annual earnings and PIA reconciliation ledgers 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 --- Makefile | 12 +- .../ss-household-calculator-4a2.added.md | 2 +- .../social_security_history.md | 262 ++++++++++++++++ .../code_health/test_rest_python_groups.py | 23 +- .../unit/test_ss_history_differential.py | 139 +++++++++ .../tests/unit/test_ss_history_engine.py | 287 ++++++++++++++++++ .../tests/unit/test_ss_history_report.py | 135 ++++++++ .../unit/test_ss_history_ssa_examples.py | 39 +++ .../tools/social_security/engine.py | 14 +- .../tools/social_security/history.py | 76 ++++- .../tools/social_security/history_report.py | 143 +++++++++ .../tools/social_security/household.py | 14 +- .../ssa/social_security/calculator/results.py | 77 +++++ 13 files changed, 1210 insertions(+), 13 deletions(-) create mode 100644 docs/contributed_reforms/social_security_history.md create mode 100644 policyengine_us/tests/unit/test_ss_history_differential.py create mode 100644 policyengine_us/tests/unit/test_ss_history_engine.py create mode 100644 policyengine_us/tests/unit/test_ss_history_report.py create mode 100644 policyengine_us/tools/social_security/history_report.py diff --git a/Makefile b/Makefile index 056dcb663a5..656997cca44 100644 --- a/Makefile +++ b/Makefile @@ -52,7 +52,7 @@ REST_SPM_TESTS := $(TESTS)/core/test_spm_policy_family.py \ # from its dry run and spreads their files over parallel runners by recent # CI times, splitting a long group into chunks. A process still runs files # of one group only, in this order, so none holds more than its group here. -REST_PYTHON_GROUPS := cached-arrays heavy-a heavy-b core-heavy ss-calculator-engine ss-calculator-differential social-security-inputs core policy-parameters policy remaining-heavy remaining +REST_PYTHON_GROUPS := cached-arrays heavy-a heavy-b core-heavy ss-calculator-engine ss-calculator-differential ss-history-engine ss-history-differential social-security-inputs core policy-parameters policy remaining-heavy remaining REST_CACHED_ARRAY_TESTS := $(TESTS)/test_formulas_do_not_write_into_cached_arrays.py REST_HEAVY_A_TESTS := $(TESTS)/test_md_poverty_line_credit_invariants.py \ $(TESTS)/core/test_ald_determinism.py \ @@ -79,6 +79,11 @@ REST_SS_CALCULATOR_ENGINE_TESTS := $(TESTS)/unit/test_ss_calculator_engine.py # Local macOS/Python 3.13.9, four numeric threads: all eight cases passed # with 2.45 GiB peak RSS, below the ~10 GB per-process target above. REST_SS_CALCULATOR_DIFFERENTIAL_TESTS := $(TESTS)/unit/test_ss_calculator_differential.py +# History ingress/dated-policy and optional-route differential checks retain +# reform trees. Keep each file in its own process on the existing Rest shards. +# Local elapsed time and memory measurements are recorded in the PR. +REST_SS_HISTORY_ENGINE_TESTS := $(TESTS)/unit/test_ss_history_engine.py +REST_SS_HISTORY_DIFFERENTIAL_TESTS := $(TESTS)/unit/test_ss_history_differential.py # Synthetic household input/branch checks load no national dataset. Keep their # retained reform systems in a separate process rather than growing core's heap. # Local 4 GiB-capped run peaked at 2.385 GiB before its 1800s loaded-host cap; @@ -102,6 +107,7 @@ REST_REMAINING_HEAVY_TESTS := $(TESTS)/test_niit_section_911_magi.py \ $(TESTS)/test_schedule_d_filer_net_capital_gain.py REST_LISTED_TESTS := $(REST_CACHED_ARRAY_TESTS) $(REST_HEAVY_A_TESTS) $(REST_HEAVY_B_TESTS) \ $(REST_CORE_HEAVY_TESTS) $(REST_SS_CALCULATOR_ENGINE_TESTS) $(REST_SS_CALCULATOR_DIFFERENTIAL_TESTS) \ + $(REST_SS_HISTORY_ENGINE_TESTS) $(REST_SS_HISTORY_DIFFERENTIAL_TESTS) \ $(REST_SOCIAL_SECURITY_INPUT_TESTS) $(REST_SOCIAL_SECURITY_PIA_RATIO_INPUT_TESTS) \ $(REST_POLICY_PARAMETER_TESTS) $(REST_REMAINING_HEAVY_TESTS) REST_IGNORES := $(addprefix --ignore=,$(TESTS)/policy/contrib $(TESTS)/microsimulation $(REST_SPM_TESTS)) @@ -337,6 +343,10 @@ test-other-python-rest-ss-calculator-engine: $(call rest_pytest,ss-calculator-engine,$(REST_SS_CALCULATOR_ENGINE_TESTS)) test-other-python-rest-ss-calculator-differential: $(call rest_pytest,ss-calculator-differential,$(REST_SS_CALCULATOR_DIFFERENTIAL_TESTS)) +test-other-python-rest-ss-history-engine: + $(call rest_pytest,ss-history-engine,$(REST_SS_HISTORY_ENGINE_TESTS)) +test-other-python-rest-ss-history-differential: + $(call rest_pytest,ss-history-differential,$(REST_SS_HISTORY_DIFFERENTIAL_TESTS)) test-other-python-rest-social-security-inputs: @failed=""; for group in compatibility pia-ratio; do \ $(MAKE) --no-print-directory test-other-python-rest-social-security-inputs-$$group || failed="$$failed $$group"; \ diff --git a/changelog.d/ss-household-calculator-4a2.added.md b/changelog.d/ss-household-calculator-4a2.added.md index d953a2ba06e..8c86c86cd23 100644 --- a/changelog.d/ss-household-calculator-4a2.added.md +++ b/changelog.d/ss-household-calculator-4a2.added.md @@ -1 +1 @@ -Add an opt-in annual earnings-history path for the Social Security household calculator. +Add an opt-in annual earnings-history Social Security calculator with shared owners' draw credit, mini-PIAs, named above-cap credit variants, and a reconciliation ledger. diff --git a/docs/contributed_reforms/social_security_history.md b/docs/contributed_reforms/social_security_history.md new file mode 100644 index 00000000000..53761324113 --- /dev/null +++ b/docs/contributed_reforms/social_security_history.md @@ -0,0 +1,262 @@ +# Social Security annual earnings histories + +The opt-in household calculator accepts an annual covered-earnings history +with `ss_calculator_source: history`. It computes conventional AIME and the +initial eligibility PIA, with optional owners' draw credit, annual mini-PIAs, +and a separate above-cap AIME+ component. All reforms are off by default. +Insured status is supplied explicitly; this route does not calculate credits +or disability, family, survivor, or minimum benefits. + +The existing supplied-AIME, supplied-PIA, and restricted-survivor routes remain +available. Calculated `ss_calculated_*` outputs remain separate from reported +public Social Security benefits, following the merged household calculator's +interface. The history selector does not replace a public benefit input. + +## Annual source and missing years + +`ss_calculator_earnings_history` is one JSON string containing the complete +source record. Keeping the career in one structural input preserves all years +and supplied decimal amounts through source extraction and reload. It also +avoids treating Core's carry-forward of an annual scalar as a new earnings +observation. + +The JSON object contains: + +| Field | Meaning | +| --- | --- | +| `first_year` | First year enumerated by the history. | +| `records` | Annual observations with `year`, `covered_wages`, and `covered_self_employment`. Both monetary fields are required, including explicit zero. | +| `certified_zero_years` | Explicitly certified zero years omitted from `records`. | +| `certified_zero_before_first_year` | Certifies all earlier potential base years as zero. Required when `first_year` is later than the worker's birth year or 1951, whichever is later. | + +Covered wages mean earnings in covered employment before the ordinary annual +cap. Existing covered self-employment means earnings after their existing +net-earnings adjustment, before the benefit cap. These are earnings amounts, +not tax liabilities. Amounts may be JSON numbers or decimal strings; strings +are useful when another application's JSON encoder would lose precision. + +Every year from `first_year` through the year before initial entitlement must +be observed or certified zero. Missing years are rejected. Duplicate records, +overlapping observations and zero certificates, nonfinite amounts, and negative +covered earnings are rejected. Later observations may remain in the source +but do not enter the initial calculation: a claim in year Q uses earnings +through Q−1. Requesting a later benefit year does not recompute the initial +earnings record. + +For example, a worker born in 1964, first eligible in 2026 and claiming in +2029 can supply the following JSON string. The certificate is an explicit +assertion about this synthetic worker's earlier earnings: + +```json +{ + "first_year": 2026, + "certified_zero_before_first_year": true, + "certified_zero_years": [2027], + "records": [ + { + "year": 2026, + "covered_wages": "60000.00", + "covered_self_employment": "0.00" + }, + { + "year": 2028, + "covered_wages": "65000.00", + "covered_self_employment": "0.00" + } + ] +} +``` + +The calculator also requires the existing own-record fields: worker ID, exact +birth date, age-62 eligibility year, first entitlement month, insured-status +assertion, and assertions about unsupported special earnings tests and prior +withholding adjustments. The supported eligibility benchmark begins in 1983. + +## Conventional AIME and the shared benefit pipeline + +For eligibility year E and initial claim year Q: + +1. Include annual earnings through Q−1, including earnings after age 62. +2. Cap each year's covered wages plus existing covered SE plus enabled owners' + draw credit at that earnings year's ordinary contribution and benefit base. +3. For a year y before E−2, multiply by `NAWI(E−2) / NAWI(y)` and round to + cents, with half cents rounded upward. Earnings in E−2 and later remain at + face value. +4. Select the highest 35 annual indexed amounts, pad with certified zeros, + divide by 420, and floor to a whole monthly dollar. +5. Call the same eligibility-year `compute_pia` service used by supplied AIME. + +The [POMS indexing procedure](https://secure.ssa.gov/poms.nsf/lnx/0300605018) +establishes the index year and cent rounding. The +[POMS AIME/PIA procedure](https://secure.ssa.gov/poms.nsf/lnx/0300605021) +specifies whole-dollar AIME and lower-dime PIA rounding for modern benchmarks. +The [initial base-year rules](https://secure.ssa.gov/poms.nsf/lnx/0300605017) +distinguish the initial calculation from later recomputations. This calculator +deliberately freezes earnings at initial claim; post-claim recomputation is +outside this deliverable. + +The household payment service then applies eligibility-origin COLAs, the +claiming adjustment, contributed payment policies, rounding, and the earnings +test once. The [SSA COLA application sequence](https://www.ssa.gov/OACT/COLA/colaapplic.html) +truncates the PIA after a COLA and the claiming-adjusted amount to dimes, +followed by the applicable final payment rounding. + +An entitlement month's check is normally paid the following month. Annual +entitlement and annual payments are separate totals. A January first +entitlement therefore normally produces 12 entitlement months and 11 checks +in that calendar year. + +## Owners' draw credit + +When E1 is enabled for an observed earnings year, its record must contain +`owners_draw` with all four signed fields: `s_corp_income`, `partnership_income`, +`passive_income`, and `already_se_income`, including explicit zero values. +Include `subsets_are_disjoint` or `passive_se_overlap` when required. +Certified-zero years supply zero owners' inputs automatically. The passive and +already-SE amounts are subsets already included in the entity totals. +`already_se_income` is a gross partnership source amount; it differs from the +record's adjusted `covered_self_employment`. + +The history route and installed reform call one shared `compute_owners_draw` +kernel. Extracting the existing arithmetic preserves the reform's intermediate +outputs and input overrides while avoiding a second implementation. Annual +policy parameters govern activation, the phase-in share, netting, the separate +reporting floor, and wages/SE/draw room priority. See the +[owners' draw specification](owners_draw.md) for signed losses and overlapping +subsets. + +The shared kernel retains the reform's existing floating-point and NumPy +intermediate arithmetic. The history adapter restores its monetary outputs +with `Decimal(str(...))`, then applies the history route's annual indexed-cent +rounding. This preserves the existing reform computation while keeping the +subsequent annual earnings ledger separate from Core's float32 input storage. + +The default netting expression is +`1 − employer_share × (SE_OASDI_rate + SE_HI_rate)`, or 0.9235 at current rates. +The planned adjusted draw must independently meet the $400 net-earnings floor +at current settings. Enabled benefit credit adds accepted adjusted draw N_D +to annual covered earnings before the ordinary cap. It does not append N_D +to AIME or derive earnings by dividing a tax amount by a rate. + +The `room_cap_basis` parameter preserves the current-law cap or explicitly +removes the draw ceiling. It changes the tax-side room; ordinary benefit +credit still follows the annual benefit cap and any separately enabled +above-threshold credit. The existing reform's active NIIT exclusion remains +`draw_oasdi_base / q` with OASDI only and the full actual gross draw with HI. + +## Mini-PIA + +The named `compute_mini_pia` function applies the same eligibility-year PIA +formula to each annual ordinary indexed covered amount divided by 12. It +selects the highest 40 annual mini-PIAs, pads shorter certified careers with +zeros, and divides their sum by 37. The divisor is always 37. + +The archived [2025 OACT B3.12 description](https://www.ssa.gov/OACT/solvency/provisions_tr2025/benefitlevel.html) +says, “Sum these individual PIAs for the 40 highest years of indexed earnings +and divide that total amount by 37”. Its retired-worker phase-in is 20% in +2032, 40% in 2033, 60% in 2034, 80% in 2035, and 100% from 2036. Disability +benefits are unchanged. The independently enabled parameter path is under +`gov.contrib.ssa.history.mini_pia`. + +**Modeling conventions:** retain precision in each mini-AIME and mini-PIA, +aggregate with /37, and blend +`mini_share_E × mini_PIA + (1 − mini_share_E) × conventional_PIA` before final +dime truncation. Conventional AIME/PIA retains its statutory rounding. Add +the separate AIME+ PIA after the blend, then truncate the combined eligibility +PIA to dimes. These intermediate rounding and composition choices come from +the calculator design; OACT's summary does not prescribe them. + +## Above-cap AIME+ credit + +Independent named parameter sets under `gov.contrib.ssa.history.donut` retain +the archived `oact_2025_e2_13` and `oact_2025_e2_14` provisions and a `custom` +set. Activation, threshold, start year, tax rate, credit flag/rate, and +inclusion of existing SE or reclassified draw are separate settings. A +credit-off variant can tax earnings while producing no AIME+ credit. + +The archived [2025 OACT payroll-tax descriptions](https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html) +state E2.13 as “Apply OASDI 12.4 percent payroll tax rate on earnings above +$400,000” and E2.14 as “Apply OASDI 12.4 percent payroll tax rate on earnings +above $250,000”. Both begin with 2027 earnings and use a 2% secondary AIME+ +factor. Their thresholds stay fixed; once the ordinary cap reaches the +threshold, the gap disappears. + +For each enabled earnings year, the separate taxed stream is +`max(selected_covered_earnings − max(ordinary_cap, threshold), 0)`. The gap +between cap and threshold receives neither additional tax nor credit. +Earnings receive AIME+ credit only when the provision taxes them and enables +credit. Including existing SE or adjusted owners' draw applies consistently +to the tax and credit ledger. + +Index the extra stream using the same E−2 convention, select its own highest +35 years, divide by 420 and floor to whole dollars. Multiply that separate +AIME+ by the credit rate and add it after the mini/conventional blend. This +stream never changes conventional AIME. These services expose hypothetical +annual tax/credit amounts for the household report; they do not change +population payroll-tax variables. + +For a custom changing path, annual tax/coverage settings determine the extra +earnings stream, and the PIA credit coefficient is read at eligibility E. +This is a cohort-selection convention; the archived variants' coefficient is +constant at 2%. Pure callers supplying mixed annual coefficients must supply +the eligibility coefficient explicitly. + +## Annual earnings ledger + +`AnnualBenefit.history` exposes the typed history calculation to Python +callers. `ss_calculated_history_ledger` exposes its annual ledger as a JSON +string for a household report. The ledger retains ordinary covered earnings, +accepted E1 credit, the separate donut-taxed and credited stream, indexing +amounts, and each year's mini-PIA so the result can be reconciled by year. + +The accompanying annual diagnostics are `ss_calculated_history_aime`, +`ss_calculated_history_aime_plus`, `ss_calculated_history_mini_pia`, and +`ss_calculated_history_conventional_pia`. These diagnostics preserve the +initial-claim earnings calculation when a later payment year is requested. +`ss_calculator_donut_variant` selects the named donut parameter set; its +default `custom` set is inactive until explicitly configured. + +The annual report prices mini-PIAs and PIA diagnostics using January +entitlement's formula. Its income ledger stays frozen at Q−1. An exact fraction +accompanies recurring mini-PIA decimals, and highest-year selection flags use +the earlier earnings year to resolve ties. Before eligibility, the report +retains the earnings ledger and leaves PIA diagnostics unset. + +## Reviewable arithmetic and compatibility + +The synthetic 2036-eligibility fixture uses flat NAWI, zero COLAs, a $120,000 +ordinary cap, $1,000/$6,000 bend points, and .90/.32/.15 factors. Thirty-three +$60,000 wage years and a $120,000 capped year give AIME $5,000 and PIA +$2,180.00. A $60,000 accepted gross draw contributes $55,410, giving AIME +$5,131 and conventional PIA $2,221.90. A $500,800 wage year contributes +$100,800 to the $400,000 donut stream: AIME+ $240 and PIA+ $4.80. + +Annual minis are $2,180.00 for a $60,000 year, $3,100.00 for the capped year, +and $2,057.60 for the owners' year. The mini with E1 is +`(33 × 2180 + 3100 + 2057.60) / 37 = 2083.718918…`. Adding PIA+ and +truncating to dimes gives $2,088.50. Claiming 24 months before FRA uses +13/15 and pays $1,810 monthly, or $21,720 for 12 entitlement months. + +Published conventional comparisons use SSA's +[annual earnings histories](https://www.ssa.gov/OACT/ProgData/retirebenefit1.html) +and [PIA examples](https://www.ssa.gov/OACT/ProgData/retirebenefit2.html). +The published 2027 Case A has AIME $6,072 and initial PIA $2,720.20. Case B +first eligible in 2022 has AIME $11,843, including later earnings through +2026, and initial PIA $3,419.60. Source data are fixed in the corresponding +test fixtures; the live SSA examples can advance to another retirement year. + +The merged implementation takes precedence over older design descriptions: + +- The existing `ss_calculated_*` interface remains separate from reported + public benefits. Payment-year totals retain the merged one-month check lag. +- Runtime parameters already include historical bends and future wage-indexed + paths. The history route reuses them, including future household claim years. +- Legacy `ss_aime` keeps its rolling lookback, age filters, and untruncated + result. The opt-in complete-history route shares applicable arithmetic and + is compared with it where those legacy assumptions match. +- One structural JSON source holds all annual observations instead of many + period-specific inputs. Explicit certificates preserve missing-versus-zero + semantics and make source export complete. + +There are no package-specific settings, population impact estimates, insured +credits algorithm, or post-claim earnings recomputations in this interface. diff --git a/policyengine_us/tests/code_health/test_rest_python_groups.py b/policyengine_us/tests/code_health/test_rest_python_groups.py index f9560c504d5..c6e781173f4 100644 --- a/policyengine_us/tests/code_health/test_rest_python_groups.py +++ b/policyengine_us/tests/code_health/test_rest_python_groups.py @@ -873,12 +873,27 @@ def test_spm_and_dataset_suites_run_once_in_separate_steps_with_reports(workflow assert uploads[0]["with"]["name"] != "rest-test-timings" -@pytest.mark.parametrize("name", ["engine", "differential"]) +@pytest.mark.parametrize( + "group,file", + [ + ( + "ss-calculator-engine", + "policyengine_us/tests/unit/test_ss_calculator_engine.py", + ), + ( + "ss-calculator-differential", + "policyengine_us/tests/unit/test_ss_calculator_differential.py", + ), + ("ss-history-engine", "policyengine_us/tests/unit/test_ss_history_engine.py"), + ( + "ss-history-differential", + "policyengine_us/tests/unit/test_ss_history_differential.py", + ), + ], +) def test_calculator_api_files_run_once_in_separate_rest_processes( - groups, group_files, name + groups, group_files, group, file ): - file = f"policyengine_us/tests/unit/test_ss_calculator_{name}.py" - group = f"ss-calculator-{name}" assert group_files[group] == [file] assert [owner for owner, files in group_files.items() if file in files] == [group] assert groups[group]["reports"] == [ diff --git a/policyengine_us/tests/unit/test_ss_history_differential.py b/policyengine_us/tests/unit/test_ss_history_differential.py new file mode 100644 index 00000000000..f59c0217746 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_history_differential.py @@ -0,0 +1,139 @@ +"""Unselected history preserves existing calculator, SSA and tax results. + +One four-person vector exercises all preexisting calculator sources. The +reference removes only the history feature's additions, so an accidental +dependency on even a neutral new default fails. Every cached reference +intermediate is compared as well as the selected public outputs. +""" + +from copy import deepcopy + +import numpy as np +from policyengine_core.periods import MONTH +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation +from policyengine_us.tests.unit.test_ss_calculator_differential import ( + DOWNSTREAM_OUTPUTS, + LEGACY_SSA_OUTPUTS, + _cached_reference_values, +) +from policyengine_us.tests.unit.test_ss_calculator_engine import _record, _survivor + + +HISTORY_INPUTS = { + "ss_calculator_earnings_history", + "ss_calculator_donut_variant", +} +YEAR = 2026 + + +class WithoutAnnualEarningsHistory(Reform): + """Keep the existing supplied calculator and all shared SSA policies.""" + + def apply(self): + self.variables = { + name: variable + for name, variable in self.variables.items() + if name not in HISTORY_INPUTS + and not name.startswith("ss_calculated_history_") + } + parent = self.parameters.gov.contrib.ssa + parent.children.pop("history") + delattr(parent, "history") + + +def _mixed_source_situation(): + people = { + "off": {"ss_calculator_source": "off", "age": {YEAR: 45}}, + "aime": _record( + "aime-worker", + ss_calculator_source="aime", + ss_calculator_supplied_aime=5_000, + age={YEAR: 62}, + ), + "pia": _record( + "pia-worker", + ss_calculator_birth_date="1959-01-02", + ss_calculator_eligibility_year=2021, + ss_calculator_entitlement_month="2024-07", + ss_calculator_supplied_pia=2_000.2, + age={YEAR: 67}, + ), + "survivor": _survivor(age={YEAR: 76}), + } + for index, person in enumerate(people.values()): + person.update( + employment_income={YEAR: (50_000, 33_400, 0, 0)[index]}, + taxable_interest_income={YEAR: (0, 0, 90_000, 20_000)[index]}, + social_security_retirement={YEAR: (0, 18_000, 30_000, 0)[index]}, + social_security_disability={YEAR: (0, 0, 0, 0)[index]}, + social_security_survivors={YEAR: (0, 0, 0, 24_000)[index]}, + social_security_dependents={YEAR: (0, 0, 0, 0)[index]}, + # Exercise legacy SSA helpers without reconstructing four careers. + # The pure legacy-history differential covers their annual window. + ss_aime={YEAR: 5_000}, + ss_claiming_age={YEAR: 62}, + ) + situation = {"people": people} + for entity in ("tax_units", "families", "spm_units", "marital_units", "households"): + situation[entity] = {name: {"members": [name]} for name in people} + for unit in situation["tax_units"].values(): + # Avoid unrelated state/federal itemization optimization. + unit["tax_unit_itemizes"] = {YEAR: False} + for household in situation["households"].values(): + household["state_code"] = {YEAR: "NY"} + return situation + + +def test_unselected_history_matches_model_without_history_through_all_cached_outputs(): + situation = _mixed_source_situation() + reference = Simulation( + situation=deepcopy(situation), + tax_benefit_system=Simulation.default_tax_benefit_system_instance, + reform=WithoutAnnualEarningsHistory, + ) + actual_inputs = deepcopy(situation) + for person in actual_inputs["people"].values(): + # Neither supplied source is selected. Parsing either value or looking + # up this nonexistent variant would fail immediately if history leaked + # into an existing off/AIME/PIA/survivor route. + person["ss_calculator_earnings_history"] = "{malformed unselected JSON" + person["ss_calculator_donut_variant"] = "unregistered-unselected-variant" + actual = Simulation(situation=actual_inputs) + + registry = reference.tax_benefit_system.variables + assert not HISTORY_INPUTS.intersection(registry) + assert not any(name.startswith("ss_calculated_history_") for name in registry) + assert ( + "history" + not in reference.tax_benefit_system.parameters.gov.contrib.ssa.children + ) + assert "ss_calculator_source" in registry + assert "ss_calculated_payment" in registry + + # Discover every existing calculator output, preserving its annual/monthly + # definition; future additions to the supplied calculator remain covered. + calculator_outputs = tuple( + (name, "2026-06" if variable.definition_period == MONTH else YEAR) + for name, variable in sorted(registry.items()) + if name.startswith("ss_calculated_") + ) + assert len(calculator_outputs) >= 13 + for name, instant in calculator_outputs: + expected = reference.calculate(name, instant) + np.testing.assert_array_equal( + actual.calculate(name, instant), expected, err_msg=f"{name} at {instant}" + ) + for name in LEGACY_SSA_OUTPUTS + DOWNSTREAM_OUTPUTS: + expected = reference.calculate(name, YEAR) + np.testing.assert_array_equal( + actual.calculate(name, YEAR), expected, err_msg=name + ) + + closure = _cached_reference_values(reference) + assert len(closure) > len(calculator_outputs) + for name, instant, expected in closure: + np.testing.assert_array_equal( + actual.calculate(name, instant), expected, err_msg=f"{name} at {instant}" + ) diff --git a/policyengine_us/tests/unit/test_ss_history_engine.py b/policyengine_us/tests/unit/test_ss_history_engine.py new file mode 100644 index 00000000000..6c479b0ec67 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_history_engine.py @@ -0,0 +1,287 @@ +"""History ingress, complete source export, and future cohort integration. + +Pure arithmetic and generated properties live in the history service tests. +These few vector cases exercise the real Simulation boundary and dated policy. +""" + +import json +from decimal import Decimal + +import numpy as np +import pytest +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation + + +def _history(claim_year): + return json.dumps( + { + "first_year": claim_year - 1, + "certified_zero_before_first_year": True, + "certified_zero_years": [], + "records": [ + { + "year": claim_year - 1, + "covered_wages": "120000.00", + "covered_self_employment": "0.01", + } + ], + } + ) + + +def _record(identifier, eligibility=2026, claim=2026, source="history"): + return { + "ss_calculator_source": source, + "ss_calculator_worker_id": identifier, + "ss_calculator_birth_date": f"{eligibility - 62}-01-02", + "ss_calculator_eligibility_year": eligibility, + "ss_calculator_entitlement_month": f"{claim}-01", + "ss_calculator_insured": True, + "ss_calculator_claiming_factor": "1", + "ss_calculator_no_special_monthly_test": True, + "ss_calculator_no_prior_withholding_adjustment": True, + "ss_calculator_earnings_test_no_trigger": True, + "ss_calculator_earnings_history": _history(claim), + "ss_calculator_supplied_aime": 285, + } + + +def _situation(people): + members = list(people) + return { + "people": people, + "tax_units": {"unit": {"members": members}}, + "families": {"unit": {"members": members}}, + "spm_units": {"unit": {"members": members}}, + "households": {"unit": {"members": members}}, + } + + +def _simulation(situation, reform=None): + if reform is None: + return Simulation(situation=situation) + return Simulation( + situation=situation, + tax_benefit_system=Simulation.default_tax_benefit_system_instance, + reform=reform, + ) + + +@pytest.fixture(scope="module") +def history_simulation(): + return _simulation( + _situation( + { + "supplied": _record("supplied", source="aime"), + "history": _record("history"), + "off": { + "ss_calculator_source": "off", + "ss_calculator_earnings_history": "{not valid JSON", + }, + } + ) + ) + + +def test_history_and_supplied_aime_match_while_off_skips_malformed_source( + history_simulation, +): + simulation = history_simulation + # A single face-value 2025 year gives floor(120000.01 / 420) = 285. + # The 2026 first factor is .90, so initial PIA is 256.50. + np.testing.assert_allclose( + simulation.calculate("ss_calculated_eligibility_pia", "2026-01"), + [256.5, 256.5, 0], + rtol=0, + atol=0.0001, + ) + np.testing.assert_array_equal( + simulation.calculate("ss_calculated_payment", "2026-01"), [0, 0, 0] + ) + np.testing.assert_array_equal( + simulation.calculate("ss_calculated_payment", "2026-02"), [256, 256, 0] + ) + np.testing.assert_array_equal( + simulation.calculate("ss_calculated_annual_payment", 2026), + [256 * 11, 256 * 11, 0], + ) + + +def test_complete_json_source_survives_export_extract_and_reload(history_simulation): + simulation = history_simulation + expected = simulation.calculate("ss_calculated_annual_payment", 2026).copy() + inputs = simulation.to_input_dict() + assert "ss_calculated_annual_payment" not in inputs + assert inputs["ss_calculator_earnings_history"]["ETERNITY"][1] == _history(2026) + assert "ss_calculator_earnings_history" in simulation.true_input_variables + + extracted = simulation.extract_person(index=1) + observed = extracted["people"]["person_2"]["ss_calculator_earnings_history"] + assert next(iter(observed.values())) == _history(2026) + reloaded = _simulation(extracted) + np.testing.assert_array_equal( + reloaded.calculate("ss_calculated_annual_payment", 2026), expected + ) + # No float32 conversion, observation carry-forward, or computed output + # replaces the supplied decimal string when the source is exported again. + assert reloaded.to_input_dict()["ss_calculator_earnings_history"]["ETERNITY"][ + 1 + ] == _history(2026) + + +def test_future_cohorts_and_2057_2059_claims_apply_each_policy_once(): + cases = ((2036, 2039), (2038, 2043), (2054, 2057), (2054, 2059)) + people = {} + for eligibility, claim in cases: + for source in ("history", "aime"): + identifier = f"{source}_{eligibility}_{claim}" + people[identifier] = _record(identifier, eligibility, claim, source) + simulation = _simulation( + _situation(people), + reform={ + "gov.contrib.ssa.pia.in_effect": {"2036-01-01": True}, + "gov.contrib.ssa.pia.factors.start_year": {"2036-01-01": 2036}, + "gov.contrib.ssa.pia.factors.first": { + "2036-01-01": 0.95, + "2050-01-01": 0.85, + }, + # An absolute zero baseline plus .10 gives a known 10% COLA, + # independent of the model's projected CPI-W observations. + "gov.contrib.ssa.benefit_levers.cola.alternative_rate": { + "2036-01-01": 0, + }, + "gov.contrib.ssa.benefit_levers.cola.pp_change": { + "2036-01-01": 0.10, + }, + "gov.contrib.ssa.benefit_levers.cola.start_year": { + "2036-01-01": 2036, + }, + "gov.contrib.ssa.benefit_levers.across_the_board.rate": { + "2036-01-01": 0.10, + }, + "gov.contrib.ssa.benefit_levers.across_the_board.start_year": { + "2036-01-01": 2036, + }, + "gov.contrib.ssa.benefit_levers.payable.share": { + "2036-01-01": 0.80, + }, + }, + ) + for case_index, (eligibility, claim) in enumerate(cases): + indices = [case_index * 2, case_index * 2 + 1] + first_factor = Decimal(".85") if eligibility >= 2050 else Decimal(".95") + initial = Decimal(int(Decimal(285) * first_factor * 10)) / 10 + np.testing.assert_allclose( + simulation.calculate("ss_calculated_eligibility_pia", f"{claim}-01")[ + indices + ], + [float(initial)] * 2, + rtol=0, + atol=0.0001, + ) + updated = initial + for _ in range(claim - eligibility): + updated = Decimal(int(updated * Decimal("1.10") * 10)) / 10 + expected = int(updated * Decimal("1.10") * Decimal(".80")) + np.testing.assert_array_equal( + simulation.calculate("ss_calculated_payment", f"{claim}-02")[indices], + [expected, expected], + ) + + +def test_all_three_reforms_reconcile_hand_computed_history_in_real_engine(): + """The design fixture reaches real inputs, parameters, and payment outputs.""" + + class SyntheticHistoryParameters(Reform): + def apply(self): + def modify(parameters): + ssa = parameters.gov.ssa + ssa.nawi.update(start="1951-01-01", value=1) + ssa.social_security.wage_base.update(start="1951-01-01", value=120_000) + scale = ssa.social_security.pia.formula_factors + scale.brackets[1].threshold.update(start="2036-01-01", value=1000) + scale.brackets[2].threshold.update(start="2036-01-01", value=6000) + + draw = parameters.gov.contrib.ssa.owners_draw + draw.in_effect.update(start="2027-01-01", value=True) + draw.start_year.update(start="1983-01-01", value=2027) + draw.phase_in_share.update(start="2027-01-01", value=1) + draw.current_law_cap.update(start="1951-01-01", value=120_000) + + history = parameters.gov.contrib.ssa.history + history.mini_pia.in_effect.update(start="1983-01-01", value=True) + history.donut.oact_2025_e2_13.in_effect.update( + start="1983-01-01", value=True + ) + cola = parameters.gov.contrib.ssa.benefit_levers.cola + cola.alternative_rate.update(start="1983-01-01", value=0) + cola.start_year.update(start="1983-01-01", value=1983) + return parameters + + self.modify_parameters(modify) + + def observed(year, wages=0, gross_draw=0): + return { + "year": year, + "covered_wages": str(wages), + "covered_self_employment": "0", + "owners_draw": { + "s_corp_income": str(gross_draw), + "partnership_income": "0", + "passive_income": "0", + "already_se_income": "0", + "subsets_are_disjoint": True, + }, + } + + record = _record("all-three", eligibility=2036, claim=2039) + # Compute the exact age-65 reduction instead of supplying a factor. + record.pop("ss_calculator_claiming_factor") + record["ss_calculator_donut_variant"] = "oact_2025_e2_13" + record["ss_calculator_earnings_history"] = json.dumps( + { + "first_year": 1999, + "certified_zero_before_first_year": True, + "certified_zero_years": list(range(1999, 2004)), + "records": [observed(year, wages=60_000) for year in range(2004, 2037)] + + [observed(2037, gross_draw=60_000), observed(2038, wages=500_800)], + } + ) + simulation = _simulation( + _situation({"worker": record}), reform=SyntheticHistoryParameters + ) + np.testing.assert_allclose( + simulation.calculate("ss_calculated_eligibility_pia", "2039-01"), + [2088.5], + rtol=0, + atol=0.0001, + ) + np.testing.assert_array_equal( + simulation.calculate("ss_calculated_payment", "2039-02"), [1810] + ) + np.testing.assert_array_equal( + simulation.calculate("ss_calculated_annual_entitlement", 2039), [21_720] + ) + np.testing.assert_array_equal( + simulation.calculate("ss_calculated_annual_payment", 2039), [19_910] + ) + assert simulation.calculate("ss_calculated_history_aime", 2039)[0] == 5131 + assert simulation.calculate("ss_calculated_history_aime_plus", 2039)[0] == 240 + assert simulation.calculate("ss_calculated_history_conventional_pia", 2039)[ + 0 + ] == pytest.approx(2221.9) + assert simulation.calculate("ss_calculated_history_mini_pia", 2039)[ + 0 + ] == pytest.approx(Decimal("77097.6") / 37) + ledger = json.loads(simulation.calculate("ss_calculated_history_ledger", 2039)[0]) + assert ledger["eligibility_pia"] == "2088.5" + assert ledger["mini_pia_exact"] == "385488/185" + assert ledger["donut_credit_pia_exact"] == "24/5" + rows = {row["year"]: row for row in ledger["rows"]} + assert rows[1999]["certified_zero"] is True + assert Decimal(rows[2037]["e1_credited"]) == 55410 + assert Decimal(rows[2037]["mini_pia"]) == Decimal("2057.6") + assert Decimal(rows[2038]["donut_taxed"]) == 100800 + assert sum(row["ordinary_top35"] for row in rows.values()) == 35 diff --git a/policyengine_us/tests/unit/test_ss_history_report.py b/policyengine_us/tests/unit/test_ss_history_report.py new file mode 100644 index 00000000000..2566a038b09 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_history_report.py @@ -0,0 +1,135 @@ +"""Exact report serialization and visible highest-year selections.""" + +from dataclasses import dataclass +from decimal import Decimal +from fractions import Fraction +import json +from types import SimpleNamespace + +import pytest + +from policyengine_us.tools.social_security.history_report import history_report + + +@dataclass(frozen=True) +class ReportRow: + year: int + covered_wages: Decimal = Decimal("420.00") + covered_self_employment: Decimal = Decimal(0) + ordinary_cap: Decimal = Decimal(120000) + ordinary_covered: Decimal = Decimal(420) + indexing_factor: Decimal = Decimal(1) + indexed_ordinary: Decimal = Decimal(420) + e1_credited: Decimal = Decimal(0) + donut_taxed: Fraction = Fraction(1, 3) + donut_credited: Fraction = Fraction(1, 3) + indexed_donut: Fraction = Fraction(1, 3) + donut_tax: Fraction = Fraction(1, 37) + source_note: str = "supplied" + + +def _annual(rows, *, details=True): + history = SimpleNamespace( + eligibility_year=2036, + claim_year=2039, + ledger=tuple(rows), + aime=35, + aime_plus=1, + mini_share=Fraction(1, 5), + donut_credit_pia=Fraction(1, 7), + ) + pia = ( + SimpleNamespace( + conventional_pia=Decimal("100.00"), + mini_pia=Fraction(40, 111), + annual_mini_pias=(Fraction(1, 3),) * len(rows), + blended_pia=Fraction(50, 3), + eligibility_pia=Decimal("16.6"), + ) + if details + else None + ) + return SimpleNamespace(history=history, history_pia=pia) + + +def test_ledger_json_preserves_money_exact_fractions_and_deterministic_ties(): + # Reverse ledger order so ties must follow the earnings year, not position. + annual = _annual([ReportRow(year) for year in range(2000, 1959, -1)]) + report = json.loads(history_report(annual, "history_ledger")) + assert report["schema_version"] == 1 + assert report["eligibility_year"] == 2036 + assert report["claim_year"] == 2039 + assert report["aime"] == 35 + assert report["aime_plus"] == 1 + assert report["mini_share"] == "0.2" + assert report["mini_share_exact"] == "1/5" + assert report["donut_credit_pia_exact"] == "1/7" + assert report["conventional_pia"] == "100.00" + assert report["mini_pia_exact"] == "40/111" + assert report["blended_pia_exact"] == "50/3" + assert report["eligibility_pia"] == "16.6" + + rows = report["rows"] + assert len(rows) == 41 + assert [row["year"] for row in rows] == list(range(2000, 1959, -1)) + first = rows[0] + assert first["covered_wages"] == "420.00" + assert first["ordinary_cap"] == "120000" + assert first["indexed_donut"] == "0." + "3" * 50 + assert first["indexed_donut_exact"] == "1/3" + assert first["mini_pia"] == "0." + "3" * 50 + assert first["mini_pia_exact"] == "1/3" + assert first["donut_tax_exact"] == "1/37" + assert first["source_note"] == "supplied" + assert {row["year"] for row in rows if row["ordinary_top35"]} == set( + range(1960, 1995) + ) + assert {row["year"] for row in rows if row["donut_top35"]} == set(range(1960, 1995)) + assert {row["year"] for row in rows if row["mini_top40"]} == set(range(1960, 2000)) + + +@pytest.mark.parametrize( + "output,expected", + [ + ("history_aime", 35), + ("history_aime_plus", 1), + ("history_mini_pia", Fraction(40, 111)), + ("history_conventional_pia", Decimal("100.00")), + ], +) +def test_numeric_report_diagnostics_retain_typed_values(output, expected): + assert history_report(_annual([ReportRow(2038)]), output) == expected + + +def test_before_eligibility_report_keeps_earnings_without_inventing_mini_pias(): + annual = _annual([ReportRow(2037), ReportRow(2038)], details=False) + assert history_report(annual, "history_aime") == 35 + assert history_report(annual, "history_mini_pia") == 0 + assert history_report(annual, "history_conventional_pia") == 0 + report = json.loads(history_report(annual, "history_ledger")) + assert len(report["rows"]) == 2 + assert report["eligibility_pia"] is None + assert all(row["mini_pia"] is None for row in report["rows"]) + assert all(row["mini_pia_exact"] is None for row in report["rows"]) + assert all(not row["mini_top40"] for row in report["rows"]) + + +def test_other_sources_return_blank_ledger_and_zero_diagnostics(): + annual = SimpleNamespace(history=None) + assert history_report(annual, "history_ledger") == "" + for output in ( + "history_aime", + "history_aime_plus", + "history_mini_pia", + "history_conventional_pia", + ): + assert history_report(annual, output) == 0 + + +def test_inconsistent_annual_mini_ledger_and_unknown_outputs_are_rejected(): + annual = _annual([ReportRow(2038)]) + annual.history_pia.annual_mini_pias = () + with pytest.raises(ValueError, match="must match"): + history_report(annual, "history_ledger") + with pytest.raises(ValueError, match="Unknown history report"): + history_report(annual, "unregistered") diff --git a/policyengine_us/tests/unit/test_ss_history_ssa_examples.py b/policyengine_us/tests/unit/test_ss_history_ssa_examples.py index ed3abf9732b..cdabad4e690 100644 --- a/policyengine_us/tests/unit/test_ss_history_ssa_examples.py +++ b/policyengine_us/tests/unit/test_ss_history_ssa_examples.py @@ -13,10 +13,12 @@ https://www.ssa.gov/OACT/COLA/AWI.html """ +from datetime import date from decimal import Decimal import pytest +from policyengine_us.tools.social_security.cola import ColaEvent from policyengine_us.tools.social_security.history import ( EarningsHistory, EarningsRecord, @@ -24,6 +26,10 @@ compute_history, compute_history_pia, ) +from policyengine_us.tools.social_security.household import ( + SuppliedRecord, + calculate_year, +) # year, case-A nominal wages, case-B nominal wages (= annual ordinary cap), @@ -113,3 +119,36 @@ def test_ssa_published_full_earnings_history( # Case B includes five years after its eligibility year; these must remain # available through Q-1 rather than stopping the history at age 62. assert calculation.ledger[-1].year == 2026 + if case == "B": + # The same retirebenefit2 example publishes these December-effective + # COLAs, its resulting PIA $4,187.40, and the FRA payment $4,187. + cola_events = tuple( + ColaEvent(f"{year}-12", f"{year + 1}-01", Decimal(rate)) + for year, rate in zip( + range(2022, 2027), (".087", ".032", ".025", ".028", ".036"), strict=True + ) + ) + record = SuppliedRecord( + source="history", + worker_id="ssa-case-b", + birth_date=date(1960, 1, 2), + entitlement_month="2027-01", + insured=True, + eligibility_year=2022, + history=history, + history_parameters=frozen_parameters, + no_special_monthly_test=True, + no_prior_withholding_adjustment=True, + earnings_test_no_trigger=True, + ) + benefit = calculate_year( + record, 2027, bend_points=bends, cola_events=cola_events + ) + january = next( + row for row in benefit.ledger if row.entitlement_month == "2027-01" + ) + assert january.eligibility_pia == Decimal("3419.6") + assert january.cola_pia == Decimal("4187.4") + assert january.payable_benefit == 4187 + assert benefit.entitlement_total == 4187 * 12 + assert benefit.payment_total == 4187 * 11 diff --git a/policyengine_us/tools/social_security/engine.py b/policyengine_us/tools/social_security/engine.py index 60fff865208..663ed1775bb 100644 --- a/policyengine_us/tools/social_security/engine.py +++ b/policyengine_us/tools/social_security/engine.py @@ -18,6 +18,7 @@ from .parameters import bend_points_at, formula_path_at from .pia import FormulaPath from .history import EarningsHistory, history_parameters_at +from .history_report import history_report def calculator_output(person, period, parameters, output): @@ -30,9 +31,13 @@ def calculator_output(person, period, parameters, output): """ year = period.start.year sources = person("ss_calculator_source", period) - result = np.zeros(len(sources)) + result = ( + np.full(len(sources), "", dtype=object) + if output == "history_ledger" + else np.zeros(len(sources)) + ) for index, source in enumerate(sources): - if source == "off": + if source == "off" or (output.startswith("history_") and source != "history"): continue def value(name, when=period): @@ -273,7 +278,10 @@ def money(name, when=period): ), earnings_test_settlement_months=settlement_months, ) - if output in ("retirement", "survivors"): + if output.startswith("history_"): + report = history_report(annual, output) + result[index] = report if output == "history_ledger" else float(report) + elif output in ("retirement", "survivors"): selected = ( source != "survivor" if output == "retirement" else source == "survivor" ) diff --git a/policyengine_us/tools/social_security/history.py b/policyengine_us/tools/social_security/history.py index cfc1fa1cda6..01a1c8bcddb 100644 --- a/policyengine_us/tools/social_security/history.py +++ b/policyengine_us/tools/social_security/history.py @@ -132,6 +132,18 @@ class AnnualEarnings: donut_credited: Decimal = Decimal(0) indexed_donut: Decimal = Decimal(0) donut_tax: Decimal = Decimal(0) + certified_zero: bool = False + ordinary_without_e1: Decimal = Decimal(0) + e1_ordinary_increment: Decimal = Decimal(0) + e1_active_income: Decimal = Decimal(0) + e1_phase_in_share: Decimal = Decimal(0) + e1_net_earnings_factor: Decimal = Decimal(0) + e1_planned_gross: Decimal = Decimal(0) + e1_oasdi_room: Decimal = Decimal(0) + e1_oasdi_tax: Decimal = Decimal(0) + e1_hi_tax: Decimal = Decimal(0) + donut_variant: str = "" + donut_tier_start: Decimal = Decimal(0) @dataclass(frozen=True) @@ -145,6 +157,15 @@ class HistoryCalculation: donut_credit_pia: Fraction = Fraction(0) +@dataclass(frozen=True) +class HistoryPIACalculation: + conventional_pia: Decimal + mini_pia: Fraction + annual_mini_pias: tuple[Fraction, ...] + blended_pia: Fraction + eligibility_pia: Decimal + + def _parameter(path, year, name, *, positive=False): if year not in path: raise ValueError(f"Missing {name} for year {year}") @@ -314,6 +335,33 @@ def compute_history( donut_tax=Decimal(donut.tax.numerator) / donut.tax.denominator if donut else Decimal(0), + certified_zero=row.year in history.certified_zero_years, + ordinary_without_e1=min(wages + se, cap), + e1_ordinary_increment=covered - min(wages + se, cap), + e1_active_income=Decimal(str(float(draw.active_income))) + if draw + else Decimal(0), + e1_phase_in_share=Decimal(str(float(draw.phase_in_share))) + if draw + else Decimal(0), + e1_net_earnings_factor=Decimal(str(float(draw.net_earnings_factor))) + if draw + else Decimal(0), + e1_planned_gross=Decimal(str(float(draw.planned_gross))) + if draw + else Decimal(0), + e1_oasdi_room=Decimal(str(float(draw.oasdi_room))) + if draw + else Decimal(0), + e1_oasdi_tax=Decimal(str(float(draw.oasdi_tax))) + if draw + else Decimal(0), + e1_hi_tax=Decimal(str(float(draw.hi_tax))) if draw else Decimal(0), + donut_variant=donut_parameters.name if donut_parameters else "", + donut_tier_start=Decimal(donut.threshold.numerator) + / donut.threshold.denominator + if donut + else Decimal(0), ) ) if parameters.donut_credit_rate is None: @@ -338,7 +386,7 @@ def compute_history( ) -def compute_history_pia( +def history_pia_details( calculation, *, bend_points, @@ -364,7 +412,31 @@ def compute_history_pia( ) blended = blend_mini_pia(conventional, minis.mini_pia, calculation.mini_share) combined = blended + calculation.donut_credit_pia - return Decimal((combined * 10).__floor__()) / 10 + return HistoryPIACalculation( + conventional, + minis.mini_pia, + minis.annual_mini_pias, + blended, + Decimal((combined * 10).__floor__()) / 10, + ) + + +def compute_history_pia( + calculation, + *, + bend_points, + formula_path=None, + payment_year=None, + recompute_mode="cohort", +): + """Combined eligibility PIA, before COLAs and claiming adjustments.""" + return history_pia_details( + calculation, + bend_points=bend_points, + formula_path=formula_path, + payment_year=payment_year, + recompute_mode=recompute_mode, + ).eligibility_pia def history_parameters_at( diff --git a/policyengine_us/tools/social_security/history_report.py b/policyengine_us/tools/social_security/history_report.py new file mode 100644 index 00000000000..813b8836a1c --- /dev/null +++ b/policyengine_us/tools/social_security/history_report.py @@ -0,0 +1,143 @@ +"""Reconcile supplied annual earnings with the selected eligibility PIA. + +Money is serialized as strings. Recurring rational mini-PIAs include an exact +fraction alongside their 50-digit decimal representation. Selection flags refer +only to supplied or certified annual rows; zero padding invents no ledger rows. +""" + +from dataclasses import asdict +from decimal import Decimal, localcontext +from fractions import Fraction +import json + + +OUTPUTS = { + "history_ledger", + "history_aime", + "history_aime_plus", + "history_mini_pia", + "history_conventional_pia", +} + + +def _amount(value): + if value is None: + return None + if isinstance(value, Fraction): + with localcontext() as context: + context.prec = 50 + return str(Decimal(value.numerator) / Decimal(value.denominator)) + return str(value) + + +def _json_value(value): + if isinstance(value, (Decimal, Fraction)): + return _amount(value) + if isinstance(value, dict): + result = {} + for key, item in value.items(): + result[key] = _json_value(item) + if isinstance(item, Fraction): + result[f"{key}_exact"] = str(item) + return result + if isinstance(value, (tuple, list)): + return [_json_value(item) for item in value] + return value + + +def _selected(rows, amounts, count): + """Break equal-amount ties by earnings year, then original row position.""" + return set( + sorted( + range(len(rows)), + key=lambda index: (-Fraction(amounts[index]), rows[index].year, index), + )[:count] + ) + + +def history_report(annual, output): + """Return an annual history diagnostic, or an empty unselected result. + + ``annual.history_pia`` is the formula diagnostic for January entitlement in + the requested year. Before eligibility it may be absent while the initial + claim-year earnings calculation remains available for reconciliation. + """ + if output not in OUTPUTS: + raise ValueError(f"Unknown history report output {output}") + history = getattr(annual, "history", None) + if history is None: + return "" if output == "history_ledger" else 0 + details = getattr(annual, "history_pia", None) + if output == "history_aime": + return history.aime + if output == "history_aime_plus": + return getattr(history, "aime_plus", 0) + if output == "history_mini_pia": + return details.mini_pia if details is not None else 0 + if output == "history_conventional_pia": + return details.conventional_pia if details is not None else 0 + + rows = tuple(history.ledger) + ordinary_selected = _selected(rows, [row.indexed_ordinary for row in rows], 35) + donut_selected = _selected( + rows, [getattr(row, "indexed_donut", 0) for row in rows], 35 + ) + mini_amounts = tuple(details.annual_mini_pias) if details is not None else None + if mini_amounts is not None and len(mini_amounts) != len(rows): + raise ValueError("Annual mini-PIAs must match the annual earnings ledger") + mini_selected = ( + _selected(rows, mini_amounts, 40) if mini_amounts is not None else set() + ) + + serialized_rows = [] + for index, row in enumerate(rows): + values = asdict(row) + serialized = _json_value(values) + # Monetary fields accept integer fixtures as well as Decimal values; + # the annual year remains an integer in the public JSON schema. + for name, value in values.items(): + if ( + name != "year" + and isinstance(value, (int, float)) + and not isinstance(value, bool) + ): + serialized[name] = _amount(value) + mini = mini_amounts[index] if mini_amounts is not None else None + serialized.update( + mini_pia=_amount(mini), + mini_pia_exact=str(mini) if mini is not None else None, + ordinary_top35=index in ordinary_selected, + donut_top35=index in donut_selected, + mini_top40=index in mini_selected, + ) + serialized_rows.append(serialized) + + def pia_amount(name): + return getattr(details, name) if details is not None else None + + mini_share = getattr(history, "mini_share", Fraction(0)) + donut_credit = getattr(history, "donut_credit_pia", Fraction(0)) + mini_pia = pia_amount("mini_pia") + blended_pia = pia_amount("blended_pia") + return json.dumps( + { + "schema_version": 1, + "eligibility_year": history.eligibility_year, + "claim_year": history.claim_year, + "aime": history.aime, + "aime_plus": getattr(history, "aime_plus", 0), + "mini_share": _amount(mini_share), + "mini_share_exact": str(mini_share), + "donut_credit_pia": _amount(donut_credit), + "donut_credit_pia_exact": str(donut_credit), + "conventional_pia": _amount(pia_amount("conventional_pia")), + "mini_pia": _amount(mini_pia), + "mini_pia_exact": str(mini_pia) if mini_pia is not None else None, + "blended_pia": _amount(blended_pia), + "blended_pia_exact": str(blended_pia) if blended_pia is not None else None, + "eligibility_pia": _amount(pia_amount("eligibility_pia")), + "rows": serialized_rows, + }, + separators=(",", ":"), + allow_nan=False, + ) diff --git a/policyengine_us/tools/social_security/household.py b/policyengine_us/tools/social_security/household.py index 206b564bad2..23e4c8dee14 100644 --- a/policyengine_us/tools/social_security/household.py +++ b/policyengine_us/tools/social_security/household.py @@ -17,8 +17,9 @@ EarningsHistory, HistoryCalculation, HistoryParameters, + HistoryPIACalculation, compute_history, - compute_history_pia, + history_pia_details, ) from policyengine_us.tools.social_security.cola import ( @@ -335,6 +336,7 @@ class AnnualBenefit: inferred_aime_interval: tuple[Decimal, Decimal] | None = None settlements: tuple[EarningsTestSettlement, ...] = () history: HistoryCalculation | None = None + history_pia: HistoryPIACalculation | None = None @property def deferred_earnings_test_payment(self): @@ -548,6 +550,7 @@ def calculate_year( claim = month_number(record.entitlement_month) rows = [] inferred_interval = None + history_pia = None for number in range(year * 12 - int(_include_previous_check), (year + 1) * 12): entitlement = month_string(number) payment = month_string(number + 1) @@ -575,13 +578,19 @@ def calculate_year( # Only infer AIME if the selected path actually changes the baseline. pia = baseline_pia if history is not None and payment_year >= benchmark: - pia = compute_history_pia( + details = history_pia_details( history, bend_points=bend_points, formula_path=path, payment_year=payment_year, recompute_mode=mode, ) + pia = details.eligibility_pia + if number == year * 12: + # An annual reconciliation prices the frozen career with the + # formula for January entitlement. December can reflect the + # following check year's distinct policy, as in 4a-1. + history_pia = details elif payment_year >= benchmark and has_active_reform( path, benchmark, payment_year, mode ): @@ -803,4 +812,5 @@ def calculate_year( inferred_interval, tuple(settlements), history, + history_pia, ) diff --git a/policyengine_us/variables/gov/ssa/social_security/calculator/results.py b/policyengine_us/variables/gov/ssa/social_security/calculator/results.py index c87878af211..1be8463b396 100644 --- a/policyengine_us/variables/gov/ssa/social_security/calculator/results.py +++ b/policyengine_us/variables/gov/ssa/social_security/calculator/results.py @@ -208,3 +208,80 @@ class ss_calculated_aime_upper(Variable): def formula(person, period, parameters): return calculator_output(person, period, parameters, "aime_upper") + + +class ss_calculated_history_ledger(Variable): + value_type = str + entity = Person + definition_period = YEAR + default_value = "" + label = "Calculated Social Security annual earnings ledger" + documentation = ( + "JSON annual earnings ledger with ordinary, owners' draw and above-cap " + "credit amounts, indexing, mini-PIAs and selected computation years. " + "Money uses decimal strings; rational mini-PIAs also retain exact fractions." + ) + + def formula(person, period, parameters): + return calculator_output(person, period, parameters, "history_ledger") + + +class ss_calculated_history_aime(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Calculated Social Security history AIME" + documentation = ( + "Conventional monthly AIME from the annual earnings record frozen at " + "initial claim; zero when the history source is unselected." + ) + + def formula(person, period, parameters): + return calculator_output(person, period, parameters, "history_aime") + + +class ss_calculated_history_aime_plus(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Calculated Social Security history AIME plus" + documentation = ( + "Separate whole-dollar monthly AIME+ from eligible above-cap earnings " + "in the initial-claim history; zero when the history source is unselected." + ) + + def formula(person, period, parameters): + return calculator_output(person, period, parameters, "history_aime_plus") + + +class ss_calculated_history_mini_pia(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Calculated Social Security history mini PIA" + documentation = ( + "Unrounded monthly mini-PIA before the cohort blend, using the January " + "entitlement formula; zero before eligibility or without a history source." + ) + + def formula(person, period, parameters): + return calculator_output(person, period, parameters, "history_mini_pia") + + +class ss_calculated_history_conventional_pia(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Calculated Social Security history conventional PIA" + documentation = ( + "Dime-rounded monthly conventional PIA before mini-PIA blending and " + "AIME+ credit, using the January entitlement formula; zero before " + "eligibility or without a history source." + ) + + def formula(person, period, parameters): + return calculator_output(person, period, parameters, "history_conventional_pia") From 7d3f1486bb70bad89825b72838fa72fa8b93ca3c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 10:19:58 -0400 Subject: [PATCH 06/14] Verify exact reform composition and document annual history use Co-Authored-By: Claude Opus 5.5 --- docs/contributed_reforms/owners_draw.md | 11 ++++-- .../social_security_history.md | 4 +- docs/usage/social-security-calculator.md | 37 ++++++++++++------- .../tests/unit/test_ss_history_engine.py | 2 +- .../tests/unit/test_ss_history_reforms.py | 25 ++++++++++++- .../tests/unit/test_ss_mini_pia.py | 3 +- 6 files changed, 59 insertions(+), 23 deletions(-) diff --git a/docs/contributed_reforms/owners_draw.md b/docs/contributed_reforms/owners_draw.md index 4118ea5d0f4..adcbdcf56a9 100644 --- a/docs/contributed_reforms/owners_draw.md +++ b/docs/contributed_reforms/owners_draw.md @@ -112,10 +112,13 @@ annual earnings to the existing `ss_aime` earnings-history helper and adds them before applying each year's wage-base cap. A supplied positive `ss_aime_input` retains its baseline precedence. This reform does not append an amount to final AIME or alter reported benefits. This bridge inherits -main's AIME lookback and age rules and its separate -`gov.ssa.social_security.wage_base` path, last authored for 2026; it does not -synchronize that path with the current tax cap. Mini-PIA integration and a -complete annual-history ledger remain separate future work. +main's AIME lookback, age rules and separate +`gov.ssa.social_security.wage_base` path. The processed current-law wage-indexed +paths now extend through 2100; tax-cap and benefit-cap reforms remain separate +policy choices. The opt-in [annual-history calculator](social_security_history.md) +uses the same per-year owners' draw kernel, retains earnings through the year +before claiming, and carries the adjusted draw into both conventional AIME +and annual mini-PIAs. Its ledger also coordinates separate above-cap credit. `owners_draw_niit_exclusion` is the matching gross-profit exclusion for a S.770 active-business NIIT option. With OASDI only it is diff --git a/docs/contributed_reforms/social_security_history.md b/docs/contributed_reforms/social_security_history.md index 53761324113..f6a749da499 100644 --- a/docs/contributed_reforms/social_security_history.md +++ b/docs/contributed_reforms/social_security_history.md @@ -198,8 +198,8 @@ population payroll-tax variables. For a custom changing path, annual tax/coverage settings determine the extra earnings stream, and the PIA credit coefficient is read at eligibility E. This is a cohort-selection convention; the archived variants' coefficient is -constant at 2%. Pure callers supplying mixed annual coefficients must supply -the eligibility coefficient explicitly. +constant at 2%. Pure callers whose credited annual streams use different +coefficients must supply the eligibility coefficient explicitly. ## Annual earnings ledger diff --git a/docs/usage/social-security-calculator.md b/docs/usage/social-security-calculator.md index adca67d9941..b28fa71b8a5 100644 --- a/docs/usage/social-security-calculator.md +++ b/docs/usage/social-security-calculator.md @@ -1,13 +1,13 @@ # Supplied-record Social Security calculator -The opt-in calculator implements design v3 D0, 4a-1 and the shared 4d core, +The opt-in calculator implements design v3 D0, 4a-1, 4a-2 and the shared 4d core, including amendments M1–M4, the top-factor structure in M6 and M8. It returns **gross benefits before Medicare deductions**, from an identified worker record. Existing public benefit inputs continue to work independently. ## Inputs and outputs -Set `ss_calculator_source` to `aime`, `pia` or `survivor`; its default is `off`. +Set `ss_calculator_source` to `aime`, `pia`, `history` or `survivor`; its default is `off`. The selector determines the route even when the selected amount is zero. Omitting the selected amount fails; it is distinct from supplying zero. Identity/source fields are eternal inputs. Supply exact recipient birth date, @@ -19,7 +19,11 @@ computations are outside this route. For own retirement, also supply `ss_calculator_eligibility_year` and either `ss_calculator_supplied_aime` (whole monthly dollars) or -`ss_calculator_supplied_pia` (monthly dimes at initial eligibility). Optional +`ss_calculator_supplied_pia` (monthly dimes at initial eligibility). The +`history` mode instead requires a complete `ss_calculator_earnings_history` +JSON source with observed or certified-zero years through the year before +claiming. See [annual histories, owners' draw, mini-PIA and above-cap credit](../contributed_reforms/social_security_history.md) +for the source contract and annual reconciliation outputs. Optional `ss_calculator_claiming_factor` is an exact decimal **string**; otherwise exact claiming months, birth-cohort FRA, early reduction and delayed credits determine it. Delayed credits earned in a midyear claiming year enter the benefit the @@ -175,7 +179,7 @@ text](https://www.congress.gov/bill/119th-congress/senate-bill/770/text). ## Shared PIA paths and policies `tools.social_security.pia.compute_pia` is the common pure monthly service for -supplied AIME, recovered AIME and future history callers. Its inputs explicitly +supplied AIME, recovered AIME and annual history callers. Its inputs explicitly separate initial eligibility, formula path, scoring/payment year and cohort vs retroactive applicability. All computations and inverses require E >= 1983. Historical bends back to 1979 are separate parameters, preserving legacy scale @@ -225,18 +229,23 @@ outputs can consume this route once public wiring exists. ## Public source-selector wiring -Source-selector PR #10400 was open when this route was built. Its canonical -COLA history and common lever parameter files are reused here. Historical bend +The reported-benefit route and this household calculator reuse canonical +COLA history and common lever parameter files. Historical bend points remain in a separate table because copying its legacy scale backfill would change existing pre-2015 `ss_pia` outputs with this route disabled. This change adds -no `_reported` twins and changes no public benefit formula. Once it merges, -map its supplied-AIME/PIA retirement modes to `ss_calculated_retirement` and its -restricted survivor mode to `ss_calculated_survivors`. Bypass population -PIA/COLA factors and any across/payable factors already applied in this ledger; -perform the earnings test once. Preserve explicit public final overrides. -For joint household tax calculations before that wiring, explicitly supply the -calculated annual checks to the existing public components in a separate -calculation; never write them into `_reported` as if they were original data. +no `_reported` twins and changes no public benefit formula. The public +calculated-source modes remain reserved; `ss_calculator_source` selects this +separate opt-in service. `ss_calculated_retirement` and +`ss_calculated_survivors` return checks that already include their PIA, COLA, +claiming, payment factors and earnings test. + +For joint household tax calculations, write the calculated annual checks to +the public component with `set_input` **after** constructing the tax simulation. +That creates an explicit final output override for the payment year and avoids +applying benefit factors again. Constructor-supplied public benefits instead +migrate to the reported source and remain sensitive to those factors. Preserve +the history source separately; calculated checks are not original reported +benefit observations. ## Sources and validation diff --git a/policyengine_us/tests/unit/test_ss_history_engine.py b/policyengine_us/tests/unit/test_ss_history_engine.py index 6c479b0ec67..b03cdaa66c5 100644 --- a/policyengine_us/tests/unit/test_ss_history_engine.py +++ b/policyengine_us/tests/unit/test_ss_history_engine.py @@ -274,7 +274,7 @@ def observed(year, wages=0, gross_draw=0): ] == pytest.approx(2221.9) assert simulation.calculate("ss_calculated_history_mini_pia", 2039)[ 0 - ] == pytest.approx(Decimal("77097.6") / 37) + ] == pytest.approx(float(Decimal("77097.6") / 37)) ledger = json.loads(simulation.calculate("ss_calculated_history_ledger", 2039)[0]) assert ledger["eligibility_pia"] == "2088.5" assert ledger["mini_pia_exact"] == "385488/185" diff --git a/policyengine_us/tests/unit/test_ss_history_reforms.py b/policyengine_us/tests/unit/test_ss_history_reforms.py index 3b39c47f741..aa7f6150627 100644 --- a/policyengine_us/tests/unit/test_ss_history_reforms.py +++ b/policyengine_us/tests/unit/test_ss_history_reforms.py @@ -32,7 +32,7 @@ OwnersDrawInputs, OwnersDrawParameters, ) -from policyengine_us.tools.social_security.pia import compute_pia +from policyengine_us.tools.social_security.pia import FormulaPath, compute_pia D = Decimal @@ -332,6 +332,29 @@ def test_donut_pia_factor_uses_the_supplied_eligibility_cohort_coefficient(): assert compute_history_pia(calculation, bend_points=BENDS) == D("2187.2") +def test_mini_precision_is_retained_until_after_fractional_donut_credit(): + # A linear sensitivity gives mini_PIA=164319.96/444=370.09 exactly. + # AIME+=420/420=1 adds .02; truncating the mini early would lose a dime. + history = EarningsHistory( + first_year=2037, + records=(EarningsRecord(2037, "44319.96", 0), EarningsRecord(2038, 400420, 0)), + certified_zero_before_first_year=True, + ) + parameters = HistoryParameters( + wage_base={2037: 120000, 2038: 120000}, + nawi={2034: 1, 2037: 1, 2038: 1}, + mini_share=1, + donut_by_year={2037: DONUT, 2038: DONUT}, + ) + calculation = compute_history(history, 2036, 2039, parameters) + assert calculation.aime_plus == 1 + assert compute_history_pia( + calculation, + bend_points=BENDS, + formula_path=FormulaPath(factor_values=(1, 1, 1)), + ) == D("370.1") + + def test_mixed_annual_donut_rates_require_an_explicit_cohort_coefficient(): history = EarningsHistory( first_year=2037, diff --git a/policyengine_us/tests/unit/test_ss_mini_pia.py b/policyengine_us/tests/unit/test_ss_mini_pia.py index e9f09d105b3..458e266b854 100644 --- a/policyengine_us/tests/unit/test_ss_mini_pia.py +++ b/policyengine_us/tests/unit/test_ss_mini_pia.py @@ -8,7 +8,7 @@ from fractions import Fraction from pathlib import Path -from hypothesis import given, strategies as st +from hypothesis import HealthCheck, given, settings, strategies as st from policyengine_core.parameters import ParameterNode import pytest @@ -98,6 +98,7 @@ def test_invalid_blend_share_rejected(share): blend_mini_pia(2180, 2028, share) +@settings(deadline=None, suppress_health_check=[HealthCheck.too_slow]) @given( conventional=st.integers(min_value=0, max_value=100000), annual=st.lists(st.integers(min_value=0, max_value=1000000), max_size=50), From 4bcaccd9ba537995f438f2af8810563bb1958fb1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 11:40:14 -0400 Subject: [PATCH 07/14] Support early covered earnings in annual donut policy lookups Co-Authored-By: Claude Opus 5.5 --- .../social_security_history.md | 2 + .../history/donut/custom/credit_enabled.yaml | 2 +- .../ssa/history/donut/custom/credit_rate.yaml | 2 +- .../ssa/history/donut/custom/in_effect.yaml | 2 +- .../donut/custom/include_owners_draw.yaml | 2 +- .../donut/custom/include_self_employment.yaml | 2 +- .../ssa/history/donut/custom/start_year.yaml | 2 +- .../ssa/history/donut/custom/tax_rate.yaml | 2 +- .../ssa/history/donut/custom/threshold.yaml | 2 +- .../donut/oact_2025_e2_13/credit_enabled.yaml | 2 +- .../donut/oact_2025_e2_13/credit_rate.yaml | 2 +- .../donut/oact_2025_e2_13/in_effect.yaml | 2 +- .../oact_2025_e2_13/include_owners_draw.yaml | 2 +- .../include_self_employment.yaml | 2 +- .../donut/oact_2025_e2_13/start_year.yaml | 2 +- .../donut/oact_2025_e2_13/tax_rate.yaml | 2 +- .../donut/oact_2025_e2_13/threshold.yaml | 2 +- .../donut/oact_2025_e2_14/credit_enabled.yaml | 2 +- .../donut/oact_2025_e2_14/credit_rate.yaml | 2 +- .../donut/oact_2025_e2_14/in_effect.yaml | 2 +- .../oact_2025_e2_14/include_owners_draw.yaml | 2 +- .../include_self_employment.yaml | 2 +- .../donut/oact_2025_e2_14/start_year.yaml | 2 +- .../donut/oact_2025_e2_14/tax_rate.yaml | 2 +- .../donut/oact_2025_e2_14/threshold.yaml | 2 +- policyengine_us/tests/unit/test_ss_donut.py | 15 +++++++ .../tests/unit/test_ss_history_engine.py | 43 ++++++++++++++++++- 27 files changed, 83 insertions(+), 25 deletions(-) diff --git a/docs/contributed_reforms/social_security_history.md b/docs/contributed_reforms/social_security_history.md index f6a749da499..2abd74e3c9d 100644 --- a/docs/contributed_reforms/social_security_history.md +++ b/docs/contributed_reforms/social_security_history.md @@ -71,6 +71,8 @@ The calculator also requires the existing own-record fields: worker ID, exact birth date, age-62 eligibility year, first entitlement month, insured-status assertion, and assertions about unsupported special earnings tests and prior withholding adjustments. The supported eligibility benchmark begins in 1983. +Earnings can precede that benchmark, beginning in 1951 or the worker's birth +year, whichever is later; annual inactive-policy defaults cover those years. ## Conventional AIME and the shared benefit pipeline diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_enabled.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_enabled.yaml index 2923ea195a4..6efada96f30 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_enabled.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_enabled.yaml @@ -1,6 +1,6 @@ description: Whether this variant's newly taxed above-threshold earnings receive separate AIME+ benefit credit. values: - 1983-01-01: false + 0000-01-01: false metadata: unit: bool label: Custom annual-history donut benefit credit diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_rate.yaml index 0e35fcf6012..824981add58 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_rate.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_rate.yaml @@ -1,6 +1,6 @@ description: The monthly PIA factor applied to this variant's separately computed AIME+. values: - 1983-01-01: 0 + 0000-01-01: 0 metadata: unit: /1 label: Custom annual-history donut AIME+ factor diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/in_effect.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/in_effect.yaml index 77a0e1440cc..09f1a0a86ef 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/in_effect.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/in_effect.yaml @@ -1,6 +1,6 @@ description: Whether this independently named annual-history donut variant is enabled. values: - 1983-01-01: false + 0000-01-01: false metadata: unit: bool label: Custom annual-history donut activation diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_owners_draw.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_owners_draw.yaml index 5b506a6ccb5..a9d020a0152 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_owners_draw.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_owners_draw.yaml @@ -1,6 +1,6 @@ description: Whether this variant includes reclassified net owners' draw consistently for above-threshold tax and credit. values: - 1983-01-01: true + 0000-01-01: true metadata: unit: bool label: Custom annual-history donut owners' draw inclusion diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_self_employment.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_self_employment.yaml index 5894f5a277c..5840b69cd22 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_self_employment.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_self_employment.yaml @@ -1,6 +1,6 @@ description: Whether this variant includes existing covered net self-employment earnings consistently for tax and credit. values: - 1983-01-01: true + 0000-01-01: true metadata: unit: bool label: Custom annual-history donut covered self-employment inclusion diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/start_year.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/start_year.yaml index 3b045ccb12b..7c8093168a5 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/start_year.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/start_year.yaml @@ -1,6 +1,6 @@ description: The first earnings year subject to this annual-history donut variant. values: - 1983-01-01: 2027 + 0000-01-01: 2027 metadata: unit: year label: Custom annual-history donut first earnings year diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/tax_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/tax_rate.yaml index a3ca17cad7a..8477a17f51b 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/tax_rate.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/tax_rate.yaml @@ -1,6 +1,6 @@ description: The combined OASDI tax rate on this variant's annual earnings above both the ordinary cap and the threshold. values: - 1983-01-01: 0 + 0000-01-01: 0 metadata: unit: /1 label: Custom annual-history donut above-threshold OASDI rate diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/threshold.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/threshold.yaml index 219c3981250..a9c43d171fb 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/threshold.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/threshold.yaml @@ -1,6 +1,6 @@ description: The annual earnings threshold for this donut variant; dated values specify the threshold path explicitly. values: - 1983-01-01: 0 + 0000-01-01: 0 metadata: unit: currency-USD label: Custom annual-history donut earnings threshold diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_enabled.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_enabled.yaml index 0e6f5a83f29..47ed7fa07cb 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_enabled.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_enabled.yaml @@ -1,6 +1,6 @@ description: Whether this variant's newly taxed above-threshold earnings receive separate AIME+ benefit credit. values: - 1983-01-01: true + 0000-01-01: true metadata: unit: bool label: 2025 OACT E2.13 annual-history donut benefit credit diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_rate.yaml index 6a030fb881b..7ef152a27f5 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_rate.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_rate.yaml @@ -1,6 +1,6 @@ description: The monthly PIA factor applied to this variant's separately computed AIME+. values: - 1983-01-01: 0.02 + 0000-01-01: 0.02 metadata: unit: /1 label: 2025 OACT E2.13 annual-history donut AIME+ factor diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/in_effect.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/in_effect.yaml index fcfc04281e4..e2f30b1d58b 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/in_effect.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/in_effect.yaml @@ -1,6 +1,6 @@ description: Whether this independently named annual-history donut variant is enabled. values: - 1983-01-01: false + 0000-01-01: false metadata: unit: bool label: 2025 OACT E2.13 annual-history donut activation diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_owners_draw.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_owners_draw.yaml index 1e1a00f45db..e7959dcc8d9 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_owners_draw.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_owners_draw.yaml @@ -1,6 +1,6 @@ description: Whether this variant includes reclassified net owners' draw consistently for above-threshold tax and credit. values: - 1983-01-01: true + 0000-01-01: true metadata: unit: bool label: 2025 OACT E2.13 annual-history donut owners' draw inclusion diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_self_employment.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_self_employment.yaml index ceb5991e937..85a27c9295a 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_self_employment.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_self_employment.yaml @@ -1,6 +1,6 @@ description: Whether this variant includes existing covered net self-employment earnings consistently for tax and credit. values: - 1983-01-01: true + 0000-01-01: true metadata: unit: bool label: 2025 OACT E2.13 annual-history donut covered self-employment inclusion diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/start_year.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/start_year.yaml index c4976f27708..daa68843d04 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/start_year.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/start_year.yaml @@ -1,6 +1,6 @@ description: The first earnings year subject to this annual-history donut variant. values: - 1983-01-01: 2027 + 0000-01-01: 2027 metadata: unit: year label: 2025 OACT E2.13 annual-history donut first earnings year diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/tax_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/tax_rate.yaml index 53c781be02a..cbecd93d736 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/tax_rate.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/tax_rate.yaml @@ -1,6 +1,6 @@ description: The combined OASDI tax rate on this variant's annual earnings above both the ordinary cap and the threshold. values: - 1983-01-01: 0.124 + 0000-01-01: 0.124 metadata: unit: /1 label: 2025 OACT E2.13 annual-history donut above-threshold OASDI rate diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/threshold.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/threshold.yaml index 7aa69fe02de..7011ebddf5d 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/threshold.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/threshold.yaml @@ -1,6 +1,6 @@ description: The annual earnings threshold for this donut variant; dated values specify the threshold path explicitly. values: - 1983-01-01: 400000 + 0000-01-01: 400000 metadata: unit: currency-USD label: 2025 OACT E2.13 annual-history donut earnings threshold diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_enabled.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_enabled.yaml index 6d77e5e26b9..d705b03dcad 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_enabled.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_enabled.yaml @@ -1,6 +1,6 @@ description: Whether this variant's newly taxed above-threshold earnings receive separate AIME+ benefit credit. values: - 1983-01-01: true + 0000-01-01: true metadata: unit: bool label: 2025 OACT E2.14 annual-history donut benefit credit diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_rate.yaml index 5d00c4c09c0..07ab71bac2b 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_rate.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_rate.yaml @@ -1,6 +1,6 @@ description: The monthly PIA factor applied to this variant's separately computed AIME+. values: - 1983-01-01: 0.02 + 0000-01-01: 0.02 metadata: unit: /1 label: 2025 OACT E2.14 annual-history donut AIME+ factor diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/in_effect.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/in_effect.yaml index 721c428dd6e..548073c31fa 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/in_effect.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/in_effect.yaml @@ -1,6 +1,6 @@ description: Whether this independently named annual-history donut variant is enabled. values: - 1983-01-01: false + 0000-01-01: false metadata: unit: bool label: 2025 OACT E2.14 annual-history donut activation diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_owners_draw.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_owners_draw.yaml index d62ece2f611..3a3f9382ba6 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_owners_draw.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_owners_draw.yaml @@ -1,6 +1,6 @@ description: Whether this variant includes reclassified net owners' draw consistently for above-threshold tax and credit. values: - 1983-01-01: true + 0000-01-01: true metadata: unit: bool label: 2025 OACT E2.14 annual-history donut owners' draw inclusion diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_self_employment.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_self_employment.yaml index 67e97777123..bf45a2b347c 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_self_employment.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_self_employment.yaml @@ -1,6 +1,6 @@ description: Whether this variant includes existing covered net self-employment earnings consistently for tax and credit. values: - 1983-01-01: true + 0000-01-01: true metadata: unit: bool label: 2025 OACT E2.14 annual-history donut covered self-employment inclusion diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/start_year.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/start_year.yaml index 1414a0e2d26..9e1f6986235 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/start_year.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/start_year.yaml @@ -1,6 +1,6 @@ description: The first earnings year subject to this annual-history donut variant. values: - 1983-01-01: 2027 + 0000-01-01: 2027 metadata: unit: year label: 2025 OACT E2.14 annual-history donut first earnings year diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/tax_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/tax_rate.yaml index 248727eb595..3d7640f97e0 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/tax_rate.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/tax_rate.yaml @@ -1,6 +1,6 @@ description: The combined OASDI tax rate on this variant's annual earnings above both the ordinary cap and the threshold. values: - 1983-01-01: 0.124 + 0000-01-01: 0.124 metadata: unit: /1 label: 2025 OACT E2.14 annual-history donut above-threshold OASDI rate diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/threshold.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/threshold.yaml index 216b766be7d..78a263d73e9 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/threshold.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/threshold.yaml @@ -1,6 +1,6 @@ description: The annual earnings threshold for this donut variant; dated values specify the threshold path explicitly. values: - 1983-01-01: 250000 + 0000-01-01: 250000 metadata: unit: currency-USD label: 2025 OACT E2.14 annual-history donut earnings threshold diff --git a/policyengine_us/tests/unit/test_ss_donut.py b/policyengine_us/tests/unit/test_ss_donut.py index 6c628a7ee3e..57da3fce60e 100644 --- a/policyengine_us/tests/unit/test_ss_donut.py +++ b/policyengine_us/tests/unit/test_ss_donut.py @@ -41,6 +41,21 @@ def _parameters(): return root +@pytest.mark.parametrize("year", [1951, 1980]) +@pytest.mark.parametrize("variant", DONUT_VARIANTS) +def test_all_off_donut_parameters_accept_pre1983_earnings_years(year, variant): + # Eligibility benchmarks start in 1983, while covered-earnings histories + # start in 1951. Read every scalar from the real authored Core subtree: + # an eligibility-dated default would fail before annual activation checks. + parameters = _parameters() + settings = donut_parameters_at(parameters, year, variant) + assert settings.in_effect is False + result = compute_donut_year( + year, covered_wages=500800, ordinary_cap=120000, parameters=settings + ) + assert result.taxed_earnings == result.credited_earnings == result.tax == 0 + + @pytest.mark.parametrize( "wages,expected", [ diff --git a/policyengine_us/tests/unit/test_ss_history_engine.py b/policyengine_us/tests/unit/test_ss_history_engine.py index b03cdaa66c5..971f03741a1 100644 --- a/policyengine_us/tests/unit/test_ss_history_engine.py +++ b/policyengine_us/tests/unit/test_ss_history_engine.py @@ -5,7 +5,7 @@ """ import json -from decimal import Decimal +from decimal import Decimal, ROUND_HALF_UP import numpy as np import pytest @@ -285,3 +285,44 @@ def observed(year, wages=0, gross_draw=0): assert Decimal(rows[2037]["mini_pia"]) == Decimal("2057.6") assert Decimal(rows[2038]["donut_taxed"]) == 100800 assert sum(row["ordinary_top35"] for row in rows.values()) == 35 + + +def test_default_history_accepts_earnings_before_1983_with_disabled_reforms(): + """Earnings-year policy reads must support a modern worker's whole career.""" + record = _record("historical-earnings", eligibility=2026, claim=2029) + record["ss_calculator_earnings_history"] = json.dumps( + { + "first_year": 1980, + "certified_zero_before_first_year": True, + "certified_zero_years": list(range(1981, 2029)), + "records": [ + { + "year": 1980, + "covered_wages": "18000.00", + "covered_self_employment": "0.00", + } + ], + } + ) + simulation = _simulation(_situation({"worker": record})) + # SSA published NAWI: 1980 $12,513.46; E-2 = 2024 $69,846.57. + # The 1980 contribution/benefit base is $25,900, above supplied wages. + # https://www.ssa.gov/OACT/COLA/AWI.html + expected_indexed = ( + Decimal("18000.00") * Decimal("69846.57") / Decimal("12513.46") + ).quantize(Decimal(".01"), rounding=ROUND_HALF_UP) + expected_aime = int(expected_indexed // 420) + assert expected_aime == 239 + assert simulation.calculate("ss_calculated_history_aime", 2029)[0] == expected_aime + assert simulation.calculate("ss_calculated_history_aime_plus", 2029)[0] == 0 + assert simulation.calculate("ss_calculated_history_conventional_pia", 2029)[ + 0 + ] == pytest.approx(215.1) + report = json.loads(simulation.calculate("ss_calculated_history_ledger", 2029)[0]) + first = report["rows"][0] + assert first["year"] == 1980 + assert Decimal(first["ordinary_cap"]) == 25_900 + assert Decimal(first["ordinary_covered"]) == 18_000 + assert Decimal(first["indexed_ordinary"]) == expected_indexed + assert Decimal(first["donut_taxed"]) == 0 + assert len(report["rows"]) == 49 From f28674251980bb6453e804401916e8597c08a4ad Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 13:18:27 -0400 Subject: [PATCH 08/14] Release owners draw accessors after annual evaluation 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 --- .../tests/unit/test_owners_draw_kernel.py | 45 +++++++++++++++++++ .../tools/social_security/owners_draw.py | 16 ++++--- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/policyengine_us/tests/unit/test_owners_draw_kernel.py b/policyengine_us/tests/unit/test_owners_draw_kernel.py index 37bfb987bc6..14c77fd311d 100644 --- a/policyengine_us/tests/unit/test_owners_draw_kernel.py +++ b/policyengine_us/tests/unit/test_owners_draw_kernel.py @@ -6,7 +6,9 @@ """ from dataclasses import fields, replace +import gc from types import SimpleNamespace +import weakref import numpy as np import pytest @@ -39,6 +41,49 @@ ) +@pytest.mark.parametrize( + "output, override_intermediates", + [("active_income", False), ("oasdi_base", True), (None, False)], +) +def test_returned_result_does_not_retain_person_accessors_until_cyclic_gc( + output, override_intermediates +): + class AccessorOwner: + """A Person accessor also owns its simulation and policy in Core.""" + + def __init__(self): + self.annual = OwnersDrawInputs(s_corp_income=1_000) + + def annual_input(self, name): + return getattr(self.annual, name) + + def intermediate(self, name): + return {"net_earnings": 923.5, "oasdi_room": 200_000}[name] + + owner = AccessorOwner() + observed = weakref.ref(owner) + was_enabled = gc.isenabled() + gc.disable() + try: + result = compute_owners_draw( + 2027, + inputs=owner.annual_input, + parameters=PARAMETERS, + output=output, + overrides=owner.intermediate if override_intermediates else None, + ) + del owner + # No explicit collection: ordinary return must release both kinds of + # callbacks even while callers keep the complete annual result alive. + assert observed() is None + assert (result.credited_earnings if output is None else result) == ( + 1_000 if output == "active_income" else 923.5 + ) + finally: + if was_enabled: + gc.enable() + + def _old_formula(output, year, inputs, p, overrides=None): """Arithmetic of the old Variables, independent of the extracted kernel.""" values = dict(overrides or {}) diff --git a/policyengine_us/tools/social_security/owners_draw.py b/policyengine_us/tools/social_security/owners_draw.py index e43c6bd8efd..4cd7c044277 100644 --- a/policyengine_us/tools/social_security/owners_draw.py +++ b/policyengine_us/tools/social_security/owners_draw.py @@ -275,8 +275,14 @@ def evaluate(name): cache[name] = value return value - if output is not None: - return evaluate(output) - return OwnersDrawResult( - **{name: evaluate(name) for name in OwnersDrawResult.__dataclass_fields__} - ) + try: + if output is not None: + return evaluate(output) + return OwnersDrawResult( + **{name: evaluate(name) for name in OwnersDrawResult.__dataclass_fields__} + ) + finally: + # Recursive closures otherwise retain each other and their Person + # accessors until cyclic GC, keeping whole simulations/policies alive. + # All evaluation is complete; sever the shared closure cell promptly. + evaluate = None From 90a05b9713c46b136377c22294ec80fcd4a688b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 18:56:20 +0000 Subject: [PATCH 09/14] Select history AIME computation years by eligibility cohort 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 --- .../tests/unit/test_ss_earnings_history.py | 204 ++++++++++++++++++ .../tests/unit/test_ss_history_engine.py | 11 + .../tests/unit/test_ss_history_report.py | 95 ++++++++ .../unit/test_ss_history_ssa_examples.py | 3 + .../tools/social_security/donut.py | 7 +- .../tools/social_security/history.py | 56 ++++- .../tools/social_security/history_report.py | 23 +- .../tools/social_security/household.py | 4 +- .../tools/social_security/mini_pia.py | 7 +- 9 files changed, 391 insertions(+), 19 deletions(-) diff --git a/policyengine_us/tests/unit/test_ss_earnings_history.py b/policyengine_us/tests/unit/test_ss_earnings_history.py index ceec8e185e3..2844fcdc868 100644 --- a/policyengine_us/tests/unit/test_ss_earnings_history.py +++ b/policyengine_us/tests/unit/test_ss_earnings_history.py @@ -13,7 +13,11 @@ import pytest from hypothesis import given, settings, strategies as st from policyengine_core.periods import period +from pathlib import Path +from policyengine_core.parameters import load_parameter_file + +from policyengine_us.tools.social_security.donut import DonutParameters from policyengine_us.tools.social_security.history import ( EarningsHistory, EarningsRecord, @@ -21,6 +25,7 @@ aime_from_indexed, compute_history, compute_history_pia, + computation_years, ) from policyengine_us.tools.social_security.household import ( SuppliedRecord, @@ -111,6 +116,205 @@ def test_top35_padding_and_whole_dollar_truncation(): assert aime_from_indexed([]) == 0 +# --- Cohort computation years (42 U.S.C. 415(b)(2)) ------------------------- +# Elapsed years run from 1951 through the year before age-62 eligibility E, +# and the computation period is elapsed years less five (POMS RS 00605.016 +# and RS 00605.017): min(35, E - 1956) for the supported benchmarks from 1983. + +SSA_PARAMETERS = Path(__file__).parents[2] / "parameters" / "gov" / "ssa" + + +@pytest.fixture(scope="module") +def ssa_parameters(): + """Load only the branch's historical caps, NAWI and bend points. + + Reading the four YAML files avoids building the country model; the + historical values used here need no uprating. + """ + files = { + "wage_base": "social_security/wage_base.yaml", + "nawi": "nawi.yaml", + "first": "social_security/pia/bend_points/first.yaml", + "second": "social_security/pia/bend_points/second.yaml", + } + return { + name: load_parameter_file(str(SSA_PARAMETERS / path), name) + for name, path in files.items() + } + + +def _real_parameters(ssa_parameters, eligibility, years): + def value(name, year): + return Decimal(str(ssa_parameters[name](f"{year}-01-01"))) + + return HistoryParameters( + wage_base={year: value("wage_base", year) for year in years}, + nawi={year: value("nawi", year) for year in set(years) | {eligibility - 2}}, + ) + + +def _real_bends(ssa_parameters, eligibility): + return tuple( + ssa_parameters[name](f"{eligibility}-01-01") for name in ("first", "second") + ) + + +def _flat_career(wages, first=1951, last=None, zeros=()): + years = range(first, last + 1) + return EarningsHistory( + 1951, + tuple(EarningsRecord(year, wages, 0) for year in years if year not in zeros), + tuple(zeros), + ) + + +def test_computation_years_follow_the_elapsed_years_rule(): + assert computation_years(1983) == 27 + assert computation_years(1990) == 34 + assert computation_years(1991) == 35 + assert computation_years(2036) == 35 + with pytest.raises(ValueError, match="before 1983"): + computation_years(1982) + + +def test_1990_witness_selects_34_years_and_divides_by_408(ssa_parameters): + """Birth 1928-01-02; $3,000 a year 1951-1989; eligibility and claim 1990. + + NAWI(1988) = 19,334.04 indexes 1951-1987; 1988-1989 stay at face value. + The highest 34 indexed years sum to $372,486.21 (the highest 35 would sum + to $375,934.10). AIME = floor(372,486.21 / 408) = $912, not + floor(375,934.10 / 420) = $895. With 1990 bends $356 / $2,145: + .9 x 356 + .32 x (912 - 356) = 498.32, floored to $498.30. FRA is 65, so + a January 1990 claim at 62 has factor 0.8: 398.64, paid as $398. + """ + years = range(1951, 1990) + career = _flat_career(3000, last=1989) + parameters = _real_parameters(ssa_parameters, 1990, years) + result = compute_history(career, 1990, 1990, parameters, birth_year=1928) + top = sorted((row.indexed_ordinary for row in result.ledger), reverse=True) + assert result.computation_years == 34 + assert sum(top[:34]) == Decimal("372486.21") + assert sum(top[:35]) == Decimal("375934.10") + assert result.aime == 912 + bends = _real_bends(ssa_parameters, 1990) + assert bends == (356, 2145) + assert compute_history_pia(result, bend_points=bends) == Decimal("498.3") + annual = calculate_year( + SuppliedRecord( + source="history", + worker_id="witness-1990", + birth_date=date(1928, 1, 2), + entitlement_month="1990-01", + insured=True, + eligibility_year=1990, + history=career, + history_parameters=parameters, + no_special_monthly_test=True, + no_prior_withholding_adjustment=True, + earnings_test_no_trigger=True, + ), + 1990, + bend_points=bends, + ) + january = next(row for row in annual.ledger if row.entitlement_month == "1990-01") + assert january.claiming_factor == Decimal("0.8") + assert january.eligibility_pia == Decimal("498.3") + assert january.payable_benefit == 398 + + +@pytest.mark.parametrize( + "eligibility,years,top_sum,aime,pia", + [ + # 1983: NAWI(1981) 13,773.10; sum/324 = 751.29; bends 254 / 1,528: + # 228.60 + .32 x 497 = 387.64 -> $387.60. + (1983, 27, "243418.77", 751, "387.6"), + # 1990: the witness above. + (1990, 34, "372486.21", 912, "498.3"), + # 1991: NAWI(1989) 20,099.55; sum/420 = 930.52; bends 370 / 2,230: + # 333.00 + .32 x 560 = 512.20. + (1991, 35, "390818.79", 930, "512.2"), + ], +) +def test_cohort_boundaries_select_27_34_and_35_years( + ssa_parameters, eligibility, years, top_sum, aime, pia +): + career = _flat_career(3000, last=eligibility - 1) + parameters = _real_parameters(ssa_parameters, eligibility, range(1951, eligibility)) + result = compute_history( + career, eligibility, eligibility, parameters, birth_year=eligibility - 62 + ) + top = sorted((row.indexed_ordinary for row in result.ledger), reverse=True) + assert result.computation_years == years + assert sum(top[:years]) == Decimal(top_sum) + assert result.aime == aime == int(Decimal(top_sum) // (12 * years)) + bends = _real_bends(ssa_parameters, eligibility) + assert compute_history_pia(result, bend_points=bends) == Decimal(pia) + + +def test_35_year_cohort_still_divides_by_420(ssa_parameters): + # Forty $3,000 years for E=2000: only the divisor 420 reproduces AIME. + eligibility = 2000 + career = _flat_career(3000, first=1960, last=1999) + career = replace(career, first_year=1960, certified_zero_before_first_year=True) + parameters = _real_parameters(ssa_parameters, eligibility, range(1960, 2000)) + result = compute_history(career, eligibility, eligibility, parameters) + top = sorted((row.indexed_ordinary for row in result.ledger), reverse=True) + assert result.computation_years == 35 + assert result.aime == int(sum(top[:35]) // 420) + assert result.aime != int(sum(top[:34]) // 408) + + +def test_zero_extra_year_moves_aime_through_the_divisor_alone(ssa_parameters): + """E=1990 with $3,000 only in 1956-1989 (34 years); 1951-1955 are zero. + + The 34 nonzero years are the same whichever count is selected, so the + 35th selected year is a certified zero and only the divisor differs. + """ + years = range(1951, 1990) + zeros = tuple(range(1951, 1956)) + career = _flat_career(3000, last=1989, zeros=zeros) + parameters = _real_parameters(ssa_parameters, 1990, years) + result = compute_history(career, 1990, 1990, parameters, birth_year=1928) + nonzero = sum(row.indexed_ordinary for row in result.ledger) + assert sum(row.indexed_ordinary for row in result.ledger[:5]) == 0 + assert result.aime == int(nonzero // 408) + assert int(nonzero // 420) < result.aime + + +def test_aime_plus_keeps_35_years_for_a_short_cohort(): + # Flat NAWI and a $120,000 cap. One 1989 year of $404,080 credits $4,080 + # above a $400,000 donut threshold: AIME+ floor(4,080 / 420) = 9, while a + # 34-year count would give floor(4,080 / 408) = 10. + donut = DonutParameters( + in_effect=True, + start_year=1951, + threshold=400000, + tax_rate=".124", + credit_enabled=True, + credit_rate=".02", + ) + years = range(1951, 1990) + career = EarningsHistory( + 1951, + tuple( + EarningsRecord(year, 404_080 if year == 1989 else 0, 0) for year in years + ), + ) + result = compute_history( + career, + 1990, + 1990, + HistoryParameters( + wage_base={year: 120_000 for year in years}, + nawi={year: 1 for year in years}, + donut_by_year={year: donut for year in years}, + ), + ) + assert result.computation_years == 34 + assert result.aime == 120_000 // 408 + assert result.aime_plus == 9 + + def test_after62_through_previous_claim_year_and_no_post_claim_recomputation(): records = [EarningsRecord(y, 0, 0) for y in range(2036, 2041)] records[2] = EarningsRecord(2038, 100_800, 0) diff --git a/policyengine_us/tests/unit/test_ss_history_engine.py b/policyengine_us/tests/unit/test_ss_history_engine.py index 971f03741a1..a60c2d819ff 100644 --- a/policyengine_us/tests/unit/test_ss_history_engine.py +++ b/policyengine_us/tests/unit/test_ss_history_engine.py @@ -277,6 +277,12 @@ def observed(year, wages=0, gross_draw=0): ] == pytest.approx(float(Decimal("77097.6") / 37)) ledger = json.loads(simulation.calculate("ss_calculated_history_ledger", 2039)[0]) assert ledger["eligibility_pia"] == "2088.5" + # E2.13's coefficient, selected at eligibility 2036; January 2039 formula. + assert ledger["donut_credit_rate"] == "0.02" + assert ledger["donut_credit_rate_year"] == 2036 + assert ledger["diagnostic_pia_entitlement_month"] == "2039-01" + assert ledger["diagnostic_pia_formula_year"] == 2039 + assert ledger["computation_years"] == 35 assert ledger["mini_pia_exact"] == "385488/185" assert ledger["donut_credit_pia_exact"] == "24/5" rows = {row["year"]: row for row in ledger["rows"]} @@ -326,3 +332,8 @@ def test_default_history_accepts_earnings_before_1983_with_disabled_reforms(): assert Decimal(first["indexed_ordinary"]) == expected_indexed assert Decimal(first["donut_taxed"]) == 0 assert len(report["rows"]) == 49 + # The inactive custom donut's coefficient, read at eligibility 2026. + assert report["donut_credit_rate"] == "0" + assert report["donut_credit_rate_year"] == 2026 + assert report["diagnostic_pia_entitlement_month"] == "2029-01" + assert report["diagnostic_pia_formula_year"] == 2029 diff --git a/policyengine_us/tests/unit/test_ss_history_report.py b/policyengine_us/tests/unit/test_ss_history_report.py index 2566a038b09..d218d8ec179 100644 --- a/policyengine_us/tests/unit/test_ss_history_report.py +++ b/policyengine_us/tests/unit/test_ss_history_report.py @@ -133,3 +133,98 @@ def test_inconsistent_annual_mini_ledger_and_unknown_outputs_are_rejected(): history_report(annual, "history_ledger") with pytest.raises(ValueError, match="Unknown history report"): history_report(annual, "unregistered") + + +def _calculated(donut_rate, *, eligibility=2036, wages=60_000, top=500_800): + """A real calculate_year result on a flat-NAWI, $120,000-cap fixture.""" + from datetime import date + + from policyengine_us.tools.social_security.donut import DonutParameters + from policyengine_us.tools.social_security.history import ( + EarningsHistory, + EarningsRecord, + HistoryParameters, + ) + from policyengine_us.tools.social_security.household import ( + SuppliedRecord, + calculate_year, + ) + + first, claim = eligibility - 37, eligibility + 3 + years = range(first, claim) + donut = DonutParameters( + in_effect=donut_rate is not None, + start_year=first, + threshold=400_000, + tax_rate=".124", + credit_enabled=donut_rate is not None, + credit_rate=donut_rate or 0, + ) + history = EarningsHistory( + first, + tuple( + EarningsRecord(year, top if year == claim - 1 else wages, 0) + for year in years + ), + certified_zero_before_first_year=True, + ) + parameters = HistoryParameters( + wage_base={year: 120_000 for year in years}, + nawi={year: 1 for year in years}, + donut_by_year={year: donut for year in years}, + # Selected at eligibility, as history_parameters_at does. + donut_credit_rate=donut.credit_rate, + donut_credit_rate_year=eligibility, + ) + record = SuppliedRecord( + source="history", + worker_id="report-fields", + birth_date=date(eligibility - 62, 1, 2), + entitlement_month=f"{claim}-01", + insured=True, + eligibility_year=eligibility, + history=history, + history_parameters=parameters, + no_special_monthly_test=True, + no_prior_withholding_adjustment=True, + earnings_test_no_trigger=True, + ) + return calculate_year(record, claim, bend_points=(1000, 6000)) + + +def test_report_names_the_donut_coefficient_and_diagnostic_period_with_donut_on(): + report = json.loads(history_report(_calculated(".02"), "history_ledger")) + # $500,800 in 2038 credits $100,800 above $400,000: AIME+ 240, PIA+ 4.80. + assert report["aime_plus"] == 240 + assert report["donut_credit_rate"] == "0.02" + assert report["donut_credit_rate_exact"] == "1/50" + assert report["donut_credit_rate_year"] == 2036 + assert report["donut_credit_pia_exact"] == "24/5" + # January 2039 entitlement is priced with the 2039 formula. + assert report["diagnostic_pia_entitlement_month"] == "2039-01" + assert report["diagnostic_pia_formula_year"] == 2039 + assert report["computation_years"] == 35 + + +def test_report_names_the_donut_coefficient_and_diagnostic_period_with_donut_off(): + report = json.loads(history_report(_calculated(None), "history_ledger")) + assert report["aime_plus"] == 0 + assert report["donut_credit_rate"] == "0" + assert report["donut_credit_rate_year"] == 2036 + assert report["donut_credit_pia_exact"] == "0" + assert report["diagnostic_pia_entitlement_month"] == "2039-01" + assert report["diagnostic_pia_formula_year"] == 2039 + + +def test_report_period_fields_are_null_before_eligibility_and_flags_follow_cohort(): + before = json.loads( + history_report(_calculated(".02", eligibility=1990), "history_ledger") + ) + assert before["computation_years"] == 34 + assert sum(row["ordinary_top35"] for row in before["rows"]) == 34 + assert sum(row["donut_top35"] for row in before["rows"]) == 35 + unpriced = json.loads( + history_report(_annual([ReportRow(2038)], details=False), "history_ledger") + ) + assert unpriced["diagnostic_pia_entitlement_month"] is None + assert unpriced["diagnostic_pia_formula_year"] is None diff --git a/policyengine_us/tests/unit/test_ss_history_ssa_examples.py b/policyengine_us/tests/unit/test_ss_history_ssa_examples.py index cdabad4e690..b91d36dc48c 100644 --- a/policyengine_us/tests/unit/test_ss_history_ssa_examples.py +++ b/policyengine_us/tests/unit/test_ss_history_ssa_examples.py @@ -82,7 +82,10 @@ @pytest.mark.parametrize( "case,birth_year,eligibility,bends,expected_aime,expected_pia", [ + # SSA prints Case A's formula result $2,720.24 and then $2,720.20. ("A", 1965, 2027, (1340, 8077), 6072, "2720.20"), + # SSA prints Case B's formula result, $3,419.61; $3,419.60 is its + # floor to a dime, the initial PIA the calculator reports. ("B", 1960, 2022, (1024, 6172), 11843, "3419.60"), ], ) diff --git a/policyengine_us/tools/social_security/donut.py b/policyengine_us/tools/social_security/donut.py index 1b187a8933f..ced9fb186cf 100644 --- a/policyengine_us/tools/social_security/donut.py +++ b/policyengine_us/tools/social_security/donut.py @@ -1,8 +1,9 @@ """Separate annual above-threshold tax and optional benefit-credit streams. -The fixed-threshold 2025 OACT E2.13/E2.14 variants remain separate parameter -sets. This service supplies a ledger for the household calculator, rather -than changing population payroll-tax variables or ordinary covered earnings. +The fixed-threshold E2.13/E2.14 variants (OACT, 2025 Trustees Report edition) +remain separate parameter sets. This service supplies a ledger for the +household calculator, rather than changing population payroll-tax variables +or ordinary covered earnings. https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html """ diff --git a/policyengine_us/tools/social_security/history.py b/policyengine_us/tools/social_security/history.py index 01a1c8bcddb..4bdf19bb256 100644 --- a/policyengine_us/tools/social_security/history.py +++ b/policyengine_us/tools/social_security/history.py @@ -2,7 +2,9 @@ Initial earnings are frozen at claim year Q, through Q-1. This is an opt-in household service, not a replacement for the population variable's rolling -lookback. Annual caps precede E-2 wage indexing; top 35 / 420 is floored. +lookback. Annual caps precede E-2 wage indexing. The ordinary AIME selects the +cohort's computation years (35 from 1991 eligibility, fewer before) and floors +their sum divided by that many months. """ from dataclasses import dataclass @@ -112,6 +114,8 @@ class HistoryParameters: mini_share: Decimal | Fraction | int | str = 0 donut_by_year: Mapping[int, DonutParameters] | None = None donut_credit_rate: Decimal | Fraction | int | str | None = None + # The year whose parameters supplied donut_credit_rate, when known. + donut_credit_rate_year: int | None = None @dataclass(frozen=True) @@ -155,6 +159,9 @@ class HistoryCalculation: mini_share: Fraction = Fraction(0) aime_plus: int = 0 donut_credit_pia: Fraction = Fraction(0) + computation_years: int = 35 + donut_credit_rate: Fraction = Fraction(0) + donut_credit_rate_year: int | None = None @dataclass(frozen=True) @@ -164,6 +171,9 @@ class HistoryPIACalculation: annual_mini_pias: tuple[Fraction, ...] blended_pia: Fraction eligibility_pia: Decimal + # Set by the household service for the annual report's January diagnostic. + entitlement_month: str | None = None + formula_year: int | None = None def _parameter(path, year, name, *, positive=False): @@ -175,12 +185,29 @@ def _parameter(path, year, name, *, positive=False): return value -def aime_from_indexed(indexed_amounts): +def computation_years(eligibility_year): + """Retirement computation years for age-62 eligibility year E. + + 42 U.S.C. 415(b)(2): elapsed years run from 1951 (or the year after age + 21, if later) through the year before eligibility, and the computation + period is elapsed years less five, at least two. For age-62 eligibility + from 1983, the supported range, that is min(35, E - 1956): 27 years in + 1983, 34 in 1990 and 35 from 1991. + """ + if _year(eligibility_year) < 1983: + raise ValueError("Computation benchmarks before 1983 are unsupported") + return min(35, eligibility_year - 1956) + + +def aime_from_indexed(indexed_amounts, years=35): + """Floor the highest ``years`` indexed amounts divided by ``years`` × 12.""" + if isinstance(years, bool) or not isinstance(years, int) or years < 1: + raise ValueError("Computation years must be a positive integer") amounts = tuple( _money(value, "Indexed annual earnings") for value in indexed_amounts ) - selected = highest_earnings(amounts or (Decimal(0),)) - return int(sum(selected, Decimal(0)) // 420) + selected = highest_earnings(amounts or (Decimal(0),), years) + return int(sum(selected, Decimal(0)) // (12 * years)) def _owners_inputs(row, wages, se): @@ -253,7 +280,7 @@ def compute_history( anchor_year = eligibility - 2 anchor = _parameter(parameters.nawi, anchor_year, "NAWI", positive=True) ledger = [] - credit_rates = set() + credit_rates = {} for row in records: wages = _money(row.covered_wages, "Covered wages") se = _money(row.covered_self_employment, "Existing covered SE earnings") @@ -292,8 +319,9 @@ def compute_history( parameters=donut_parameters, ) if donut.credited_earnings: - credit_rates.add( - _number(donut_parameters.credit_rate, "donut credit rate") + credit_rates.setdefault( + _number(donut_parameters.credit_rate, "donut credit rate"), + row.year, ) donut_taxed = ( Decimal(donut.taxed_earnings.numerator) / donut.taxed_earnings.denominator @@ -369,20 +397,29 @@ def compute_history( raise ValueError( "Mixed annual donut rates require an explicit eligibility-cohort coefficient" ) - credit_rate = next(iter(credit_rates), Fraction(0)) + # One rate across every credited year: report the first such year. + credit_rate, credit_rate_year = next( + iter(credit_rates.items()), (Fraction(0), None) + ) else: credit_rate = _number(parameters.donut_credit_rate, "donut credit rate") + credit_rate_year = parameters.donut_credit_rate_year if credit_rate < 0: raise ValueError("Donut credit rate must be nonnegative") + # AIME+ keeps its own highest-35 / 420 selection for every cohort. aime_plus = aime_from_indexed(row.indexed_donut for row in ledger) + years = computation_years(eligibility) return HistoryCalculation( eligibility, claim_year, tuple(ledger), - aime_from_indexed(row.indexed_ordinary for row in ledger), + aime_from_indexed((row.indexed_ordinary for row in ledger), years), mini_share, aime_plus, credit_rate * aime_plus, + years, + credit_rate, + credit_rate_year, ) @@ -485,4 +522,5 @@ def history_parameters_at( donut_credit_rate=donut_parameters_at( parameters, eligibility_year, donut_variant ).credit_rate, + donut_credit_rate_year=eligibility_year, ) diff --git a/policyengine_us/tools/social_security/history_report.py b/policyengine_us/tools/social_security/history_report.py index 813b8836a1c..591b9c684c5 100644 --- a/policyengine_us/tools/social_security/history_report.py +++ b/policyengine_us/tools/social_security/history_report.py @@ -59,8 +59,10 @@ def history_report(annual, output): """Return an annual history diagnostic, or an empty unselected result. ``annual.history_pia`` is the formula diagnostic for January entitlement in - the requested year. Before eligibility it may be absent while the initial - claim-year earnings calculation remains available for reconciliation. + the requested year; the ledger JSON names that period. Before eligibility + it may be absent while the initial claim-year earnings calculation remains + available for reconciliation. The ledger also names the donut credit + coefficient applied and the year whose parameters selected it. """ if output not in OUTPUTS: raise ValueError(f"Unknown history report output {output}") @@ -78,7 +80,12 @@ def history_report(annual, output): return details.conventional_pia if details is not None else 0 rows = tuple(history.ledger) - ordinary_selected = _selected(rows, [row.indexed_ordinary for row in rows], 35) + # The ordinary flag keeps its key; it marks the cohort's computation years + # (35 from 1991 eligibility, fewer before). AIME+ always selects 35. + computation_years = getattr(history, "computation_years", 35) + ordinary_selected = _selected( + rows, [row.indexed_ordinary for row in rows], computation_years + ) donut_selected = _selected( rows, [getattr(row, "indexed_donut", 0) for row in rows], 35 ) @@ -119,6 +126,7 @@ def pia_amount(name): donut_credit = getattr(history, "donut_credit_pia", Fraction(0)) mini_pia = pia_amount("mini_pia") blended_pia = pia_amount("blended_pia") + credit_rate = getattr(history, "donut_credit_rate", Fraction(0)) return json.dumps( { "schema_version": 1, @@ -136,6 +144,15 @@ def pia_amount(name): "blended_pia": _amount(blended_pia), "blended_pia_exact": str(blended_pia) if blended_pia is not None else None, "eligibility_pia": _amount(pia_amount("eligibility_pia")), + "computation_years": computation_years, + "donut_credit_rate": _amount(credit_rate), + "donut_credit_rate_exact": str(credit_rate), + "donut_credit_rate_year": getattr(history, "donut_credit_rate_year", None), + # The January diagnostic's entitlement month and formula year. + "diagnostic_pia_entitlement_month": getattr( + details, "entitlement_month", None + ), + "diagnostic_pia_formula_year": getattr(details, "formula_year", None), "rows": serialized_rows, }, separators=(",", ":"), diff --git a/policyengine_us/tools/social_security/household.py b/policyengine_us/tools/social_security/household.py index 23e4c8dee14..da3fc665325 100644 --- a/policyengine_us/tools/social_security/household.py +++ b/policyengine_us/tools/social_security/household.py @@ -590,7 +590,9 @@ def calculate_year( # An annual reconciliation prices the frozen career with the # formula for January entitlement. December can reflect the # following check year's distinct policy, as in 4a-1. - history_pia = details + history_pia = replace( + details, entitlement_month=entitlement, formula_year=payment_year + ) elif payment_year >= benchmark and has_active_reform( path, benchmark, payment_year, mode ): diff --git a/policyengine_us/tools/social_security/mini_pia.py b/policyengine_us/tools/social_security/mini_pia.py index b5d78d21b67..19a5efd1da8 100644 --- a/policyengine_us/tools/social_security/mini_pia.py +++ b/policyengine_us/tools/social_security/mini_pia.py @@ -1,8 +1,9 @@ """Annual mini-PIAs for the opt-in covered-earnings history route. -OACT's archived 2025 B3.12 specifies the highest 40 annual mini-PIAs and the -fixed divisor 37. Keeping annual and blended values exact until the combined -eligibility PIA's final dime truncation is an explicit modeling convention. +OACT's archived B3.12 (2025 Trustees Report edition) specifies the highest 40 +annual mini-PIAs and the fixed divisor 37. Keeping annual and blended values +exact until the combined eligibility PIA's final dime truncation is an +explicit modeling convention. https://www.ssa.gov/OACT/solvency/provisions_tr2025/benefitlevel.html """ From 076a1101cc39453d0fb2e686d105336c9873139f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 18:56:21 +0000 Subject: [PATCH 10/14] Document cohort computation years, conventions and source edition 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 --- .../social_security_history.md | 79 +++++++++++++------ .../history/donut/custom/credit_enabled.yaml | 2 +- .../ssa/history/donut/custom/credit_rate.yaml | 2 +- .../ssa/history/donut/custom/in_effect.yaml | 2 +- .../donut/custom/include_owners_draw.yaml | 2 +- .../donut/custom/include_self_employment.yaml | 2 +- .../ssa/history/donut/custom/start_year.yaml | 2 +- .../ssa/history/donut/custom/tax_rate.yaml | 2 +- .../ssa/history/donut/custom/threshold.yaml | 2 +- .../donut/oact_2025_e2_13/credit_enabled.yaml | 2 +- .../donut/oact_2025_e2_13/credit_rate.yaml | 2 +- .../donut/oact_2025_e2_13/in_effect.yaml | 2 +- .../oact_2025_e2_13/include_owners_draw.yaml | 2 +- .../include_self_employment.yaml | 2 +- .../donut/oact_2025_e2_13/start_year.yaml | 2 +- .../donut/oact_2025_e2_13/tax_rate.yaml | 2 +- .../donut/oact_2025_e2_13/threshold.yaml | 2 +- .../donut/oact_2025_e2_14/credit_enabled.yaml | 2 +- .../donut/oact_2025_e2_14/credit_rate.yaml | 2 +- .../donut/oact_2025_e2_14/in_effect.yaml | 2 +- .../oact_2025_e2_14/include_owners_draw.yaml | 2 +- .../include_self_employment.yaml | 2 +- .../donut/oact_2025_e2_14/start_year.yaml | 2 +- .../donut/oact_2025_e2_14/tax_rate.yaml | 2 +- .../donut/oact_2025_e2_14/threshold.yaml | 2 +- .../ssa/history/mini_pia/in_effect.yaml | 2 +- .../contrib/ssa/history/mini_pia/share.yaml | 2 +- 27 files changed, 81 insertions(+), 50 deletions(-) diff --git a/docs/contributed_reforms/social_security_history.md b/docs/contributed_reforms/social_security_history.md index 2abd74e3c9d..677927cd48e 100644 --- a/docs/contributed_reforms/social_security_history.md +++ b/docs/contributed_reforms/social_security_history.md @@ -84,10 +84,24 @@ For eligibility year E and initial claim year Q: 3. For a year y before E−2, multiply by `NAWI(E−2) / NAWI(y)` and round to cents, with half cents rounded upward. Earnings in E−2 and later remain at face value. -4. Select the highest 35 annual indexed amounts, pad with certified zeros, - divide by 420, and floor to a whole monthly dollar. +4. Select the cohort's computation years: `min(35, E − 1956)`, so 27 years + for E = 1983, 34 for E = 1990 and 35 from E = 1991. Select that many + highest annual indexed amounts, pad with certified zeros, divide by that + count times 12 (324, 408 or 420 months in those examples), and floor to a + whole monthly dollar. 5. Call the same eligibility-year `compute_pia` service used by supplied AIME. +Step 4 follows [42 U.S.C. 415(b)(2)](https://www.law.cornell.edu/uscode/text/42/415): +elapsed years run from 1951 (or the year after age 21, if later) through the +year before age-62 eligibility, and the computation period is elapsed years +less five, with a minimum of two (POMS RS 00605.016 and RS 00605.017). A +worker first eligible in 1990 has 39 elapsed years and 34 computation years. +For example, $3,000 of covered wages in every year 1951–1989 with eligibility +and claim in 1990 indexes to a highest-34 sum of $372,486.21, so AIME is +floor(372,486.21 / 408) = $912 and the PIA is $498.30; a fixed 35-year, +420-month selection would give $895 and $492.80. Legacy `ss_aime` keeps its +own window, and the supplied-AIME and supplied-PIA routes are unchanged. + The [POMS indexing procedure](https://secure.ssa.gov/poms.nsf/lnx/0300605018) establishes the index year and cent rounding. The [POMS AIME/PIA procedure](https://secure.ssa.gov/poms.nsf/lnx/0300605021) @@ -153,20 +167,22 @@ formula to each annual ordinary indexed covered amount divided by 12. It selects the highest 40 annual mini-PIAs, pads shorter certified careers with zeros, and divides their sum by 37. The divisor is always 37. -The archived [2025 OACT B3.12 description](https://www.ssa.gov/OACT/solvency/provisions_tr2025/benefitlevel.html) +The archived [B3.12 description in OACT's 2025 Trustees Report edition](https://www.ssa.gov/OACT/solvency/provisions_tr2025/benefitlevel.html) says, “Sum these individual PIAs for the 40 highest years of indexed earnings -and divide that total amount by 37”. Its retired-worker phase-in is 20% in -2032, 40% in 2033, 60% in 2034, 80% in 2035, and 100% from 2036. Disability +and divide that total amount by 37”. Its retired-worker phase-in, in that +2025 edition, is 20% in 2032, 40% in 2033, 60% in 2034, 80% in 2035, and 100% from 2036. Disability benefits are unchanged. The independently enabled parameter path is under `gov.contrib.ssa.history.mini_pia`. -**Modeling conventions:** retain precision in each mini-AIME and mini-PIA, -aggregate with /37, and blend -`mini_share_E × mini_PIA + (1 − mini_share_E) × conventional_PIA` before final -dime truncation. Conventional AIME/PIA retains its statutory rounding. Add -the separate AIME+ PIA after the blend, then truncate the combined eligibility -PIA to dimes. These intermediate rounding and composition choices come from -the calculator design; OACT's summary does not prescribe them. +**Modeling convention: combined dime rounding.** Retain precision in each +mini-AIME and mini-PIA, aggregate with /37, and blend +`mini_share_E × mini_PIA + (1 − mini_share_E) × conventional_PIA`. +Conventional AIME/PIA retains its statutory rounding. Add the separate AIME+ +PIA after the blend, then floor the combined eligibility PIA to a dime once. +42 U.S.C. 415(a)(1)(A) rounds the summed formula result down to a multiple of +$0.10; the provision descriptions do not address rounding. For example, a mini of $399.99 plus a +$0.02 credit gives a $400 PIA and payment; truncating the mini first would +give $399.90 and $399. ## Above-cap AIME+ credit @@ -176,11 +192,14 @@ set. Activation, threshold, start year, tax rate, credit flag/rate, and inclusion of existing SE or reclassified draw are separate settings. A credit-off variant can tax earnings while producing no AIME+ credit. -The archived [2025 OACT payroll-tax descriptions](https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html) +The archived [payroll-tax descriptions in OACT's 2025 Trustees Report edition](https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html) state E2.13 as “Apply OASDI 12.4 percent payroll tax rate on earnings above $400,000” and E2.14 as “Apply OASDI 12.4 percent payroll tax rate on earnings -above $250,000”. Both begin with 2027 earnings and use a 2% secondary AIME+ -factor. Their thresholds stay fixed; once the ordinary cap reaches the +above $250,000”. In that 2025 edition both begin with 2027 earnings and use a +2% secondary AIME+ factor. The 2026 Trustees Report edition moved the start of E2.13 +and E2.14 to 2028. This calculator keeps the 2025 edition's years and +citations; its named variants (`oact_2025_e2_13`, `oact_2025_e2_14`) and the +B3.12 phase-in above are the 2025 edition's. Their thresholds stay fixed; once the ordinary cap reaches the threshold, the gap disappears. For each enabled earnings year, the separate taxed stream is @@ -197,11 +216,15 @@ stream never changes conventional AIME. These services expose hypothetical annual tax/credit amounts for the household report; they do not change population payroll-tax variables. -For a custom changing path, annual tax/coverage settings determine the extra -earnings stream, and the PIA credit coefficient is read at eligibility E. -This is a cohort-selection convention; the archived variants' coefficient is -constant at 2%. Pure callers whose credited annual streams use different -coefficients must supply the eligibility coefficient explicitly. +**Modeling convention: cohort-selected donut coefficient.** For a custom +changing path, annual tax/coverage settings determine the extra earnings +stream, and the PIA credit coefficient is read at eligibility E, as benefit +formulas select by cohort. The named E2.13 and E2.14 variants carry a +constant 2%, so this only moves a custom path that varies. For example, AIME+ +$240 at 2% against 3% gives $4.80 against $7.20; with a $2,180 baseline PIA +and claiming at 65, payment is $1,893 against $1,895. Pure callers whose +credited annual streams use different coefficients must supply the +eligibility coefficient explicitly. ## Annual earnings ledger @@ -219,7 +242,13 @@ initial-claim earnings calculation when a later payment year is requested. default `custom` set is inactive until explicitly configured. The annual report prices mini-PIAs and PIA diagnostics using January -entitlement's formula. Its income ledger stays frozen at Q−1. An exact fraction +entitlement's formula; `diagnostic_pia_entitlement_month` and +`diagnostic_pia_formula_year` name that period. `donut_credit_rate` (with its +exact fraction) and `donut_credit_rate_year` name the AIME+ coefficient +applied and the year whose parameters selected it. `computation_years` gives +the ordinary selection count, which the `ordinary_top35` flags follow; the +`donut_top35` flags always mark 35 years. Its income ledger stays frozen at +Q−1. An exact fraction accompanies recurring mini-PIA decimals, and highest-year selection flags use the earlier earnings year to resolve ties. Before eligibility, the report retains the earnings ledger and leaves PIA diagnostics unset. @@ -242,9 +271,11 @@ truncating to dimes gives $2,088.50. Claiming 24 months before FRA uses Published conventional comparisons use SSA's [annual earnings histories](https://www.ssa.gov/OACT/ProgData/retirebenefit1.html) and [PIA examples](https://www.ssa.gov/OACT/ProgData/retirebenefit2.html). -The published 2027 Case A has AIME $6,072 and initial PIA $2,720.20. Case B -first eligible in 2022 has AIME $11,843, including later earnings through -2026, and initial PIA $3,419.60. Source data are fixed in the corresponding +In the 2027 examples, Case A has AIME $6,072; SSA prints the formula result +$2,720.24 and then the initial PIA $2,720.20. Case B, first eligible in 2022, +has AIME $11,843, including later earnings through 2026; SSA's page prints the +formula result $3,419.61, and $3,419.60 is its floor to a dime, which the +calculator reports as the initial PIA. Source data are fixed in the corresponding test fixtures; the live SSA examples can advance to another retirement year. The merged implementation takes precedence over older design descriptions: diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_enabled.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_enabled.yaml index 6efada96f30..82244469bb9 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_enabled.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_enabled.yaml @@ -6,5 +6,5 @@ metadata: label: Custom annual-history donut benefit credit economy: false reference: - - title: 2025 OACT fixed-threshold donut provisions + - title: OACT fixed-threshold donut provisions, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_rate.yaml index 824981add58..9f39b6e17ad 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_rate.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/credit_rate.yaml @@ -6,5 +6,5 @@ metadata: label: Custom annual-history donut AIME+ factor economy: false reference: - - title: 2025 OACT fixed-threshold donut provisions + - title: OACT fixed-threshold donut provisions, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/in_effect.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/in_effect.yaml index 09f1a0a86ef..50800eb06f1 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/in_effect.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/in_effect.yaml @@ -6,5 +6,5 @@ metadata: label: Custom annual-history donut activation economy: false reference: - - title: 2025 OACT fixed-threshold donut provisions + - title: OACT fixed-threshold donut provisions, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_owners_draw.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_owners_draw.yaml index a9d020a0152..18d8fbcdbc3 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_owners_draw.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_owners_draw.yaml @@ -6,5 +6,5 @@ metadata: label: Custom annual-history donut owners' draw inclusion economy: false reference: - - title: 2025 OACT fixed-threshold donut provisions + - title: OACT fixed-threshold donut provisions, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_self_employment.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_self_employment.yaml index 5840b69cd22..6c0a4362fa8 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_self_employment.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/include_self_employment.yaml @@ -6,5 +6,5 @@ metadata: label: Custom annual-history donut covered self-employment inclusion economy: false reference: - - title: 2025 OACT fixed-threshold donut provisions + - title: OACT fixed-threshold donut provisions, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/start_year.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/start_year.yaml index 7c8093168a5..080b5ba885f 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/start_year.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/start_year.yaml @@ -6,5 +6,5 @@ metadata: label: Custom annual-history donut first earnings year economy: false reference: - - title: 2025 OACT fixed-threshold donut provisions + - title: OACT fixed-threshold donut provisions, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/tax_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/tax_rate.yaml index 8477a17f51b..ed314253527 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/tax_rate.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/tax_rate.yaml @@ -6,5 +6,5 @@ metadata: label: Custom annual-history donut above-threshold OASDI rate economy: false reference: - - title: 2025 OACT fixed-threshold donut provisions + - title: OACT fixed-threshold donut provisions, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/threshold.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/threshold.yaml index a9c43d171fb..3d21e281d11 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/threshold.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/custom/threshold.yaml @@ -6,5 +6,5 @@ metadata: label: Custom annual-history donut earnings threshold economy: false reference: - - title: 2025 OACT fixed-threshold donut provisions + - title: OACT fixed-threshold donut provisions, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_enabled.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_enabled.yaml index 47ed7fa07cb..c7d48dcaa60 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_enabled.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_enabled.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.13 annual-history donut benefit credit economy: false reference: - - title: 2025 OACT provision E2.13 + - title: OACT provision E2.13, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_rate.yaml index 7ef152a27f5..e1d4636bca1 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_rate.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/credit_rate.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.13 annual-history donut AIME+ factor economy: false reference: - - title: 2025 OACT provision E2.13 + - title: OACT provision E2.13, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/in_effect.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/in_effect.yaml index e2f30b1d58b..569be0c364a 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/in_effect.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/in_effect.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.13 annual-history donut activation economy: false reference: - - title: 2025 OACT provision E2.13 + - title: OACT provision E2.13, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_owners_draw.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_owners_draw.yaml index e7959dcc8d9..60b8e74ae90 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_owners_draw.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_owners_draw.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.13 annual-history donut owners' draw inclusion economy: false reference: - - title: 2025 OACT provision E2.13 + - title: OACT provision E2.13, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_self_employment.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_self_employment.yaml index 85a27c9295a..b7e66be447c 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_self_employment.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/include_self_employment.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.13 annual-history donut covered self-employment inclusion economy: false reference: - - title: 2025 OACT provision E2.13 + - title: OACT provision E2.13, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/start_year.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/start_year.yaml index daa68843d04..081d84b853c 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/start_year.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/start_year.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.13 annual-history donut first earnings year economy: false reference: - - title: 2025 OACT provision E2.13 + - title: OACT provision E2.13, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/tax_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/tax_rate.yaml index cbecd93d736..53f93c1805e 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/tax_rate.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/tax_rate.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.13 annual-history donut above-threshold OASDI rate economy: false reference: - - title: 2025 OACT provision E2.13 + - title: OACT provision E2.13, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/threshold.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/threshold.yaml index 7011ebddf5d..11f31e5686d 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/threshold.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_13/threshold.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.13 annual-history donut earnings threshold economy: false reference: - - title: 2025 OACT provision E2.13 + - title: OACT provision E2.13, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_enabled.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_enabled.yaml index d705b03dcad..acf57b33913 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_enabled.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_enabled.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.14 annual-history donut benefit credit economy: false reference: - - title: 2025 OACT provision E2.14 + - title: OACT provision E2.14, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_rate.yaml index 07ab71bac2b..f462892f867 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_rate.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/credit_rate.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.14 annual-history donut AIME+ factor economy: false reference: - - title: 2025 OACT provision E2.14 + - title: OACT provision E2.14, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/in_effect.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/in_effect.yaml index 548073c31fa..98948f1a8c0 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/in_effect.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/in_effect.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.14 annual-history donut activation economy: false reference: - - title: 2025 OACT provision E2.14 + - title: OACT provision E2.14, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_owners_draw.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_owners_draw.yaml index 3a3f9382ba6..0d1ee0c3104 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_owners_draw.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_owners_draw.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.14 annual-history donut owners' draw inclusion economy: false reference: - - title: 2025 OACT provision E2.14 + - title: OACT provision E2.14, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_self_employment.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_self_employment.yaml index bf45a2b347c..db5dde910af 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_self_employment.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/include_self_employment.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.14 annual-history donut covered self-employment inclusion economy: false reference: - - title: 2025 OACT provision E2.14 + - title: OACT provision E2.14, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/start_year.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/start_year.yaml index 9e1f6986235..ae870e128d4 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/start_year.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/start_year.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.14 annual-history donut first earnings year economy: false reference: - - title: 2025 OACT provision E2.14 + - title: OACT provision E2.14, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/tax_rate.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/tax_rate.yaml index 3d7640f97e0..65ab7a868fe 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/tax_rate.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/tax_rate.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.14 annual-history donut above-threshold OASDI rate economy: false reference: - - title: 2025 OACT provision E2.14 + - title: OACT provision E2.14, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/threshold.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/threshold.yaml index 78a263d73e9..755a975fef0 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/threshold.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/donut/oact_2025_e2_14/threshold.yaml @@ -6,5 +6,5 @@ metadata: label: 2025 OACT E2.14 annual-history donut earnings threshold economy: false reference: - - title: 2025 OACT provision E2.14 + - title: OACT provision E2.14, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/payrolltax.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/in_effect.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/in_effect.yaml index 7821e46eab5..ab5cf9ccead 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/in_effect.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/in_effect.yaml @@ -6,5 +6,5 @@ metadata: label: Annual-history mini-PIA activation economy: false reference: - - title: 2025 OACT provision B3.12 + - title: OACT provision B3.12, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/benefitlevel.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/share.yaml b/policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/share.yaml index ee4da28683b..6ce585e084f 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/share.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/history/mini_pia/share.yaml @@ -11,5 +11,5 @@ metadata: label: Mini-PIA eligibility cohort share economy: false reference: - - title: 2025 OACT provision B3.12 + - title: OACT provision B3.12, 2025 Trustees Report edition href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/benefitlevel.html From 84eaa12954699e5e27ca34d4a0aacb31ae35ceff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:04:26 +0000 Subject: [PATCH 11/14] Classify gov.contrib.ssa.history as calculator-only 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 Claude-Session: https://claude.ai/code/session_01LwQq6HPC2jULDq8hPQKD71 --- policyengine_us/tests/unit/test_ss_calculator_differential.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/policyengine_us/tests/unit/test_ss_calculator_differential.py b/policyengine_us/tests/unit/test_ss_calculator_differential.py index d59466ec760..2aef36a2139 100644 --- a/policyengine_us/tests/unit/test_ss_calculator_differential.py +++ b/policyengine_us/tests/unit/test_ss_calculator_differential.py @@ -30,6 +30,9 @@ # Benefit levers and statutory COLA history also remain in the reference. "gov.ssa.social_security.pia.bend_points", "gov.ssa.social_security.cola", + # Read only by the calculator's history route (history_parameters_at + # through donut.py and mini_pia.py). + "gov.contrib.ssa.history", ) # Contributed namespaces whose every child node must be classified: either # calculator-only (listed in NEW_PARAMETER_NODES, so the reference drops it) From 2166886f482c1999a2a7c4763e1de98cb1a6afc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:23:10 +0000 Subject: [PATCH 12/14] Start the annual history route at eligibility year 1984 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 --- .../social_security_history.md | 22 ++++++-- docs/usage/social-security-calculator.md | 3 + policyengine_us/tests/unit/test_ss_donut.py | 4 +- .../tests/unit/test_ss_earnings_history.py | 56 +++++++++++++++---- .../tests/unit/test_ss_history_engine.py | 3 +- .../tools/social_security/history.py | 28 ++++++++-- .../tools/social_security/household.py | 4 ++ 7 files changed, 95 insertions(+), 25 deletions(-) diff --git a/docs/contributed_reforms/social_security_history.md b/docs/contributed_reforms/social_security_history.md index 677927cd48e..164742c8b16 100644 --- a/docs/contributed_reforms/social_security_history.md +++ b/docs/contributed_reforms/social_security_history.md @@ -70,10 +70,22 @@ assertion about this synthetic worker's earlier earnings: The calculator also requires the existing own-record fields: worker ID, exact birth date, age-62 eligibility year, first entitlement month, insured-status assertion, and assertions about unsupported special earnings tests and prior -withholding adjustments. The supported eligibility benchmark begins in 1983. +withholding adjustments. The supported eligibility benchmark begins in 1984. Earnings can precede that benchmark, beginning in 1951 or the worker's birth year, whichever is later; annual inactive-policy defaults cover those years. +The route computes only the wage-indexed formula of +[42 U.S.C. 415(a)(1)](https://www.law.cornell.edu/uscode/text/42/415). For a +worker with covered earnings before 1979 who becomes eligible before 1984, +415(a)(4)(B)(i) keeps the computation under the subsection as in effect in +December 1978 when that amount is larger. The route cannot compute that +amount, so it refuses eligibility years before 1984. Section 415(a)(4)(B)(ii) +likewise keeps the subsection (d) computation when it is larger; subsection (d) +computes the 1939 Act primary insurance benefit, which counts wages before +1951. Annual records begin in 1951, so the route cannot take those wages or +compute that amount, and it assumes the worker had no covered wages before +1951. + ## Conventional AIME and the shared benefit pipeline For eligibility year E and initial claim year Q: @@ -84,10 +96,10 @@ For eligibility year E and initial claim year Q: 3. For a year y before E−2, multiply by `NAWI(E−2) / NAWI(y)` and round to cents, with half cents rounded upward. Earnings in E−2 and later remain at face value. -4. Select the cohort's computation years: `min(35, E − 1956)`, so 27 years - for E = 1983, 34 for E = 1990 and 35 from E = 1991. Select that many +4. Select the cohort's computation years: `min(35, E − 1956)`, so 28 years + for E = 1984, 34 for E = 1990 and 35 from E = 1991. Select that many highest annual indexed amounts, pad with certified zeros, divide by that - count times 12 (324, 408 or 420 months in those examples), and floor to a + count times 12 (336, 408 or 420 months in those examples), and floor to a whole monthly dollar. 5. Call the same eligibility-year `compute_pia` service used by supplied AIME. @@ -219,7 +231,7 @@ population payroll-tax variables. **Modeling convention: cohort-selected donut coefficient.** For a custom changing path, annual tax/coverage settings determine the extra earnings stream, and the PIA credit coefficient is read at eligibility E, as benefit -formulas select by cohort. The named E2.13 and E2.14 variants carry a +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. For example, AIME+ $240 at 2% against 3% gives $4.80 against $7.20; with a $2,180 baseline PIA and claiming at 65, payment is $1,893 against $1,895. Pure callers whose diff --git a/docs/usage/social-security-calculator.md b/docs/usage/social-security-calculator.md index 5a8b8d28cf0..ab6906ee466 100644 --- a/docs/usage/social-security-calculator.md +++ b/docs/usage/social-security-calculator.md @@ -182,6 +182,9 @@ text](https://www.congress.gov/bill/119th-congress/senate-bill/770/text). supplied AIME, recovered AIME and annual history callers. Its inputs explicitly separate initial eligibility, formula path, scoring/payment year and cohort vs retroactive applicability. All computations and inverses require E >= 1983. +The annual history route requires E >= 1984, because 42 U.S.C. +415(a)(4)(B)(i) keeps the December 1978 computation, when larger, for workers +eligible before 1984; see [annual histories](../contributed_reforms/social_security_history.md). Historical bends back to 1979 are separate parameters, preserving legacy scale values. Future bends use the model's existing wage-indexed projections. diff --git a/policyengine_us/tests/unit/test_ss_donut.py b/policyengine_us/tests/unit/test_ss_donut.py index 57da3fce60e..a8482b00bde 100644 --- a/policyengine_us/tests/unit/test_ss_donut.py +++ b/policyengine_us/tests/unit/test_ss_donut.py @@ -43,8 +43,8 @@ def _parameters(): @pytest.mark.parametrize("year", [1951, 1980]) @pytest.mark.parametrize("variant", DONUT_VARIANTS) -def test_all_off_donut_parameters_accept_pre1983_earnings_years(year, variant): - # Eligibility benchmarks start in 1983, while covered-earnings histories +def test_all_off_donut_parameters_accept_pre1984_earnings_years(year, variant): + # History eligibility benchmarks start in 1984, while covered-earnings histories # start in 1951. Read every scalar from the real authored Core subtree: # an eligibility-dated default would fail before annual activation checks. parameters = _parameters() diff --git a/policyengine_us/tests/unit/test_ss_earnings_history.py b/policyengine_us/tests/unit/test_ss_earnings_history.py index 2844fcdc868..b3a868dd08d 100644 --- a/policyengine_us/tests/unit/test_ss_earnings_history.py +++ b/policyengine_us/tests/unit/test_ss_earnings_history.py @@ -26,6 +26,7 @@ compute_history, compute_history_pia, computation_years, + history_pia_details, ) from policyengine_us.tools.social_security.household import ( SuppliedRecord, @@ -119,7 +120,9 @@ def test_top35_padding_and_whole_dollar_truncation(): # --- Cohort computation years (42 U.S.C. 415(b)(2)) ------------------------- # Elapsed years run from 1951 through the year before age-62 eligibility E, # and the computation period is elapsed years less five (POMS RS 00605.016 -# and RS 00605.017): min(35, E - 1956) for the supported benchmarks from 1983. +# and RS 00605.017): min(35, E - 1956) for the supported benchmarks from 1984. +# 42 U.S.C. 415(a)(4)(B)(i) keeps the December 1978 computation, when larger, +# for workers eligible before 1984, so the history route refuses E <= 1983. SSA_PARAMETERS = Path(__file__).parents[2] / "parameters" / "gov" / "ssa" @@ -169,12 +172,13 @@ def _flat_career(wages, first=1951, last=None, zeros=()): def test_computation_years_follow_the_elapsed_years_rule(): - assert computation_years(1983) == 27 + assert computation_years(1984) == 28 assert computation_years(1990) == 34 assert computation_years(1991) == 35 assert computation_years(2036) == 35 - with pytest.raises(ValueError, match="before 1983"): - computation_years(1982) + for year in (1983, 1982): + with pytest.raises(ValueError, match=r"before 1984.*415\(a\)\(4\)\(B\)\(i\)"): + computation_years(year) def test_1990_witness_selects_34_years_and_divides_by_408(ssa_parameters): @@ -225,9 +229,10 @@ def test_1990_witness_selects_34_years_and_divides_by_408(ssa_parameters): @pytest.mark.parametrize( "eligibility,years,top_sum,aime,pia", [ - # 1983: NAWI(1981) 13,773.10; sum/324 = 751.29; bends 254 / 1,528: - # 228.60 + .32 x 497 = 387.64 -> $387.60. - (1983, 27, "243418.77", 751, "387.6"), + # 1984: NAWI(1982) 14,531.34 indexes 1951-1981; the highest 28 are + # 1951-1978 (1979-1983 drop). Sum/336 = 776.63; bends 267 / 1,612: + # 240.30 + .32 x 509 = 403.18 -> $403.10. + (1984, 28, "260949.26", 776, "403.1"), # 1990: the witness above. (1990, 34, "372486.21", 912, "498.3"), # 1991: NAWI(1989) 20,099.55; sum/420 = 930.52; bends 370 / 2,230: @@ -235,7 +240,7 @@ def test_1990_witness_selects_34_years_and_divides_by_408(ssa_parameters): (1991, 35, "390818.79", 930, "512.2"), ], ) -def test_cohort_boundaries_select_27_34_and_35_years( +def test_cohort_boundaries_select_28_34_and_35_years( ssa_parameters, eligibility, years, top_sum, aime, pia ): career = _flat_career(3000, last=eligibility - 1) @@ -400,9 +405,38 @@ def test_duplicate_year_unknown_field_and_missing_se_rejected(): EarningsHistory.from_json(payload) -def test_benchmarks_before1983_and_uninsured_history_rejected(): - with pytest.raises(ValueError, match="before 1983"): - compute_history(fixture(), 1982, 2039, parameters()) +EARLY = r"before 1984.*415\(a\)\(4\)\(B\)\(i\)" + + +@pytest.mark.parametrize("eligibility", [1983, 1982]) +def test_benchmarks_before1984_rejected_at_each_history_entry_point(eligibility): + birth = date(eligibility - 62, 1, 2) + with pytest.raises(ValueError, match=EARLY): + computation_years(eligibility) + with pytest.raises(ValueError, match=EARLY): + compute_history(fixture(), eligibility, 2039, parameters()) + record = replace( + worker(history=fixture(), history_parameters=parameters()), + birth_date=birth, + entitlement_month=f"{eligibility}-02", + eligibility_year=eligibility, + ) + # E = 1982 meets the shared 1983 refusal first; E = 1983 meets the + # history route's own 1984 refusal. + with pytest.raises(ValueError, match=EARLY if eligibility == 1983 else "1983"): + record.benchmark() + if eligibility == 1983: + # Supplied AIME and PIA sources keep their own 1983 start. + assert replace(record, source="aime", aime=1000).benchmark() == 1983 + early = replace( + compute_history(fixture(), 2036, 2039, parameters()), + eligibility_year=eligibility, + ) + with pytest.raises(ValueError, match=EARLY): + history_pia_details(early, bend_points=(254, 1528)) + + +def test_uninsured_history_rejected(): with pytest.raises(ValueError, match="insured-status"): replace(worker(), insured=False).benchmark() diff --git a/policyengine_us/tests/unit/test_ss_history_engine.py b/policyengine_us/tests/unit/test_ss_history_engine.py index a60c2d819ff..4730ed2c0d9 100644 --- a/policyengine_us/tests/unit/test_ss_history_engine.py +++ b/policyengine_us/tests/unit/test_ss_history_engine.py @@ -277,7 +277,8 @@ def observed(year, wages=0, gross_draw=0): ] == pytest.approx(float(Decimal("77097.6") / 37)) ledger = json.loads(simulation.calculate("ss_calculated_history_ledger", 2039)[0]) assert ledger["eligibility_pia"] == "2088.5" - # E2.13's coefficient, selected at eligibility 2036; January 2039 formula. + # E2.13's coefficient (2025 Trustees Report edition), selected at + # eligibility 2036; January 2039 formula. assert ledger["donut_credit_rate"] == "0.02" assert ledger["donut_credit_rate_year"] == 2036 assert ledger["diagnostic_pia_entitlement_month"] == "2039-01" diff --git a/policyengine_us/tools/social_security/history.py b/policyengine_us/tools/social_security/history.py index 4bdf19bb256..23acc0b5a70 100644 --- a/policyengine_us/tools/social_security/history.py +++ b/policyengine_us/tools/social_security/history.py @@ -185,17 +185,33 @@ def _parameter(path, year, name, *, positive=False): return value +# 42 U.S.C. 415(a)(4)(B)(i) keeps the December 1978 computation, when larger, +# for a worker with pre-1979 earnings who becomes eligible before 1984. This +# route computes only the wage-indexed formula of 415(a)(1), so it starts at +# eligibility year 1984. +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" +) + + +def _check_supported_eligibility(eligibility_year): + if eligibility_year < EARLIEST_HISTORY_ELIGIBILITY_YEAR: + raise ValueError(EARLY_BENCHMARK_MESSAGE) + + def computation_years(eligibility_year): """Retirement computation years for age-62 eligibility year E. 42 U.S.C. 415(b)(2): elapsed years run from 1951 (or the year after age 21, if later) through the year before eligibility, and the computation period is elapsed years less five, at least two. For age-62 eligibility - from 1983, the supported range, that is min(35, E - 1956): 27 years in - 1983, 34 in 1990 and 35 from 1991. + from 1984, the supported range, that is min(35, E - 1956): 28 years in + 1984, 34 in 1990 and 35 from 1991. """ - if _year(eligibility_year) < 1983: - raise ValueError("Computation benchmarks before 1983 are unsupported") + _check_supported_eligibility(_year(eligibility_year)) return min(35, eligibility_year - 1956) @@ -269,8 +285,7 @@ def compute_history( ): """Compute a complete career, including earnings after 62 through Q-1.""" eligibility = _year(eligibility_year) - if eligibility < 1983: - raise ValueError("Computation benchmarks before 1983 are unsupported") + _check_supported_eligibility(eligibility) if claim_year < eligibility: raise ValueError("Claim year cannot precede eligibility") records = history.through_claim(claim_year, birth_year) @@ -431,6 +446,7 @@ def history_pia_details( payment_year=None, recompute_mode="cohort", ): + _check_supported_eligibility(_year(calculation.eligibility_year)) conventional = compute_pia( calculation.aime, calculation.eligibility_year, diff --git a/policyengine_us/tools/social_security/household.py b/policyengine_us/tools/social_security/household.py index da3fc665325..b499682c5cf 100644 --- a/policyengine_us/tools/social_security/household.py +++ b/policyengine_us/tools/social_security/household.py @@ -14,6 +14,8 @@ from typing import Mapping from .history import ( + EARLIEST_HISTORY_ELIGIBILITY_YEAR, + EARLY_BENCHMARK_MESSAGE, EarningsHistory, HistoryCalculation, HistoryParameters, @@ -262,6 +264,8 @@ def benchmark(self): ) if year < 1983: raise ValueError("Computation benchmarks before 1983 are unsupported") + if self.source == "history" and year < EARLIEST_HISTORY_ELIGIBILITY_YEAR: + raise ValueError(EARLY_BENCHMARK_MESSAGE) # RS 00201.001: the worker must be 62 throughout the first reduced # entitlement month. Birthdays on day 1 or 2 permit the birth month. earliest = year * 12 + self.birth_date.month - 1 From 8d80d184abf66b4c5f01114f94868b1cf717db9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:43:48 +0000 Subject: [PATCH 13/14] Give the 415(a)(4)(B)(i) reason at every pre-1984 history benchmark 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 --- .../tests/unit/test_ss_earnings_history.py | 25 ++++++++++++++++--- .../tools/social_security/household.py | 4 +-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/policyengine_us/tests/unit/test_ss_earnings_history.py b/policyengine_us/tests/unit/test_ss_earnings_history.py index b3a868dd08d..587331b409c 100644 --- a/policyengine_us/tests/unit/test_ss_earnings_history.py +++ b/policyengine_us/tests/unit/test_ss_earnings_history.py @@ -421,13 +421,12 @@ def test_benchmarks_before1984_rejected_at_each_history_entry_point(eligibility) entitlement_month=f"{eligibility}-02", eligibility_year=eligibility, ) - # E = 1982 meets the shared 1983 refusal first; E = 1983 meets the - # history route's own 1984 refusal. - with pytest.raises(ValueError, match=EARLY if eligibility == 1983 else "1983"): + with pytest.raises(ValueError, match=EARLY): record.benchmark() if eligibility == 1983: # Supplied AIME and PIA sources keep their own 1983 start. assert replace(record, source="aime", aime=1000).benchmark() == 1983 + assert replace(record, source="pia", pia=500).benchmark() == 1983 early = replace( compute_history(fixture(), 2036, 2039, parameters()), eligibility_year=eligibility, @@ -436,6 +435,26 @@ def test_benchmarks_before1984_rejected_at_each_history_entry_point(eligibility) history_pia_details(early, bend_points=(254, 1528)) +def test_history_benchmark_boundary_follows_age_62_attainment(): + # GN 00302.400: a January 1, 1922 birth attains 62 on December 31, 1983. + record = replace( + worker(history=fixture(), history_parameters=parameters()), + entitlement_month="1984-02", + ) + with pytest.raises(ValueError, match=EARLY): + replace(record, birth_date=date(1922, 1, 1), eligibility_year=1983).benchmark() + assert ( + replace(record, birth_date=date(1922, 1, 2), eligibility_year=1984).benchmark() + == 1984 + ) + + +def test_annual_records_cannot_begin_before_1951(): + early = EarningsHistory(1950, (EarningsRecord(1950, 3000, 0),)) + with pytest.raises(ValueError, match="base years"): + early.through_claim(1984) + + def test_uninsured_history_rejected(): with pytest.raises(ValueError, match="insured-status"): replace(worker(), insured=False).benchmark() diff --git a/policyengine_us/tools/social_security/household.py b/policyengine_us/tools/social_security/household.py index b499682c5cf..10302bc0631 100644 --- a/policyengine_us/tools/social_security/household.py +++ b/policyengine_us/tools/social_security/household.py @@ -262,10 +262,10 @@ def benchmark(self): raise ValueError( "Own-retirement eligibility year must equal age-62 attainment year" ) - if year < 1983: - raise ValueError("Computation benchmarks before 1983 are unsupported") if self.source == "history" and year < EARLIEST_HISTORY_ELIGIBILITY_YEAR: raise ValueError(EARLY_BENCHMARK_MESSAGE) + if year < 1983: + raise ValueError("Computation benchmarks before 1983 are unsupported") # RS 00201.001: the worker must be 62 throughout the first reduced # entitlement month. Birthdays on day 1 or 2 permit the birth month. earliest = year * 12 + self.birth_date.month - 1 From 0761d7b2bfed593aab01ce48d0d944b34d5cbdb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:43:49 +0000 Subject: [PATCH 14/14] State the 415(a)(4) and 415(d)(2) conditions in the history docs 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 --- .../social_security_history.md | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/contributed_reforms/social_security_history.md b/docs/contributed_reforms/social_security_history.md index 164742c8b16..aeef7e01ab5 100644 --- a/docs/contributed_reforms/social_security_history.md +++ b/docs/contributed_reforms/social_security_history.md @@ -76,15 +76,18 @@ year, whichever is later; annual inactive-policy defaults cover those years. The route computes only the wage-indexed formula of [42 U.S.C. 415(a)(1)](https://www.law.cornell.edu/uscode/text/42/415). For a -worker with covered earnings before 1979 who becomes eligible before 1984, -415(a)(4)(B)(i) keeps the computation under the subsection as in effect in -December 1978 when that amount is larger. The route cannot compute that -amount, so it refuses eligibility years before 1984. Section 415(a)(4)(B)(ii) -likewise keeps the subsection (d) computation when it is larger; subsection (d) -computes the 1939 Act primary insurance benefit, which counts wages before -1951. Annual records begin in 1951, so the route cannot take those wages or -compute that amount, and it assumes the worker had no covered wages before -1951. +worker with covered earnings before 1979 who first becomes eligible from 1979 +through 1983, 415(a)(4)(B)(i) keeps the computation under subsection (a) as in +effect in December 1978 when that amount is larger. The route cannot compute +that amount, so it refuses eligibility years before 1984. At any eligibility +year, 415(a)(4)(B)(ii) also keeps the subsection (d) computation when it is +larger. Subsection (d) computes the 1939 Act primary insurance benefit, which +counts wages before 1951, and 415(d)(2)(A) limits it to a worker with a quarter +of coverage before 1951. Annual records begin in 1951, so the route cannot take +those wages or compute that amount, and it assumes the worker had no covered +wages before 1951. It also assumes the worker had no disability-benefit +eligibility before January 1979, the case 415(a)(4)(A) takes outside +paragraph (1). ## Conventional AIME and the shared benefit pipeline @@ -231,12 +234,12 @@ population payroll-tax variables. **Modeling convention: cohort-selected donut coefficient.** For a custom changing path, annual tax/coverage settings determine the extra earnings stream, and the PIA credit coefficient is read at eligibility E, as benefit -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. For example, AIME+ -$240 at 2% against 3% gives $4.80 against $7.20; with a $2,180 baseline PIA -and claiming at 65, payment is $1,893 against $1,895. Pure callers whose -credited annual streams use different coefficients must supply the -eligibility coefficient explicitly. +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. For example, AIME+ $240 at 2% against 3% gives $4.80 against $7.20; +with a $2,180 baseline PIA and claiming at 65, payment is $1,893 against +$1,895. Pure callers whose credited annual streams use different coefficients +must supply the eligibility coefficient explicitly. ## Annual earnings ledger