@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,667 @@
1
+ /**
2
+ * Normalized candidate generators (ledger-native decision engine, Phase 2).
3
+ *
4
+ * Every recommendation source is a `CandidateGenerator`: pure, bounded, and
5
+ * fast — no simulate() calls, just concrete plan patches or conversion
6
+ * schedules. Generation never decides whether something is recommended; the
7
+ * exact-ledger evaluation does.
8
+ */
9
+ import { packForYear, rmdStartAgeForBirthYear, LATEST_PACK_YEAR, EMBEDDED_REAL_YIELD_CURVE } from '../params/index.js';
10
+ import { BRIDGE_FUNDING_MIN_FRACTION, sizeBridge } from '../ladder/bridge.js';
11
+ import { QLAC_DEFERRED_PAYOUT_RATE, spiaPayoutRate } from './spiaQuotes.js';
12
+ function fillToTargetPatch(target, targetValue, startYear, endYear) {
13
+ return {
14
+ strategies: {
15
+ rothConversion: { mode: 'fillToTarget', target, targetValue, startYear, endYear },
16
+ },
17
+ };
18
+ }
19
+ /**
20
+ * Income-boundary years where the cheap-conversion regime typically ends: each
21
+ * person's Social-Security claim year and RMD start year. A whole-horizon
22
+ * bracket fill keeps converting past these boundaries even when the marginal
23
+ * dollars stop being cheap; windowed fills stop at the boundary instead —
24
+ * the classic "convert during the bridge, then stop" shape a sequential
25
+ * withdrawal order makes optimal. Bounded and generation-pure (no simulate).
26
+ */
27
+ function conversionWindowBoundaries(ctx, startYear, endYear) {
28
+ const plan = ctx.plan;
29
+ const boundaries = new Map();
30
+ // Liquid-reserve depletion: the first baseline year where spending taps the
31
+ // traditional balance. Up to then, spending rides on cash/taxable and every
32
+ // converted dollar starts at the bottom of the brackets; afterwards the
33
+ // spending withdrawals fill the cheap bands themselves (sequential order), so
34
+ // the classic optimal shape is "convert hard while reserves last, then stop."
35
+ // Reads the already-computed baseline result — no new simulation.
36
+ const firstTraditionalDrawYear = ctx.baselineResult.years.find((year) => year.withdrawals.traditional - year.rmd - year.inheritedDistribution > 1)?.year;
37
+ if (firstTraditionalDrawYear !== undefined && firstTraditionalDrawYear > startYear && firstTraditionalDrawYear <= endYear) {
38
+ boundaries.set(firstTraditionalDrawYear, `while cash and taxable cover spending (through ${firstTraditionalDrawYear - 1})`);
39
+ }
40
+ for (const income of plan.incomes) {
41
+ if (income.type !== 'socialSecurity')
42
+ continue;
43
+ const person = plan.household.people.find((p) => p.id === income.personId);
44
+ if (!person)
45
+ continue;
46
+ const claimYear = dobYear(person.dob) + income.claimAge.years;
47
+ if (claimYear > startYear && claimYear <= endYear && !boundaries.has(claimYear)) {
48
+ boundaries.set(claimYear, `until Social Security starts (${claimYear})`);
49
+ }
50
+ }
51
+ const hasTraditional = plan.accounts.some((a) => a.type === 'traditional' && !a.inherited);
52
+ if (hasTraditional) {
53
+ for (const person of plan.household.people) {
54
+ const birthYear = dobYear(person.dob);
55
+ const rmdYear = birthYear + rmdStartAgeForBirthYear(birthYear);
56
+ if (rmdYear > startYear && rmdYear <= endYear && !boundaries.has(rmdYear)) {
57
+ boundaries.set(rmdYear, `until RMDs begin (${rmdYear})`);
58
+ }
59
+ }
60
+ }
61
+ return [...boundaries.entries()]
62
+ .sort(([a], [b]) => a - b)
63
+ // Hard bound on the candidate fan-out. A married couple legitimately has 5
64
+ // distinct boundaries (reserve depletion + 2 SS claims + 2 RMD starts);
65
+ // truncating below that silently drops the latest — typically the younger
66
+ // spouse's RMD wall, exactly the window shape these candidates exist for.
67
+ .slice(0, 5)
68
+ .map(([year, suffix]) => ({ year, suffix }));
69
+ }
70
+ /**
71
+ * The simple fill-to-target Roth strategies of the bounded tournament: bracket
72
+ * fills, the ACA cliff cap, and the first IRMAA tier cap over the whole
73
+ * horizon, plus *windowed* bracket fills that stop at income boundaries (SS
74
+ * claim / RMD start) — under a sequential withdrawal order, spending draws fill
75
+ * the cheap brackets themselves once cash/taxable deplete or income starts, so
76
+ * "fill high early, then stop" beats any whole-horizon fill on bridge-shaped
77
+ * plans. Generated unconditionally — a plan with no convertible traditional
78
+ * simply executes $0 and evaluates neutral.
79
+ */
80
+ export const simpleRothConversionGenerator = {
81
+ id: 'roth-fill-to-target',
82
+ generate(ctx) {
83
+ const { startYear, endYear } = ctx.baselineResult;
84
+ const make = (id, label, target, targetValue, category, window) => ({
85
+ id,
86
+ source: 'heuristic',
87
+ category,
88
+ label,
89
+ // Windowed candidates are always bracket fills, so the phrase comes
90
+ // straight from targetValue — no scraping the human label back apart.
91
+ explanation: window
92
+ ? `Roth conversions up to the ${targetValue}% bracket in each year before ${window.year}, evaluated on the exact ledger.`
93
+ : `Roth conversions each year up to ${label.toLowerCase().replace(/^fill |^convert up to /, '')}, evaluated on the exact ledger.`,
94
+ planPatch: fillToTargetPatch(target, targetValue, startYear, window ? window.year - 1 : endYear),
95
+ });
96
+ const candidates = [
97
+ make('bracket-10', 'Fill the 10% bracket', 'topOfBracket', 10, 'roth'),
98
+ make('bracket-12', 'Fill the 12% bracket', 'topOfBracket', 12, 'roth'),
99
+ make('bracket-22', 'Fill the 22% bracket', 'topOfBracket', 22, 'roth'),
100
+ make('bracket-24', 'Fill the 24% bracket', 'topOfBracket', 24, 'roth'),
101
+ make('aca-cliff-cap', 'Convert up to the ACA cliff', 'acaCliff', null, 'tax-cliff'),
102
+ make('irmaa-tier-1-cap', 'Convert up to the first IRMAA tier', 'irmaaTier', 1, 'tax-cliff'),
103
+ ];
104
+ for (const window of conversionWindowBoundaries(ctx, startYear, endYear)) {
105
+ for (const bracket of [12, 22, 24]) {
106
+ candidates.push(make(`bracket-${bracket}-until-${window.year}`, `Fill the ${bracket}% bracket ${window.suffix}`, 'topOfBracket', bracket, 'roth', window));
107
+ }
108
+ }
109
+ return candidates;
110
+ },
111
+ };
112
+ /** The do-nothing alternative, only meaningful when the plan currently converts. */
113
+ export const noConversionGenerator = {
114
+ id: 'roth-no-conversion',
115
+ generate(ctx) {
116
+ if (ctx.plan.strategies.rothConversion.mode === 'none')
117
+ return [];
118
+ return [
119
+ {
120
+ id: 'no-conversion',
121
+ source: 'heuristic',
122
+ category: 'roth',
123
+ label: 'Stop Roth conversions',
124
+ explanation: 'Runs the plan with no Roth conversions, in case the current schedule costs more than it saves.',
125
+ planPatch: { strategies: { rothConversion: { mode: 'none' } } },
126
+ },
127
+ ];
128
+ },
129
+ };
130
+ /** Withdrawal-order alternatives to whatever the plan currently uses. */
131
+ export const withdrawalOrderGenerator = {
132
+ id: 'withdrawal-order',
133
+ generate(ctx) {
134
+ const current = ctx.plan.strategies.withdrawalOrder;
135
+ const alternatives = [
136
+ { id: 'withdrawal-sequential', label: 'Sequential withdrawal order', strategy: { mode: 'sequential' } },
137
+ { id: 'withdrawal-proportional', label: 'Proportional withdrawal order', strategy: { mode: 'proportional' } },
138
+ { id: 'withdrawal-bracket-12', label: 'Bracket-targeted withdrawals (12%)', strategy: { mode: 'bracketTargeted', bracketPct: 12 } },
139
+ { id: 'withdrawal-bracket-22', label: 'Bracket-targeted withdrawals (22%)', strategy: { mode: 'bracketTargeted', bracketPct: 22 } },
140
+ ];
141
+ return alternatives
142
+ .filter(({ strategy }) => {
143
+ if (strategy.mode !== current.mode)
144
+ return true;
145
+ return strategy.mode === 'bracketTargeted' && current.mode === 'bracketTargeted'
146
+ ? strategy.bracketPct !== current.bracketPct
147
+ : false;
148
+ })
149
+ .map(({ id, label, strategy }) => ({
150
+ id,
151
+ source: 'heuristic',
152
+ category: 'withdrawal',
153
+ label,
154
+ explanation: `Funds spending with a ${label.toLowerCase()} instead of the current strategy.`,
155
+ planPatch: { strategies: { withdrawalOrder: strategy } },
156
+ }));
157
+ },
158
+ };
159
+ /**
160
+ * Probability-band safe-spend guardrail candidates. Generation is deliberately
161
+ * pure: the candidate carries the success band as metadata and a concrete
162
+ * ledger-native guardrail patch; exact/stochastic surfaces can then evaluate it
163
+ * through the shared decision and Monte Carlo runners without private models.
164
+ */
165
+ export function probabilityBandSpendingGuardrailGenerator(options = {}) {
166
+ const requiredFloorPct = options.requiredFloorPct ?? 80;
167
+ const lowerSuccessPct = options.lowerSuccessPct ?? 70;
168
+ const upperSuccessPct = options.upperSuccessPct ?? 95;
169
+ return {
170
+ id: 'probability-band-spending-guardrails',
171
+ generate(ctx) {
172
+ // Skip plans that already run any guardrail policy (withdrawal-rate or
173
+ // risk-based) — the candidate would just re-add what is already there.
174
+ const mode = ctx.plan.expenses.spendingPolicy?.mode;
175
+ if (mode !== undefined && mode !== 'fixedTarget')
176
+ return [];
177
+ const baseAnnual = ctx.plan.expenses.baseAnnual;
178
+ if (baseAnnual <= 0)
179
+ return [];
180
+ const requiredAnnual = Math.min(ctx.plan.expenses.requiredAnnual ?? Math.round(baseAnnual * requiredFloorPct / 100), baseAnnual);
181
+ return [
182
+ {
183
+ id: `safe-spend-band-${lowerSuccessPct}-${upperSuccessPct}`,
184
+ source: 'heuristic',
185
+ category: 'spending',
186
+ label: `Safe-spend guardrails (${lowerSuccessPct}-${upperSuccessPct}% band)`,
187
+ explanation: 'Adds a required spending floor and ledger-native withdrawal-rate guardrails as the modelable candidate for probability-band safe-spend comparisons.',
188
+ planPatch: {
189
+ expenses: {
190
+ requiredAnnual,
191
+ spendingPolicy: {
192
+ mode: 'withdrawalRateGuardrails',
193
+ upperGuardrailPct: 120,
194
+ lowerGuardrailPct: 80,
195
+ adjustmentPct: 10,
196
+ allowRaisesAboveTarget: true,
197
+ },
198
+ },
199
+ },
200
+ metadata: {
201
+ decisionRule: 'probabilityBandSafeSpend',
202
+ lowerSuccessPct,
203
+ upperSuccessPct,
204
+ requiredFloorPct,
205
+ },
206
+ },
207
+ ];
208
+ },
209
+ };
210
+ }
211
+ const SS_CLAIM_AGES = [
212
+ { years: 62, months: 0, suffix: 'at 62' },
213
+ { years: 67, months: 0, suffix: 'at 67 (FRA)' },
214
+ { years: 70, months: 0, suffix: 'at 70' },
215
+ ];
216
+ const SS_GRID_CLAIM_AGES = [62, 63, 64, 65, 66, 67, 68, 69, 70];
217
+ function dobYear(dob) {
218
+ return Number(dob.slice(0, 4));
219
+ }
220
+ function gridClaimAgesForPerson(person, startYear) {
221
+ const currentAge = startYear - dobYear(person.dob);
222
+ const ages = SS_GRID_CLAIM_AGES.filter((age) => age >= currentAge);
223
+ return ages.length > 0 ? [...ages] : [70];
224
+ }
225
+ /**
226
+ * Bounded Social Security claim-age candidates: for up to two SS streams, the
227
+ * three canonical claim ages (62 / FRA / 70) that differ from the current
228
+ * claim. Each candidate replaces the whole incomes array (scenario patches
229
+ * replace arrays wholesale, keeping the patch order-safe).
230
+ */
231
+ export const socialSecurityClaimGenerator = {
232
+ id: 'social-security-claim',
233
+ generate(ctx) {
234
+ const candidates = [];
235
+ const ssStreams = ctx.plan.incomes.filter((income) => income.type === 'socialSecurity');
236
+ for (const stream of ssStreams.slice(0, 2)) {
237
+ const person = ctx.plan.household.people.find((p) => p.id === stream.personId);
238
+ const personLabel = person?.name ?? 'household member';
239
+ for (const claim of SS_CLAIM_AGES) {
240
+ if (stream.claimAge.years === claim.years && stream.claimAge.months === claim.months)
241
+ continue;
242
+ const incomes = ctx.plan.incomes.map((income) => income === stream ? { ...stream, claimAge: { years: claim.years, months: claim.months } } : income);
243
+ candidates.push({
244
+ id: `ss-claim-${stream.id}-${claim.years}-${claim.months}`,
245
+ source: 'heuristic',
246
+ category: 'social-security',
247
+ label: `${personLabel} claims Social Security ${claim.suffix}`,
248
+ explanation: `Moves ${personLabel}'s claim from ${stream.claimAge.years}y${stream.claimAge.months}m to ${claim.years}y${claim.months}m and reprices the whole plan on the exact ledger.`,
249
+ planPatch: { incomes },
250
+ });
251
+ }
252
+ }
253
+ return candidates;
254
+ },
255
+ };
256
+ function largestStaticAllocated(plan, type) {
257
+ const eligible = plan.accounts.filter((a) => a.type === type && a.allocation?.mode === 'static' && a.balance > 0);
258
+ return eligible.sort((a, b) => b.balance - a.balance)[0];
259
+ }
260
+ /**
261
+ * Asset-location candidates (asset-allocation-and-return-model-v2, step 5).
262
+ *
263
+ * Proposes swapping *where* classes are held while leaving the household's
264
+ * total dollars in each class unchanged: bonds move toward traditional
265
+ * (sheltering their ordinary-income yield and slowing the RMD/heir-taxed
266
+ * balance), US stocks move toward taxable (qualified dividends + basis
267
+ * machinery) or Roth (sheltering the highest expected growth). Swaps pair the
268
+ * largest eligible static-allocation accounts and are expressed as plan
269
+ * patches over the accounts array; the exact ledger prices every candidate —
270
+ * generation never decides. Bounded: at most 3 candidates.
271
+ */
272
+ export const assetLocationGenerator = {
273
+ id: 'asset-location',
274
+ generate(ctx) {
275
+ const plan = ctx.plan;
276
+ const taxable = largestStaticAllocated(plan, 'taxable');
277
+ const traditional = largestStaticAllocated(plan, 'traditional');
278
+ const roth = largestStaticAllocated(plan, 'roth');
279
+ const candidates = [];
280
+ const patchWith = (replacements) => ({
281
+ accounts: plan.accounts.map((a) => replacements.get(a.id) ?? a),
282
+ });
283
+ const shifted = (weights, from, to, pct) => {
284
+ // Clamp to the schema's 0–100 bounds; move exactly what the source can
285
+ // give so the pair of shifts stays sum-preserving under float rounding.
286
+ const moved = Math.min(pct, weights[from]);
287
+ return { ...weights, [from]: weights[from] - moved, [to]: Math.min(100, weights[to] + moved) };
288
+ };
289
+ /** Swap `dollars` of `sellClass` in a for `sellClass` exposure in b (b gives up buyClass). */
290
+ const swap = (id, label, explanation, a, b, aGivesUp, bGivesUp, dollars) => {
291
+ const aPct = (dollars / a.balance) * 100;
292
+ const bPct = (dollars / b.balance) * 100;
293
+ const replacements = new Map([
294
+ [a.id, { ...a, allocation: { ...a.allocation, weights: shifted(a.allocation.weights, aGivesUp, bGivesUp, aPct) } }],
295
+ [b.id, { ...b, allocation: { ...b.allocation, weights: shifted(b.allocation.weights, bGivesUp, aGivesUp, bPct) } }],
296
+ ]);
297
+ return {
298
+ id,
299
+ source: 'heuristic',
300
+ category: 'asset-location',
301
+ label,
302
+ explanation,
303
+ planPatch: patchWith(replacements),
304
+ metadata: { swappedDollars: Math.round(dollars) },
305
+ };
306
+ };
307
+ // Bonds → traditional, stocks → taxable: swap the taxable account's bond
308
+ // dollars for the traditional account's US-stock dollars (full and half).
309
+ if (taxable && traditional) {
310
+ const dollars = Math.min((taxable.allocation.weights.bonds / 100) * taxable.balance, (traditional.allocation.weights.usStocks / 100) * traditional.balance);
311
+ if (dollars > 1000) {
312
+ const explanation = 'Holds the same household mix, but moves bond exposure into the traditional account (sheltering its ordinary-income yield) and US stocks into taxable (qualified dividends, basis step-up at death). Priced on the exact ledger.';
313
+ candidates.push(swap('asset-location-bonds-to-traditional', 'Hold bonds in traditional, stocks in taxable', explanation, taxable, traditional, 'bonds', 'usStocks', dollars), swap('asset-location-bonds-to-traditional-half', 'Shift half the taxable bonds into traditional', explanation, taxable, traditional, 'bonds', 'usStocks', dollars / 2));
314
+ }
315
+ }
316
+ // Bonds → traditional, stocks → Roth: both tax-advantaged (no taxable
317
+ // trade), concentrating the highest expected growth where it is never
318
+ // taxed again.
319
+ if (roth && traditional) {
320
+ const dollars = Math.min((roth.allocation.weights.bonds / 100) * roth.balance, (traditional.allocation.weights.usStocks / 100) * traditional.balance);
321
+ if (dollars > 1000) {
322
+ candidates.push(swap('asset-location-stocks-to-roth', 'Hold stocks in Roth, bonds in traditional', 'Holds the same household mix, but concentrates US stocks in the Roth (tax-free growth for you or heirs) and bonds in the traditional account. Priced on the exact ledger.', roth, traditional, 'bonds', 'usStocks', dollars));
323
+ }
324
+ }
325
+ return candidates;
326
+ },
327
+ };
328
+ /**
329
+ * Full Social Security claiming grid for the optimizer page: every whole-year
330
+ * claim combination from 62-70 (respecting current age) for up to two claiming
331
+ * streams. When a stream's current claim age is a whole year within range, that
332
+ * combination reproduces the current plan; a current claim age with months, below
333
+ * 62, above 70, or already past is not emitted here. Bounded: 9 single-person
334
+ * candidates or 81 couple candidates.
335
+ */
336
+ export const socialSecurityClaimGridGenerator = {
337
+ id: 'social-security-claim-grid',
338
+ generate(ctx) {
339
+ const entries = ctx.plan.incomes
340
+ .filter((income) => income.type === 'socialSecurity')
341
+ // Match the planner's claiming-person logic: a default record carries
342
+ // piaMonthly 0 (not null), which resolves to no benefit, so treat only a
343
+ // positive entered PIA or an earnings history as a real claiming stream.
344
+ // Otherwise a zero-PIA spouse inflates the grid with duplicate finalists.
345
+ .filter((income) => (income.piaMonthly !== null && income.piaMonthly > 0) || (income.earnings?.length ?? 0) > 0)
346
+ .slice(0, 2)
347
+ .map((stream) => ({ stream, person: ctx.plan.household.people.find((p) => p.id === stream.personId) }))
348
+ .filter((entry) => entry.person !== undefined);
349
+ if (entries.length === 0)
350
+ return [];
351
+ const combos = [{}];
352
+ for (const { person } of entries) {
353
+ const ages = gridClaimAgesForPerson(person, ctx.simulateOptions.startYear);
354
+ const next = [];
355
+ for (const partial of combos) {
356
+ for (const age of ages)
357
+ next.push({ ...partial, [person.id]: age });
358
+ }
359
+ combos.length = 0;
360
+ combos.push(...next);
361
+ }
362
+ return combos.map((claimByPersonId) => {
363
+ const incomes = ctx.plan.incomes.map((income) => income.type === 'socialSecurity' && claimByPersonId[income.personId] !== undefined
364
+ ? { ...income, claimAge: { years: claimByPersonId[income.personId], months: 0 } }
365
+ : income);
366
+ const label = entries.map(({ person }) => `${person.name} ${claimByPersonId[person.id]}`).join(' / ');
367
+ const id = `ss-claim-grid-${entries.map(({ person }) => `${person.id}-${claimByPersonId[person.id]}`).join('-')}`;
368
+ return {
369
+ id,
370
+ source: 'scenario-sweep',
371
+ category: 'social-security',
372
+ label: `Social Security claim ages: ${label}`,
373
+ explanation: 'Runs this whole-year Social Security claim-age combination through the shared exact-ledger decision engine.',
374
+ planPatch: { incomes },
375
+ metadata: {
376
+ decisionRule: 'socialSecurityClaimGrid',
377
+ claimByPersonId,
378
+ },
379
+ };
380
+ });
381
+ },
382
+ };
383
+ /**
384
+ * Wrap MILP optimizer output (raw and post-processed schedules) as candidates.
385
+ * The solver is one candidate generator among several — never the authority.
386
+ */
387
+ export function milpScheduleGenerator(schedules) {
388
+ return {
389
+ id: 'milp-schedules',
390
+ generate() {
391
+ const candidates = [];
392
+ if (schedules.raw && schedules.raw.conversions.length > 0) {
393
+ candidates.push({
394
+ id: 'milp-raw',
395
+ source: 'milp',
396
+ category: 'roth',
397
+ label: 'Optimizer schedule (raw)',
398
+ explanation: 'The raw MILP conversion schedule, priced on the exact ledger.',
399
+ conversions: schedules.raw.conversions,
400
+ });
401
+ }
402
+ if (schedules.cleanedConversions && schedules.cleanedConversions.length > 0) {
403
+ candidates.push({
404
+ id: 'milp-cleaned',
405
+ source: 'milp',
406
+ category: 'roth',
407
+ label: 'Optimizer schedule (exact-ledger cleaned)',
408
+ explanation: 'The post-processed MILP schedule the exact ledger can execute in full.',
409
+ conversions: schedules.cleanedConversions,
410
+ });
411
+ }
412
+ return candidates;
413
+ },
414
+ };
415
+ }
416
+ /** Ending balance of an account, 0 for the types that don't carry one. */
417
+ function accountBalance(account) {
418
+ return 'balance' in account ? account.balance : 0;
419
+ }
420
+ /**
421
+ * Annuity purchase candidates (guaranteed-income-and-estate-depth, step 6;
422
+ * ladders added by annuity-pension-and-home-equity, step 1): no purchase /
423
+ * cover-the-floor SPIA / a laddered version of the same SPIA (three dated
424
+ * tranches, so rising age-payout rates and shorter rate lock-in are priced) /
425
+ * QLAC at the cap. Each is a concrete, bounded plan patch that adds (or
426
+ * removes) annuity purchases; the exact ledger prices the liquidity ↓ /
427
+ * durability ↑ / estate Δ tradeoff — generation never decides. Bounded: at
428
+ * most four candidates. Payout rates come from the sourced default table in
429
+ * ./spiaQuotes (user-entered purchases carry their own quotes).
430
+ */
431
+ export const annuityPurchaseGenerator = {
432
+ id: 'annuity-purchase',
433
+ generate(ctx) {
434
+ const plan = ctx.plan;
435
+ const startYear = ctx.simulateOptions.startYear;
436
+ const primary = plan.household.people[0];
437
+ if (!primary)
438
+ return [];
439
+ const currentAge = startYear - dobYear(primary.dob);
440
+ const candidates = [];
441
+ // No-purchase alternative, only meaningful when the plan already buys one.
442
+ if (plan.accounts.some((a) => a.type === 'annuity' && a.purchase)) {
443
+ candidates.push({
444
+ id: 'annuity-none',
445
+ source: 'heuristic',
446
+ category: 'guaranteed-income',
447
+ label: 'Buy no annuity',
448
+ explanation: 'Runs the plan with the annuity purchase removed, so the liquidity and estate cost of buying guaranteed income is priced on the exact ledger.',
449
+ planPatch: { accounts: plan.accounts.filter((a) => !(a.type === 'annuity' && a.purchase)) },
450
+ });
451
+ }
452
+ // Cover-the-floor SPIA: an immediate non-qualified annuity funded from the
453
+ // largest liquid account, premium bounded at a quarter of that balance.
454
+ const liquid = plan.accounts
455
+ .filter((a) => a.type === 'cash' || a.type === 'taxable')
456
+ .sort((a, b) => accountBalance(b) - accountBalance(a))[0];
457
+ if (liquid && accountBalance(liquid) > 25_000) {
458
+ const startAge = Math.min(95, Math.max(currentAge, 65));
459
+ const premium = Math.min(accountBalance(liquid) * 0.25, 250_000);
460
+ const monthly = (premium * spiaPayoutRate(startAge)) / 12;
461
+ const annuity = {
462
+ // Namespaced by year + funding account so the synthetic candidate account
463
+ // cannot collide with (and silently overwrite) a real user account id.
464
+ id: `annuity-spia-candidate-${startYear}-${liquid.id}`,
465
+ type: 'annuity',
466
+ name: 'SPIA (candidate)',
467
+ ownerPersonId: primary.id,
468
+ annualReturnPct: null,
469
+ startAge,
470
+ monthlyAmount: monthly,
471
+ colaPct: 0,
472
+ taxablePct: 100,
473
+ purchase: { year: startYear, premium, fundingAccountId: liquid.id, taxQualification: 'nonQualified' },
474
+ };
475
+ candidates.push({
476
+ id: 'annuity-spia',
477
+ source: 'heuristic',
478
+ category: 'guaranteed-income',
479
+ label: 'Buy a cover-the-floor SPIA',
480
+ explanation: `Trades $${Math.round(premium).toLocaleString()} of liquid savings for an immediate life annuity (~$${Math.round(monthly).toLocaleString()}/mo), taxed by exclusion ratio and priced on the exact ledger.`,
481
+ planPatch: { accounts: [...plan.accounts, annuity] },
482
+ metadata: { premium: Math.round(premium), monthly: Math.round(monthly) },
483
+ });
484
+ // Laddered alternative: the same premium in three dated tranches (now,
485
+ // +3y, +6y). Later tranches buy at older-age (higher) payout rates and
486
+ // keep the deferred dollars invested meanwhile; the exact ledger prices
487
+ // whether that beats the single purchase.
488
+ const trancheYears = [0, 3, 6];
489
+ const tranchePremium = premium / trancheYears.length;
490
+ const ladderAccounts = trancheYears.map((offset) => {
491
+ const trancheStartAge = Math.min(95, startAge + offset);
492
+ return {
493
+ id: `annuity-spia-ladder-candidate-${startYear + offset}-${liquid.id}`,
494
+ type: 'annuity',
495
+ name: `SPIA ladder tranche ${startYear + offset} (candidate)`,
496
+ ownerPersonId: primary.id,
497
+ annualReturnPct: null,
498
+ startAge: trancheStartAge,
499
+ monthlyAmount: (tranchePremium * spiaPayoutRate(trancheStartAge)) / 12,
500
+ colaPct: 0,
501
+ taxablePct: 100,
502
+ purchase: {
503
+ year: startYear + offset,
504
+ premium: tranchePremium,
505
+ fundingAccountId: liquid.id,
506
+ taxQualification: 'nonQualified',
507
+ },
508
+ };
509
+ });
510
+ candidates.push({
511
+ id: 'annuity-spia-ladder',
512
+ source: 'heuristic',
513
+ category: 'guaranteed-income',
514
+ label: 'Ladder the SPIA over three purchases',
515
+ explanation: `Splits the same $${Math.round(premium).toLocaleString()} into three SPIA purchases (now, +3y, +6y) at each age's payout rate, keeping deferred dollars invested meanwhile; priced on the exact ledger.`,
516
+ planPatch: { accounts: [...plan.accounts, ...ladderAccounts] },
517
+ metadata: { premium: Math.round(premium), tranches: trancheYears.length },
518
+ });
519
+ }
520
+ // QLAC at the cap: a deferred qualified annuity funded from the largest
521
+ // owner-controlled traditional account, premium held to the statutory cap.
522
+ const traditional = plan.accounts
523
+ .filter((a) => a.type === 'traditional' && !a.inherited)
524
+ .sort((a, b) => b.balance - a.balance)[0];
525
+ if (traditional && traditional.balance > 50_000 && currentAge < 83) {
526
+ // Match the projection's statutory-limit indexing: for a start year past the
527
+ // latest pack the QLAC cap is inflation-projected, so an un-indexed cap would
528
+ // systematically under-shoot "at the cap" (and mis-price the candidate).
529
+ const { pack: qlacPack, isStandIn } = packForYear(startYear);
530
+ const inflation = plan.assumptions.inflationPct / 100;
531
+ const capGrowth = isStandIn && startYear > LATEST_PACK_YEAR ? Math.pow(1 + inflation, startYear - qlacPack.year) : 1;
532
+ const cap = qlacPack.annuities.qlacPremiumCap * capGrowth;
533
+ const premium = Math.min(cap, traditional.balance * 0.25);
534
+ const startAge = Math.min(85, Math.max(currentAge + 1, 80));
535
+ // A QLAC bought years before it starts pays a much higher deferred rate.
536
+ const monthly = (premium * QLAC_DEFERRED_PAYOUT_RATE) / 12;
537
+ const annuity = {
538
+ // Namespaced by year + funding account so the synthetic candidate account
539
+ // cannot collide with (and silently overwrite) a real user account id.
540
+ id: `annuity-qlac-candidate-${startYear}-${traditional.id}`,
541
+ type: 'annuity',
542
+ name: 'QLAC (candidate)',
543
+ ownerPersonId: traditional.ownerPersonId ?? primary.id,
544
+ annualReturnPct: null,
545
+ startAge,
546
+ monthlyAmount: monthly,
547
+ colaPct: 0,
548
+ taxablePct: 100,
549
+ purchase: { year: startYear, premium, fundingAccountId: traditional.id, taxQualification: 'qualified', qlac: true },
550
+ };
551
+ candidates.push({
552
+ id: 'annuity-qlac',
553
+ source: 'heuristic',
554
+ category: 'guaranteed-income',
555
+ label: 'Buy a QLAC at the cap',
556
+ explanation: `Moves $${Math.round(premium).toLocaleString()} of traditional savings into a deferred longevity annuity starting at ${startAge} (fully taxable payouts, premium out of the RMD base), priced on the exact ledger.`,
557
+ planPatch: { accounts: [...plan.accounts, annuity] },
558
+ metadata: { premium: Math.round(premium), monthly: Math.round(monthly), startAge },
559
+ });
560
+ }
561
+ return candidates;
562
+ },
563
+ };
564
+ /**
565
+ * Social Security bridge / TIPS-ladder candidates
566
+ * (social-security-bridge-and-tips-ladder, step 3): fund the gap years between
567
+ * retirement and each claimant's chosen claim age with a TIPS bridge ladder
568
+ * paying the forgone age-62 benefit, purchased from the largest liquid
569
+ * account; plus the remove-the-ladders alternative when the plan already has
570
+ * some. Bounded (≤ one bridge per person + one removal); the exact ledger
571
+ * prices the liquidity ↓ / durability ↑ tradeoff — generation never decides.
572
+ */
573
+ export const bridgeLadderGenerator = {
574
+ id: 'bridge-ladder',
575
+ generate(ctx) {
576
+ const plan = ctx.plan;
577
+ const startYear = ctx.simulateOptions.startYear;
578
+ const candidates = [];
579
+ if ((plan.incomeFloor?.ladders.length ?? 0) > 0) {
580
+ candidates.push({
581
+ id: 'income-floor-none',
582
+ source: 'heuristic',
583
+ category: 'guaranteed-income',
584
+ label: 'Hold no TIPS ladders',
585
+ explanation: 'Runs the plan with every TIPS ladder removed, so the cost of the guaranteed floor is priced on the exact ledger.',
586
+ planPatch: { incomeFloor: { ladders: [] } },
587
+ });
588
+ }
589
+ const liquid = plan.accounts
590
+ .filter((a) => a.type === 'cash' || a.type === 'taxable')
591
+ .sort((a, b) => accountBalance(b) - accountBalance(a))[0];
592
+ if (!liquid)
593
+ return candidates;
594
+ const newLadders = [];
595
+ let totalCost = 0;
596
+ for (const stream of plan.incomes) {
597
+ if (stream.type !== 'socialSecurity' || stream.piaMonthly === null || stream.piaMonthly <= 0)
598
+ continue;
599
+ const person = plan.household.people.find((p) => p.id === stream.personId);
600
+ if (!person)
601
+ continue;
602
+ const dobYearNum = dobYear(person.dob);
603
+ const dobMonth = Number(person.dob.slice(5, 7));
604
+ const dobDay = Number(person.dob.slice(8, 10));
605
+ const retirementYear = person.retirementAge !== null ? dobYearNum + person.retirementAge : startYear;
606
+ const sized = sizeBridge({
607
+ piaMonthly: stream.piaMonthly,
608
+ dob: { year: dobYearNum, month: dobMonth, day: dobDay },
609
+ claimAge: stream.claimAge,
610
+ currentYear: startYear,
611
+ retirementYear,
612
+ curve: EMBEDDED_REAL_YIELD_CURVE,
613
+ });
614
+ if (!sized)
615
+ continue;
616
+ // Skip when a plan ladder already covers the whole window (a ladder
617
+ // ending even one year short leaves a real unfunded gap year).
618
+ const covered = plan.incomeFloor?.ladders.some((l) => l.startYear <= sized.startYear && l.endYear >= sized.endYear);
619
+ if (covered)
620
+ continue;
621
+ totalCost += sized.ladderCost;
622
+ newLadders.push({
623
+ // Namespaced by person so the synthetic candidate id cannot collide
624
+ // with a real ladder id.
625
+ id: `bridge-candidate-${startYear}-${stream.personId}`,
626
+ name: `SS bridge (${person.name})`,
627
+ purpose: 'bridge',
628
+ startYear: sized.startYear,
629
+ endYear: sized.endYear,
630
+ annualRealAmount: sized.annualRealAmount,
631
+ purchase: { year: startYear, fundingAccountId: liquid.id },
632
+ });
633
+ }
634
+ // Same affordability gate as the ss-bridge-gap detector, so both surfaces
635
+ // agree on whether a bridge is fundable.
636
+ if (newLadders.length === 0 || accountBalance(liquid) < totalCost * BRIDGE_FUNDING_MIN_FRACTION)
637
+ return candidates;
638
+ candidates.push({
639
+ id: 'bridge-ladder',
640
+ source: 'heuristic',
641
+ category: 'guaranteed-income',
642
+ label: 'Fund the Social Security gap years with a TIPS bridge ladder',
643
+ explanation: `Buys ~$${Math.round(totalCost).toLocaleString()} of TIPS maturing across the years before the chosen claim age, paying the forgone age-62 benefit so delaying Social Security never cuts lifestyle; priced on the exact ledger.`,
644
+ planPatch: { incomeFloor: { ladders: [...(plan.incomeFloor?.ladders ?? []), ...newLadders] } },
645
+ metadata: { totalCost: Math.round(totalCost), ladders: newLadders.length },
646
+ });
647
+ return candidates;
648
+ },
649
+ };
650
+ /** Wrap a modelable Insights detector action as a decision candidate generator. */
651
+ export function insightActionGenerator(args) {
652
+ return {
653
+ id: `insight-${args.id}`,
654
+ generate() {
655
+ return [
656
+ {
657
+ id: args.id,
658
+ source: 'detector',
659
+ category: args.category,
660
+ label: args.label,
661
+ explanation: args.explanation,
662
+ planPatch: args.planPatch,
663
+ },
664
+ ];
665
+ },
666
+ };
667
+ }