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 new file mode 100644 index 00000000000..8c86c86cd23 --- /dev/null +++ b/changelog.d/ss-household-calculator-4a2.added.md @@ -0,0 +1 @@ +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/owners_draw.md b/docs/contributed_reforms/owners_draw.md index 34dc304c919..8225af17561 100644 --- a/docs/contributed_reforms/owners_draw.md +++ b/docs/contributed_reforms/owners_draw.md @@ -127,10 +127,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 `draw_oasdi_base` diff --git a/docs/contributed_reforms/social_security_history.md b/docs/contributed_reforms/social_security_history.md new file mode 100644 index 00000000000..aeef7e01ab5 --- /dev/null +++ b/docs/contributed_reforms/social_security_history.md @@ -0,0 +1,310 @@ +# 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 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 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 + +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 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 (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. + +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) +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 [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, 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 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 + +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 [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”. 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 +`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. + +**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. + +## 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; `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. + +## 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). +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: + +- 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/docs/usage/social-security-calculator.md b/docs/usage/social-security-calculator.md index b3c637bb708..ab6906ee466 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,9 +179,12 @@ 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. +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. @@ -241,6 +248,16 @@ and payable factors already applied in this ledger. The earnings test runs once. Explicit public final overrides retain their precedence. Calculated checks are never written into `_reported` as original data. +The public selectors do not yet accept `history`: a public source of `history`, +or any pair other than those above, raises an error. With +`ss_calculator_source` of `history`, read the calculated checks from +`ss_calculated_retirement` and the other `ss_calculated_*` outputs. To place +such a check in a public component, call `set_input` on that component +**after** constructing the simulation. A later public input is an explicit +final override, so no benefit factor is applied to it again. A public benefit +supplied when the simulation is constructed is instead treated as a reported +source, and the benefit factors apply to it. + ## Sources and validation Published fixtures cite [SSA Supplement 2023 Appendix D](https://www.ssa.gov/policy/docs/statcomps/supplement/2023/apnd.html), 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..82244469bb9 --- /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: + 0000-01-01: false +metadata: + unit: bool + label: Custom annual-history donut benefit credit + economy: false + reference: + - 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 new file mode 100644 index 00000000000..9f39b6e17ad --- /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: + 0000-01-01: 0 +metadata: + unit: /1 + label: Custom annual-history donut AIME+ factor + economy: false + reference: + - 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 new file mode 100644 index 00000000000..50800eb06f1 --- /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: + 0000-01-01: false +metadata: + unit: bool + label: Custom annual-history donut activation + economy: false + reference: + - 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 new file mode 100644 index 00000000000..18d8fbcdbc3 --- /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: + 0000-01-01: true +metadata: + unit: bool + label: Custom annual-history donut owners' draw inclusion + economy: false + reference: + - 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 new file mode 100644 index 00000000000..6c0a4362fa8 --- /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: + 0000-01-01: true +metadata: + unit: bool + label: Custom annual-history donut covered self-employment inclusion + economy: false + reference: + - 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 new file mode 100644 index 00000000000..080b5ba885f --- /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: + 0000-01-01: 2027 +metadata: + unit: year + label: Custom annual-history donut first earnings year + economy: false + reference: + - 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 new file mode 100644 index 00000000000..ed314253527 --- /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: + 0000-01-01: 0 +metadata: + unit: /1 + label: Custom annual-history donut above-threshold OASDI rate + economy: false + reference: + - 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 new file mode 100644 index 00000000000..3d21e281d11 --- /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: + 0000-01-01: 0 +metadata: + unit: currency-USD + label: Custom annual-history donut earnings threshold + economy: false + reference: + - 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 new file mode 100644 index 00000000000..c7d48dcaa60 --- /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: + 0000-01-01: true +metadata: + unit: bool + label: 2025 OACT E2.13 annual-history donut benefit credit + economy: false + reference: + - 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 new file mode 100644 index 00000000000..e1d4636bca1 --- /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: + 0000-01-01: 0.02 +metadata: + unit: /1 + label: 2025 OACT E2.13 annual-history donut AIME+ factor + economy: false + reference: + - 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 new file mode 100644 index 00000000000..569be0c364a --- /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: + 0000-01-01: false +metadata: + unit: bool + label: 2025 OACT E2.13 annual-history donut activation + economy: false + reference: + - 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 new file mode 100644 index 00000000000..60b8e74ae90 --- /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: + 0000-01-01: true +metadata: + unit: bool + label: 2025 OACT E2.13 annual-history donut owners' draw inclusion + economy: false + reference: + - 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 new file mode 100644 index 00000000000..b7e66be447c --- /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: + 0000-01-01: true +metadata: + unit: bool + label: 2025 OACT E2.13 annual-history donut covered self-employment inclusion + economy: false + reference: + - 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 new file mode 100644 index 00000000000..081d84b853c --- /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: + 0000-01-01: 2027 +metadata: + unit: year + label: 2025 OACT E2.13 annual-history donut first earnings year + economy: false + reference: + - 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 new file mode 100644 index 00000000000..53f93c1805e --- /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: + 0000-01-01: 0.124 +metadata: + unit: /1 + label: 2025 OACT E2.13 annual-history donut above-threshold OASDI rate + economy: false + reference: + - 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 new file mode 100644 index 00000000000..11f31e5686d --- /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: + 0000-01-01: 400000 +metadata: + unit: currency-USD + label: 2025 OACT E2.13 annual-history donut earnings threshold + economy: false + reference: + - 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 new file mode 100644 index 00000000000..acf57b33913 --- /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: + 0000-01-01: true +metadata: + unit: bool + label: 2025 OACT E2.14 annual-history donut benefit credit + economy: false + reference: + - 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 new file mode 100644 index 00000000000..f462892f867 --- /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: + 0000-01-01: 0.02 +metadata: + unit: /1 + label: 2025 OACT E2.14 annual-history donut AIME+ factor + economy: false + reference: + - 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 new file mode 100644 index 00000000000..98948f1a8c0 --- /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: + 0000-01-01: false +metadata: + unit: bool + label: 2025 OACT E2.14 annual-history donut activation + economy: false + reference: + - 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 new file mode 100644 index 00000000000..0d1ee0c3104 --- /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: + 0000-01-01: true +metadata: + unit: bool + label: 2025 OACT E2.14 annual-history donut owners' draw inclusion + economy: false + reference: + - 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 new file mode 100644 index 00000000000..db5dde910af --- /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: + 0000-01-01: true +metadata: + unit: bool + label: 2025 OACT E2.14 annual-history donut covered self-employment inclusion + economy: false + reference: + - 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 new file mode 100644 index 00000000000..ae870e128d4 --- /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: + 0000-01-01: 2027 +metadata: + unit: year + label: 2025 OACT E2.14 annual-history donut first earnings year + economy: false + reference: + - 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 new file mode 100644 index 00000000000..65ab7a868fe --- /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: + 0000-01-01: 0.124 +metadata: + unit: /1 + label: 2025 OACT E2.14 annual-history donut above-threshold OASDI rate + economy: false + reference: + - 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 new file mode 100644 index 00000000000..755a975fef0 --- /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: + 0000-01-01: 250000 +metadata: + unit: currency-USD + label: 2025 OACT E2.14 annual-history donut earnings threshold + economy: false + reference: + - 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 new file mode 100644 index 00000000000..ab5cf9ccead --- /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: 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 new file mode 100644 index 00000000000..6ce585e084f --- /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: OACT provision B3.12, 2025 Trustees Report edition + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/benefitlevel.html diff --git a/policyengine_us/reforms/ssa/owners_draw.py b/policyengine_us/reforms/ssa/owners_draw.py index 8d676167dda..624da0324c4 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, ) @@ -29,17 +33,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(): @@ -75,31 +91,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 @@ -109,9 +101,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 @@ -120,18 +110,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 @@ -141,12 +120,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 @@ -156,9 +130,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 @@ -168,22 +140,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 @@ -193,10 +150,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 @@ -206,8 +160,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 @@ -217,10 +170,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 @@ -235,13 +185,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 @@ -255,9 +199,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/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_owners_draw_kernel.py b/policyengine_us/tests/unit/test_owners_draw_kernel.py new file mode 100644 index 00000000000..14c77fd311d --- /dev/null +++ b/policyengine_us/tests/unit/test_owners_draw_kernel.py @@ -0,0 +1,381 @@ +"""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 +import gc +from types import SimpleNamespace +import weakref + +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 +) + + +@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 {}) + 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_calculator_differential.py b/policyengine_us/tests/unit/test_ss_calculator_differential.py index b5c15d881ef..f8bf1049e19 100644 --- a/policyengine_us/tests/unit/test_ss_calculator_differential.py +++ b/policyengine_us/tests/unit/test_ss_calculator_differential.py @@ -31,6 +31,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 leaf must be classified: either # calculator-only (at or under a path in NEW_PARAMETER_NODES, so the reference 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..a8482b00bde --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_donut.py @@ -0,0 +1,221 @@ +"""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("year", [1951, 1980]) +@pytest.mark.parametrize("variant", DONUT_VARIANTS) +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() + 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", + [ + (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_earnings_history.py b/policyengine_us/tests/unit/test_ss_earnings_history.py new file mode 100644 index 00000000000..587331b409c --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_earnings_history.py @@ -0,0 +1,577 @@ +"""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, 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, + HistoryParameters, + aime_from_indexed, + compute_history, + compute_history_pia, + computation_years, + history_pia_details, +) +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 + + +# --- 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 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" + + +@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(1984) == 28 + assert computation_years(1990) == 34 + assert computation_years(1991) == 35 + assert computation_years(2036) == 35 + 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): + """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", + [ + # 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: + # 333.00 + .32 x 560 = 512.20. + (1991, 35, "390818.79", 930, "512.2"), + ], +) +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) + 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) + 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) + + +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, + ) + 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, + ) + with pytest.raises(ValueError, match=EARLY): + 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() + + +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 + + +@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), +) +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) + + +@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)} + 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_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..4730ed2c0d9 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_history_engine.py @@ -0,0 +1,340 @@ +"""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, ROUND_HALF_UP + +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(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 (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" + 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"]} + 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 + + +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 + # 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_reforms.py b/policyengine_us/tests/unit/test_ss_history_reforms.py new file mode 100644 index 00000000000..aa7f6150627 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_history_reforms.py @@ -0,0 +1,472 @@ +"""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 FormulaPath, 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_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, + 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/tests/unit/test_ss_history_report.py b/policyengine_us/tests/unit/test_ss_history_report.py new file mode 100644 index 00000000000..d218d8ec179 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_history_report.py @@ -0,0 +1,230 @@ +"""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") + + +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 new file mode 100644 index 00000000000..b91d36dc48c --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_history_ssa_examples.py @@ -0,0 +1,157 @@ +"""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 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, + HistoryParameters, + 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), +# 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", + [ + # 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"), + ], +) +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 + 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/tests/unit/test_ss_mini_pia.py b/policyengine_us/tests/unit/test_ss_mini_pia.py new file mode 100644 index 00000000000..458e266b854 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_mini_pia.py @@ -0,0 +1,173 @@ +"""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 HealthCheck, given, settings, 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) + + +@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), +) +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/donut.py b/policyengine_us/tools/social_security/donut.py new file mode 100644 index 00000000000..ced9fb186cf --- /dev/null +++ b/policyengine_us/tools/social_security/donut.py @@ -0,0 +1,108 @@ +"""Separate annual above-threshold tax and optional benefit-credit streams. + +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 +""" + +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/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 b49f055dab6..9d5db9f5385 100644 --- a/policyengine_us/tools/social_security/engine.py +++ b/policyengine_us/tools/social_security/engine.py @@ -17,6 +17,8 @@ ) 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 from .means_test import cola_override, component_withholding, indexed_cola_rate @@ -30,9 +32,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): @@ -107,6 +113,24 @@ 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, + donut_variant=text("donut_variant"), + ), + ) # Include the following January to price December entitlement, and any # supplied source valuation used for baseline survivor recovery. through = max( @@ -322,7 +346,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 new file mode 100644 index 00000000000..23acc0b5a70 --- /dev/null +++ b/policyengine_us/tools/social_security/history.py @@ -0,0 +1,542 @@ +"""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. 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 +from decimal import Decimal +from fractions import Fraction +import json +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 + + +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 + owners_draw: OwnersDrawInputs | dict | None = None + + +@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, OwnersDrawInputs())) + for year in range(first, claim) + ) + + +@dataclass(frozen=True) +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 + 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) +class AnnualEarnings: + year: int + covered_wages: Decimal + covered_self_employment: Decimal + ordinary_cap: Decimal + 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) + donut_taxed: Decimal = Decimal(0) + 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) +class HistoryCalculation: + eligibility_year: int + claim_year: int + ledger: tuple[AnnualEarnings, ...] + aime: int + 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) +class HistoryPIACalculation: + conventional_pia: Decimal + mini_pia: Fraction + 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): + 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 + + +# 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 1984, the supported range, that is min(35, E - 1956): 28 years in + 1984, 34 in 1990 and 35 from 1991. + """ + _check_supported_eligibility(_year(eligibility_year)) + 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),), years) + return int(sum(selected, Decimal(0)) // (12 * years)) + + +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 +): + """Compute a complete career, including earnings after 62 through Q-1.""" + eligibility = _year(eligibility_year) + _check_supported_eligibility(eligibility) + 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 = [] + credit_rates = {} + 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) + 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 + 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.setdefault( + _number(donut_parameters.credit_rate, "donut credit rate"), + row.year, + ) + 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, + 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), + 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), + 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: + if len(credit_rates) > 1: + raise ValueError( + "Mixed annual donut rates require an explicit eligibility-cohort coefficient" + ) + # 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), years), + mini_share, + aime_plus, + credit_rate * aime_plus, + years, + credit_rate, + credit_rate_year, + ) + + +def history_pia_details( + calculation, + *, + bend_points, + formula_path=None, + payment_year=None, + recompute_mode="cohort", +): + _check_supported_eligibility(_year(calculation.eligibility_year)) + conventional = compute_pia( + calculation.aime, + calculation.eligibility_year, + formula_path, + payment_year, + 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) + combined = blended + calculation.donut_credit_pia + 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( + 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 + + 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 + for year in years + }, + nawi={ + year: parameters.gov.ssa.nawi(f"{year}-01-01") + for year in set(years) | {eligibility_year - 2} + }, + 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, + 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 new file mode 100644 index 00000000000..591b9c684c5 --- /dev/null +++ b/policyengine_us/tools/social_security/history_report.py @@ -0,0 +1,160 @@ +"""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; 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}") + 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) + # 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 + ) + 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") + credit_rate = getattr(history, "donut_credit_rate", Fraction(0)) + 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")), + "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=(",", ":"), + allow_nan=False, + ) diff --git a/policyengine_us/tools/social_security/household.py b/policyengine_us/tools/social_security/household.py index d4865fb6a54..10302bc0631 100644 --- a/policyengine_us/tools/social_security/household.py +++ b/policyengine_us/tools/social_security/household.py @@ -13,6 +13,17 @@ from fractions import Fraction from typing import Mapping +from .history import ( + EARLIEST_HISTORY_ELIGIBILITY_YEAR, + EARLY_BENCHMARK_MESSAGE, + EarningsHistory, + HistoryCalculation, + HistoryParameters, + HistoryPIACalculation, + compute_history, + history_pia_details, +) + from policyengine_us.tools.social_security.cola import ( adjusted_events as adjust_cola_events, apply_colas, @@ -223,12 +234,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) @@ -247,6 +262,8 @@ def benchmark(self): raise ValueError( "Own-retirement eligibility year must equal age-62 attainment year" ) + 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 @@ -322,6 +339,8 @@ 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 + history_pia: HistoryPIACalculation | None = None @property def deferred_earnings_test_payment(self): @@ -475,8 +494,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() @@ -522,6 +554,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) @@ -548,7 +581,23 @@ 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: + 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 = replace( + details, entitlement_month=entitlement, formula_year=payment_year + ) + elif payment_year >= benchmark and has_active_reform( path, benchmark, payment_year, mode ): if aime is None and baseline_pia != 0: @@ -768,4 +817,6 @@ def calculate_year( pia_interval, inferred_interval, tuple(settlements), + history, + history_pia, ) 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..19a5efd1da8 --- /dev/null +++ b/policyengine_us/tools/social_security/mini_pia.py @@ -0,0 +1,121 @@ +"""Annual mini-PIAs for the opt-in covered-earnings history route. + +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 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/owners_draw.py b/policyengine_us/tools/social_security/owners_draw.py new file mode 100644 index 00000000000..4cd7c044277 --- /dev/null +++ b/policyengine_us/tools/social_security/owners_draw.py @@ -0,0 +1,288 @@ +"""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 + + 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 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( 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 83fe288dad7..d158468ae57 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,36 @@ 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_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): 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") 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)