@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,29 @@
1
+ /**
2
+ * Required minimum distributions (SECURE 2.0).
3
+ *
4
+ * Uses the Uniform Lifetime Table, except when the sole-beneficiary spouse is
5
+ * more than 10 years younger — then the Joint Life & Last Survivor expectancy
6
+ * applies (a larger divisor → smaller RMD), from IRS Pub 590-B Table II.
7
+ * Inherited-account 10-year rules and the first-year April 1 deferral are later
8
+ * phases (the RMD is taken in the age-attained year).
9
+ * Roth IRAs and Roth 401(k)s (since 2024) have no lifetime RMDs.
10
+ *
11
+ * @see DOCS/domain/domain-rules-reference.md §6
12
+ */
13
+ import type { Sex } from '../montecarlo/mortality.js';
14
+ import type { ParameterPack } from '../params/types.js';
15
+ export interface RmdSpouse {
16
+ ageAttained: number;
17
+ sex: Sex;
18
+ }
19
+ export interface RmdOptions {
20
+ /** Kept for API compatibility; IRS Table II is not sex-specific. */
21
+ ownerSex?: Sex;
22
+ /** Sole-beneficiary spouse, if any; triggers the Joint Life table when >10 yrs younger. */
23
+ spouse?: RmdSpouse;
24
+ }
25
+ /**
26
+ * RMD for one traditional account-year.
27
+ * `priorYearEndBalance` is the Dec 31 balance of the previous year.
28
+ */
29
+ export declare function requiredMinimumDistribution(pack: ParameterPack, birthYear: number, ageAttained: number, priorYearEndBalance: number, opts?: RmdOptions): number;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Required minimum distributions (SECURE 2.0).
3
+ *
4
+ * Uses the Uniform Lifetime Table, except when the sole-beneficiary spouse is
5
+ * more than 10 years younger — then the Joint Life & Last Survivor expectancy
6
+ * applies (a larger divisor → smaller RMD), from IRS Pub 590-B Table II.
7
+ * Inherited-account 10-year rules and the first-year April 1 deferral are later
8
+ * phases (the RMD is taken in the age-attained year).
9
+ * Roth IRAs and Roth 401(k)s (since 2024) have no lifetime RMDs.
10
+ *
11
+ * @see DOCS/domain/domain-rules-reference.md §6
12
+ */
13
+ import { rmdStartAgeForBirthYear, uniformLifetimeDivisor } from '../params/index.js';
14
+ import { jointLifeTableDivisor } from './jointLifeTable.js';
15
+ /**
16
+ * RMD for one traditional account-year.
17
+ * `priorYearEndBalance` is the Dec 31 balance of the previous year.
18
+ */
19
+ export function requiredMinimumDistribution(pack, birthYear, ageAttained, priorYearEndBalance, opts = {}) {
20
+ if (priorYearEndBalance <= 0)
21
+ return 0;
22
+ if (ageAttained < rmdStartAgeForBirthYear(birthYear))
23
+ return 0;
24
+ let divisor = uniformLifetimeDivisor(pack, ageAttained);
25
+ if (divisor === undefined || divisor <= 0)
26
+ return 0;
27
+ // Joint Life & Last Survivor table when the spouse beneficiary is >10 yrs younger.
28
+ if (opts.spouse && ageAttained - opts.spouse.ageAttained > 10) {
29
+ const joint = jointLifeTableDivisor(ageAttained, opts.spouse.ageAttained);
30
+ if (joint !== undefined)
31
+ divisor = Math.max(divisor, joint);
32
+ }
33
+ return priorYearEndBalance / divisor;
34
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Named scenarios: patch application, diffing, and side-by-side comparison
3
+ * (roadmap V4, feature catalog §12).
4
+ *
5
+ * A scenario is a partial deep-override of the plan ("retire at 62", "17% SS
6
+ * cut from 2034", "no Roth conversions"). Patches are stored loosely typed on
7
+ * the plan (plan.scenarios[].patch); applying one deep-merges it over the
8
+ * base plan and re-parses through the Zod schema, so an invalid override
9
+ * fails loudly instead of simulating garbage.
10
+ *
11
+ * Merge rule: plain objects merge recursively; arrays, primitives, and null
12
+ * replace wholesale. Replacing arrays keeps patches predictable (overriding
13
+ * "accounts[1].balance" positionally would silently break when the user
14
+ * reorders accounts).
15
+ */
16
+ import type { Plan, Scenario } from '../model/plan.js';
17
+ import { type ParsePlanResult } from '../model/plan.js';
18
+ import type { MarketModelConfig } from '../montecarlo/marketModels.js';
19
+ import { type ProjectionSummary } from '../projection/compare.js';
20
+ import { type SimulateOptions } from '../projection/simulate.js';
21
+ import type { TaxCalculator } from '../projection/types.js';
22
+ /**
23
+ * Deep-merge a scenario patch over the plan and validate the result. The
24
+ * merged plan keeps the base plan's id/name/scenarios; the patch cannot
25
+ * change the schema version.
26
+ */
27
+ export declare function applyScenarioPatch(plan: Plan, patch: Record<string, unknown>): ParsePlanResult;
28
+ export interface ScenarioDiffEntry {
29
+ /** Dotted path of the overridden leaf (e.g. "assumptions.ssHaircut.cutPct"). */
30
+ path: string;
31
+ baseValue: unknown;
32
+ scenarioValue: unknown;
33
+ }
34
+ /** Leaf-level diff of what a patch changes, for the comparison UI's "changed assumptions" panel. */
35
+ export declare function diffScenarioPatch(plan: Plan, patch: Record<string, unknown>): ScenarioDiffEntry[];
36
+ export interface ScenarioMonteCarloOptions {
37
+ model: MarketModelConfig;
38
+ pathCount: number;
39
+ seed: number;
40
+ }
41
+ export interface CompareScenariosOptions extends SimulateOptions {
42
+ /** When present, each scenario also gets a Monte Carlo success rate (same seed ⇒ same market paths). */
43
+ monteCarlo?: ScenarioMonteCarloOptions;
44
+ /**
45
+ * Build the tax stack from each row's own (patched) plan instead of pricing
46
+ * every row with `taxCalculator`. Needed when a patch changes tax
47
+ * assumptions (flat state-rate override, local rate) — e.g. relocation
48
+ * scenarios — so the scenario reproduces the surface that created it.
49
+ */
50
+ taxCalculatorForPlan?: (plan: Plan) => TaxCalculator;
51
+ }
52
+ export interface ScenarioComparisonRow {
53
+ /** null for the base plan row. */
54
+ scenarioId: string | null;
55
+ name: string;
56
+ summary: ProjectionSummary;
57
+ /** Patch-application or validation problems; when set, summary metrics are absent. */
58
+ error: string | null;
59
+ diff: ScenarioDiffEntry[];
60
+ successRate: number | null;
61
+ }
62
+ export interface ScenarioComparison {
63
+ rows: ScenarioComparisonRow[];
64
+ }
65
+ /**
66
+ * Run the base plan plus the given scenarios (default: all of plan.scenarios)
67
+ * and produce one comparison row each. Scenario rows that fail validation get
68
+ * an error string instead of metrics, so one bad patch never sinks the table.
69
+ */
70
+ export declare function compareScenarios(plan: Plan, opts: CompareScenariosOptions, scenarios?: Scenario[]): ScenarioComparison;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Named scenarios: patch application, diffing, and side-by-side comparison
3
+ * (roadmap V4, feature catalog §12).
4
+ *
5
+ * A scenario is a partial deep-override of the plan ("retire at 62", "17% SS
6
+ * cut from 2034", "no Roth conversions"). Patches are stored loosely typed on
7
+ * the plan (plan.scenarios[].patch); applying one deep-merges it over the
8
+ * base plan and re-parses through the Zod schema, so an invalid override
9
+ * fails loudly instead of simulating garbage.
10
+ *
11
+ * Merge rule: plain objects merge recursively; arrays, primitives, and null
12
+ * replace wholesale. Replacing arrays keeps patches predictable (overriding
13
+ * "accounts[1].balance" positionally would silently break when the user
14
+ * reorders accounts).
15
+ */
16
+ import { parsePlan } from '../model/plan.js';
17
+ import { createMarketModel } from '../montecarlo/marketModels.js';
18
+ import { aggregateMonteCarlo, runMonteCarloPaths } from '../montecarlo/run.js';
19
+ import { summarizeProjection } from '../projection/compare.js';
20
+ import { simulatePlan } from '../projection/simulate.js';
21
+ function isPlainObject(v) {
22
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
23
+ }
24
+ function deepMerge(base, patch) {
25
+ if (!isPlainObject(base) || !isPlainObject(patch))
26
+ return patch;
27
+ const out = { ...base };
28
+ for (const [key, value] of Object.entries(patch)) {
29
+ out[key] = key in out ? deepMerge(out[key], value) : value;
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * Deep-merge a scenario patch over the plan and validate the result. The
35
+ * merged plan keeps the base plan's id/name/scenarios; the patch cannot
36
+ * change the schema version.
37
+ */
38
+ export function applyScenarioPatch(plan, patch) {
39
+ const merged = deepMerge(plan, patch);
40
+ merged.schemaVersion = plan.schemaVersion;
41
+ merged.id = plan.id;
42
+ merged.scenarios = plan.scenarios;
43
+ return parsePlan(merged);
44
+ }
45
+ /** Leaf-level diff of what a patch changes, for the comparison UI's "changed assumptions" panel. */
46
+ export function diffScenarioPatch(plan, patch) {
47
+ const entries = [];
48
+ const walk = (base, node, path) => {
49
+ if (isPlainObject(node) && isPlainObject(base)) {
50
+ for (const [key, value] of Object.entries(node)) {
51
+ walk(base[key], value, path ? `${path}.${key}` : key);
52
+ }
53
+ return;
54
+ }
55
+ if (JSON.stringify(base) !== JSON.stringify(node)) {
56
+ entries.push({ path, baseValue: base, scenarioValue: node });
57
+ }
58
+ };
59
+ walk(plan, patch, '');
60
+ return entries;
61
+ }
62
+ function runOne(plan, opts) {
63
+ const taxCalculator = opts.taxCalculatorForPlan ? opts.taxCalculatorForPlan(plan) : opts.taxCalculator;
64
+ const summary = summarizeProjection(plan, simulatePlan(plan, { startYear: opts.startYear, taxCalculator }));
65
+ let successRate = null;
66
+ if (opts.monteCarlo) {
67
+ const result = runMonteCarloPaths(plan, {
68
+ startYear: opts.startYear,
69
+ taxCalculator,
70
+ model: createMarketModel(opts.monteCarlo.model),
71
+ seed: opts.monteCarlo.seed,
72
+ pathCount: opts.monteCarlo.pathCount,
73
+ });
74
+ successRate = aggregateMonteCarlo(result).successRate;
75
+ }
76
+ return { summary, successRate };
77
+ }
78
+ const EMPTY_SUMMARY = {
79
+ lifetimeTaxesAndPenalties: 0,
80
+ lifetimeRothConversions: 0,
81
+ endingInvestable: 0,
82
+ endingNetWorth: 0,
83
+ endingAfterTaxEstate: 0,
84
+ endingEstateHeirTax: 0,
85
+ endingEstateToCharity: 0,
86
+ estateBreakdown: [],
87
+ endingByCategory: { cash: 0, taxable: 0, traditional: 0, roth: 0, hsa: 0 },
88
+ depletionYear: null,
89
+ warnings: [],
90
+ savingsRates: [],
91
+ averagePreRetirementSavingsRatePct: 0,
92
+ fiNumber: 0,
93
+ fiYear: null,
94
+ fiAge: null,
95
+ coastFireNumber: 0,
96
+ };
97
+ /**
98
+ * Run the base plan plus the given scenarios (default: all of plan.scenarios)
99
+ * and produce one comparison row each. Scenario rows that fail validation get
100
+ * an error string instead of metrics, so one bad patch never sinks the table.
101
+ */
102
+ export function compareScenarios(plan, opts, scenarios) {
103
+ const rows = [];
104
+ const base = runOne(plan, opts);
105
+ rows.push({ scenarioId: null, name: 'Base plan', summary: base.summary, error: null, diff: [], successRate: base.successRate });
106
+ for (const scenario of scenarios ?? plan.scenarios) {
107
+ const applied = applyScenarioPatch(plan, scenario.patch);
108
+ if (!applied.ok) {
109
+ rows.push({
110
+ scenarioId: scenario.id,
111
+ name: scenario.name,
112
+ summary: EMPTY_SUMMARY,
113
+ error: `Scenario overrides are invalid: ${applied.issues.join('; ')}`,
114
+ diff: diffScenarioPatch(plan, scenario.patch),
115
+ successRate: null,
116
+ });
117
+ continue;
118
+ }
119
+ const run = runOne(applied.plan, opts);
120
+ rows.push({
121
+ scenarioId: scenario.id,
122
+ name: scenario.name,
123
+ summary: run.summary,
124
+ error: null,
125
+ diff: diffScenarioPatch(plan, scenario.patch),
126
+ successRate: run.successRate,
127
+ });
128
+ }
129
+ return { rows };
130
+ }
@@ -0,0 +1,13 @@
1
+ import type { FraComponents } from './nra.js';
2
+ /**
3
+ * Retirement benefit as a fraction of PIA for claiming at **completed** age
4
+ * `claimAgeYears` (62–70), vs FRA. Uses SSA monthly reduction / delayed credit rules
5
+ * (same formula for all NRAs once expressed in months early / late).
6
+ *
7
+ * @see https://www.ssa.gov/benefits/retirement/planner/agereduction.html
8
+ */
9
+ /** Months before FRA: first 36 at 5/9% per month, beyond 36 at 5/12% per month. */
10
+ export declare function earlyRetirementFactor(monthsBeforeFra: number): number;
11
+ /** DRC: 2/3% of PIA per month after FRA, up to age 70 (no further increase). */
12
+ export declare function delayedRetirementFactor(monthsAfterFra: number, maxMonthsAfterFraToAge70: number): number;
13
+ export declare function retirementBenefitPiaFactor(claimAgeYears: number, fra: FraComponents): number;
@@ -0,0 +1,40 @@
1
+ import { fraTotalMonths, ageToTotalMonths } from './nra.js';
2
+ /**
3
+ * Retirement benefit as a fraction of PIA for claiming at **completed** age
4
+ * `claimAgeYears` (62–70), vs FRA. Uses SSA monthly reduction / delayed credit rules
5
+ * (same formula for all NRAs once expressed in months early / late).
6
+ *
7
+ * @see https://www.ssa.gov/benefits/retirement/planner/agereduction.html
8
+ */
9
+ /** Months before FRA: first 36 at 5/9% per month, beyond 36 at 5/12% per month. */
10
+ export function earlyRetirementFactor(monthsBeforeFra) {
11
+ if (monthsBeforeFra <= 0)
12
+ return 1;
13
+ const m = monthsBeforeFra;
14
+ const first = Math.min(36, m);
15
+ const second = Math.max(0, m - 36);
16
+ const reductionPct = first * (5 / 9) + second * (5 / 12);
17
+ return 1 - reductionPct / 100;
18
+ }
19
+ /** DRC: 2/3% of PIA per month after FRA, up to age 70 (no further increase). */
20
+ export function delayedRetirementFactor(monthsAfterFra, maxMonthsAfterFraToAge70) {
21
+ if (monthsAfterFra <= 0)
22
+ return 1;
23
+ const d = Math.min(monthsAfterFra, Math.max(0, maxMonthsAfterFraToAge70));
24
+ return 1 + (d * (2 / 3)) / 100;
25
+ }
26
+ export function retirementBenefitPiaFactor(claimAgeYears, fra) {
27
+ if (claimAgeYears < 62 || claimAgeYears > 70) {
28
+ throw new RangeError('claimAgeYears must be between 62 and 70 inclusive');
29
+ }
30
+ const claimM = ageToTotalMonths(claimAgeYears);
31
+ const fraM = fraTotalMonths(fra);
32
+ const maxDrcMonths = Math.max(0, ageToTotalMonths(70) - fraM);
33
+ if (claimM < fraM) {
34
+ return earlyRetirementFactor(fraM - claimM);
35
+ }
36
+ if (claimM > fraM) {
37
+ return delayedRetirementFactor(claimM - fraM, maxDrcMonths);
38
+ }
39
+ return 1;
40
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Monthly-granularity claiming factor for the v2 engine.
3
+ *
4
+ * Reuses the FRA schedule and reduction/credit math in this directory, which
5
+ * already works in months — v1 only ever called it with whole-year claim
6
+ * ages. The projection engine (engine/projection/simulate.ts) imports this
7
+ * module directly; it must stay pure (no UI, storage, or DOM access).
8
+ */
9
+ export interface ClaimAge {
10
+ years: number;
11
+ months: number;
12
+ }
13
+ /**
14
+ * Retirement benefit as a fraction of PIA for claiming at `claimAge`
15
+ * (62y0m–70y0m), for a person born on the given date.
16
+ */
17
+ export declare function claimFactor(dobYear: number, dobMonth: number, dobDay: number, claimAge: ClaimAge): number;
18
+ /**
19
+ * Spousal benefit as a fraction of the worker's PIA (so the spousal benefit is
20
+ * this × 0.5 × workerPIA at FRA, before the reduction below).
21
+ *
22
+ * Key differences from the retirement factor: the spousal benefit earns **no
23
+ * delayed retirement credits** (it tops out at FRA), and the early-claim
24
+ * reduction schedule is steeper — 25/36 of 1% per month for the first 36
25
+ * months early, then 5/12 of 1% per month beyond that.
26
+ *
27
+ * Simplifications (deemed-filing era, born 1954+): assumes the worker has
28
+ * already filed so the spouse is eligible, and ignores the spouse's own
29
+ * earnings-test interaction. Returns the fraction to apply to 0.5 × workerPIA.
30
+ *
31
+ * @see https://www.ssa.gov/benefits/retirement/planner/applying7.html
32
+ */
33
+ export declare function spousalBenefitFactor(dobYear: number, dobMonth: number, dobDay: number, claimAge: ClaimAge): number;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Monthly-granularity claiming factor for the v2 engine.
3
+ *
4
+ * Reuses the FRA schedule and reduction/credit math in this directory, which
5
+ * already works in months — v1 only ever called it with whole-year claim
6
+ * ages. The projection engine (engine/projection/simulate.ts) imports this
7
+ * module directly; it must stay pure (no UI, storage, or DOM access).
8
+ */
9
+ import { delayedRetirementFactor, earlyRetirementFactor } from './benefitFactor.js';
10
+ import { effectiveBirthYear, fraForBirthYear, fraTotalMonths } from './nra.js';
11
+ /**
12
+ * Retirement benefit as a fraction of PIA for claiming at `claimAge`
13
+ * (62y0m–70y0m), for a person born on the given date.
14
+ */
15
+ export function claimFactor(dobYear, dobMonth, dobDay, claimAge) {
16
+ const claimM = claimAge.years * 12 + claimAge.months;
17
+ if (claimM < 62 * 12 || claimM > 70 * 12) {
18
+ throw new RangeError('claim age must be between 62y0m and 70y0m');
19
+ }
20
+ const effY = effectiveBirthYear(dobYear, dobMonth, dobDay);
21
+ const fraM = fraTotalMonths(fraForBirthYear(effY));
22
+ const maxDrcMonths = Math.max(0, 70 * 12 - fraM);
23
+ if (claimM < fraM)
24
+ return earlyRetirementFactor(fraM - claimM);
25
+ if (claimM > fraM)
26
+ return delayedRetirementFactor(claimM - fraM, maxDrcMonths);
27
+ return 1;
28
+ }
29
+ /**
30
+ * Spousal benefit as a fraction of the worker's PIA (so the spousal benefit is
31
+ * this × 0.5 × workerPIA at FRA, before the reduction below).
32
+ *
33
+ * Key differences from the retirement factor: the spousal benefit earns **no
34
+ * delayed retirement credits** (it tops out at FRA), and the early-claim
35
+ * reduction schedule is steeper — 25/36 of 1% per month for the first 36
36
+ * months early, then 5/12 of 1% per month beyond that.
37
+ *
38
+ * Simplifications (deemed-filing era, born 1954+): assumes the worker has
39
+ * already filed so the spouse is eligible, and ignores the spouse's own
40
+ * earnings-test interaction. Returns the fraction to apply to 0.5 × workerPIA.
41
+ *
42
+ * @see https://www.ssa.gov/benefits/retirement/planner/applying7.html
43
+ */
44
+ export function spousalBenefitFactor(dobYear, dobMonth, dobDay, claimAge) {
45
+ const claimM = claimAge.years * 12 + claimAge.months;
46
+ if (claimM < 62 * 12 || claimM > 70 * 12) {
47
+ throw new RangeError('claim age must be between 62y0m and 70y0m');
48
+ }
49
+ const effY = effectiveBirthYear(dobYear, dobMonth, dobDay);
50
+ const fraM = fraTotalMonths(fraForBirthYear(effY));
51
+ if (claimM >= fraM)
52
+ return 1; // no delayed credits on spousal benefits
53
+ const monthsEarly = fraM - claimM;
54
+ const first = Math.min(36, monthsEarly);
55
+ const beyond = Math.max(0, monthsEarly - 36);
56
+ const reductionPct = first * (25 / 36) + beyond * (5 / 12);
57
+ return Math.max(0, 1 - reductionPct / 100);
58
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Social Security disability (SSDI) — the pure helper behind the `disability`
3
+ * input on a Social Security stream. Cited in DOCS/domain/domain-rules-reference.md
4
+ * §4 (SSDI); illustrative, not a filing tool.
5
+ *
6
+ * The defining rule: **SSDI pays the worker's full PIA with no early-retirement
7
+ * reduction** (unlike early *retirement* claiming), starting at a disability-onset
8
+ * age. At FRA it **converts to the retirement benefit at the same dollar amount**
9
+ * (the PIA — continuous, no jump), so the PIA persists from onset through life and
10
+ * the recipient earns no delayed-retirement credits (the benefit is already being
11
+ * paid). Pre-FRA, earnings above **Substantial Gainful Activity (SGA)** suspend
12
+ * SSDI (SSA replaces the retirement earnings test with SGA for disabled workers).
13
+ *
14
+ * Documented simplifications (out of scope for the first build): the disability
15
+ * freeze (excludes disability months from the AIME average), the trial-work
16
+ * period / extended Medicare / expedited reinstatement, and auxiliary/family
17
+ * benefits on SSDI. The planner uses the PIA the user entered or derived from
18
+ * earnings (the freeze is not recomputed).
19
+ */
20
+ /** SSDI monthly benefit = the worker's full PIA (no early-retirement reduction). */
21
+ export declare function ssdiMonthlyBenefit(piaMonthly: number): number;
22
+ /** Annual-approximation months used to convert the monthly SGA limit to a year. */
23
+ export declare const SGA_ANNUAL_MONTHS = 12;
24
+ /**
25
+ * SGA gate (annual approximation): wages above SGA × 12 suspend SSDI for the
26
+ * year. The monthly SGA limit comes from the parameter pack
27
+ * (`socialSecurity.sgaMonthlyNonBlind`); `annualSgaLimit` is that value scaled
28
+ * to a year (×12 × inflation growth, supplied by the caller).
29
+ */
30
+ export declare function ssdiSuspendedBySga(annualWages: number, annualSgaLimit: number): boolean;
31
+ /**
32
+ * Whether a person is in the SSDI window (onset ≤ age < FRA). Pre-FRA, SSDI
33
+ * applies and SGA (not the retirement earnings test) gates it. At/after FRA the
34
+ * benefit has converted to retirement (still the PIA) and no earnings test applies.
35
+ */
36
+ export declare function inSsdiWindow(ageAttained: number, onsetAge: number, fraYears: number): boolean;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Social Security disability (SSDI) — the pure helper behind the `disability`
3
+ * input on a Social Security stream. Cited in DOCS/domain/domain-rules-reference.md
4
+ * §4 (SSDI); illustrative, not a filing tool.
5
+ *
6
+ * The defining rule: **SSDI pays the worker's full PIA with no early-retirement
7
+ * reduction** (unlike early *retirement* claiming), starting at a disability-onset
8
+ * age. At FRA it **converts to the retirement benefit at the same dollar amount**
9
+ * (the PIA — continuous, no jump), so the PIA persists from onset through life and
10
+ * the recipient earns no delayed-retirement credits (the benefit is already being
11
+ * paid). Pre-FRA, earnings above **Substantial Gainful Activity (SGA)** suspend
12
+ * SSDI (SSA replaces the retirement earnings test with SGA for disabled workers).
13
+ *
14
+ * Documented simplifications (out of scope for the first build): the disability
15
+ * freeze (excludes disability months from the AIME average), the trial-work
16
+ * period / extended Medicare / expedited reinstatement, and auxiliary/family
17
+ * benefits on SSDI. The planner uses the PIA the user entered or derived from
18
+ * earnings (the freeze is not recomputed).
19
+ */
20
+ /** SSDI monthly benefit = the worker's full PIA (no early-retirement reduction). */
21
+ export function ssdiMonthlyBenefit(piaMonthly) {
22
+ return Math.max(0, piaMonthly);
23
+ }
24
+ /** Annual-approximation months used to convert the monthly SGA limit to a year. */
25
+ export const SGA_ANNUAL_MONTHS = 12;
26
+ /**
27
+ * SGA gate (annual approximation): wages above SGA × 12 suspend SSDI for the
28
+ * year. The monthly SGA limit comes from the parameter pack
29
+ * (`socialSecurity.sgaMonthlyNonBlind`); `annualSgaLimit` is that value scaled
30
+ * to a year (×12 × inflation growth, supplied by the caller).
31
+ */
32
+ export function ssdiSuspendedBySga(annualWages, annualSgaLimit) {
33
+ return annualWages > annualSgaLimit;
34
+ }
35
+ /**
36
+ * Whether a person is in the SSDI window (onset ≤ age < FRA). Pre-FRA, SSDI
37
+ * applies and SGA (not the retirement earnings test) gates it. At/after FRA the
38
+ * benefit has converted to retirement (still the PIA) and no earnings test applies.
39
+ */
40
+ export function inSsdiWindow(ageAttained, onsetAge, fraYears) {
41
+ return ageAttained >= onsetAge && ageAttained < fraYears;
42
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Retirement/survivor family maximum (MFB), using SSA's PIA-based formula.
3
+ *
4
+ * The worker's own benefit is never reduced by the family maximum; auxiliary
5
+ * benefits payable on that worker's record share the remaining room. RetireGolden
6
+ * currently models only the current-spouse auxiliary on a worker record (no child
7
+ * dependents), so the capping helper applies that single auxiliary conservatively.
8
+ *
9
+ * @see https://www.ssa.gov/oact/cola/familymax.html
10
+ */
11
+ import type { ClaimAge } from './claimFactor.js';
12
+ /** Year of eligibility for the retirement/survivor family maximum. */
13
+ export declare function familyMaximumEligibilityYearFromDobParts(year: number, month: number, day: number): number;
14
+ /**
15
+ * Monthly family maximum for a retirement/survivor worker record, before COLA
16
+ * and trust-fund haircut.
17
+ */
18
+ export declare function familyMaximumMonthlyFromPia(piaMonthly: number, eligibilityYear: number): number;
19
+ export interface AuxiliaryFamilyMaximumInput {
20
+ /** Worker PIA on whose record the auxiliary is paid, today's dollars. */
21
+ workerPiaMonthly: number;
22
+ /** Worker payable monthly benefit on that record, before COLA/haircut. */
23
+ workerActualMonthly: number;
24
+ /** Worker date of birth, used to pick the eligibility-year bend points. */
25
+ workerDob: {
26
+ year: number;
27
+ month: number;
28
+ day: number;
29
+ };
30
+ /** Proposed auxiliary benefit on the worker record, before COLA/haircut. */
31
+ auxiliaryMonthly: number;
32
+ }
33
+ /**
34
+ * Cap a single auxiliary benefit to the available room under the worker's MFB.
35
+ * With no child benefits modeled, this is the full current-spouse allocation.
36
+ */
37
+ export declare function capAuxiliaryForFamilyMaximum(input: AuxiliaryFamilyMaximumInput): number;
38
+ export declare function claimAgeTotalMonths(claimAge: ClaimAge): number;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Retirement/survivor family maximum (MFB), using SSA's PIA-based formula.
3
+ *
4
+ * The worker's own benefit is never reduced by the family maximum; auxiliary
5
+ * benefits payable on that worker's record share the remaining room. RetireGolden
6
+ * currently models only the current-spouse auxiliary on a worker record (no child
7
+ * dependents), so the capping helper applies that single auxiliary conservatively.
8
+ *
9
+ * @see https://www.ssa.gov/oact/cola/familymax.html
10
+ */
11
+ import { effectiveBirthYear } from './nra.js';
12
+ import { familyMaximumBendPointsForEligibilityYearOrLatest } from './ssaWageData.js';
13
+ function floorToDime(value) {
14
+ return Math.floor(value * 10 + 1e-9) / 10;
15
+ }
16
+ /** Year of eligibility for the retirement/survivor family maximum. */
17
+ export function familyMaximumEligibilityYearFromDobParts(year, month, day) {
18
+ return effectiveBirthYear(year, month, day) + 62;
19
+ }
20
+ /**
21
+ * Monthly family maximum for a retirement/survivor worker record, before COLA
22
+ * and trust-fund haircut.
23
+ */
24
+ export function familyMaximumMonthlyFromPia(piaMonthly, eligibilityYear) {
25
+ if (piaMonthly <= 0)
26
+ return 0;
27
+ const bp = familyMaximumBendPointsForEligibilityYearOrLatest(eligibilityYear);
28
+ const first = Math.min(piaMonthly, bp.first);
29
+ const second = Math.max(0, Math.min(piaMonthly, bp.second) - bp.first);
30
+ const third = Math.max(0, Math.min(piaMonthly, bp.third) - bp.second);
31
+ const above = Math.max(0, piaMonthly - bp.third);
32
+ return floorToDime(first * 1.5 + second * 2.72 + third * 1.34 + above * 1.75);
33
+ }
34
+ /**
35
+ * Cap a single auxiliary benefit to the available room under the worker's MFB.
36
+ * With no child benefits modeled, this is the full current-spouse allocation.
37
+ */
38
+ export function capAuxiliaryForFamilyMaximum(input) {
39
+ if (input.auxiliaryMonthly <= 0 || input.workerPiaMonthly <= 0)
40
+ return 0;
41
+ const eligibilityYear = familyMaximumEligibilityYearFromDobParts(input.workerDob.year, input.workerDob.month, input.workerDob.day);
42
+ const familyMaximum = familyMaximumMonthlyFromPia(input.workerPiaMonthly, eligibilityYear);
43
+ const roomForAuxiliaries = Math.max(0, familyMaximum - Math.max(0, input.workerActualMonthly));
44
+ return Math.min(input.auxiliaryMonthly, roomForAuxiliaries);
45
+ }
46
+ export function claimAgeTotalMonths(claimAge) {
47
+ return claimAge.years * 12 + claimAge.months;
48
+ }
@@ -0,0 +1,57 @@
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 type { FormerSpouse } from '../model/plan.js';
25
+ import { type ClaimAge } from './claimFactor.js';
26
+ export declare const DIVORCED_MIN_MARRIAGE_YEARS = 10;
27
+ export declare const SURVIVOR_MIN_MARRIAGE_YEARS = 0.75;
28
+ export declare const SURVIVOR_MIN_AGE = 60;
29
+ export declare const DIVORCED_EX_MIN_AGE = 62;
30
+ export declare const REMARRIAGE_SURVIVOR_PRESERVE_AGE = 60;
31
+ export type MaritalBenefitKind = 'divorcedSpousal' | 'survivor';
32
+ export interface MaritalBenefitContext {
33
+ claimantDob: {
34
+ year: number;
35
+ month: number;
36
+ day: number;
37
+ };
38
+ /** Claim age for retirement/divorced-spousal factors, including any ARF credit the caller applies. */
39
+ claimantClaimAge: ClaimAge;
40
+ /** Claim age for survivor factors; defaults to claimantClaimAge for direct helper callers. */
41
+ claimantSurvivorClaimAge?: ClaimAge;
42
+ /** Whole age the claimant has attained in the year being evaluated. */
43
+ claimantAge: number;
44
+ /** Calendar year being evaluated (to derive the ex-spouse's current age). */
45
+ year: number;
46
+ /** True when the claimant has no current spouse (single household). */
47
+ claimantIsSingle: boolean;
48
+ }
49
+ export interface MaritalBenefitCandidate {
50
+ kind: MaritalBenefitKind;
51
+ /** Monthly benefit at the claimant's claim age, today's dollars, before COLA/haircut. */
52
+ monthly: number;
53
+ }
54
+ /** Eligibility + monthly amount for one former-spouse record; null if not eligible this year. */
55
+ export declare function maritalBenefitFor(record: FormerSpouse, ctx: MaritalBenefitContext): MaritalBenefitCandidate | null;
56
+ /** Largest eligible monthly marital benefit across all former spouses; null if none. */
57
+ export declare function bestMaritalBenefit(records: FormerSpouse[] | undefined, ctx: MaritalBenefitContext): MaritalBenefitCandidate | null;