@retiregolden/engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +79 -0
  3. package/dist/allocation/assetClasses.d.ts +94 -0
  4. package/dist/allocation/assetClasses.js +215 -0
  5. package/dist/decisions/annuitization.d.ts +72 -0
  6. package/dist/decisions/annuitization.js +184 -0
  7. package/dist/decisions/decisionFixtures.d.ts +48 -0
  8. package/dist/decisions/decisionFixtures.js +253 -0
  9. package/dist/decisions/evaluateCandidate.d.ts +61 -0
  10. package/dist/decisions/evaluateCandidate.js +193 -0
  11. package/dist/decisions/generators.d.ts +110 -0
  12. package/dist/decisions/generators.js +667 -0
  13. package/dist/decisions/index.d.ts +17 -0
  14. package/dist/decisions/index.js +17 -0
  15. package/dist/decisions/insightsAdapter.d.ts +27 -0
  16. package/dist/decisions/insightsAdapter.js +51 -0
  17. package/dist/decisions/objectives.d.ts +96 -0
  18. package/dist/decisions/objectives.js +319 -0
  19. package/dist/decisions/pensionElection.d.ts +112 -0
  20. package/dist/decisions/pensionElection.js +239 -0
  21. package/dist/decisions/search.d.ts +51 -0
  22. package/dist/decisions/search.js +110 -0
  23. package/dist/decisions/spendingSolver.d.ts +70 -0
  24. package/dist/decisions/spendingSolver.js +180 -0
  25. package/dist/decisions/spiaQuotes.d.ts +21 -0
  26. package/dist/decisions/spiaQuotes.js +43 -0
  27. package/dist/decisions/stochastic.d.ts +11 -0
  28. package/dist/decisions/stochastic.js +66 -0
  29. package/dist/decisions/swrComparator.d.ts +54 -0
  30. package/dist/decisions/swrComparator.js +89 -0
  31. package/dist/decisions/tournament.d.ts +53 -0
  32. package/dist/decisions/tournament.js +104 -0
  33. package/dist/decisions/types.d.ts +125 -0
  34. package/dist/decisions/types.js +12 -0
  35. package/dist/index.d.ts +13 -0
  36. package/dist/index.js +12 -0
  37. package/dist/insights/detectors/annuitizationHeadroom.d.ts +11 -0
  38. package/dist/insights/detectors/annuitizationHeadroom.js +77 -0
  39. package/dist/insights/detectors/assetLocation.d.ts +9 -0
  40. package/dist/insights/detectors/assetLocation.js +86 -0
  41. package/dist/insights/detectors/hecmBufferCandidate.d.ts +12 -0
  42. package/dist/insights/detectors/hecmBufferCandidate.js +76 -0
  43. package/dist/insights/detectors/incomeFloorFunded.d.ts +8 -0
  44. package/dist/insights/detectors/incomeFloorFunded.js +52 -0
  45. package/dist/insights/detectors/irmaaTierEdge.d.ts +2 -0
  46. package/dist/insights/detectors/irmaaTierEdge.js +102 -0
  47. package/dist/insights/detectors/pensionElectionPending.d.ts +11 -0
  48. package/dist/insights/detectors/pensionElectionPending.js +62 -0
  49. package/dist/insights/detectors/qcdEfficiency.d.ts +2 -0
  50. package/dist/insights/detectors/qcdEfficiency.js +62 -0
  51. package/dist/insights/detectors/rothBridgeHeadroom.d.ts +2 -0
  52. package/dist/insights/detectors/rothBridgeHeadroom.js +77 -0
  53. package/dist/insights/detectors/spendingGuardrails.d.ts +2 -0
  54. package/dist/insights/detectors/spendingGuardrails.js +77 -0
  55. package/dist/insights/detectors/spendingHeadroom.d.ts +12 -0
  56. package/dist/insights/detectors/spendingHeadroom.js +92 -0
  57. package/dist/insights/detectors/ssBridgeGap.d.ts +9 -0
  58. package/dist/insights/detectors/ssBridgeGap.js +92 -0
  59. package/dist/insights/detectors/stateRelocation.d.ts +12 -0
  60. package/dist/insights/detectors/stateRelocation.js +99 -0
  61. package/dist/insights/detectors/widowsPenalty.d.ts +12 -0
  62. package/dist/insights/detectors/widowsPenalty.js +145 -0
  63. package/dist/insights/registry.d.ts +4 -0
  64. package/dist/insights/registry.js +62 -0
  65. package/dist/insights/runInsights.d.ts +6 -0
  66. package/dist/insights/runInsights.js +15 -0
  67. package/dist/insights/types.d.ts +70 -0
  68. package/dist/insights/types.js +1 -0
  69. package/dist/ladder/bridge.d.ts +72 -0
  70. package/dist/ladder/bridge.js +59 -0
  71. package/dist/ladder/fedInvest.d.ts +55 -0
  72. package/dist/ladder/fedInvest.js +77 -0
  73. package/dist/ladder/fundedRatio.d.ts +41 -0
  74. package/dist/ladder/fundedRatio.js +46 -0
  75. package/dist/ladder/ladderMath.d.ts +88 -0
  76. package/dist/ladder/ladderMath.js +140 -0
  77. package/dist/longevity/ssaPeriod2022.d.ts +10 -0
  78. package/dist/longevity/ssaPeriod2022.js +51 -0
  79. package/dist/longevity/types.d.ts +39 -0
  80. package/dist/longevity/types.js +1 -0
  81. package/dist/model/migrations.d.ts +20 -0
  82. package/dist/model/migrations.js +67 -0
  83. package/dist/model/plan.d.ts +3108 -0
  84. package/dist/model/plan.js +1514 -0
  85. package/dist/montecarlo/frontiers.d.ts +22 -0
  86. package/dist/montecarlo/frontiers.js +61 -0
  87. package/dist/montecarlo/historicalReturns.d.ts +27 -0
  88. package/dist/montecarlo/historicalReturns.js +124 -0
  89. package/dist/montecarlo/historicalSuites.d.ts +45 -0
  90. package/dist/montecarlo/historicalSuites.js +102 -0
  91. package/dist/montecarlo/ltcShock.d.ts +42 -0
  92. package/dist/montecarlo/ltcShock.js +62 -0
  93. package/dist/montecarlo/marketModels.d.ts +248 -0
  94. package/dist/montecarlo/marketModels.js +672 -0
  95. package/dist/montecarlo/mortality.d.ts +31 -0
  96. package/dist/montecarlo/mortality.js +61 -0
  97. package/dist/montecarlo/riskBasedGuardrails.d.ts +93 -0
  98. package/dist/montecarlo/riskBasedGuardrails.js +200 -0
  99. package/dist/montecarlo/rng.d.ts +18 -0
  100. package/dist/montecarlo/rng.js +44 -0
  101. package/dist/montecarlo/run.d.ts +210 -0
  102. package/dist/montecarlo/run.js +353 -0
  103. package/dist/montecarlo/sharedPaths.d.ts +43 -0
  104. package/dist/montecarlo/sharedPaths.js +28 -0
  105. package/dist/montecarlo/survival.d.ts +49 -0
  106. package/dist/montecarlo/survival.js +126 -0
  107. package/dist/params/data/realYieldCurve2026.d.ts +17 -0
  108. package/dist/params/data/realYieldCurve2026.js +26 -0
  109. package/dist/params/data/year2026.d.ts +14 -0
  110. package/dist/params/data/year2026.js +199 -0
  111. package/dist/params/index.d.ts +63 -0
  112. package/dist/params/index.js +134 -0
  113. package/dist/params/provenance.d.ts +29 -0
  114. package/dist/params/provenance.js +123 -0
  115. package/dist/params/state/data/year2026.d.ts +18 -0
  116. package/dist/params/state/data/year2026.js +581 -0
  117. package/dist/params/state/index.d.ts +13 -0
  118. package/dist/params/state/index.js +26 -0
  119. package/dist/params/state/types.d.ts +73 -0
  120. package/dist/params/state/types.js +10 -0
  121. package/dist/params/types.d.ts +201 -0
  122. package/dist/params/types.js +7 -0
  123. package/dist/projection/annuityForms.d.ts +62 -0
  124. package/dist/projection/annuityForms.js +87 -0
  125. package/dist/projection/compare.d.ts +96 -0
  126. package/dist/projection/compare.js +216 -0
  127. package/dist/projection/flatTax.d.ts +7 -0
  128. package/dist/projection/flatTax.js +14 -0
  129. package/dist/projection/optimizePlan.d.ts +328 -0
  130. package/dist/projection/optimizePlan.js +1039 -0
  131. package/dist/projection/relocation.d.ts +149 -0
  132. package/dist/projection/relocation.js +317 -0
  133. package/dist/projection/simulate.d.ts +61 -0
  134. package/dist/projection/simulate.js +2754 -0
  135. package/dist/projection/types.d.ts +381 -0
  136. package/dist/projection/types.js +9 -0
  137. package/dist/rmd/jointLifeTable.d.ts +8 -0
  138. package/dist/rmd/jointLifeTable.js +663 -0
  139. package/dist/rmd/rmd.d.ts +29 -0
  140. package/dist/rmd/rmd.js +34 -0
  141. package/dist/scenarios/scenarios.d.ts +70 -0
  142. package/dist/scenarios/scenarios.js +130 -0
  143. package/dist/socialSecurity/benefitFactor.d.ts +13 -0
  144. package/dist/socialSecurity/benefitFactor.js +40 -0
  145. package/dist/socialSecurity/claimFactor.d.ts +33 -0
  146. package/dist/socialSecurity/claimFactor.js +58 -0
  147. package/dist/socialSecurity/disability.d.ts +36 -0
  148. package/dist/socialSecurity/disability.js +42 -0
  149. package/dist/socialSecurity/familyMaximum.d.ts +38 -0
  150. package/dist/socialSecurity/familyMaximum.js +48 -0
  151. package/dist/socialSecurity/maritalBenefits.d.ts +57 -0
  152. package/dist/socialSecurity/maritalBenefits.js +87 -0
  153. package/dist/socialSecurity/nra.d.ts +30 -0
  154. package/dist/socialSecurity/nra.js +83 -0
  155. package/dist/socialSecurity/piaFromEarnings.d.ts +100 -0
  156. package/dist/socialSecurity/piaFromEarnings.js +202 -0
  157. package/dist/socialSecurity/ssaWageData.d.ts +68 -0
  158. package/dist/socialSecurity/ssaWageData.js +306 -0
  159. package/dist/socialSecurity/survivorBenefit.d.ts +61 -0
  160. package/dist/socialSecurity/survivorBenefit.js +61 -0
  161. package/dist/spending/abw.d.ts +57 -0
  162. package/dist/spending/abw.js +80 -0
  163. package/dist/spending/flexibleGoals.d.ts +62 -0
  164. package/dist/spending/flexibleGoals.js +136 -0
  165. package/dist/spending/guardrails.d.ts +78 -0
  166. package/dist/spending/guardrails.js +100 -0
  167. package/dist/spending/layers.d.ts +81 -0
  168. package/dist/spending/layers.js +67 -0
  169. package/dist/spending/shapePresets.d.ts +42 -0
  170. package/dist/spending/shapePresets.js +77 -0
  171. package/dist/strategies/accountEligibility.d.ts +81 -0
  172. package/dist/strategies/accountEligibility.js +98 -0
  173. package/dist/strategies/inheritedIra.d.ts +45 -0
  174. package/dist/strategies/inheritedIra.js +43 -0
  175. package/dist/strategies/iraBasis.d.ts +48 -0
  176. package/dist/strategies/iraBasis.js +46 -0
  177. package/dist/strategies/optimizer.d.ts +307 -0
  178. package/dist/strategies/optimizer.js +551 -0
  179. package/dist/strategies/rothBasis.d.ts +67 -0
  180. package/dist/strategies/rothBasis.js +83 -0
  181. package/dist/strategies/rothConversion.d.ts +55 -0
  182. package/dist/strategies/rothConversion.js +83 -0
  183. package/dist/strategies/sepp.d.ts +38 -0
  184. package/dist/strategies/sepp.js +53 -0
  185. package/dist/tax/aca.d.ts +40 -0
  186. package/dist/tax/aca.js +82 -0
  187. package/dist/tax/federalTax.d.ts +110 -0
  188. package/dist/tax/federalTax.js +249 -0
  189. package/dist/tax/medicare.d.ts +20 -0
  190. package/dist/tax/medicare.js +36 -0
  191. package/dist/tax/propertySale.d.ts +46 -0
  192. package/dist/tax/propertySale.js +35 -0
  193. package/dist/tax/stateTax.d.ts +66 -0
  194. package/dist/tax/stateTax.js +198 -0
  195. package/dist/testing/money.d.ts +7 -0
  196. package/dist/testing/money.js +17 -0
  197. package/dist/testing/planFixtures.d.ts +29 -0
  198. package/dist/testing/planFixtures.js +141 -0
  199. package/package.json +75 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Marital-history benefit menu (V7 phase 3): the divorced-spousal and survivor
