diff --git a/changelog.d/ss-cola-means-test-e3.added.md b/changelog.d/ss-cola-means-test-e3.added.md new file mode 100644 index 00000000000..9b79cb1d69d --- /dev/null +++ b/changelog.d/ss-cola-means-test-e3.added.md @@ -0,0 +1 @@ +Add an opt-in Social Security COLA means test using prior-year MAGI excluding Social Security, configurable indexed thresholds and filing groups, cohort or all-beneficiary scope, statutory or chained CPI-U COLAs, income-history sensitivities, and an A9 preset. Apply each withholding decision jointly with other COLA changes in reported benefits and the household calculator, with current-law defaults unchanged. diff --git a/docs/social-security-benefit-levers.md b/docs/social-security-benefit-levers.md index 935fcfe4611..c09263434b6 100644 --- a/docs/social-security-benefit-levers.md +++ b/docs/social-security-benefit-levers.md @@ -115,8 +115,10 @@ For each exposed payment event, statutory baseline rate c and configured addition delta give adjusted rate max(0,c+delta), without a second rounding of the policy addition. The joint multiplier is `(1+adjusted)/(1+c)` unless that event is withheld, in which case it is `1/(1+c)`. Each component's -`_cola_withheld` term defaults false; a subsequent means-test implementation -supplies the rules. Switching a policy off later preserves its earlier effect +`_cola_withheld` term defaults false; the optional +[COLA means test](social-security-cola-means-test.md) supplies prior-year income +decisions when enabled. Explicit decisions remain available. Switching a policy +off later preserves its earlier effect on the benefit level. Public reported-route benefits multiply this joint factor, the PIA ratio factor, the across-the-board factor, and the payable factor once. diff --git a/docs/social-security-cola-means-test.md b/docs/social-security-cola-means-test.md new file mode 100644 index 00000000000..7e49eeba5a3 --- /dev/null +++ b/docs/social-security-cola-means-test.md @@ -0,0 +1,254 @@ +# Social Security COLA means test + +The contributed COLA means test is disabled under current law. It can withhold +an annual COLA based on the beneficiary's tax unit, while retaining the benefit +level reached before that event. It supports both reported benefit components +and the supplied-record household calculator. Scenario thresholds and dates +come from the scoring settings; PE-US does not choose package values. + +## Policy basis and choices + +The income test follows [OACT provision A9 on the 2026 Trustees Report +basis](https://www.ssa.gov/OACT/solvency/provisions/cola.html). Its instruction +is: “Use prior tax year income data for this determination.” The live page +was available when this implementation was prepared. + +A9 uses a chained CPI-U COLA below the income line and no COLA above it, for +all beneficiaries beginning with the December 2028 COLA. Its single, +head-of-household and separate filers share a line; joint filers have twice +that line. It also specifies chained CPI-U before benefit receipt. Its example +lines are $85,000 and $170,000 in 2018 dollars, indexed by CPI-U. The preset +below implements these mechanics. OACT's displayed December 2028 dollar +estimates use its projections; this model calculates indexed lines from its +own pinned CPI-U path. + +The default enabled scenario differs from A9 in two parameterized ways: it +retains the statutory COLA below the line and restricts the policy to people +first eligible from 2026. The effective date, first-eligible year, filing-group +mapping, dollar lines, base year, index lag and rounding increment are also +parameters. A surviving spouse maps to the joint line by default; this is a +modeling choice because A9 does not name that status. Only income strictly +above the line withholds a COLA. Equality receives the COLA, an explicit +convention for A9's otherwise unspecified equality case. + +## Parameters and activation + +All paths in this table are relative to +`gov.contrib.ssa.benefit_levers.cola.means_test`. +Choice parameters use numeric codes because the parameter engine does not +accept string-valued choices. The semantic names below label those codes; +reforms must supply the numbers. + +| Parameter | Current-law/default value | Scenario meaning | +| --- | --- | --- | +| `enabled` | `false` | Opt into the means test. | +| `scope` | `0` = `first_eligible_cohorts` | Alternatively, `1` = `all_beneficiaries`. | +| `first_eligible_year` | `2026` | Inclusive first-eligibility boundary in cohort mode. | +| `effective_year`, `effective_month` | `2026`, `12` | First affected effective COLA; ordinarily first paid January 2027. | +| `below_threshold_cola_index` | `0` = `statutory` | Alternatively, `1` = `chained_cpi_u`. | +| `pre_receipt_cola_index` | `-1` = `follow_below_threshold` | Follow the below-line choice, or explicitly select `0` = `statutory` or `1` = `chained_cpi_u`. | +| `income_sensitivity` | `0` = `prior_year` | Alternatively, `1` = `never_withheld` or `2` = `always_withheld`. | +| `thresholds.base_year` | `2026` | Dollar year of the supplied lines. | +| `thresholds.single`, `thresholds.joint` | `0`, `0` | Neutral placeholders; evaluated `prior_year` income decisions require positive scenario lines. | +| `thresholds.index_lag` | `1` | Years before the payment year for the December CPI-U observation. | +| `thresholds.rounding` | `100` | Dollar increment, rounded half up. | +| `filing_groups.SINGLE` | `0` = `single` | Select the single line; `1` selects joint. | +| `filing_groups.HEAD_OF_HOUSEHOLD` | `0` = `single` | Select the single line; `1` selects joint. | +| `filing_groups.SEPARATE` | `0` = `single` | Select the single line; `1` selects joint. | +| `filing_groups.JOINT` | `1` = `joint` | Select the joint line; `0` selects single. | +| `filing_groups.SURVIVING_SPOUSE` | `1` = `joint` | Modeling choice; `0` selects single. | + +`means_test.enabled` activates both the income decision and the selected index +without requiring another switch. The enclosing `cola.withholding_enabled` +remains the A5 switch for externally resolved withholding decisions when the +means test is disabled. With either channel enabled, explicit component +withholding values and `ss_calculator_withhold_cola` are deliberate decision +overrides. The automatic timing, cohort and receipt restrictions apply to +calculated decisions; supplied decisions retain normal override precedence. +When the automatic policy is inactive, manual component inputs retain their +legacy carry-forward behavior. Calculator-specific decisions apply only in +their explicitly supplied year. + +Positive single and joint lines are required when a `prior_year` income +decision is evaluated. The `never_withheld` and `always_withheld` sensitivities +and pre-receipt index-only events do not read those lines. + +The `a9` preset in `policyengine_us.reforms.ssa.cola_means_test` enables the +policy and selects: + +```yaml +scope: 1 # all_beneficiaries +effective_year: 2028 +effective_month: 12 +below_threshold_cola_index: 1 # chained_cpi_u +pre_receipt_cola_index: 1 # chained_cpi_u +income_sensitivity: 0 # prior_year +thresholds: + base_year: 2018 + single: 85000 + joint: 170000 + index_lag: 1 + rounding: 100 +``` + +Apply it as a reform, for example: + +```python +from policyengine_us import Simulation +from policyengine_us.reforms.ssa.cola_means_test import a9 + +simulation = Simulation(situation=situation, reform=a9) +``` + +These are public A9 example settings, not a scenario's package values. The +first affected checks are in January 2029. The ordinary contributed scenario +instead supplies its own positive 2026-dollar lines and retains the default +statutory/cohort choices unless its policy specifies otherwise. + +## Prior-year income and filing status + +`social_security_cola_means_test_magi` is a dedicated annual TaxUnit variable. +Its definition is AGI less taxable Social Security, plus tax-exempt interest +and the IRMAA-style exclusions under Internal Revenue Code sections 135, 911, +931 and 933. AGI retains its applicable deductions. This implements the +required MAGI excluding Social Security, replacing the earlier gross-source +income proposal. The [A9-linked actuarial memorandum, provision 5 on page +8](https://www.ssa.gov/OACT/solvency/SJohnson_20161208.pdf) connects the lines +to Medicare IRMAA; [H.R. 6489 section +103(d)](https://www.congress.gov/bill/114th-congress/house-bill/6489/text) +supplies the corresponding IRMAA definition reference. Excluding Social +Security and using the prior year are requirements of this contributed lever. + +For COLAs first paid in year `y`, the decision reads this MAGI and filing +status for tax year `y - 1`. It never substitutes current-year AGI. A supplied +MAGI history can directly set the dedicated variable for the relevant tax +years; supplied histories of its income sources and filing status also take +precedence over projected records. A tax unit's decision maps to each +beneficiary, subject to that component's exposure and cohort scope. +Core stores complete input arrays for each variable and period; an income +history column must supply a value for every tax unit in that array. + +Although prior-year AGI can depend on prior-year Social Security, every +benefit-related lookback moves to an earlier year. The dependency chain stops +before the policy's first affected event. It cannot return to the same +benefit/year pair. Multi-year dependency-graph tests exercise this decreasing +year chain, including deductions whose MAGI depends on taxable benefits. +The household engine resolves decisions only through the requested check +year for payment outputs. It does not eagerly read the income for the next +January COLA while computing this year's checks. December entitlement and +annual entitlement can require that next event; their prior-income dependency +uses the separate public payment route. + +Population scoring uses a **static-cross-section prior-income proxy**. +Dataset extension starts each year from the same original record and uprates +its monetary sources separately. The `y - 1` record is that same record's +extended prior-year copy, not an observed longitudinal income history. Ages, +cohorts and household composition are not carried forward as a person's +history. Supplied histories override this proxy. Compare +`income_sensitivity = 1` (`never_withheld`) and `2` (`always_withheld`) to +report the missing-income-history sensitivity envelope; both retain policy +timing and cohort scope. + +## Threshold indexing and COLA construction + +For filing group `g`, base year `b`, payment year `y`, index lag `L` and +rounding increment `R`, the income line is: + +```text +line(g, y) = round_half_up_to_R( + base_line(g) * CPI_U(December, y - L) / CPI_U(December, b) +) +withheld(y) = prior_year_MAGI > line(g, y) +``` + +The CPI-U series is `gov.bls.cpi.cpi_u`. The default observes the December +before the check year, including December 2028 for January 2029 checks. +Parameter snapshots are held until the next authored observation, including +in projections. The December snapshot, one-year payment lag and nearest-$100 +half-up rounding are documented modeling conventions: A9 specifies CPI-U +indexing without prescribing these details. Pin both the index path and +these settings for a score. + +The chained option uses Q3 averages of July, August and September levels from +`gov.bls.cpi.c_cpi_u`. As with the statutory CPI-W schedule, positive growth +is rounded half up to 0.1 percentage point; nonpositive or rounded-zero growth +gives no COLA. The comparison base is the Q3 associated with the last positive +index-derived COLA. A withheld year still advances that index schedule's +comparison base when its rounded rate is positive: income withholding must +not create a catch-up COLA later. Neither withholding nor a downward rate +adjustment reduces the nominal benefit level. Historical benefit-rounding +rules remain attached to their individual events. + +Before receipt, `pre_receipt_cola_index` controls COLAs accrued since initial +eligibility. Its default follows `below_threshold_cola_index`; the A9 preset +therefore uses chained CPI-U for these events. From the policy's effective +start, these pre-receipt events use the chosen index without an income test. +Supply entitlement and worker eligibility dates when known. Missing-date +cohort and exposure approximations remain those described in the +[benefit-lever guide](social-security-benefit-levers.md), including +adjacent-cohort sensitivities for integer-age uncertainty. + +An active A5 `cola.alternative_rate` overrides the index selected here. The +applicable `cola.pp_change` is then added to that rate, and the final rate is +floored at zero. Withholding overrides the entire resulting increase. + +## One joint COLA adjustment + +Each annual Person variable `social_security_X_cola_withheld`, for `X` equal +to `retirement`, `disability`, `survivors` or `dependents`, has a formula that +resolves its tax unit's decision. Explicit values override that formula. +For a reported benefit, the population adjustment accumulates one ratio for +each exposed event: + +```text +event numerator = 1 if withheld + 1 + adjusted_COLA otherwise +event ratio = event numerator / (1 + statutory_COLA) +joint factor = product of the exposed event ratios +``` + +The adjusted rate includes the selected index and applicable percentage-point +change, with a floor of zero. For example, statutory rates of 2% and 3% plus a +CPI-E-style addition of 0.15 percentage points give numerators `1.0215` and +`1.0315` if paid. If the second year is withheld, the two-year factor is +`(1.0215 / 1.02) * (1 / 1.03)`, approximately `0.97230`. The reformed level +from a pre-event amount of $1,000 is $1,021.50, compared with the statutory +$1,050.60. The withheld event receives no CPI-E increase. An independently +multiplied withholding factor would give the wrong result. + +The household calculator applies the same per-year decisions inside its COLA +schedule. It rounds the resulting PIA under the applicable event rule and +applies claiming and payment calculations once. Its explicit annual +`ss_calculator_withhold_cola` value takes precedence over the shared decision. + +For a public retirement component, select `social_security_retirement_source` +as `supplied_aime` or `supplied_pia`, and supply the matching +`ss_calculator_source` of `aime` or `pia`. For a restricted survivor record, +select `social_security_survivors_source = supplied_ratio_survivor` and +`ss_calculator_source = survivor`. These routes retain the calculator's input +and scope requirements in the [calculator +guide](usage/social-security-calculator.md). + +Calculated public routes bypass all population benefit factors: the engine +already applies the COLA, across-the-board and payable policies to its ledger. +Reported sources use the population factors. This avoids applying any of +these policies twice. Switching a policy off later does not restore an +earlier withheld increase. + +## Validation contract + +Validation covers all statutory/chained and cohort/all-beneficiary +combinations, both sides of the first-eligibility boundary, the A9 preset, +all filing statuses, equality, supplied histories, and both income +sensitivities. Boundary cases include a zero statutory COLA, negative rate +adjustments, high-water years and combined CPI-E changes and withholding. +Multi-year graph checks establish that the prior-year income read has no +cycle. Calculated-source cases check that the COLA policy is applied once. + +Property checks require disabled-policy equality to the stacked base, +nonincreasing benefits when a year is withheld, monotone withholding as income +rises at a fixed line, and equality of the joint factor to the product of its +per-year ratios. Differential checks cover reported situations, downstream +outputs and household calculator examples with this means test disabled. +Population impact requires a separately pinned scoring run; these mechanics +do not substitute OACT's solvency estimates for a PE-US result. diff --git a/docs/usage/social-security-calculator.md b/docs/usage/social-security-calculator.md index adca67d9941..8f9ce8fd72a 100644 --- a/docs/usage/social-security-calculator.md +++ b/docs/usage/social-security-calculator.md @@ -220,23 +220,23 @@ Withholding sets that event's COLA to zero, jointly with any rate change; no second COLA factor is applied. Payable shares must lie in [0,1], and rates must not imply a negative benefit. A depletion-year midyear blend is supplied in the annual payable-share path. E2 and E3 income/threshold computation belong to -their respective modules; their policies and jointly calculated tax-unit -outputs can consume this route once public wiring exists. +their respective modules. The [COLA means test](../social-security-cola-means-test.md) +now supplies these decisions from prior-tax-year MAGI and filing status. ## Public source-selector wiring -Source-selector PR #10400 was open when this route was built. Its canonical -COLA history and common lever parameter files are reused here. Historical bend +The source selectors reuse the canonical COLA history and common lever +parameter files. Historical bend points remain in a separate table because copying its legacy scale backfill -would change existing pre-2015 `ss_pia` outputs with this route disabled. This change adds -no `_reported` twins and changes no public benefit formula. Once it merges, -map its supplied-AIME/PIA retirement modes to `ss_calculated_retirement` and its -restricted survivor mode to `ss_calculated_survivors`. Bypass population -PIA/COLA factors and any across/payable factors already applied in this ledger; -perform the earnings test once. Preserve explicit public final overrides. -For joint household tax calculations before that wiring, explicitly supply the -calculated annual checks to the existing public components in a separate -calculation; never write them into `_reported` as if they were original data. +would change existing pre-2015 `ss_pia` outputs with this route disabled. +Select `social_security_retirement_source` as `supplied_aime` or `supplied_pia` +with matching `ss_calculator_source` of `aime` or `pia`. For the restricted +survivor route, select `social_security_survivors_source` as +`supplied_ratio_survivor` with calculator source `survivor`. The public benefit +then reads the calculated checks, bypassing population PIA, COLA, across-the-board +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. ## Sources and validation diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/below_threshold_cola_index.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/below_threshold_cola_index.yaml new file mode 100644 index 00000000000..bf563f666c3 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/below_threshold_cola_index.yaml @@ -0,0 +1,9 @@ +description: "COLA index for income at or below the means-test threshold: 0 = statutory pays the full statutory COLA; 1 = chained_cpi_u uses gov.bls.cpi.c_cpi_u with statutory rounding, high-water and no-decrease rules. The A9 preset uses 1. Numeric codes provide compatibility with the parameter engine." +values: + 0000-01-01: 0 +metadata: + label: Social Security below-threshold COLA index + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/effective_month.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/effective_month.yaml new file mode 100644 index 00000000000..7a1279b3b56 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/effective_month.yaml @@ -0,0 +1,10 @@ +description: First effective COLA calendar month for the contributed means test. The default December COLA first changes January payments in the following calendar year. +values: + 0000-01-01: 12 +metadata: + unit: month + label: Social Security COLA means-test effective month + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/effective_year.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/effective_year.yaml new file mode 100644 index 00000000000..0844cc81316 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/effective_year.yaml @@ -0,0 +1,10 @@ +description: First effective COLA calendar year for the contributed means test. A December COLA first changes January payments in the following year. The A9 preset uses December 2028. +values: + 0000-01-01: 2026 +metadata: + unit: year + label: Social Security COLA means-test effective year + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/enabled.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/enabled.yaml new file mode 100644 index 00000000000..e7e51312b3e --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/enabled.yaml @@ -0,0 +1,10 @@ +description: Enable the contributed Social Security COLA means test. Positive single and joint income thresholds supplied by the scenario are required when a prior_year income decision is evaluated. The never_withheld and always_withheld sensitivities and pre-receipt index-only events do not read the thresholds. +values: + 0000-01-01: false +metadata: + unit: bool + label: Social Security COLA means test enabled + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/HEAD_OF_HOUSEHOLD.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/HEAD_OF_HOUSEHOLD.yaml new file mode 100644 index 00000000000..67cc10e808f --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/HEAD_OF_HOUSEHOLD.yaml @@ -0,0 +1,9 @@ +description: "Income threshold group for a head of household under the COLA means test: 0 = single; 1 = joint. OACT A9 uses 0; scenarios may supply another mapping." +values: + 0000-01-01: 0 +metadata: + label: Social Security COLA means-test head of household group + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/JOINT.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/JOINT.yaml new file mode 100644 index 00000000000..04c1be6c5ed --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/JOINT.yaml @@ -0,0 +1,9 @@ +description: "Income threshold group for joint filers under the COLA means test: 0 = single; 1 = joint. OACT A9 uses 1; scenarios may supply another mapping." +values: + 0000-01-01: 1 +metadata: + label: Social Security COLA means-test joint filer group + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/SEPARATE.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/SEPARATE.yaml new file mode 100644 index 00000000000..3e0e554b285 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/SEPARATE.yaml @@ -0,0 +1,9 @@ +description: "Income threshold group for married filing separately under the COLA means test: 0 = single; 1 = joint. OACT A9 uses 0; scenarios may supply another mapping." +values: + 0000-01-01: 0 +metadata: + label: Social Security COLA means-test separate filer group + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/SINGLE.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/SINGLE.yaml new file mode 100644 index 00000000000..d387884fe99 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/SINGLE.yaml @@ -0,0 +1,9 @@ +description: "Income threshold group for a single filer under the COLA means test: 0 = single; 1 = joint. OACT A9 uses 0; scenarios may supply another mapping." +values: + 0000-01-01: 0 +metadata: + label: Social Security COLA means-test single filer group + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/SURVIVING_SPOUSE.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/SURVIVING_SPOUSE.yaml new file mode 100644 index 00000000000..b556c9f6558 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/filing_groups/SURVIVING_SPOUSE.yaml @@ -0,0 +1,9 @@ +description: "Income threshold group for a surviving spouse under the COLA means test: 0 = single; 1 = joint. OACT A9 does not name this filing status; the default 1 is an explicit modeling choice that scenarios may change." +values: + 0000-01-01: 1 +metadata: + label: Social Security COLA means-test surviving spouse group + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/first_eligible_year.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/first_eligible_year.yaml new file mode 100644 index 00000000000..1288983bef2 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/first_eligible_year.yaml @@ -0,0 +1,7 @@ +description: First eligibility cohort reached when the Social Security COLA means-test scope is first_eligible_cohorts. This contributed reform is disabled by default. +values: + 0000-01-01: 2026 +metadata: + unit: year + label: Social Security COLA means-test first eligibility cohort + economy: false diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/income_sensitivity.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/income_sensitivity.yaml new file mode 100644 index 00000000000..094768cab92 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/income_sensitivity.yaml @@ -0,0 +1,9 @@ +description: "Income decision for the COLA means test: 0 = prior_year uses the preceding tax year's MAGI excluding Social Security; 1 = never_withheld and 2 = always_withheld provide the missing-history sensitivity envelope. Extended population data are static cross-sections, so the prior-year copy of a record is a proxy rather than an observed income history; supplied histories override that proxy. Numeric codes provide compatibility with the parameter engine." +values: + 0000-01-01: 0 +metadata: + label: Social Security COLA means-test income sensitivity + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/pre_receipt_cola_index.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/pre_receipt_cola_index.yaml new file mode 100644 index 00000000000..3c70485234e --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/pre_receipt_cola_index.yaml @@ -0,0 +1,9 @@ +description: "COLA index before benefit receipt: -1 = follow_below_threshold uses below_threshold_cola_index; 0 = statutory and 1 = chained_cpi_u override it. The default therefore follows the below-threshold policy. The A9 preset uses 1. Numeric codes provide compatibility with the parameter engine." +values: + 0000-01-01: -1 +metadata: + label: Social Security pre-receipt COLA index + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/scope.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/scope.yaml new file mode 100644 index 00000000000..ef35ac91277 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/scope.yaml @@ -0,0 +1,9 @@ +description: "COLA means-test scope: 0 = first_eligible_cohorts limits the reform to the configured first eligibility cohort and later; 1 = all_beneficiaries applies to every beneficiary. The A9 preset uses 1. Numeric codes provide compatibility with the parameter engine." +values: + 0000-01-01: 0 +metadata: + label: Social Security COLA means-test scope + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/base_year.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/base_year.yaml new file mode 100644 index 00000000000..2a4dffb9efc --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/base_year.yaml @@ -0,0 +1,10 @@ +description: Dollar base year for supplied single and joint COLA means-test thresholds. Scenario values are supplied at scoring time; the A9 example is 85000 single and 170000 joint in 2018 dollars. +values: + 0000-01-01: 2026 +metadata: + unit: year + label: Social Security COLA means-test threshold dollar base year + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/index_lag.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/index_lag.yaml new file mode 100644 index 00000000000..7987aa45ba8 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/index_lag.yaml @@ -0,0 +1,10 @@ +description: Years subtracted from a COLA payment year to select the December CPI-U threshold-index snapshot, relative to December of the dollar base year. The default uses the preceding December. This lag is a modeling convention; OACT A9 specifies CPI-U indexing without this snapshot rule. +values: + 0000-01-01: 1 +metadata: + unit: year + label: Social Security COLA means-test threshold CPI-U lag + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/joint.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/joint.yaml new file mode 100644 index 00000000000..b021f64445a --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/joint.yaml @@ -0,0 +1,10 @@ +description: Joint-group COLA means-test income threshold in base-year dollars. Zero is an unset sentinel; a positive scenario value is required when a prior_year income decision is evaluated. The never_withheld and always_withheld sensitivities and pre-receipt index-only events do not read this threshold. The documented A9 example is 170000 in 2018 dollars, not a default package value. +values: + 0000-01-01: 0 +metadata: + unit: currency-USD + label: Social Security COLA means-test joint income threshold + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/rounding.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/rounding.yaml new file mode 100644 index 00000000000..16ceea795e4 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/rounding.yaml @@ -0,0 +1,10 @@ +description: Dollar increment for half-up rounding of CPI-U-indexed COLA means-test thresholds. The example increment of 100 dollars is a modeling convention reflecting the precision of OACT A9's displayed estimates; it is configurable for scenarios. +values: + 0000-01-01: 100 +metadata: + unit: currency-USD + label: Social Security COLA means-test threshold rounding increment + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/single.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/single.yaml new file mode 100644 index 00000000000..8bdbd07db93 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/cola/means_test/thresholds/single.yaml @@ -0,0 +1,10 @@ +description: Single-group COLA means-test income threshold in base-year dollars. Zero is an unset sentinel; a positive scenario value is required when a prior_year income decision is evaluated. The never_withheld and always_withheld sensitivities and pre-receipt index-only events do not read this threshold. The documented A9 example is 85000 in 2018 dollars, not a default package value. +values: + 0000-01-01: 0 +metadata: + unit: currency-USD + label: Social Security COLA means-test single income threshold + economy: false + reference: + - title: OACT provision A9, 2026 Trustees Report basis + href: https://www.ssa.gov/OACT/solvency/provisions/cola.html diff --git a/policyengine_us/reforms/ssa/cola_means_test.py b/policyengine_us/reforms/ssa/cola_means_test.py new file mode 100644 index 00000000000..348b885fb49 --- /dev/null +++ b/policyengine_us/reforms/ssa/cola_means_test.py @@ -0,0 +1,41 @@ +"""Named OACT A9 mechanics; applying this preset opts into the contribution.""" + +from policyengine_core.reforms import Reform + + +class a9(Reform): + def apply(self): + # Shared systems call this unbound method with the system as self. + self.modify_parameters(a9.configure_parameters) + + @staticmethod + def configure_parameters(parameters): + p = parameters.gov.contrib.ssa.benefit_levers.cola.means_test + values = { + "enabled": True, + "scope": 1, + "effective_year": 2028, + "effective_month": 12, + "below_threshold_cola_index": 1, + "pre_receipt_cola_index": 1, + "income_sensitivity": 0, + } + for name, value in values.items(): + getattr(p, name).update(start="0001-01-01", value=value) + for name, value in { + "base_year": 2018, + "single": 85000, + "joint": 170000, + "index_lag": 1, + "rounding": 100, + }.items(): + getattr(p.thresholds, name).update(start="0001-01-01", value=value) + for name, value in { + "SINGLE": 0, + "JOINT": 1, + "SEPARATE": 0, + "HEAD_OF_HOUSEHOLD": 0, + "SURVIVING_SPOUSE": 1, + }.items(): + getattr(p.filing_groups, name).update(start="0001-01-01", value=value) + return parameters diff --git a/policyengine_us/tests/core/test_social_security_inputs.py b/policyengine_us/tests/core/test_social_security_inputs.py index 3f5328e40d5..5392c0e8b45 100644 --- a/policyengine_us/tests/core/test_social_security_inputs.py +++ b/policyengine_us/tests/core/test_social_security_inputs.py @@ -1130,12 +1130,14 @@ def test_reported_twins_are_pure_and_original_dataset_names_keep_their_uprater() @pytest.mark.parametrize( "source", ["supplied_aime", "supplied_pia", "supplied_ratio_survivor", "history"] ) -def test_reserved_sources_fail_explicitly_instead_of_using_reported_fallback(source): +def test_unmatched_sources_fail_explicitly_instead_of_using_reported_fallback(source): public = SOCIAL_SECURITY_COMPONENTS[0] simulation = make_simulation( inputs={f"{public}_source": {2024: source}, f"{public}_reported": {2024: 0}} ) - with pytest.raises(NotImplementedError, match="reserved"): + with pytest.raises( + ValueError, match="must match the supported ss_calculator_source" + ): simulation.calculate(public, 2024) diff --git a/policyengine_us/tests/core/test_social_security_lever_properties.py b/policyengine_us/tests/core/test_social_security_lever_properties.py index f2488eb7766..e71ed53b335 100644 --- a/policyengine_us/tests/core/test_social_security_lever_properties.py +++ b/policyengine_us/tests/core/test_social_security_lever_properties.py @@ -280,6 +280,7 @@ def exposure_parameters(**overrides): name: {"values": value if isinstance(value, dict) else {"0000-01-01": value}} for name, value in values.items() } + cola["means_test"] = {"enabled": {"values": {"0000-01-01": False}}} params = ParameterNode( data={ "gov": { diff --git a/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_cola_means_test.yaml b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_cola_means_test.yaml new file mode 100644 index 00000000000..3cf0c634490 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_cola_means_test.yaml @@ -0,0 +1,44 @@ +- name: Prior-year income withholds only above the line in covered cohorts + period: 2027 + absolute_error_margin: 0.01 + input: + gov.contrib.ssa.benefit_levers.cola.means_test.enabled: true + gov.contrib.ssa.benefit_levers.cola.means_test.thresholds.single: 100000 + gov.contrib.ssa.benefit_levers.cola.means_test.thresholds.joint: 200000 + gov.contrib.ssa.benefit_levers.cola.start_year: 2027 + gov.contrib.ssa.benefit_levers.cola.pp_change: 0.0015 + people: + equal: + age: 63 + social_security_retirement_worker_eligibility_year: 2026 + social_security_retirement_first_cola_year: 2027 + social_security_retirement_reported: 12000 + above: + age: 63 + social_security_retirement_worker_eligibility_year: 2026 + social_security_retirement_first_cola_year: 2027 + social_security_retirement_reported: 12000 + older_cohort: + age: 64 + social_security_retirement_worker_eligibility_year: 2025 + social_security_retirement_first_cola_year: 2027 + social_security_retirement_reported: 12000 + tax_units: + equal: + members: [equal] + social_security_cola_means_test_magi: + 2026: 100000 + above: + members: [above] + social_security_cola_means_test_magi: + 2026: 100001 + older_cohort: + members: [older_cohort] + social_security_cola_means_test_magi: + 2026: 100001 + output: + social_security_retirement_cola_withheld: [false, true, false] + # January 2027 statutory rate is 3.6%. The CPI-E addition appears only + # in the two paid numerators; the withheld numerator is exactly one. + social_security_retirement: + [12017.374517374517, 11583.011583011583, 12017.374517374517] diff --git a/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_retirement.yaml b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_retirement.yaml index d659a6ea1ef..38836902520 100644 --- a/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_retirement.yaml +++ b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_retirement.yaml @@ -99,7 +99,7 @@ output: social_security_dependents: 6000 -- name: Calculated-base extension point bypasses population PIA and COLA factors +- name: Complete calculated base bypasses every population benefit factor period: 2027 input: age: 70 @@ -109,13 +109,15 @@ gov.contrib.ssa.benefit_levers.across_the_board.rate: 0.1 gov.contrib.ssa.benefit_levers.payable.share: 0.8 social_security_retirement_source: supplied_pia - # The route itself is reserved. A supplied selected base stands in for - # the future service's amount already containing its reformed PIA/COLAs. + # This replaces the calculator's complete checks, including its PIA, + # COLAs, across-the-board change and payable share before the earnings test. social_security_retirement_base: 10000 social_security_retirement_pia_ratio_factor: 1.2 social_security_retirement_joint_cola_factor: 1.3 output: - social_security_retirement: 8800 + # The public route returns 10000 * 1. Applying 1.1 * 0.8 again would + # incorrectly reduce these already calculated checks to 8800. + social_security_retirement: 10000 - name: Selecting an OASI-only basis before depletion remains neutral period: 2027 diff --git a/policyengine_us/tests/unit/test_ss_cola_means_test.py b/policyengine_us/tests/unit/test_ss_cola_means_test.py new file mode 100644 index 00000000000..6edd6e278f9 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_cola_means_test.py @@ -0,0 +1,911 @@ +"""COLA means-test acceptance, historical inputs and joint-event invariants. + +Synthetic thresholds are examples, never contributed package settings. The +payment-year convention means December2026's COLA first changes January2027. +Country systems are reused; pure schedule properties do not clone the model. +""" + +from copy import deepcopy +from decimal import Decimal, ROUND_DOWN +from math import prod + +import numpy as np +import pytest +from hypothesis import given, settings, strategies as st +from policyengine_core.parameters import ParameterNode + +from policyengine_us import Simulation +from policyengine_us.reforms.ssa.cola_means_test import a9 +from policyengine_us.tools.social_security.means_test import ( + chained_cola_rates, + indexed_threshold, +) +from policyengine_us.tools.social_security_benefit_levers import ( + cola_event_multiplier, + cola_events, + joint_cola_factor, +) + + +MEANS_TEST = "gov.contrib.ssa.benefit_levers.cola.means_test" +COMPONENTS = ("retirement", "disability", "survivors", "dependents") + + +def _reform(**overrides): + values = { + "enabled": True, + "scope": "first_eligible_cohorts", + "first_eligible_year": 2026, + "effective_year": 2026, + "effective_month": 12, + "below_threshold_cola_index": "statutory", + "thresholds.base_year": 2026, + "thresholds.single": 100_000, + "thresholds.joint": 200_000, + "thresholds.index_lag": 1, + "thresholds.rounding": 100, + } + values.update(overrides) + enum_values = { + "scope": {"first_eligible_cohorts": 0, "all_beneficiaries": 1}, + "below_threshold_cola_index": {"statutory": 0, "chained_cpi_u": 1}, + "pre_receipt_cola_index": { + "follow_below_threshold": -1, + "statutory": 0, + "chained_cpi_u": 1, + }, + "income_sensitivity": { + "prior_year": 0, + "never_withheld": 1, + "always_withheld": 2, + }, + } + for name, value in tuple(values.items()): + mapping = ( + {"single": 0, "joint": 1} + if name.startswith("filing_groups.") + else enum_values.get(name, {}) + ) + values[name] = mapping.get(value, value) + return { + f"{MEANS_TEST}.{name}": {"2020-01-01": value} for name, value in values.items() + } + + +def _record(eligibility=2026, **overrides): + record = {"age": {2027: 63}, "is_tax_unit_head": True} + for component in COMPONENTS: + prefix = f"social_security_{component}" + record[f"{prefix}_reported"] = {2027: 12_000} + record[f"{prefix}_worker_eligibility_year"] = eligibility + record[f"{prefix}_first_cola_year"] = 2027 + record.update(overrides) + return record + + +def _situation(records=None, incomes=None, statuses=None): + records = records or {"worker": _record()} + incomes = incomes or {} + statuses = statuses or {} + situation = { + "people": deepcopy(records), + "tax_units": {}, + "families": {}, + "spm_units": {}, + "households": {}, + } + for person in records: + unit = {"members": [person]} + if person in incomes: + income = incomes[person] + unit["social_security_cola_means_test_magi"] = ( + income if isinstance(income, dict) else {2026: income} + ) + if person in statuses: + status = statuses[person] + unit["filing_status"] = ( + status if isinstance(status, dict) else {2026: status} + ) + unit["tax_unit_itemizes"] = {year: False for year in range(2025, 2031)} + situation["tax_units"][person] = unit + for entity in ("families", "spm_units", "households"): + situation[entity][person] = {"members": [person]} + return situation + + +def _simulation(situation, reform=None, system=None): + return Simulation( + situation=deepcopy(situation), + tax_benefit_system=(system or Simulation.default_tax_benefit_system_instance), + **({"reform": reform} if reform is not None else {}), + ) + + +@pytest.fixture(scope="module") +def means_test_system(): + return _simulation(_situation(), _reform()).tax_benefit_system + + +@pytest.fixture(scope="module") +def disabled_means_test_system(): + return _simulation( + _situation(), + _reform( + enabled=False, + scope="all_beneficiaries", + below_threshold_cola_index="chained_cpi_u", + ), + ).tax_benefit_system + + +def _event(year, rate): + return { + "payment_year": year, + "payment_month": 1, + "rate": rate, + "rounding_rule": 2, + } + + +def test_neutral_defaults_require_explicit_threshold_package(): + p = Simulation.default_tax_benefit_system_instance.parameters("2027-01-01") + means_test = p.gov.contrib.ssa.benefit_levers.cola.means_test + assert not means_test.enabled + assert means_test.scope == 0 + assert means_test.first_eligible_year == 2026 + assert means_test.below_threshold_cola_index == 0 + assert means_test.pre_receipt_cola_index == -1 + assert means_test.thresholds.single == means_test.thresholds.joint == 0 + + +@pytest.mark.parametrize( + "amount,base,current,rounding,expected", + [ + (85_000, 100, 120, 100, 102_000), + (170_000, 100, 120, 100, 204_000), + (85_000, 100, 138, 100, 117_300), + (170_000, 100, 138, 100, 234_600), + (100, 100, 100.5, 1, 101), + (100_000, 100, 100.05, 100, 100_100), + ], +) +def test_cpi_u_threshold_indexing_and_decimal_half_up( + amount, base, current, rounding, expected +): + assert indexed_threshold(amount, base, current, rounding) == expected + + +def _chained_parameters(levels): + return ParameterNode( + data={ + "gov": { + "bls": { + "cpi": { + "c_cpi_u": { + "values": { + f"{year}-07-01": value for year, value in levels.items() + } + } + } + } + } + } + ) + + +def test_chained_index_uses_q3_high_water_and_half_up(): + parameters = _chained_parameters({2000: 100, 2001: 99, 2002: 100.04, 2003: 101}) + rates = chained_cola_rates(parameters, 2004) + assert [rates[year] for year in (2002, 2003, 2004)] == [0, 0, 0.01] + half_up = chained_cola_rates(_chained_parameters({2000: 100, 2001: 100.05}), 2002) + assert half_up[2002] == 0.001 + + +def test_chained_index_reads_all_three_q3_months(): + parameters = _chained_parameters({2000: 100, 2001: 101}) + index = parameters.gov.bls.cpi.c_cpi_u + index.update(start="2001-08-01", value=102) + index.update(start="2001-09-01", value=103) + # Q3 means100 and102 give2%; September alone would give3%. + assert chained_cola_rates(parameters, 2002)[2002] == 0.02 + + +@pytest.mark.parametrize("index", ["statutory", "chained_cpi_u"]) +@pytest.mark.parametrize("scope", ["first_eligible_cohorts", "all_beneficiaries"]) +def test_each_index_scope_combination_and_first_eligible_boundary(index, scope): + records = { + "old_low": _record(2025), + "old_high": _record(2025), + "new_low": _record(2026), + "new_high": _record(2026), + } + incomes = {name: (200_000 if name.endswith("high") else 0) for name in records} + simulation = _simulation( + _situation(records, incomes), + _reform(below_threshold_cola_index=index, scope=scope), + ) + parameters = simulation.tax_benefit_system.parameters + baseline_rate = next( + event["rate"] + for event in cola_events(parameters, 2027) + if event["payment_year"] == 2027 + ) + below_rate = ( + baseline_rate + if index == "statutory" + else chained_cola_rates(parameters, 2027)[2027] + ) + below_factor = (1 + below_rate) / (1 + baseline_rate) + withheld_factor = 1 / (1 + baseline_rate) + expected_withholding = ( + [False, False, False, True] + if scope == "first_eligible_cohorts" + else [False, True, False, True] + ) + expected_factors = ( + [1, 1, below_factor, withheld_factor] + if scope == "first_eligible_cohorts" + else [below_factor, withheld_factor, below_factor, withheld_factor] + ) + for component in COMPONENTS: + np.testing.assert_array_equal( + simulation.calculate(f"social_security_{component}_cola_withheld", 2027), + expected_withholding, + ) + np.testing.assert_allclose( + simulation.calculate( + f"social_security_{component}_joint_cola_factor", 2027 + ), + expected_factors, + ) + + +def test_equality_is_paid_and_each_filing_status_has_an_explicit_group( + means_test_system, +): + statuses = ["SINGLE", "HEAD_OF_HOUSEHOLD", "SEPARATE", "JOINT", "SURVIVING_SPOUSE"] + records = {name: _record() for name in statuses} + records["equality"] = _record() + simulation = _simulation( + _situation( + records, + {**{name: 100_001 for name in statuses}, "equality": 100_000}, + {**{name: name for name in statuses}, "equality": "SINGLE"}, + ), + system=means_test_system, + ) + np.testing.assert_array_equal( + simulation.calculate("social_security_retirement_cola_withheld", 2027), + [True, True, True, False, False, False], + ) + + +def test_surviving_spouse_mapping_is_a_parameter(): + simulation = _simulation( + _situation( + incomes={"worker": 100_001}, statuses={"worker": "SURVIVING_SPOUSE"} + ), + _reform(**{"filing_groups.SURVIVING_SPOUSE": "single"}), + ) + assert simulation.calculate("social_security_retirement_cola_withheld", 2027)[0] + + +def test_static_cohort_proxy_is_reconstructed_within_each_score_year(means_test_system): + record = _record(age={2026: 70, 2035: 70}) + for component in COMPONENTS: + record.pop(f"social_security_{component}_worker_eligibility_year") + record.pop(f"social_security_{component}_first_cola_year") + simulation = _simulation( + _situation( + {"worker": record}, + {"worker": {year: 1_000_000 for year in range(2025, 2035)}}, + ), + system=means_test_system, + ) + # Fixed-age static cross-sections infer E2018 in2026 and E2027 in2035. + # Within the2035 score, all exposed events use E2027 rather than re-aging + # the row separately at each historical payment event. + assert ( + simulation.calculate("social_security_retirement_joint_cola_factor", 2026)[0] + == 1 + ) + events = cola_events(simulation.tax_benefit_system.parameters, 2035) + expected = prod( + 1 / (1 + event["rate"]) for event in events if event["payment_year"] >= 2028 + ) + assert simulation.calculate("social_security_retirement_joint_cola_factor", 2035)[ + 0 + ] == pytest.approx(expected) + + +def test_only_prior_tax_year_income_and_filing_status_make_the_decision( + means_test_system, +): + simulation = _simulation( + _situation( + incomes={"worker": {2026: 150_000, 2027: 0}}, + statuses={"worker": {2026: "SINGLE", 2027: "JOINT"}}, + ), + system=means_test_system, + ) + assert simulation.calculate("social_security_retirement_cola_withheld", 2027)[0] + + +def test_magi_excludes_social_security_and_retains_irmaa_addbacks(): + situation = _situation() + situation["people"]["worker"].update( + tax_exempt_interest_income={2026: 2_000}, + us_bonds_for_higher_ed={2026: 300}, + ) + situation["people"]["spouse"] = { + "age": {2026: 63}, + "is_tax_unit_spouse": True, + "tax_exempt_interest_income": {2026: 500}, + } + situation["people"]["dependent"] = { + "age": {2026: 19}, + "is_tax_unit_dependent": True, + "tax_exempt_interest_income": {2026: 100_000}, + # The parent's education-bond exclusion can be recorded on the child. + "us_bonds_for_higher_ed": {2026: 700}, + } + members = list(situation["people"]) + unit = situation["tax_units"]["worker"] + unit.update( + members=members, + adjusted_gross_income={2026: 100_000}, + tax_unit_taxable_social_security={2026: 10_000}, + section_911_excluded_income={2026: 4_000}, + specified_possession_income={2026: 5_000}, + puerto_rico_income={2026: 6_000}, + ) + for entity in ("families", "spm_units", "households"): + situation[entity]["worker"]["members"] = members + situation["marital_units"] = { + "couple": {"members": ["worker", "spouse"]}, + "dependent": {"members": ["dependent"]}, + } + simulation = _simulation(situation) + # 100000-10000+2500+1000+4000+5000+6000; the dependent's own interest + # is on a separate return and never enters the parents' income measure. + assert ( + simulation.calculate("social_security_cola_means_test_magi", 2026)[0] == 108_500 + ) + + +def test_supplied_income_history_overrides_the_extended_record_proxy( + means_test_system, +): + # Core stores one complete array per input/period. A partially supplied + # TaxUnit input would fill other units with its default zero, so compare + # the supplied-history and calculated-income situations independently. + decisions = [] + for histories in ({"worker": {2026: 90_000}}, {}): + simulation = _simulation( + _situation( + {"worker": _record(employment_income={2026: 200_000})}, histories + ), + system=means_test_system, + ) + decisions.append( + bool( + simulation.calculate("social_security_retirement_cola_withheld", 2027)[ + 0 + ] + ) + ) + assert decisions == [False, True] + + +@pytest.mark.parametrize( + "sensitivity,income,expected", + [("never_withheld", 1_000_000, False), ("always_withheld", 0, True)], +) +def test_static_cross_section_proxy_sensitivities(sensitivity, income, expected): + simulation = _simulation( + _situation(incomes={"worker": income}), + _reform(income_sensitivity=sensitivity), + ) + assert ( + bool(simulation.calculate("social_security_retirement_cola_withheld", 2027)[0]) + is expected + ) + + +def test_explicit_withholding_input_remains_a_final_decision(means_test_system): + simulation = _simulation( + _situation( + {"worker": _record(social_security_retirement_cola_withheld={2027: True})}, + {"worker": 0}, + ), + system=means_test_system, + ) + assert simulation.calculate("social_security_retirement_cola_withheld", 2027)[0] + assert simulation.calculate("social_security_retirement", 2027)[0] < 12_000 + + +def test_disabled_means_test_preserves_manual_input_carry_over(): + simulation = _simulation( + _situation( + {"worker": _record(social_security_retirement_cola_withheld={2026: True})} + ) + ) + assert simulation.calculate("social_security_retirement_cola_withheld", 2027)[0] + + +def test_zero_cola_and_downward_adjustment_floor(): + assert cola_event_multiplier(0, 0.0015, True) == 1 + assert cola_event_multiplier(0, -0.003, False) == 1 + assert cola_event_multiplier(0.002, -0.003, False) == pytest.approx(1 / 1.002) + assert cola_event_multiplier(0.002, -0.003, True) == pytest.approx(1 / 1.002) + + +def test_two_year_cpi_e_and_withholding_use_one_joint_numerator(): + events = [_event(2029, 0.03), _event(2030, 0.04)] + factor = joint_cola_factor( + events, (2029, 1), 2030, {2029: 0.0015, 2030: 0.0015}, {2029: True} + ) + # Baseline1000*1.03*1.04=1071.20. First COLA is withheld, so the + # reformed path is1000*1*1.0415=1041.50, rather than retaining CPI-E. + assert factor == pytest.approx(1041.50 / 1071.20) + assert 1071.20 * factor == pytest.approx(1041.50) + separate_factors = ( + joint_cola_factor(events, (2029, 1), 2030, {2029: 0.0015, 2030: 0.0015}, {}) + / 1.03 + ) + assert factor != pytest.approx(separate_factors) + + +@pytest.mark.parametrize( + "below,pre_receipt,use_chained_before_receipt", + [ + ("chained_cpi_u", "follow_below_threshold", True), + ("chained_cpi_u", "statutory", False), + ("statutory", "chained_cpi_u", True), + ], +) +def test_pre_receipt_index_is_separate_and_has_no_income_withholding( + below, pre_receipt, use_chained_before_receipt +): + simulation = _simulation( + _situation( + { + "worker": _record( + social_security_retirement_entitlement_year=2029, + social_security_retirement_entitlement_month=12, + social_security_retirement_reported={2030: 12_000}, + ) + }, + {"worker": {year: 1_000_000 for year in range(2026, 2030)}}, + ), + _reform( + below_threshold_cola_index=below, + pre_receipt_cola_index=pre_receipt, + ), + ) + parameters = simulation.tax_benefit_system.parameters + chained = chained_cola_rates(parameters, 2030) + baseline = { + event["payment_year"]: event["rate"] for event in cola_events(parameters, 2030) + } + expected = prod( + (1 + chained[year]) / (1 + baseline[year]) if use_chained_before_receipt else 1 + for year in (2027, 2028, 2029) + ) / (1 + baseline[2030]) + assert not simulation.calculate("social_security_retirement_cola_withheld", 2029)[0] + assert simulation.calculate("social_security_retirement_cola_withheld", 2030)[0] + assert simulation.calculate("social_security_retirement_joint_cola_factor", 2030)[ + 0 + ] == pytest.approx(expected) + + +def _calculator_record(name): + record = _record() + record.update( + social_security_retirement_source="supplied_pia", + ss_calculator_source="pia", + ss_calculator_worker_id=name, + ss_calculator_birth_date="1964-01-02", + ss_calculator_entitlement_month="2026-01", + ss_calculator_eligibility_year=2026, + ss_calculator_supplied_pia=1000, + ss_calculator_claiming_factor="1", + ss_calculator_insured=True, + ss_calculator_no_special_monthly_test=True, + ss_calculator_no_prior_withholding_adjustment=True, + ss_calculator_earnings_test_no_trigger=True, + ) + return record + + +@pytest.mark.parametrize("other_payment_factors", [False, True]) +def test_calculated_source_applies_withholding_and_cpi_e_once(other_payment_factors): + reform = _reform() + reform.update( + { + "gov.contrib.ssa.benefit_levers.cola.start_year": {"2020-01-01": 2027}, + "gov.contrib.ssa.benefit_levers.cola.pp_change": {"2020-01-01": 0.0015}, + } + ) + if other_payment_factors: + reform.update( + { + "gov.contrib.ssa.benefit_levers.across_the_board.start_year": { + "2020-01-01": 2027 + }, + "gov.contrib.ssa.benefit_levers.across_the_board.rate": { + "2020-01-01": 0.08 + }, + "gov.contrib.ssa.benefit_levers.payable.share": {"2020-01-01": 0.8}, + } + ) + simulation = _simulation( + _situation( + {name: _calculator_record(name) for name in ("paid", "withheld")}, + {"paid": {2026: 0}, "withheld": {2026: 1_000_000}}, + ), + reform, + ) + # Baseline3.6%+.15pp produces PIA1037.5 ->1037 checks. Above the + # line, the single joint numerator produces PIA1000 ->1000 checks. + np.testing.assert_array_equal( + simulation.calculate("ss_calculated_payment", "2027-01"), + [896, 864] if other_payment_factors else [1037, 1000], + ) + np.testing.assert_array_equal( + simulation.calculate("social_security_retirement_joint_cola_factor", 2027), + [1, 1], + ) + np.testing.assert_array_equal( + simulation.calculate("social_security_retirement", 2027), + simulation.calculate("ss_calculated_retirement", 2027), + ) + np.testing.assert_array_equal( + simulation.calculate("social_security_retirement", 2027), + [10_752, 10_368] if other_payment_factors else [12_444, 12_000], + ) + + +def test_midyear_first_check_retains_the_pre_receipt_index(): + records = { + "reported": _record(), + "calculated": _calculator_record("calculated"), + } + for record in records.values(): + record.update( + social_security_retirement_entitlement_year=2028, + social_security_retirement_entitlement_month=6, + ) + records["calculated"]["ss_calculator_entitlement_month"] = "2028-06" + simulation = _simulation( + _situation( + records, + {name: {year: 1_000_000 for year in range(2026, 2029)} for name in records}, + ), + _reform(pre_receipt_cola_index="chained_cpi_u"), + ) + # First receipt is July2028. January2028's COLA therefore uses the + # pre-receipt index regardless of income; January2029 is income-tested. + np.testing.assert_array_equal( + simulation.calculate("social_security_retirement_cola_withheld", 2028), + [False, False], + ) + np.testing.assert_array_equal( + simulation.calculate("social_security_retirement_cola_withheld", 2029), + [True, True], + ) + parameters = simulation.tax_benefit_system.parameters + chained = chained_cola_rates(parameters, 2028) + baseline = { + event["payment_year"]: event["rate"] for event in cola_events(parameters, 2028) + } + expected = prod((1 + chained[year]) / (1 + baseline[year]) for year in (2027, 2028)) + assert simulation.calculate("social_security_retirement_joint_cola_factor", 2028)[ + 0 + ] == pytest.approx(expected) + pia = Decimal(1000) + for year in (2027, 2028): + pia = (pia * (1 + Decimal(str(chained[year])))).quantize( + Decimal("0.1"), rounding=ROUND_DOWN + ) + assert simulation.calculate("ss_calculated_payment", "2028-07")[1] == int(pia) + + +def test_named_withholding_uses_selected_calculator_cohort_and_receipt( + means_test_system, disabled_means_test_system +): + records = {} + for name, birth, eligibility in ( + ("old", "1955-01-02", 2017), + ("new", "1964-01-02", 2026), + ): + # Only calculator dates identify these records; ordinary population + # age, worker-cohort and entitlement inputs are deliberately absent. + records[name] = { + field: value + for field, value in _calculator_record(name).items() + if field.startswith("ss_calculator_") + or field in ("social_security_retirement_source", "is_tax_unit_head") + } + records[name].update( + ss_calculator_birth_date=birth, + ss_calculator_eligibility_year=eligibility, + ss_calculator_entitlement_month="2028-06", + ) + situation = _situation( + records, + {name: {year: 1_000_000 for year in range(2026, 2029)} for name in records}, + ) + simulation = _simulation(situation, system=means_test_system) + baseline = _simulation(situation, system=disabled_means_test_system) + np.testing.assert_array_equal( + simulation.calculate("social_security_retirement_cola_withheld", 2028), + [False, False], + ) + np.testing.assert_array_equal( + simulation.calculate("social_security_retirement_cola_withheld", 2029), + [False, True], + ) + np.testing.assert_array_equal( + simulation.calculate("ss_calculated_payment", "2028-07"), + baseline.calculate("ss_calculated_payment", "2028-07"), + ) + payments = simulation.calculate("ss_calculated_payment", "2029-01") + baseline_payments = baseline.calculate("ss_calculated_payment", "2029-01") + assert payments[0] == baseline_payments[0] + assert payments[1] < baseline_payments[1] + + +def test_a9_preset_reproduces_captured_description(): + simulation = _simulation( + _situation( + { + name: _record( + 2025, + age={2028: 70, 2029: 70}, + social_security_retirement_first_cola_year=2029, + ) + for name in ("below", "above") + }, + {"below": {2027: 0, 2028: 0}, "above": {2027: 1_000_000, 2028: 1_000_000}}, + ), + a9, + ) + p = simulation.tax_benefit_system.parameters("2029-01-01") + means_test = p.gov.contrib.ssa.benefit_levers.cola.means_test + assert means_test.enabled + assert means_test.scope == 1 + assert means_test.effective_year == 2028 + assert means_test.effective_month == 12 + assert means_test.below_threshold_cola_index == 1 + assert means_test.pre_receipt_cola_index == 1 + assert means_test.thresholds.base_year == 2018 + assert means_test.thresholds.single == 85_000 + assert means_test.thresholds.joint == 170_000 + np.testing.assert_array_equal( + simulation.calculate("social_security_retirement_cola_withheld", 2028), + [False, False], + ) + np.testing.assert_array_equal( + simulation.calculate("social_security_retirement_cola_withheld", 2029), + [False, True], + ) + parameters = simulation.tax_benefit_system.parameters + baseline = next( + event["rate"] + for event in cola_events(parameters, 2029) + if event["payment_year"] == 2029 + ) + chained = chained_cola_rates(parameters, 2029)[2029] + np.testing.assert_allclose( + simulation.calculate("social_security_retirement_joint_cola_factor", 2029), + [(1 + chained) / (1 + baseline), 1 / (1 + baseline)], + ) + + +def test_disabled_later_policy_retains_an_earlier_withheld_event(): + reform = _reform() + reform[f"{MEANS_TEST}.enabled"]["2028-01-01"] = False + simulation = _simulation( + _situation(incomes={"worker": {2026: 1_000_000, 2027: 1_000_000}}), reform + ) + baseline = next( + event["rate"] + for event in cola_events(simulation.tax_benefit_system.parameters, 2027) + if event["payment_year"] == 2027 + ) + assert not simulation.calculate("social_security_retirement_cola_withheld", 2028)[0] + assert simulation.calculate("social_security_retirement_joint_cola_factor", 2028)[ + 0 + ] == pytest.approx(1 / (1 + baseline)) + + +@pytest.mark.parametrize("calculated", [False, True], ids=["reported", "calculator"]) +def test_multi_year_income_dependency_graph_is_acyclic(means_test_system, calculated): + years = range(2025, 2031) + record = _record( + age={year: year - 1964 for year in years}, + employment_income={year: 120_000 for year in years}, + traditional_ira_contributions={year: 1_000 for year in years}, + social_security_retirement_reported={year: 12_000 for year in years}, + ) + if calculated: + record.update(_calculator_record("worker")) + record["age"] = {year: year - 1964 for year in years} + record["employment_income"] = {year: 10_000 for year in years} + record["taxable_interest_income"] = {year: 120_000 for year in years} + simulation = _simulation(_situation({"worker": record}), system=means_test_system) + simulation.trace = True + for year in (2028, 2029, 2030): + benefit = simulation.calculate("social_security_retirement", year) + assert np.isfinite(benefit).all() + assert (benefit > 0).all() + edges = {} + + def key(node): + return node.name, str(node.period), getattr(node, "branch_name", None) + + def visit(node): + parent = key(node) + edges.setdefault(parent, set()) + for child in node.children: + edges[parent].add(key(child)) + visit(child) + + for tree in simulation.tracer.trees: + visit(tree) + active, complete = set(), set() + + def assert_dag(node): + assert node not in active, f"Dependency cycle at {node}" + if node in complete: + return + active.add(node) + for child in edges[node]: + assert_dag(child) + active.remove(node) + complete.add(node) + + for node in edges: + assert_dag(node) + income_periods = { + int(period[:4]) + for name, period, _ in edges + if name == "social_security_cola_means_test_magi" + } + assert {2027, 2028, 2029} <= income_periods + income_edges = [] + for parent, children in edges.items(): + if parent[0] != "social_security_cola_means_test_withheld": + continue + for child in children: + if child[0] == "social_security_cola_means_test_magi": + income_edges.append((parent, child)) + assert int(child[1][:4]) == int(parent[1][:4]) - 1 + assert income_edges, "The graph must exercise the actual prior-year income read" + + +@settings(max_examples=12, deadline=None) +@given(lower=st.integers(0, 250_000), increase=st.integers(0, 250_000)) +def test_property_withholding_is_monotone_in_income(means_test_system, lower, increase): + simulation = _simulation( + _situation( + {"lower": _record(), "higher": _record()}, + {"lower": lower, "higher": lower + increase}, + ), + system=means_test_system, + ) + decisions = simulation.calculate("social_security_retirement_cola_withheld", 2027) + assert int(decisions[0]) <= int(decisions[1]) + + +@settings(max_examples=60, deadline=None) +@given( + rate=st.integers(0, 100).map(lambda value: value / 1000), + delta=st.integers(-100, 100).map(lambda value: value / 10000), +) +def test_property_a_withheld_year_never_raises_the_benefit(rate, delta): + withheld = float(cola_event_multiplier(rate, delta, True)) + paid = float(cola_event_multiplier(rate, delta, False)) + assert withheld <= 1 + assert withheld <= paid + + +@settings(max_examples=60, deadline=None) +@given( + values=st.lists( + st.tuples(st.integers(0, 100), st.integers(-100, 100), st.booleans()), + min_size=1, + max_size=8, + ) +) +def test_property_joint_factor_equals_product_of_per_year_ratios(values): + events = [_event(2027 + i, c / 1000) for i, (c, _, _) in enumerate(values)] + deltas = {2027 + i: d / 10000 for i, (_, d, _) in enumerate(values)} + withheld = {2027 + i: w for i, (_, _, w) in enumerate(values)} + expected = prod( + (1 if w else 1 + max(0, c / 1000 + d / 10000)) / (1 + c / 1000) + for c, d, w in values + ) + actual = joint_cola_factor(events, (2027, 1), 2034, deltas, withheld) + assert actual == pytest.approx(expected) + + +@settings(max_examples=8, deadline=None) +@given( + reported=st.integers(0, 100_000), + prior_income=st.integers(0, 1_000_000), +) +def test_property_means_test_off_preserves_stacked_base_outputs( + disabled_means_test_system, reported, prior_income +): + record = _record() + for component in COMPONENTS: + record[f"social_security_{component}"] = {2027: reported} + record.pop(f"social_security_{component}_reported") + situation = _situation({"worker": record}, {"worker": prior_income}) + baseline = _simulation(situation) + disabled = _simulation(situation, system=disabled_means_test_system) + for variable in ( + *(f"social_security_{component}" for component in COMPONENTS), + "social_security", + *(f"social_security_{component}_joint_cola_factor" for component in COMPONENTS), + ): + np.testing.assert_array_equal( + disabled.calculate(variable, 2027), baseline.calculate(variable, 2027) + ) + + +def _assert_cached_outputs_equal(reference, actual): + for population in reference.populations.values(): + for name, holder in population._holders.items(): + for period in holder.get_known_periods(): + expected = holder.get_array(period) + if expected is not None: + np.testing.assert_array_equal( + actual.calculate(name, period), + expected, + err_msg=f"{name} at {period}", + ) + + +@pytest.mark.parametrize("calculated", [False, True], ids=["reported", "calculator"]) +def test_means_test_off_preserves_downstream_dependency_closure( + disabled_means_test_system, calculated +): + record = _calculator_record("worker") if calculated else _record() + if not calculated: + # Differential ingress uses legacy public inputs, as M9 requires. + for component in COMPONENTS: + record[f"social_security_{component}"] = {2027: 6_000} + record.pop(f"social_security_{component}_reported") + record.update( + employment_income={2027: 10_000}, + taxable_interest_income={2027: 20_000}, + ) + situation = _situation({"worker": record}) + situation["households"]["worker"]["state_code"] = {2027: "NY"} + baseline = _simulation(situation) + disabled = _simulation(situation, system=disabled_means_test_system) + for name in ( + "social_security", + "taxable_social_security", + "adjusted_gross_income", + "income_tax", + "net_investment_income_tax", + "medicare_part_b_premium", + "household_net_income", + ): + np.testing.assert_array_equal( + disabled.calculate(name, 2027), baseline.calculate(name, 2027), err_msg=name + ) + if calculated: + for name, period in ( + ("ss_calculated_payment", "2027-01"), + ("ss_calculated_annual_entitlement", 2027), + ("ss_calculated_retirement", 2027), + ): + np.testing.assert_array_equal( + disabled.calculate(name, period), baseline.calculate(name, period) + ) + _assert_cached_outputs_equal(baseline, disabled) diff --git a/policyengine_us/tools/social_security/engine.py b/policyengine_us/tools/social_security/engine.py index 8c779c480b9..0fc219e0c50 100644 --- a/policyengine_us/tools/social_security/engine.py +++ b/policyengine_us/tools/social_security/engine.py @@ -17,6 +17,7 @@ ) from .parameters import bend_points_at, formula_path_at from .pia import FormulaPath +from .means_test import component_withholding, indexed_cola_rate def calculator_output(person, period, parameters, output): @@ -168,9 +169,31 @@ def money(name, when=period): lower[earnings_year] = test.exempt_amount_under_fra higher[earnings_year] = test.exempt_amount_year_of_fra changes, absolute_rates, withheld_years, across, payable = {}, {}, [], {}, {} + # December entitlement is paid the following January. Payment outputs + # for y do not need that future event's y-income decision; eagerly + # reading it would depend on the very checks being calculated. An + # entitlement output can read it through the separate payment route. + policy_horizon = year + int( + output + in ( + "annual_entitlement", + "earnings_test_reduction", + "deferred_earnings_test_payment", + ) + or (output == "benefit" and str(period)[5:7] == "12") + ) + component = "survivors" if source == "survivor" else "retirement" + eligibility = np.full(len(sources), benchmark) + receipt = np.full( + len(sources), + int(record.entitlement_month[:4]) + (record.entitlement_month[5:7] == "12"), + ) + receipt_month = np.full( + len(sources), int(record.entitlement_month[5:7]) % 12 + 1 + ) for event in events: payment_year = int(event.payment_month[:4]) - if payment_year < 1983: + if payment_year < 1983 or payment_year > policy_horizon: continue policy = parameters.gov.contrib.ssa.benefit_levers.cola( f"{payment_year}-01-01" @@ -196,22 +219,61 @@ def money(name, when=period): applies = applies and ( not policy.end_year or payment_year <= policy.end_year ) - withheld = bool( - policy.withholding_enabled - and has_input_for_period( - person.simulation, - "ss_calculator_withhold_cola", - str(payment_year), + withholding_enabled = ( + policy.withholding_enabled or policy.means_test.enabled + ) + if withholding_enabled and has_input_for_period( + person.simulation, + "ss_calculator_withhold_cola", + str(payment_year), + ): + withheld = bool(value("withhold_cola", str(payment_year))) + elif withholding_enabled: + withheld = bool( + component_withholding( + person, + payment_year, + parameters, + component, + eligibility_year=eligibility, + receipt_year=receipt, + receipt_month=receipt_month, + use_explicit=True, + )[index] ) - and value("withhold_cola", str(payment_year)) + else: + withheld = False + index_rate = indexed_cola_rate( + person, + period, + parameters, + component, + payment_year, + float(event.rate), + eligibility_year=eligibility, + receipt_year=receipt, + receipt_month=receipt_month, + ) + resolved_index = Decimal( + str(float(np.broadcast_to(index_rate, len(sources))[index])) ) changes[payment_year] = delta if applies else Decimal(0) - if applies and alternative >= 0: + if (applies and alternative >= 0) or resolved_index != event.rate: # An absolute alternative replaces both 1999's as-paid rate # and its corrected rate. Ordinary baseline+delta changes # instead retain that historical distinction in adjusted_events. absolute_rates[event.payment_month] = ( - Decimal(0) if withheld else max(Decimal(0), alternative + delta) + Decimal(0) + if withheld + else max( + Decimal(0), + ( + alternative + if applies and alternative >= 0 + else resolved_index + ) + + (delta if applies else Decimal(0)), + ) ) if withheld: withheld_years.append(payment_year) diff --git a/policyengine_us/tools/social_security/means_test.py b/policyengine_us/tools/social_security/means_test.py new file mode 100644 index 00000000000..15f93f56c47 --- /dev/null +++ b/policyengine_us/tools/social_security/means_test.py @@ -0,0 +1,411 @@ +"""Shared payment-year decisions for the contributed COLA means test. + +Tax returns are read only at y-1 for a January y COLA. Population records use +their supplied/extended prior-year copy; this module never substitutes current +AGI for a missing history. Cohort proxies are resolved within each score year. +""" + +from datetime import date +from decimal import Decimal, ROUND_HALF_UP + +import numpy as np +from policyengine_core.periods import period as to_period + +from policyengine_us.tools.general import has_input_for_period + + +def indexed_threshold(amount, base_index, current_index, rounding=100): + """Index a base-dollar line and round half up to the configured dollars.""" + amount, base, current, quantum = map( + lambda value: Decimal(str(value)), + (amount, base_index, current_index, rounding), + ) + if any(not value.is_finite() for value in (amount, base, current, quantum)): + raise ValueError("Means-test threshold inputs must be finite") + if amount <= 0 or base <= 0 or current <= 0 or quantum <= 0: + raise ValueError( + "Means-test thresholds, CPI levels and rounding must be positive" + ) + return float( + (amount * current / base / quantum).quantize(Decimal(1), rounding=ROUND_HALF_UP) + * quantum + ) + + +def policy_settings(parameters, payment_year): + return parameters.gov.contrib.ssa.benefit_levers.cola.means_test( + f"{int(payment_year):04d}-01-01" + ) + + +def parameter_choice(value, choices): + """Decode numeric policy choices supported by Core parameter values.""" + try: + return choices[value] + except (KeyError, TypeError) as error: + raise ValueError( + f"Unknown COLA means-test parameter choice: {value}" + ) from error + + +def policy_active(settings, payment_year): + if not settings.enabled: + return False + if not 1 <= settings.effective_month <= 12: + raise ValueError("Means-test effective month must lie in [1, 12]") + # Contemporary COLAs take effect in December and change January checks. + return (int(payment_year) - 1, 12) >= ( + int(settings.effective_year), + int(settings.effective_month), + ) + + +def chained_cola_rates(parameters, through_year): + """C-CPI-U Q3 rates with A5's high-water, half-up and no-decrease rules. + + Monthly observations are averaged over July--September of the effective + year. Projected snapshots hold the model's annual index observations; they + are an explicit projection approximation, not observed monthly forecasts. + The first available complete Q3 (2000) anchors the payment-2001 series. + Withholding never changes this index's high-water mark. + """ + index = parameters.gov.bls.cpi.c_cpi_u + levels = {} + for payment_year in range(2001, int(through_year) + 1): + levels[payment_year] = ( + sum( + ( + Decimal(str(index(f"{payment_year - 1}-{month:02d}-01"))) + for month in (7, 8, 9) + ), + Decimal(0), + ) + / 3 + ) + if any(not level.is_finite() or level <= 0 for level in levels.values()): + raise ValueError("Chained CPI-U levels must be finite and positive") + if not levels: + return {} + anchor = levels[2001] + rates = {} + for year, level in list(levels.items())[1:]: + rate = max(Decimal(0), level / anchor - 1).quantize( + Decimal("0.001"), rounding=ROUND_HALF_UP + ) + rates[year] = float(rate) + if rate > 0: + anchor = level + return rates + + +def tax_unit_withholding(tax_unit, period, parameters): + """Income decision before beneficiary-specific cohort/receipt restrictions.""" + year = period.start.year + p = policy_settings(parameters, year) + if not policy_active(p, year): + return False + sensitivity = parameter_choice( + p.income_sensitivity, + {0: "prior_year", 1: "never_withheld", 2: "always_withheld"}, + ) + if sensitivity == "never_withheld": + return False + if sensitivity == "always_withheld": + return True + previous = period.offset(-1, "year") + income = tax_unit("social_security_cola_means_test_magi", previous) + status = tax_unit("filing_status", previous) + thresholds = p.thresholds + if thresholds.index_lag < 0: + raise ValueError("Means-test CPI-U lag must be nonnegative") + index = parameters.gov.bls.cpi.cpi_u + base = index(f"{int(thresholds.base_year):04d}-12-01") + current = index(f"{year - int(thresholds.index_lag):04d}-12-01") + lines = np.zeros(len(income)) + for filing_status in status.possible_values: + group = parameter_choice( + getattr(p.filing_groups, filing_status.name), {0: "single", 1: "joint"} + ) + line = indexed_threshold( + getattr(thresholds, group), base, current, thresholds.rounding + ) + lines = np.where(status == filing_status, line, lines) + # A9 leaves equality unspecified. Equality receives a COLA by convention. + return income > lines + + +def calculator_component_basis(person, period, component): + """Read selected calculator dates through the calculator's own validation. + + Only the fields used by SuppliedRecord.benchmark are needed here; monetary + inputs and tax calculations are left to the benefit engine. SurvivorBasis + retains its ordinary non-WINDEX, disability and exact-date assertions. + """ + if component not in ("retirement", "survivors"): + return {} + from policyengine_us.tools.social_security_benefit_levers import ( + SocialSecurityBenefitSource, + ) + + from .household import SuppliedRecord, SurvivorBasis, month_number + + sources = person(f"social_security_{component}_source", period) + reported = sources == SocialSecurityBenefitSource.reported + if np.all(reported): + return {} + calculator_sources = person("ss_calculator_source", period) + allowed = ( + ((SocialSecurityBenefitSource.supplied_ratio_survivor, "survivor"),) + if component == "survivors" + else ( + (SocialSecurityBenefitSource.supplied_aime, "aime"), + (SocialSecurityBenefitSource.supplied_pia, "pia"), + ) + ) + results = {} + for index in np.flatnonzero(~reported): + source = str(calculator_sources[index]) + if not any( + sources[index] == selector.index and source == mode + for selector, mode in allowed + ): + raise ValueError( + "Public Social Security source must match the supported ss_calculator_source" + ) + + def value(name): + scalar = person("ss_calculator_" + name, period)[index] + return scalar.item() if hasattr(scalar, "item") else scalar + + def optional_date(name): + text = str(value(name)) + return date.fromisoformat(text) if text else None + + survivor = None + if source == "survivor": + prior_disability = value("prior_disability_eligibility") + if prior_disability not in (0, 1): + raise ValueError( + "ss_calculator_prior_disability_eligibility must explicitly " + "be 0 (no prior eligibility) or 1 (prior disability eligibility)" + ) + same_year = int(value("same_year_death_before_62")) + if same_year not in (-1, 0, 1): + raise ValueError("Same-year death assertion must be -1, 0 or 1") + survivor = SurvivorBasis( + worker_birth_year=int(value("worker_birth_year")), + death_year=int(value("death_year")), + provenance=str(value("basis_provenance")), + worker_birth_date=optional_date("worker_birth_date"), + death_date=optional_date("death_date"), + authoritative_year=int(value("authoritative_year")) or None, + prior_disability_eligibility=bool(prior_disability), + ordinary_non_windex=bool(value("ordinary_non_windex")), + year_only_not_january_1=bool(value("year_only_not_january_1")), + same_year_death_before_62=None if same_year == -1 else bool(same_year), + ) + record = SuppliedRecord( + source=source, + worker_id=str(value("worker_id")), + birth_date=date.fromisoformat(str(value("birth_date"))), + entitlement_month=str(value("entitlement_month")), + insured=bool(value("insured")), + eligibility_year=int(value("eligibility_year")) or None, + source_basis=str(value("source_basis")), + survivor=survivor, + ) + benchmark = record.benchmark() + check_year, check_month = divmod(month_number(record.entitlement_month) + 1, 12) + results[index] = benchmark, check_year, check_month + 1 + return results + + +def component_eligibility_year(person, period, component): + """Resolve supplied worker links before the documented cross-section proxy.""" + calculated = calculator_component_basis(person, period, component) + prefix = f"social_security_{component}" + eligibility = person(f"{prefix}_worker_eligibility_year", period).copy() + links = person(f"{prefix}_worker_id", period) + shared = person("social_security_worker_computation_year", period) + known = {str(identifier): i for i, identifier in enumerate(person.ids)} + for index, link in enumerate(links): + if index in calculated or not link: + continue + if str(link) not in known: + raise ValueError("Social Security worker_id does not identify a person") + other = known[str(link)] + linked = eligibility[other] or shared[other] + if eligibility[index] and linked and eligibility[index] != linked: + raise ValueError("Conflicting supplied and linked worker computation years") + eligibility[index] = eligibility[index] or linked + year = period.start.year + age = person("age", period) + entitlement = person(f"{prefix}_entitlement_year", period) + if component == "retirement": + proxy = year - age + 62 + elif component == "survivors": + proxy = np.where(age >= 60, year - age + 60, 1975) + else: + proxy = np.where(entitlement > 0, entitlement, 1975) + result = np.where(eligibility > 0, eligibility, proxy) + for index, (benchmark, _, _) in calculated.items(): + result[index] = benchmark + return result + + +def component_receipt_year(person, period, component): + prefix = f"social_security_{component}" + entitlement = person(f"{prefix}_entitlement_year", period) + month = person(f"{prefix}_entitlement_month", period) + # Unknown receipt dates use already-in-payment exposure, as A5 does. + result = np.where(entitlement > 0, entitlement + (month == 12), 0) + for index, (_, check_year, _) in calculator_component_basis( + person, period, component + ).items(): + result[index] = check_year + return result + + +def component_receipt_month(person, period, component): + """First check month following the supplied entitlement month.""" + prefix = f"social_security_{component}" + entitlement = person(f"{prefix}_entitlement_year", period) + month = person(f"{prefix}_entitlement_month", period) + if np.any((entitlement > 0) & ((month < 1) | (month > 12))): + raise ValueError("Social Security entitlement months must lie in [1, 12]") + result = np.where(entitlement > 0, month % 12 + 1, 1) + for index, (_, _, check_month) in calculator_component_basis( + person, period, component + ).items(): + result[index] = check_month + return result + + +def before_receipt(payment_year, receipt_year, receipt_month): + """Whether a January COLA check event precedes the first benefit check.""" + return (payment_year < receipt_year) | ( + (payment_year == receipt_year) & (1 < receipt_month) + ) + + +def cohort_mask(settings, eligibility_year): + scope = parameter_choice( + settings.scope, {0: "first_eligible_cohorts", 1: "all_beneficiaries"} + ) + if scope == "all_beneficiaries": + return np.ones_like(eligibility_year, dtype=bool) + return np.asarray(eligibility_year) >= settings.first_eligible_year + + +def component_withholding( + person, + period, + parameters, + component, + eligibility_year=None, + receipt_year=None, + receipt_month=None, + use_explicit=False, +): + """Apply the TaxUnit decision to this beneficiary and payment event. + + The reported-factor caller supplies its score-year eligibility proxy for + every historical event. It does not re-age a static row within one score. + Explicit per-event withholding inputs retain A5's override channel. + """ + period = to_period(period) + year = period.start.year + p = policy_settings(parameters, year) + if use_explicit and has_input_for_period( + person.simulation, f"social_security_{component}_cola_withheld", period + ): + return person(f"social_security_{component}_cola_withheld", period) + if not policy_active(p, year): + # Before E3, this was a pure annual input. Preserve Core's carry-over + # of supplied manual decisions when the automatic policy is inactive. + # Formula caches must never become manual decision sources. + name = f"social_security_{component}_cola_withheld" + earlier = [ + when + for when in person.simulation._get_set_input_periods(name) + if when.start < period.start + and has_input_for_period(person.simulation, name, when) + ] + if earlier: + return person(name, max(earlier, key=lambda when: when.start)) + return np.zeros(len(person.ids), dtype=bool) + eligibility_year = ( + component_eligibility_year(person, period, component) + if eligibility_year is None + else np.asarray(eligibility_year) + ) + receipt_year = ( + component_receipt_year(person, period, component) + if receipt_year is None + else np.asarray(receipt_year) + ) + receipt_month = ( + component_receipt_month(person, period, component) + if receipt_month is None + else np.asarray(receipt_month) + ) + eligible = cohort_mask(p, eligibility_year) & ~before_receipt( + year, receipt_year, receipt_month + ) + if not np.any(eligible): + return eligible + return eligible & person.tax_unit( + "social_security_cola_means_test_withheld", period + ) + + +def indexed_cola_rate( + person, + reference_period, + parameters, + component, + payment_year, + baseline_rate, + eligibility_year=None, + receipt_year=None, + receipt_month=None, +): + """Select the below-line or pre-receipt index before A5's policy addition.""" + p = policy_settings(parameters, payment_year) + if not policy_active(p, payment_year): + return baseline_rate + eligibility_year = ( + component_eligibility_year(person, reference_period, component) + if eligibility_year is None + else np.asarray(eligibility_year) + ) + receipt_year = ( + component_receipt_year(person, reference_period, component) + if receipt_year is None + else np.asarray(receipt_year) + ) + receipt_month = ( + component_receipt_month(person, reference_period, component) + if receipt_month is None + else np.asarray(receipt_month) + ) + below = parameter_choice( + p.below_threshold_cola_index, {0: "statutory", 1: "chained_cpi_u"} + ) + before = parameter_choice( + p.pre_receipt_cola_index, + {-1: "follow_below_threshold", 0: "statutory", 1: "chained_cpi_u"}, + ) + if before == "follow_below_threshold": + before = below + chained = cohort_mask(p, eligibility_year) & np.where( + before_receipt(payment_year, receipt_year, receipt_month), + before == "chained_cpi_u", + below == "chained_cpi_u", + ) + if not np.any(chained): + return baseline_rate + rates = chained_cola_rates(parameters, payment_year) + if payment_year not in rates: + raise ValueError("Chained CPI-U COLAs require payment year 2002 or later") + return np.where(chained, rates[payment_year], baseline_rate) diff --git a/policyengine_us/tools/social_security_benefit_levers.py b/policyengine_us/tools/social_security_benefit_levers.py index fb86fa14647..6fee8482cf9 100644 --- a/policyengine_us/tools/social_security_benefit_levers.py +++ b/policyengine_us/tools/social_security_benefit_levers.py @@ -72,9 +72,13 @@ def joint_cola_factor(events, first_payment, through_year, pp_changes, withheld) def cola_event_multiplier(rate, delta, withheld): """Shared scalar/vector algebra for an exposed payment event.""" - if not np.isfinite(rate) or rate < 0 or not np.isfinite(delta): + if ( + not np.all(np.isfinite(rate)) + or np.any(np.asarray(rate) < 0) + or not np.all(np.isfinite(delta)) + ): raise ValueError("COLA rates must be nonnegative and changes finite") - numerator = np.where(withheld, 1, 1 + max(0, rate + delta)) + numerator = np.where(withheld, 1, 1 + np.maximum(0, rate + delta)) return numerator / (1 + rate) @@ -100,7 +104,7 @@ def cola_events(parameters, through_year): last_actual = max(e["payment_year"] for e in events) if through_year > last_actual: indices = { - year: parameters(f"{year}-01-01").gov.ssa.uprating + year: parameters.gov.ssa.uprating(f"{year}-01-01") for year in range(last_actual, through_year + 1) } for year, rate in projected_cola_rates(indices).items(): @@ -123,12 +127,31 @@ def cola_events(parameters, through_year): def selected_reported_benefit(person, period, component): prefix = f"social_security_{component}" source = person(f"{prefix}_source", period) - if np.any(source != SocialSecurityBenefitSource.reported): + reported = source == SocialSecurityBenefitSource.reported + amount = person(f"{prefix}_reported", period) + if np.all(reported): + return amount + if component not in ("retirement", "survivors"): raise NotImplementedError( - "Calculated Social Security source modes are reserved; " - "this implementation supports the reported route only" + "The calculator supports own retirement and restricted survivor records" + ) + calculator_source = person("ss_calculator_source", period) + allowed = ( + ((SocialSecurityBenefitSource.supplied_ratio_survivor, "survivor"),) + if component == "survivors" + else ( + (SocialSecurityBenefitSource.supplied_aime, "aime"), + (SocialSecurityBenefitSource.supplied_pia, "pia"), + ) + ) + valid = reported.copy() + for selector, calculator_mode in allowed: + valid |= (source == selector) & (calculator_source == calculator_mode) + if not np.all(valid): + raise ValueError( + "Public Social Security source must match the supported ss_calculator_source" ) - return person(f"{prefix}_reported", period) + return np.where(reported, amount, person(f"ss_calculated_{component}", period)) def reported_joint_cola_factor(person, period, parameters, component): @@ -139,10 +162,10 @@ def reported_joint_cola_factor(person, period, parameters, component): if not np.any(reported): return 1 year = period.start.year - p = parameters(period).gov.contrib.ssa.benefit_levers.cola + raw_policy = parameters.gov.contrib.ssa.benefit_levers.cola + p = raw_policy(str(period.start)) if not 1 <= p.effective_month <= 12: raise ValueError("COLA effective month must lie in [1, 12]") - raw_policy = parameters.gov.contrib.ssa.benefit_levers.cola # An earlier withheld or adjusted COLA remains in the payment level after # a policy is switched off. Inspect the parameter history, not just today. active = any( @@ -151,6 +174,11 @@ def reported_joint_cola_factor(person, period, parameters, component): for name in ("withholding_enabled", "pp_change", "alternative_rate") for entry in getattr(raw_policy, name).values_list ) + means_test_ever_enabled = any( + entry.instant_str <= f"{year}-12-31" and bool(entry.value) + for entry in raw_policy.means_test.enabled.values_list + ) + active |= means_test_ever_enabled if not active: return 1 events = cola_events(parameters, year) @@ -185,6 +213,21 @@ def reported_joint_cola_factor(person, period, parameters, component): elif identifier != "": raise ValueError("Social Security worker_id does not identify a person") entitlement = person(f"{prefix}_entitlement_year", period) + from policyengine_us.tools.social_security.means_test import ( + component_eligibility_year, + component_receipt_month, + component_receipt_year, + component_withholding, + indexed_cola_rate, + ) + + # A5's manual channel needs no calculator or tax-history inputs. Keep + # calculated rows outside population validation when E3 has never applied. + means_eligibility = receipt_year = receipt_month = None + if means_test_ever_enabled: + means_eligibility = component_eligibility_year(person, period, component) + receipt_year = component_receipt_year(person, period, component) + receipt_month = component_receipt_month(person, period, component) age = person("age", period) if component == "retirement": eligibility = year - age + 62 @@ -221,7 +264,7 @@ def reported_joint_cola_factor(person, period, parameters, component): if not np.any(exposed): continue validate_cola_rounding_rule(event["rounding_rule"]) - policy = parameters(f"{payment_year}-01-01").gov.contrib.ssa.benefit_levers.cola + policy = raw_policy(f"{payment_year}-01-01") effective = event["effective_date"] applies = ( ( @@ -242,15 +285,43 @@ def reported_joint_cola_factor(person, period, parameters, component): raise ValueError( "COLA change must be finite; alternative rate must be nonnegative or -1" ) + index_rate = ( + indexed_cola_rate( + person, + period, + parameters, + component, + payment_year, + baseline, + eligibility_year=means_eligibility, + receipt_year=receipt_year, + receipt_month=receipt_month, + ) + if policy.means_test.enabled + else baseline + ) rate = ( - (alternative if alternative >= 0 else baseline) + policy.pp_change + (alternative if alternative >= 0 else index_rate) + policy.pp_change if applies - else baseline + else index_rate ) withheld = ( - person(f"{prefix}_cola_withheld", payment_year) - if policy.withholding_enabled - else False + component_withholding( + person, + payment_year, + parameters, + component, + eligibility_year=means_eligibility, + receipt_year=receipt_year, + receipt_month=receipt_month, + use_explicit=True, + ) + if policy.means_test.enabled + else ( + person(f"{prefix}_cola_withheld", payment_year) + if policy.withholding_enabled + else False + ) ) multiplier = cola_event_multiplier(baseline, rate - baseline, withheld) result *= np.where(exposed, multiplier, 1) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_cola_means_test_magi.py b/policyengine_us/variables/gov/ssa/ss/social_security_cola_means_test_magi.py new file mode 100644 index 00000000000..47101a45ad6 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_cola_means_test_magi.py @@ -0,0 +1,45 @@ +from policyengine_us.model_api import * + + +class social_security_cola_means_test_magi(Variable): + value_type = float + entity = TaxUnit + label = "Social Security COLA means-test MAGI excluding Social Security" + unit = USD + definition_period = YEAR + documentation = ( + "Income for the contributed COLA means test in this tax year. The " + "OACT A9-linked proposal uses the IRMAA MAGI definition in Social " + "Security Act section 1839(i)(4): AGI with sections 135, 911, 931 " + "and 933 exclusions added back, plus tax-exempt interest. This " + "reform excludes taxable Social Security from that measure. Income " + "and deductions follow the filer's return, leaving out dependents' " + "own amounts; parent-owned savings bond exclusions recorded on a " + "dependent retain the model's filer-amount convention. The COLA " + "decision reads this variable for the prior tax year, so benefit " + "dependencies move strictly backward in time. Supplying this " + "variable for each tax year overrides the calculation and provides " + "an explicit income history. Dataset extension supplies a " + "same-record prior-year proxy in static population cross-sections, " + "which is not an observed longitudinal income history." + ) + reference = ( + "https://www.ssa.gov/OACT/solvency/provisions/cola.html", + "https://www.ssa.gov/OACT/solvency/SJohnson_20161208.pdf#page=8", + "https://www.congress.gov/bill/114th-congress/house-bill/6489/text", + "https://www.ssa.gov/OP_Home/ssact/title18/1839.htm", + ) + + def formula(tax_unit, period, parameters): + agi_with_exclusions = tax_unit( + "agi_plus_section_911_931_933_exclusions", period + ) + p = parameters(period).gov.irs.ald + other_addbacks = tax_unit_non_dep_add( + tax_unit, + period, + ["tax_exempt_interest_income", "us_bonds_for_higher_ed"], + include_dependents=p.filer_amounts_recorded_on_dependents, + ) + taxable_social_security = tax_unit("tax_unit_taxable_social_security", period) + return agi_with_exclusions + other_addbacks - taxable_social_security diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_cola_means_test_withheld.py b/policyengine_us/variables/gov/ssa/ss/social_security_cola_means_test_withheld.py new file mode 100644 index 00000000000..7b3b5cbeec2 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_cola_means_test_withheld.py @@ -0,0 +1,14 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.means_test import tax_unit_withholding + + +class social_security_cola_means_test_withheld(Variable): + value_type = bool + entity = TaxUnit + definition_period = YEAR + label = "Social Security COLA prior-year income test" + documentation = "Prior-tax-year MAGI strictly exceeds the CPI-U-indexed filing-group line. Beneficiary scope is applied separately." + reference = "https://www.ssa.gov/OACT/solvency/provisions/cola.html" + + def formula(tax_unit, period, parameters): + return tax_unit_withholding(tax_unit, period, parameters) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents.py index 17a31f93045..32e84917faf 100644 --- a/policyengine_us/variables/gov/ssa/ss/social_security_dependents.py +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents.py @@ -13,11 +13,15 @@ class social_security_dependents(Variable): documentation = "Selected source with applicable benefit levers; an explicit public input is a final override." def formula(person, period, parameters): - return ( - person("social_security_dependents_base", period) + base = person("social_security_dependents_base", period) + reported = ( + person("social_security_dependents_source", period) + == SocialSecurityBenefitSource.reported + ) + reported_benefit = ( + base * where( - person("social_security_dependents_source", period) - == SocialSecurityBenefitSource.reported, + reported, person("social_security_dependents_joint_cola_factor", period) * person("social_security_dependents_pia_ratio_factor", period), 1, @@ -25,3 +29,6 @@ def formula(person, period, parameters): * person("social_security_across_the_board_factor", period) * person("social_security_dependents_payable_factor", period) ) + # The calculator applies the complete payment policy before its + # earnings test. Its public route must not repeat any of those factors. + return where(reported, reported_benefit, base) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_cola_withheld.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_cola_withheld.py index 99af5c2342a..5570c210b36 100644 --- a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_cola_withheld.py +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_cola_withheld.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.social_security.means_test import component_withholding class social_security_dependents_cola_withheld(Variable): @@ -7,4 +8,7 @@ class social_security_dependents_cola_withheld(Variable): entity = Person definition_period = YEAR label = "Social Security dependents cola withheld" - documentation = "Whether this payment year COLA is withheld; inactive unless withholding_enabled is set." + documentation = "Automatic prior-year income decision when the COLA means test is enabled, with explicit per-year decision overrides." + + def formula(person, period, parameters): + return component_withholding(person, period, parameters, "dependents") diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability.py index b9987fb0bf1..d5c1068b80b 100644 --- a/policyengine_us/variables/gov/ssa/ss/social_security_disability.py +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability.py @@ -13,11 +13,15 @@ class social_security_disability(Variable): documentation = "Selected source with applicable benefit levers; an explicit public input is a final override." def formula(person, period, parameters): - return ( - person("social_security_disability_base", period) + base = person("social_security_disability_base", period) + reported = ( + person("social_security_disability_source", period) + == SocialSecurityBenefitSource.reported + ) + reported_benefit = ( + base * where( - person("social_security_disability_source", period) - == SocialSecurityBenefitSource.reported, + reported, person("social_security_disability_joint_cola_factor", period) * person("social_security_disability_pia_ratio_factor", period), 1, @@ -25,3 +29,6 @@ def formula(person, period, parameters): * person("social_security_across_the_board_factor", period) * person("social_security_disability_payable_factor", period) ) + # The calculator applies the complete payment policy before its + # earnings test. Its public route must not repeat any of those factors. + return where(reported, reported_benefit, base) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_cola_withheld.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_cola_withheld.py index 96208e14665..65234ed823b 100644 --- a/policyengine_us/variables/gov/ssa/ss/social_security_disability_cola_withheld.py +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_cola_withheld.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.social_security.means_test import component_withholding class social_security_disability_cola_withheld(Variable): @@ -7,4 +8,7 @@ class social_security_disability_cola_withheld(Variable): entity = Person definition_period = YEAR label = "Social Security disability cola withheld" - documentation = "Whether this payment year COLA is withheld; inactive unless withholding_enabled is set." + documentation = "Automatic prior-year income decision when the COLA means test is enabled, with explicit per-year decision overrides." + + def formula(person, period, parameters): + return component_withholding(person, period, parameters, "disability") diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement.py index 9918ec46f7b..6d3eaf99967 100644 --- a/policyengine_us/variables/gov/ssa/ss/social_security_retirement.py +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement.py @@ -13,11 +13,15 @@ class social_security_retirement(Variable): documentation = "Selected source with applicable benefit levers; an explicit public input is a final override." def formula(person, period, parameters): - return ( - person("social_security_retirement_base", period) + base = person("social_security_retirement_base", period) + reported = ( + person("social_security_retirement_source", period) + == SocialSecurityBenefitSource.reported + ) + reported_benefit = ( + base * where( - person("social_security_retirement_source", period) - == SocialSecurityBenefitSource.reported, + reported, person("social_security_retirement_joint_cola_factor", period) * person("social_security_retirement_pia_ratio_factor", period), 1, @@ -25,3 +29,6 @@ def formula(person, period, parameters): * person("social_security_across_the_board_factor", period) * person("social_security_retirement_payable_factor", period) ) + # The calculator applies the complete payment policy before its + # earnings test. Its public route must not repeat any of those factors. + return where(reported, reported_benefit, base) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_cola_withheld.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_cola_withheld.py index c448d954234..1051300d454 100644 --- a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_cola_withheld.py +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_cola_withheld.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.social_security.means_test import component_withholding class social_security_retirement_cola_withheld(Variable): @@ -7,4 +8,7 @@ class social_security_retirement_cola_withheld(Variable): entity = Person definition_period = YEAR label = "Social Security retirement cola withheld" - documentation = "Whether this payment year COLA is withheld; inactive unless withholding_enabled is set." + documentation = "Automatic prior-year income decision when the COLA means test is enabled, with explicit per-year decision overrides." + + def formula(person, period, parameters): + return component_withholding(person, period, parameters, "retirement") diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors.py index 78d1a8505ff..60883a30b2c 100644 --- a/policyengine_us/variables/gov/ssa/ss/social_security_survivors.py +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors.py @@ -13,11 +13,15 @@ class social_security_survivors(Variable): documentation = "Selected source with applicable benefit levers; an explicit public input is a final override." def formula(person, period, parameters): - return ( - person("social_security_survivors_base", period) + base = person("social_security_survivors_base", period) + reported = ( + person("social_security_survivors_source", period) + == SocialSecurityBenefitSource.reported + ) + reported_benefit = ( + base * where( - person("social_security_survivors_source", period) - == SocialSecurityBenefitSource.reported, + reported, person("social_security_survivors_joint_cola_factor", period) * person("social_security_survivors_pia_ratio_factor", period), 1, @@ -25,3 +29,6 @@ def formula(person, period, parameters): * person("social_security_across_the_board_factor", period) * person("social_security_survivors_payable_factor", period) ) + # The calculator applies the complete payment policy before its + # earnings test. Its public route must not repeat any of those factors. + return where(reported, reported_benefit, base) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_cola_withheld.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_cola_withheld.py index 0e151f3d6da..2f0fe18a6d4 100644 --- a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_cola_withheld.py +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_cola_withheld.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.social_security.means_test import component_withholding class social_security_survivors_cola_withheld(Variable): @@ -7,4 +8,7 @@ class social_security_survivors_cola_withheld(Variable): entity = Person definition_period = YEAR label = "Social Security survivors cola withheld" - documentation = "Whether this payment year COLA is withheld; inactive unless withholding_enabled is set." + documentation = "Automatic prior-year income decision when the COLA means test is enabled, with explicit per-year decision overrides." + + def formula(person, period, parameters): + return component_withholding(person, period, parameters, "survivors")