3
+ * benefits a person can claim on a *former* spouse's record, separate from the
4
+ * current-spouse spousal top-up the engine already models for couples.
5
+ *
6
+ * Eligibility rules (from the gap analysis):
7
+ * - Divorced-spousal: marriage lasted ≥10 years, the claimant is currently
8
+ * unmarried, and the ex is eligible (≥62) — the ex need not have filed. Worth
9
+ * up to 50% of the ex's PIA, reduced for the claimant's own (early) claim age,
10
+ * with no delayed credits (same factor as current-spousal).
11
+ * - Survivor: marriage lasted ≥9 months, the claimant is ≥60, and remarriage
12
+ * rules are satisfied (remarrying before 60 forfeits it; at/after 60 preserves
13
+ * it). Survivor benefit is based on the deceased's actual benefit, with the
14
+ * early-claim widow(er) reduction and the RIB-LIM widow's-limit cap applied by
15
+ * the shared `survivorBenefitMonthly` helper (cited in domain rules §4).
16
+ *
17
+ * Simplifications (spec §6): survivor is modeled at the claimant's own claim age
18
+ * (the ledger doesn't model separate survivor-vs-own claim ages here — that
19
+ * sequencing lives in the actuarial `survivorSwitching` view); the
20
+ * 2-years-since-divorce "independently entitled" rule for divorced-spousal on a
21
+ * living ex is ignored (rarely binds in planning). Here a person receives the
22
+ * larger of own vs the best marital benefit at their single claim age.
23
+ */
24
+ import { claimFactor, spousalBenefitFactor } from './claimFactor.js';
25
+ import { effectiveBirthYear, fraForBirthYear, fraTotalMonths, survivorFraForBirthYear } from './nra.js';
26
+ import { survivorBenefitMonthly } from './survivorBenefit.js';
27
+ export const DIVORCED_MIN_MARRIAGE_YEARS = 10;
28
+ export const SURVIVOR_MIN_MARRIAGE_YEARS = 0.75; // 9 months
29
+ export const SURVIVOR_MIN_AGE = 60;
30
+ export const DIVORCED_EX_MIN_AGE = 62;
31
+ export const REMARRIAGE_SURVIVOR_PRESERVE_AGE = 60;
32
+ function birthYear(dob) {
33
+ return Number(dob.slice(0, 4));
34
+ }
35
+ /** Eligibility + monthly amount for one former-spouse record; null if not eligible this year. */
36
+ export function maritalBenefitFor(record, ctx) {
37
+ // A benefit on someone else's record only starts once the claimant has claimed.
38
+ if (ctx.claimantAge < ctx.claimantClaimAge.years)
39
+ return null;
40
+ if (record.relationship === 'divorced') {
41
+ if (!ctx.claimantIsSingle)
42
+ return null;
43
+ if (record.marriageYears < DIVORCED_MIN_MARRIAGE_YEARS)
44
+ return null;
45
+ if (ctx.year - birthYear(record.dob) < DIVORCED_EX_MIN_AGE)
46
+ return null;
47
+ const factor = spousalBenefitFactor(ctx.claimantDob.year, ctx.claimantDob.month, ctx.claimantDob.day, ctx.claimantClaimAge);
48
+ return { kind: 'divorcedSpousal', monthly: 0.5 * record.piaMonthly * factor };
49
+ }
50
+ // Deceased former spouse → survivor.
51
+ if (record.marriageYears < SURVIVOR_MIN_MARRIAGE_YEARS)
52
+ return null;
53
+ if (ctx.claimantAge < SURVIVOR_MIN_AGE)
54
+ return null;
55
+ if (record.remarriedAtAge !== null && record.remarriedAtAge < REMARRIAGE_SURVIVOR_PRESERVE_AGE)
56
+ return null;
57
+ // Survivor base = the deceased ex's actual (claim-age-adjusted) benefit, with
58
+ // the RIB-LIM widow's-limit cap and the early-claim widow(er) reduction — both
59
+ // computed by the shared helper (cited in domain rules §4). The deceased's
60
+ // claim age defaults to "claimed at FRA" (factor 1, actual = PIA) when omitted.
61
+ const exDobYear = birthYear(record.dob);
62
+ const exDobMonth = Number(record.dob.slice(5, 7));
63
+ const exDobDay = Number(record.dob.slice(8, 10));
64
+ const exEffYear = effectiveBirthYear(exDobYear, exDobMonth, exDobDay);
65
+ const exFra = fraForBirthYear(exEffYear);
66
+ const exClaimAge = record.deceasedClaimAge ?? { years: exFra.years, months: exFra.extraMonths };
67
+ const deceasedActualMonthly = record.piaMonthly * claimFactor(exDobYear, exDobMonth, exDobDay, exClaimAge);
68
+ const claimantEffYear = effectiveBirthYear(ctx.claimantDob.year, ctx.claimantDob.month, ctx.claimantDob.day);
69
+ const survivorFraMonths = fraTotalMonths(survivorFraForBirthYear(claimantEffYear));
70
+ const monthly = survivorBenefitMonthly({
71
+ deceasedPiaMonthly: record.piaMonthly,
72
+ deceasedActualMonthly,
73
+ survivorClaimAge: ctx.claimantSurvivorClaimAge ?? ctx.claimantClaimAge,
74
+ survivorFraMonths,
75
+ });
76
+ return { kind: 'survivor', monthly };
77
+ }
78
+ /** Largest eligible monthly marital benefit across all former spouses; null if none. */
79
+ export function bestMaritalBenefit(records, ctx) {
80
+ let best = null;
81
+ for (const record of records ?? []) {
82
+ const candidate = maritalBenefitFor(record, ctx);
83
+ if (candidate && (best === null || candidate.monthly > best.monthly))
84
+ best = candidate;
85
+ }
86
+ return best;
87
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Full retirement age (FRA) in years + months after age 65, by **year of birth**
3
+ * (SSA “normal retirement age” schedule). Jan 1 DOB uses prior calendar year.
4
+ *
5
+ * @see https://www.ssa.gov/benefits/retirement/planner/agereduction.html
6
+ */
7
+ export declare function effectiveBirthYear(year: number, month: number, day: number): number;
8
+ /** FRA as completed years + extra months (0, 2, …, 10) beyond those full years. */
9
+ export interface FraComponents {
10
+ years: number;
11
+ extraMonths: number;
12
+ }
13
+ /**
14
+ * SSA NRA schedule (simplified to birth **year**; month-of-year refinements omitted).
15
+ * Years before 1938: treated as 65 + 0 (legacy). Years after 2025: 67 + 0 (current law; may change).
16
+ */
17
+ export declare function fraForBirthYear(birthYearEffective: number): FraComponents;
18
+ /** Total “month slots” from birth to reach FRA / claim age (approximation: 12y + extra). */
19
+ export declare function ageToTotalMonths(ageYears: number, extraMonths?: number): number;
20
+ export declare function fraTotalMonths(fra: FraComponents): number;
21
+ /**
22
+ * Survivor (widow(er)) full retirement age — a separate, **earlier** schedule
23
+ * than the worker retirement FRA above: 65y0m for born ≤1945, ramping to 66y0m
24
+ * for 1951–56, then 66y2m→66y8m for 1957–60, topping out at **66y8m** for born
25
+ * 1960+ (it never reaches 67). The widow(er) early-claim reduction (up to 28.5%
26
+ * at 60) is measured against this FRA, not the worker FRA.
27
+ *
28
+ * @see https://www.ssa.gov/oact/ProgData/nra.html (Full Retirement Age for Survivors)
29
+ */
30
+ export declare function survivorFraForBirthYear(birthYearEffective: number): FraComponents;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Full retirement age (FRA) in years + months after age 65, by **year of birth**
3
+ * (SSA “normal retirement age” schedule). Jan 1 DOB uses prior calendar year.
4
+ *
5
+ * @see https://www.ssa.gov/benefits/retirement/planner/agereduction.html
6
+ */
7
+ export function effectiveBirthYear(year, month, day) {
8
+ if (month === 1 && day === 1)
9
+ return year - 1;
10
+ return year;
11
+ }
12
+ /**
13
+ * SSA NRA schedule (simplified to birth **year**; month-of-year refinements omitted).
14
+ * Years before 1938: treated as 65 + 0 (legacy). Years after 2025: 67 + 0 (current law; may change).
15
+ */
16
+ export function fraForBirthYear(birthYearEffective) {
17
+ const y = birthYearEffective;
18
+ if (y <= 1937)
19
+ return { years: 65, extraMonths: 0 };
20
+ if (y === 1938)
21
+ return { years: 65, extraMonths: 2 };
22
+ if (y === 1939)
23
+ return { years: 65, extraMonths: 4 };
24
+ if (y === 1940)
25
+ return { years: 65, extraMonths: 6 };
26
+ if (y === 1941)
27
+ return { years: 65, extraMonths: 8 };
28
+ if (y === 1942)
29
+ return { years: 65, extraMonths: 10 };
30
+ if (y >= 1943 && y <= 1954)
31
+ return { years: 66, extraMonths: 0 };
32
+ if (y === 1955)
33
+ return { years: 66, extraMonths: 2 };
34
+ if (y === 1956)
35
+ return { years: 66, extraMonths: 4 };
36
+ if (y === 1957)
37
+ return { years: 66, extraMonths: 6 };
38
+ if (y === 1958)
39
+ return { years: 66, extraMonths: 8 };
40
+ if (y === 1959)
41
+ return { years: 66, extraMonths: 10 };
42
+ return { years: 67, extraMonths: 0 };
43
+ }
44
+ /** Total “month slots” from birth to reach FRA / claim age (approximation: 12y + extra). */
45
+ export function ageToTotalMonths(ageYears, extraMonths = 0) {
46
+ return ageYears * 12 + extraMonths;
47
+ }
48
+ export function fraTotalMonths(fra) {
49
+ return fra.years * 12 + fra.extraMonths;
50
+ }
51
+ /**
52
+ * Survivor (widow(er)) full retirement age — a separate, **earlier** schedule
53
+ * than the worker retirement FRA above: 65y0m for born ≤1945, ramping to 66y0m
54
+ * for 1951–56, then 66y2m→66y8m for 1957–60, topping out at **66y8m** for born
55
+ * 1960+ (it never reaches 67). The widow(er) early-claim reduction (up to 28.5%
56
+ * at 60) is measured against this FRA, not the worker FRA.
57
+ *
58
+ * @see https://www.ssa.gov/oact/ProgData/nra.html (Full Retirement Age for Survivors)
59
+ */
60
+ export function survivorFraForBirthYear(birthYearEffective) {
61
+ const y = birthYearEffective;
62
+ if (y <= 1945)
63
+ return { years: 65, extraMonths: 0 };
64
+ if (y === 1946)
65
+ return { years: 65, extraMonths: 2 };
66
+ if (y === 1947)
67
+ return { years: 65, extraMonths: 4 };
68
+ if (y === 1948)
69
+ return { years: 65, extraMonths: 6 };
70
+ if (y === 1949)
71
+ return { years: 65, extraMonths: 8 };
72
+ if (y === 1950)
73
+ return { years: 65, extraMonths: 10 };
74
+ if (y >= 1951 && y <= 1956)
75
+ return { years: 66, extraMonths: 0 };
76
+ if (y === 1957)
77
+ return { years: 66, extraMonths: 2 };
78
+ if (y === 1958)
79
+ return { years: 66, extraMonths: 4 };
80
+ if (y === 1959)
81
+ return { years: 66, extraMonths: 6 };
82
+ return { years: 66, extraMonths: 8 }; // born 1960+
83
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * PIA from covered earnings (retirement), using SSA wage indexing and bend points.
3
+ * @see https://www.ssa.gov/oact/COLA/Benefits.html
4
+ * @see DOCS/features/social-security.md
5
+ */
6
+ export type PiaFromEarningsErrorCode = 'eligibility_before_1979' | 'missing_awi' | 'missing_bend_points' | 'no_computation_years' | 'last_earnings_year_out_of_range';
7
+ export interface PiaFromEarningsError {
8
+ code: PiaFromEarningsErrorCode;
9
+ message: string;
10
+ }
11
+ export interface YearEarning {
12
+ year: number;
13
+ /** Taxed Social Security earnings for that calendar year (before wage-base cap). */
14
+ amount: number;
15
+ }
16
+ /**
17
+ * Optional projection of future covered earnings. Without it, every base year
18
+ * after `lastEarningsYear` is treated as zero — which understates the PIA for
19
+ * someone who is still working but will retire a few years out. With it, those
20
+ * years are filled at an assumed wage up to a retirement age.
21
+ */
22
+ export interface EarningsProjection {
23
+ /** Covered earnings to assume for each projected year; null = reuse the last reported year's amount. */
24
+ assumedAnnualEarnings: number | null;
25
+ /** Project covered earnings through the worker's last full working year before this age. */
26
+ throughAge: number;
27
+ }
28
+ export interface PiaFromEarningsInput {
29
+ dobYear: number;
30
+ dobMonth: number;
31
+ dobDay: number;
32
+ /** Calendar years and covered earnings; years outside the base window are ignored. */
33
+ earnings: YearEarning[];
34
+ /** Last calendar year with covered earnings; later base years are treated as zero (unless projected). */
35
+ lastEarningsYear: number;
36
+ /** Optional: fill base years after `lastEarningsYear` with assumed earnings instead of zero. */
37
+ projection?: EarningsProjection | null;
38
+ }
39
+ export interface IndexedYearDetail {
40
+ year: number;
41
+ rawEarnings: number;
42
+ cappedEarnings: number;
43
+ indexedAnnual: number;
44
+ wageIndexed: boolean;
45
+ /** True when this year's earnings were filled in by the future-earnings projection (not reported). */
46
+ projected: boolean;
47
+ }
48
+ export interface PiaFromEarningsResult {
49
+ eligibilityYear: number;
50
+ firstBaseYear: number;
51
+ lastBaseYear: number;
52
+ indexingYearAwi: number;
53
+ indexedYears: IndexedYearDetail[];
54
+ /** After dropout / top-35 selection, in eligibility-year dollars (indexed or nominal per rules). */
55
+ yearsUsedInAime: number[];
56
+ computationYearCount: number;
57
+ /** How many base years were filled by the future-earnings projection. */
58
+ projectedYearCount: number;
59
+ aime: number;
60
+ /** Monthly PIA at full retirement age before COLA (rounded down to next lower $0.10). */
61
+ piaMonthly: number;
62
+ /**
63
+ * True when eligibility or some indexed earnings year is beyond published SSA tables in this app;
64
+ * latest bend points and/or AWI are used as a rough stand-in (SSA will apply official values later).
65
+ */
66
+ usesStandInForFutureTables: boolean;
67
+ }
68
+ /** Year of eligibility for retirement: calendar year worker attains age 62 (Jan 1 rule via effective birth year). */
69
+ export declare function eligibilityYearFromDobParts(y: number, m: number, d: number): number;
70
+ /**
71
+ * PIA from AIME using eligibility-year bend points (monthly formula).
72
+ * @see https://www.ssa.gov/oact/COLA/piaformula.html
73
+ */
74
+ export declare function piaMonthlyFromAime(aime: number, eligibilityYear: number): number;
75
+ /**
76
+ * Full earnings-history → AIME → PIA path for a retirement benefit illustration.
77
+ */
78
+ export declare function computePiaFromEarnings(input: PiaFromEarningsInput): PiaFromEarningsResult | PiaFromEarningsError;
79
+ export declare function isPiaFromEarningsError(x: PiaFromEarningsResult | PiaFromEarningsError): x is PiaFromEarningsError;
80
+ /**
81
+ * Assemble a {@link PiaFromEarningsInput} from a person's date of birth and an
82
+ * earnings history, clamping `lastEarningsYear` into the AIME base window. Shared
83
+ * by the projection engine and the planner UI so the windowing logic lives in
84
+ * one place. Assumes `earnings` is non-empty (callers guard).
85
+ */
86
+ export declare function piaInputFromEarnings(dobYear: number, dobMonth: number, dobDay: number, earnings: YearEarning[], projection?: EarningsProjection | null): PiaFromEarningsInput;
87
+ /**
88
+ * Resolve the plan's stored earnings-projection settings into an engine
89
+ * {@link EarningsProjection}, defaulting `throughAge` to the person's retirement
90
+ * age. Returns null when no projection is configured or no age is available.
91
+ */
92
+ export declare function resolveEarningsProjection(stored: {
93
+ assumedAnnualEarnings: number | null;
94
+ throughAge: number | null;
95
+ } | null | undefined, retirementAge: number | null): EarningsProjection | null;
96
+ /** Parse "YYYY amount" lines (whitespace-separated). */
97
+ export declare function parseEarningsLines(text: string): {
98
+ rows: YearEarning[];
99
+ errors: string[];
100
+ };
@@ -0,0 +1,202 @@
1
+ /**
2
+ * PIA from covered earnings (retirement), using SSA wage indexing and bend points.
3
+ * @see https://www.ssa.gov/oact/COLA/Benefits.html
4
+ * @see DOCS/features/social-security.md
5
+ */
6
+ import { effectiveBirthYear } from './nra.js';
7
+ import { awiForYear, awiForYearOrLatest, bendPointsForEligibilityYearOrLatest, LATEST_PIA_BEND_POINT_ELIGIBILITY_YEAR, wageBaseForYearOrLatest, } from './ssaWageData.js';
8
+ function floorToDime(x) {
9
+ return Math.floor(x * 10 + 1e-9) / 10;
10
+ }
11
+ /** Year of eligibility for retirement: calendar year worker attains age 62 (Jan 1 rule via effective birth year). */
12
+ export function eligibilityYearFromDobParts(y, m, d) {
13
+ return effectiveBirthYear(y, m, d) + 62;
14
+ }
15
+ /**
16
+ * PIA from AIME using eligibility-year bend points (monthly formula).
17
+ * @see https://www.ssa.gov/oact/COLA/piaformula.html
18
+ */
19
+ export function piaMonthlyFromAime(aime, eligibilityYear) {
20
+ const bp = bendPointsForEligibilityYearOrLatest(eligibilityYear);
21
+ const b1 = bp.first;
22
+ const b2 = bp.second;
23
+ let pia = 0.9 * Math.min(aime, b1) +
24
+ 0.32 * Math.max(0, Math.min(aime, b2) - b1) +
25
+ 0.15 * Math.max(0, aime - b2);
26
+ pia = floorToDime(pia);
27
+ return pia;
28
+ }
29
+ function capEarnings(year, amount) {
30
+ // Cap at the year's taxable maximum, falling back to the latest published wage
31
+ // base for projected/future years SSA has not set yet (otherwise high earners'
32
+ // projected years would inflate AIME past the Social Security taxable maximum).
33
+ return Math.max(0, Math.min(amount, wageBaseForYearOrLatest(year)));
34
+ }
35
+ /**
36
+ * Full earnings-history → AIME → PIA path for a retirement benefit illustration.
37
+ */
38
+ export function computePiaFromEarnings(input) {
39
+ const { dobYear, dobMonth, dobDay, earnings, lastEarningsYear, projection } = input;
40
+ const eligibilityYear = eligibilityYearFromDobParts(dobYear, dobMonth, dobDay);
41
+ if (eligibilityYear < 1979) {
42
+ return {
43
+ code: 'eligibility_before_1979',
44
+ message: 'This tool uses the modern PIA formula (1979+). For birth dates implying eligibility before 1979, enter PIA manually in quick mode.',
45
+ };
46
+ }
47
+ const effBirth = effectiveBirthYear(dobYear, dobMonth, dobDay);
48
+ const firstBaseYear = effBirth + 22;
49
+ const lastBaseYear = eligibilityYear - 1;
50
+ if (lastEarningsYear < firstBaseYear || lastEarningsYear > lastBaseYear) {
51
+ return {
52
+ code: 'last_earnings_year_out_of_range',
53
+ message: `Last earnings year must be between ${firstBaseYear} and ${lastBaseYear} for this date of birth.`,
54
+ };
55
+ }
56
+ const awiNumYear = eligibilityYear - 2;
57
+ let usesStandInForFutureTables = eligibilityYear > LATEST_PIA_BEND_POINT_ELIGIBILITY_YEAR;
58
+ if (awiForYear(awiNumYear) === undefined)
59
+ usesStandInForFutureTables = true;
60
+ if (!usesStandInForFutureTables) {
61
+ for (let y = firstBaseYear; y <= lastBaseYear && y <= eligibilityYear - 2; y++) {
62
+ if (awiForYear(y) === undefined) {
63
+ usesStandInForFutureTables = true;
64
+ break;
65
+ }
66
+ }
67
+ }
68
+ const indexingAwi = awiForYearOrLatest(awiNumYear);
69
+ const byYear = new Map();
70
+ for (const row of earnings) {
71
+ if (!Number.isFinite(row.year) || !Number.isFinite(row.amount))
72
+ continue;
73
+ const y = Math.trunc(row.year);
74
+ const prev = byYear.get(y) ?? 0;
75
+ byYear.set(y, prev + Math.max(0, row.amount));
76
+ }
77
+ // Future-earnings projection: fill base years after the last reported year up
78
+ // to the worker's last full working year before `throughAge` (effBirth + age).
79
+ const projectionThroughYear = projection ? effBirth + projection.throughAge - 1 : lastEarningsYear;
80
+ const projectedAmount = projection
81
+ ? (projection.assumedAnnualEarnings ?? byYear.get(lastEarningsYear) ?? 0)
82
+ : 0;
83
+ const indexedDetails = [];
84
+ const annualIndexedList = [];
85
+ let projectedYearCount = 0;
86
+ for (let year = firstBaseYear; year <= lastBaseYear; year++) {
87
+ let raw;
88
+ let projected = false;
89
+ if (year <= lastEarningsYear) {
90
+ raw = byYear.get(year) ?? 0;
91
+ }
92
+ else if (projection && year <= projectionThroughYear) {
93
+ raw = projectedAmount;
94
+ projected = true;
95
+ projectedYearCount++;
96
+ }
97
+ else {
98
+ raw = 0;
99
+ }
100
+ const capped = capEarnings(year, raw);
101
+ const wageIndexed = year <= eligibilityYear - 2;
102
+ let indexedAnnual = capped;
103
+ if (wageIndexed) {
104
+ const awiY = awiForYearOrLatest(year);
105
+ indexedAnnual = Math.floor((capped * indexingAwi) / awiY);
106
+ }
107
+ indexedDetails.push({
108
+ year,
109
+ rawEarnings: raw,
110
+ cappedEarnings: capped,
111
+ indexedAnnual,
112
+ wageIndexed,
113
+ projected,
114
+ });
115
+ annualIndexedList.push(indexedAnnual);
116
+ }
117
+ annualIndexedList.sort((a, b) => a - b);
118
+ const afterDropout = annualIndexedList.slice(5);
119
+ if (afterDropout.length === 0) {
120
+ return {
121
+ code: 'no_computation_years',
122
+ message: 'Not enough covered earnings years in the base period after dropout (need at least one year).',
123
+ };
124
+ }
125
+ afterDropout.sort((a, b) => b - a);
126
+ const top = afterDropout.slice(0, 35);
127
+ const computationYearCount = Math.min(35, afterDropout.length);
128
+ const sumTop = top.reduce((s, v) => s + v, 0);
129
+ const divisorMonths = 12 * computationYearCount;
130
+ const aime = Math.floor(sumTop / divisorMonths);
131
+ const pia = piaMonthlyFromAime(aime, eligibilityYear);
132
+ return {
133
+ eligibilityYear,
134
+ firstBaseYear,
135
+ lastBaseYear,
136
+ indexingYearAwi: indexingAwi,
137
+ indexedYears: indexedDetails,
138
+ yearsUsedInAime: top,
139
+ computationYearCount,
140
+ projectedYearCount,
141
+ aime,
142
+ piaMonthly: pia,
143
+ usesStandInForFutureTables,
144
+ };
145
+ }
146
+ export function isPiaFromEarningsError(x) {
147
+ return 'code' in x;
148
+ }
149
+ /**
150
+ * Assemble a {@link PiaFromEarningsInput} from a person's date of birth and an
151
+ * earnings history, clamping `lastEarningsYear` into the AIME base window. Shared
152
+ * by the projection engine and the planner UI so the windowing logic lives in
153
+ * one place. Assumes `earnings` is non-empty (callers guard).
154
+ */
155
+ export function piaInputFromEarnings(dobYear, dobMonth, dobDay, earnings, projection) {
156
+ const effBirth = effectiveBirthYear(dobYear, dobMonth, dobDay);
157
+ const firstBaseYear = effBirth + 22;
158
+ const lastBaseYear = effBirth + 61;
159
+ const lastEarningsYear = Math.min(Math.max(...earnings.map((e) => e.year), firstBaseYear), lastBaseYear);
160
+ return { dobYear, dobMonth, dobDay, earnings, lastEarningsYear, projection: projection ?? null };
161
+ }
162
+ /**
163
+ * Resolve the plan's stored earnings-projection settings into an engine
164
+ * {@link EarningsProjection}, defaulting `throughAge` to the person's retirement
165
+ * age. Returns null when no projection is configured or no age is available.
166
+ */
167
+ export function resolveEarningsProjection(stored, retirementAge) {
168
+ if (!stored)
169
+ return null;
170
+ const throughAge = stored.throughAge ?? retirementAge;
171
+ if (throughAge === null)
172
+ return null;
173
+ return { assumedAnnualEarnings: stored.assumedAnnualEarnings, throughAge };
174
+ }
175
+ /** Parse "YYYY amount" lines (whitespace-separated). */
176
+ export function parseEarningsLines(text) {
177
+ const rows = [];
178
+ const errors = [];
179
+ const lines = text.split(/\r?\n/);
180
+ for (let i = 0; i < lines.length; i++) {
181
+ const line = lines[i].trim();
182
+ if (!line || line.startsWith('#'))
183
+ continue;
184
+ const parts = line.split(/[\s,]+/).filter(Boolean);
185
+ if (parts.length < 2) {
186
+ errors.push(`Line ${i + 1}: need year and amount (e.g. "1995 42000").`);
187
+ continue;
188
+ }
189
+ const y = Number.parseInt(parts[0], 10);
190
+ const amt = Number.parseFloat(parts[1]);
191
+ if (!Number.isFinite(y) || y < 1951 || y > 2100) {
192
+ errors.push(`Line ${i + 1}: invalid year.`);
193
+ continue;
194
+ }
195
+ if (!Number.isFinite(amt) || amt < 0) {
196
+ errors.push(`Line ${i + 1}: invalid amount.`);
197
+ continue;
198
+ }
199
+ rows.push({ year: y, amount: amt });
200
+ }
201
+ return { rows, errors };
202
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Official SSA series for wage indexing, bend points, family maximum bend
3
+ * points, and taxable maximum.
4
+ * @see https://www.ssa.gov/oact/COLA/awiseries.html
5
+ * @see https://www.ssa.gov/oact/COLA/bendpoints.html
6
+ * @see https://www.ssa.gov/oact/COLA/cbb.html
7
+ */
8
+ /** National average wage index (AWI), dollars. */
9
+ export declare const AWI_BY_YEAR: Readonly<Record<number, number>>;
10
+ /** PIA formula bend points by year of eligibility (attain age 62 / disability / death). */
11
+ export declare const PIA_BEND_POINTS: Readonly<Record<number, {
12
+ first: number;
13
+ second: number;
14
+ }>>;
15
+ /** Retirement/survivor family-maximum formula bend points by year of eligibility. */
16
+ export declare const FAMILY_MAXIMUM_BEND_POINTS: Readonly<Record<number, {
17
+ first: number;
18
+ second: number;
19
+ third: number;
20
+ }>>;
21
+ /** OASDI contribution & benefit base (taxable maximum), dollars. */
22
+ export declare const WAGE_BASE_BY_YEAR: Readonly<Record<number, number>>;
23
+ /** Latest calendar year for which `AWI_BY_YEAR` has an official value. */
24
+ export declare const LATEST_PUBLISHED_AWI_YEAR: number;
25
+ /** Latest eligibility year for which `PIA_BEND_POINTS` has an official pair. */
26
+ export declare const LATEST_PIA_BEND_POINT_ELIGIBILITY_YEAR: number;
27
+ /** Latest eligibility year for which `FAMILY_MAXIMUM_BEND_POINTS` has an official triplet. */
28
+ export declare const LATEST_FAMILY_MAXIMUM_BEND_POINT_ELIGIBILITY_YEAR: number;
29
+ /** Latest calendar year for which `WAGE_BASE_BY_YEAR` has an official taxable maximum. */
30
+ export declare const LATEST_PUBLISHED_WAGE_BASE_YEAR: number;
31
+ export declare function awiForYear(year: number): number | undefined;
32
+ /**
33
+ * Published AWI, or the latest on file when the year is not yet released (illustrative only).
34
+ */
35
+ export declare function awiForYearOrLatest(year: number): number;
36
+ export declare function bendPointsForEligibilityYear(year: number): {
37
+ first: number;
38
+ second: number;
39
+ } | undefined;
40
+ /**
41
+ * Official bend points for that eligibility year, or the latest on file when SSA has not yet
42
+ * published figures (illustrative only).
43
+ */
44
+ export declare function bendPointsForEligibilityYearOrLatest(eligibilityYear: number): {
45
+ first: number;
46
+ second: number;
47
+ };
48
+ export declare function familyMaximumBendPointsForEligibilityYear(year: number): {
49
+ first: number;
50
+ second: number;
51
+ third: number;
52
+ } | undefined;
53
+ /**
54
+ * Official family-maximum bend points for that eligibility year, or the latest
55
+ * on file when SSA has not yet published figures (illustrative only).
56
+ */
57
+ export declare function familyMaximumBendPointsForEligibilityYearOrLatest(eligibilityYear: number): {
58
+ first: number;
59
+ second: number;
60
+ third: number;
61
+ };
62
+ export declare function wageBaseForYear(year: number): number | undefined;
63
+ /**
64
+ * Social Security taxable maximum (wage base) for a year, or the latest on file
65
+ * when SSA has not yet published it (illustrative only). Used to cap projected
66
+ * future earnings so they cannot exceed the taxable maximum.
67
+ */
68
+ export declare function wageBaseForYearOrLatest(year: number): number;