@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,216 @@
1
+ /**
2
+ * Projection summaries and the convert-vs-don't comparison (roadmap V3).
3
+ * Scenario diffing generalizes in V4; this covers the headline question.
4
+ */
5
+ import { simulatePlan } from './simulate.js';
6
+ function isoYear(isoDate) {
7
+ return Number(isoDate.slice(0, 4));
8
+ }
9
+ /**
10
+ * Resolve an account's estate destination for the after-tax estate metric.
11
+ * Explicit `estateBeneficiary` wins; otherwise the legacy defaults reproduce the
12
+ * old flat haircut exactly: pre-tax (traditional) balances go to a taxed heir,
13
+ * an HSA follows its legacy `beneficiary` shorthand, and everything else passes
14
+ * to a (untaxed) spouse-equivalent.
15
+ */
16
+ function resolveEstateDestination(account) {
17
+ if ('estateBeneficiary' in account && account.estateBeneficiary) {
18
+ return {
19
+ destination: account.estateBeneficiary.destination,
20
+ charityPct: account.estateBeneficiary.charityPct ?? 0,
21
+ };
22
+ }
23
+ if (account.type === 'hsa') {
24
+ return { destination: account.beneficiary === 'nonSpouse' ? 'nonSpouse' : 'spouse', charityPct: 0 };
25
+ }
26
+ if (account.type === 'traditional')
27
+ return { destination: 'nonSpouse', charityPct: 0 };
28
+ return { destination: 'spouse', charityPct: 0 };
29
+ }
30
+ export function summarizeProjection(plan, result) {
31
+ let taxes = 0;
32
+ let conversions = 0;
33
+ for (const y of result.years) {
34
+ taxes += y.tax + y.penalties;
35
+ conversions += y.rothConversion;
36
+ }
37
+ const endingByCategory = { cash: 0, taxable: 0, traditional: 0, roth: 0, hsa: 0 };
38
+ const last = result.years[result.years.length - 1];
39
+ if (last) {
40
+ for (const account of plan.accounts) {
41
+ if (account.type in endingByCategory) {
42
+ endingByCategory[account.type] += last.balances[account.id] ?? 0;
43
+ }
44
+ }
45
+ }
46
+ // --- estate depth (guaranteed-income-and-estate-depth) --------------------
47
+ // Discount each account's ending balance on the way to heirs/charity, so the
48
+ // summary can explain how every class was treated. Pre-tax classes are taxed
49
+ // at a class heir rate (heirTaxByClass override, else the flat heirTaxRatePct);
50
+ // charity carve-outs pass untaxed; Roth/taxable (stepped-up)/cash pass free.
51
+ // With no beneficiary fields set anywhere the defaults reproduce the old flat
52
+ // haircut exactly, so existing plans stay byte-identical.
53
+ // Scope: the breakdown covers investable account classes only. Property,
54
+ // debts, TIPS-ladder face, insurance cash value, and any HECM loan (already
55
+ // netted into endingNetWorth with its non-recourse cap) ride through
56
+ // netWorth without per-account rows here, so the breakdown's rows sum to
57
+ // the investable slice of the estate, not the whole headline figure.
58
+ const flatHeirRate = plan.assumptions.heirTaxRatePct / 100;
59
+ const heirRateFor = (category) => {
60
+ const byClass = plan.assumptions.heirTaxByClass;
61
+ if (category === 'traditional' && byClass?.traditional !== undefined)
62
+ return byClass.traditional / 100;
63
+ if (category === 'hsa' && byClass?.hsa !== undefined)
64
+ return byClass.hsa / 100;
65
+ return flatHeirRate;
66
+ };
67
+ // Nondeductible IRA basis passes to the heir tax-free (Form 8606); allocate it
68
+ // across traditional accounts by balance so per-account heir tax nets it out.
69
+ const basisTotal = Math.min(result.endingNondeductibleIraBasis, endingByCategory.traditional);
70
+ const estateBreakdown = [];
71
+ if (last) {
72
+ for (const account of plan.accounts) {
73
+ // Equity comp is stepped-up at death like a taxable account, and it is part
74
+ // of ending net worth, so it must appear in the breakdown (and honor a
75
+ // charity destination) to keep endingAfterTaxEstate consistent — otherwise
76
+ // it would silently pass untaxed but be un-markable and invisible here.
77
+ const category = (account.type === 'equityComp' ? 'taxable' : account.type);
78
+ if (!(category in endingByCategory))
79
+ continue;
80
+ const grossBalance = last.balances[account.id] ?? 0;
81
+ if (grossBalance <= 0)
82
+ continue;
83
+ const { destination, charityPct } = resolveEstateDestination(account);
84
+ let taxablePretaxBase = 0;
85
+ if (category === 'traditional') {
86
+ const allocatedBasis = endingByCategory.traditional > 0 ? basisTotal * (grossBalance / endingByCategory.traditional) : 0;
87
+ taxablePretaxBase = Math.max(0, grossBalance - allocatedBasis);
88
+ }
89
+ else if (category === 'hsa' && destination !== 'spouse') {
90
+ // An HSA passing to anyone but a spouse is a fully taxable distribution.
91
+ taxablePretaxBase = grossBalance;
92
+ }
93
+ const heirTaxRatePct = heirRateFor(category) * 100;
94
+ const charityFraction = destination === 'charity' ? Math.min(1, charityPct / 100) : 0;
95
+ const charityAmount = grossBalance * charityFraction;
96
+ // A spouse rollover is untaxed; otherwise the non-charity slice of the
97
+ // pre-tax base is taxed at the class heir rate.
98
+ const heirTax = destination === 'spouse' ? 0 : taxablePretaxBase * (1 - charityFraction) * heirRateFor(category);
99
+ estateBreakdown.push({
100
+ accountId: account.id,
101
+ name: account.name,
102
+ category,
103
+ grossBalance,
104
+ destination,
105
+ taxablePretaxBase,
106
+ heirTaxRatePct,
107
+ charityAmount,
108
+ heirTax,
109
+ netToHeirs: grossBalance - charityAmount - heirTax,
110
+ });
111
+ }
112
+ }
113
+ const heirTax = estateBreakdown.reduce((sum, b) => sum + b.heirTax, 0);
114
+ const estateToCharity = estateBreakdown.reduce((sum, b) => sum + b.charityAmount, 0);
115
+ // FIRE metrics computation
116
+ const startYear = result.startYear;
117
+ const inflationRate = plan.assumptions.inflationPct / 100;
118
+ const defaultReturn = plan.assumptions.defaultReturnPct / 100;
119
+ const realReturn = defaultReturn - inflationRate;
120
+ const primary = plan.household.people[0];
121
+ const birthYear = primary ? isoYear(primary.dob) : 1980;
122
+ const retirementAge = primary?.retirementAge ?? 65;
123
+ const targetYear = birthYear + retirementAge;
124
+ // 1. Savings rates
125
+ const savingsRates = result.years.map((y) => {
126
+ const savings = y.contributions + y.employerMatch + y.surplusInvested;
127
+ const gross = y.incomes.total;
128
+ const ratePct = gross > 0 ? Math.max(0, Math.min(100, (savings / gross) * 100)) : 0;
129
+ return { year: y.year, ratePct };
130
+ });
131
+ // 2. Average pre-retirement savings rate
132
+ const preRetirementRates = savingsRates.filter((r) => r.year < targetYear);
133
+ const averagePreRetirementSavingsRatePct = preRetirementRates.length > 0
134
+ ? preRetirementRates.reduce((acc, r) => acc + r.ratePct, 0) / preRetirementRates.length
135
+ : 0;
136
+ // 3. FI Number
137
+ const targetResult = result.years.find((y) => y.year === Math.max(startYear, targetYear)) ?? result.years[0];
138
+ const nominalSpendingAtFI = targetResult
139
+ ? targetResult.expenses.total + targetResult.tax + targetResult.penalties
140
+ : plan.expenses.baseAnnual;
141
+ const yearsToFIYear = targetResult ? targetResult.year - startYear : 0;
142
+ const annualSpendingAtFIToday = nominalSpendingAtFI / Math.pow(1 + inflationRate, yearsToFIYear);
143
+ const swr = (plan.assumptions.safeWithdrawalRatePct ?? 4) / 100;
144
+ const fiNumber = annualSpendingAtFIToday / swr;
145
+ // 4. FI Year and Age
146
+ let fiYear = null;
147
+ let fiAge = null;
148
+ for (const y of result.years) {
149
+ const deflatedInvestable = y.investableTotal / Math.pow(1 + inflationRate, y.year - startYear);
150
+ if (deflatedInvestable >= fiNumber) {
151
+ fiYear = y.year;
152
+ fiAge = y.year - birthYear;
153
+ break;
154
+ }
155
+ }
156
+ // 5. Coast FIRE Number
157
+ const currentAge = startYear - birthYear;
158
+ const yearsToRetire = Math.max(0, retirementAge - currentAge);
159
+ const coastFireNumber = fiNumber / Math.pow(1 + realReturn, yearsToRetire);
160
+ return {
161
+ lifetimeTaxesAndPenalties: taxes,
162
+ lifetimeRothConversions: conversions,
163
+ endingInvestable: result.endingInvestable,
164
+ endingNetWorth: result.endingNetWorth,
165
+ // What heirs receive: net worth less heir tax and less any charitable
166
+ // bequests (charity leaves the heirs' estate but is never taxed). With no
167
+ // charity destination this equals net worth − heir tax, as before.
168
+ endingAfterTaxEstate: result.endingNetWorth - estateToCharity - heirTax,
169
+ endingEstateHeirTax: heirTax,
170
+ endingEstateToCharity: estateToCharity,
171
+ estateBreakdown,
172
+ endingByCategory,
173
+ depletionYear: result.depletionYear,
174
+ warnings: result.warnings,
175
+ savingsRates,
176
+ averagePreRetirementSavingsRatePct,
177
+ fiNumber,
178
+ fiYear,
179
+ fiAge,
180
+ coastFireNumber,
181
+ };
182
+ }
183
+ /** Runs the plan as-is and with conversions disabled, for side-by-side display. */
184
+ export function compareRothConversion(plan, opts) {
185
+ const disabled = {
186
+ ...plan,
187
+ strategies: { ...plan.strategies, rothConversion: { mode: 'none' } },
188
+ };
189
+ return {
190
+ withConversions: summarizeProjection(plan, simulatePlan(plan, opts)),
191
+ withoutConversions: summarizeProjection(disabled, simulatePlan(disabled, opts)),
192
+ };
193
+ }
194
+ /**
195
+ * The "LTC stress" comparison: how a deterministic care episode hits the plan,
196
+ * and what the LTC policy does about it.
197
+ *
198
+ * noCare and careUninsured both hold LTC out, so they differ only by the care
199
+ * episode — `noCare − careUninsured` is the raw care shock, free of premium
200
+ * effects. careInsured adds the policies back, so `careInsured − careUninsured`
201
+ * is the policy's value *net of its premiums* (it can be negative if lifetime
202
+ * premiums exceed the benefits paid). The UI labels it as such.
203
+ */
204
+ export function compareLtcStress(plan, opts) {
205
+ const run = (p) => summarizeProjection(p, simulatePlan(p, opts));
206
+ const withoutLtc = plan.insurance.filter((i) => i.kind !== 'ltc');
207
+ const noCare = { ...plan, careEvents: [], insurance: withoutLtc };
208
+ const careUninsured = { ...plan, insurance: withoutLtc };
209
+ return {
210
+ noCare: run(noCare),
211
+ careUninsured: run(careUninsured),
212
+ careInsured: run(plan),
213
+ hasCareEvents: plan.careEvents.length > 0,
214
+ hasLtcPolicy: plan.insurance.some((i) => i.kind === 'ltc'),
215
+ };
216
+ }
@@ -0,0 +1,7 @@
1
+ import type { TaxCalculator } from './types.js';
2
+ /**
3
+ * V1 placeholder: one flat effective rate over all income, with 85% of Social
4
+ * Security included (the statutory maximum taxable share). Replaced by the
5
+ * real federal engine in roadmap phase V2 — do not add precision here.
6
+ */
7
+ export declare function createFlatTaxCalculator(effectiveRatePct: number): TaxCalculator;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * V1 placeholder: one flat effective rate over all income, with 85% of Social
3
+ * Security included (the statutory maximum taxable share). Replaced by the
4
+ * real federal engine in roadmap phase V2 — do not add precision here.
5
+ */
6
+ export function createFlatTaxCalculator(effectiveRatePct) {
7
+ const rate = effectiveRatePct / 100;
8
+ return {
9
+ compute(input) {
10
+ const base = input.ordinaryIncome + input.capitalGains + (input.qualifiedDividends ?? 0) + 0.85 * input.ssBenefits;
11
+ return Math.max(0, base * rate);
12
+ },
13
+ };
14
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Plan → optimizer bridge + orchestration (roadmap V8, PR 1).
3
+ *
4
+ * Turns a whole Plan into the optimizer's linearised `OptimizerInput`, runs the
5
+ * MILP (strategies/optimizer.ts), and writes the result back as an `optimized`
6
+ * Roth-conversion strategy. The pure solver stays UI- and ledger-agnostic; this
7
+ * module is where it meets the real engine.
8
+ *
9
+ * The bridge derives each year's exogenous inputs from a single BASELINE
10
+ * projection (conversions stripped) via `captureOptimizerInputs` — so spending,
11
+ * healthcare/IRMAA/ACA, Social Security, and RMDs are taken at their
12
+ * no-conversion levels and held fixed for the LP (V8 §3.1: the LP is a
13
+ * linearisation; re-running `simulate` with the emitted schedule reports exact
14
+ * numbers and any gap). Balances, growth, and inflation come straight from the
15
+ * plan.
16
+ */
17
+ import type { Plan } from '../model/plan.js';
18
+ import { type ObjectivePolicy, type ObjectivePolicyId } from '../decisions/index.js';
19
+ import { type OptimizedSchedule, type OptimizerInput } from '../strategies/optimizer.js';
20
+ import { type ProjectionSummary } from './compare.js';
21
+ import { type SimulateOptions } from './simulate.js';
22
+ import type { ProjectionResult } from './types.js';
23
+ export interface OptimizePlanOptions {
24
+ startYear: number;
25
+ /** Same calculator the baseline/comparison run uses (federal ± state). */
26
+ taxCalculator: SimulateOptions['taxCalculator'];
27
+ /**
28
+ * Haircut on leftover traditional in the objective. Defaults to the plan's
29
+ * `heirTaxRatePct` so the optimizer's objective matches the after-tax-estate
30
+ * metric (`summarizeProjection`). Fraction is `pct / 100`.
31
+ */
32
+ liquidationRatePct?: number;
33
+ solver?: OptimizerInput['options'];
34
+ /**
35
+ * Coordinate-descent refinement of a winning candidate schedule over the
36
+ * exact ledger (decision-engine Phase 4). Off by default — the worker path
37
+ * enables it with a bounded simulation budget.
38
+ */
39
+ search?: ExactLedgerSearchOptions | false;
40
+ /**
41
+ * Objective policy the tournament ranks by (sustainable-spending plan,
42
+ * Step 5). Omitted or `max-after-tax-estate` keeps the original tournament
43
+ * behavior; any other policy re-ranks the same exact-ledger evaluations
44
+ * through the shared `rankEvaluations` under that policy's constraints.
45
+ */
46
+ policy?: ObjectivePolicy;
47
+ /**
48
+ * Exact-ledger convergence loop (optimizer-exact-ledger-convergence Track 1,
49
+ * Step 1). Off/undefined
50
+ * or `maxIterations ≤ 1` reproduces the single-solve schedule byte-for-byte.
51
+ * When enabled, the MILP is re-solved against exogenous inputs recaptured from
52
+ * the exact-ledger run of the incumbent schedule (taxable-SS phase-in and
53
+ * IRMAA/ACA-priced spending become exact at the incumbent), iterating to a
54
+ * fixed point so the recommendation is *optimal on the exact ledger to
55
+ * tolerance* rather than "best found within a single linearization".
56
+ */
57
+ convergence?: ExactLedgerConvergenceOptions | false;
58
+ }
59
+ export interface ExactLedgerSearchOptions {
60
+ /** Hard cap on extra exact-ledger simulations spent refining the winner. */
61
+ maxSimulations?: number;
62
+ }
63
+ export interface ExactLedgerConvergenceOptions {
64
+ /**
65
+ * Hard cap on outer solve+sim iterations, including the first solve. `≤ 1`
66
+ * disables the loop (single solve = today's schedule). Default
67
+ * `DEFAULT_CONVERGENCE_ITERATIONS`.
68
+ */
69
+ maxIterations?: number;
70
+ /**
71
+ * Stop once no year's conversion moves more than this between iterations
72
+ * (nominal dollars). Default `DEFAULT_CONVERGENCE_SCHEDULE_TOLERANCE_DOLLARS`.
73
+ */
74
+ scheduleToleranceDollars?: number;
75
+ /**
76
+ * Stop once the exact-ledger after-tax estate improves by less than this over
77
+ * the previous iteration (nominal dollars). Default
78
+ * `DEFAULT_CONVERGENCE_OBJECTIVE_TOLERANCE_DOLLARS`.
79
+ */
80
+ objectiveToleranceDollars?: number;
81
+ /**
82
+ * Trust-region step limit: the largest per-year conversion change adopted in a
83
+ * single iteration (nominal dollars), damping SLP oscillation. Default
84
+ * `DEFAULT_CONVERGENCE_MAX_YEAR_STEP_DOLLARS`.
85
+ */
86
+ maxYearStepDollars?: number;
87
+ /**
88
+ * Relaxation factor in `(0, 1]`: fraction of the solved step taken each
89
+ * iteration before the trust-region clamp. `1` (default) takes the full step;
90
+ * lower values damp harder.
91
+ */
92
+ dampingFactor?: number;
93
+ }
94
+ export interface ExactLedgerConvergenceDiagnostics {
95
+ /** True when the loop actually ran (enabled and `maxIterations > 1`). */
96
+ enabled: boolean;
97
+ /** Outer solves performed (1 = first solve only). */
98
+ iterations: number;
99
+ /** True when the schedule/objective settled within tolerance before the cap. */
100
+ converged: boolean;
101
+ /** Exact after-tax estate at the final schedule minus at the first solve (nominal). */
102
+ estateGainOverFirstSolveDollars: number;
103
+ /** Largest per-year conversion move on the final adopted iteration (nominal). */
104
+ finalMaxYearMoveDollars: number;
105
+ /**
106
+ * Which schedule the pipeline kept after post-processing both: the converged
107
+ * schedule, or the first solve when its post-processed (trim + estate-prune)
108
+ * form priced higher on the exact ledger — SLP converges to a local optimum,
109
+ * and the post-processor can rescue a raw first solve into a better cleaned
110
+ * schedule. Absent when the loop never adopted a step.
111
+ */
112
+ keptSchedule?: 'converged' | 'first-solve';
113
+ }
114
+ /**
115
+ * Build the optimizer input from a probe projection.
116
+ *
117
+ * By default the probe is a conversion-free baseline, so the LP's exogenous
118
+ * inputs (taxable SS, RMD divisors, spending, healthcare/IRMAA) are taken at
119
+ * their no-conversion levels and held fixed for the solve. The exact-ledger
120
+ * convergence loop (Step 1) passes the *incumbent* schedule as `probeSourcePlan`
121
+ * so those same inputs are recaptured at the schedule the solver last emitted —
122
+ * the SLP re-linearization that turns the solver's optimum into the exact-ledger
123
+ * optimum by iteration (see `optimizePlan`).
124
+ */
125
+ export declare function buildOptimizerInput(plan: Plan, opts: OptimizePlanOptions, probeSourcePlan?: Plan): OptimizerInput;
126
+ export interface OptimizePlanResult {
127
+ schedule: OptimizedSchedule;
128
+ input: OptimizerInput;
129
+ postProcessed: ExactLedgerPostProcessing | null;
130
+ tournament: ExactLedgerTournament;
131
+ /**
132
+ * Convergence-loop diagnostics (Step 1). Always present: reports `enabled:
133
+ * false` on the default single-solve path so callers can render it uniformly.
134
+ */
135
+ convergence: ExactLedgerConvergenceDiagnostics;
136
+ }
137
+ export interface SimpleCandidateEvaluation {
138
+ id: string;
139
+ label: string;
140
+ /** Total conversions the exact ledger executed under this candidate strategy. */
141
+ executedConversionTotal: number;
142
+ afterTaxEstateDelta: number;
143
+ lifetimeTaxDelta: number;
144
+ moneyLastsYearsDelta: number;
145
+ }
146
+ export interface ExactLedgerTournament {
147
+ /** Objective policy that ranked this tournament (default `max-after-tax-estate`). */
148
+ policyId: ObjectivePolicyId;
149
+ /** All simple-candidate evaluations, in fixed generation order. */
150
+ candidates: SimpleCandidateEvaluation[];
151
+ /**
152
+ * Who supplies the recommended schedule: the post-processed MILP, a simple
153
+ * candidate, the plan's own already-applied conversion strategy
154
+ * ('incumbent': nothing evaluated beat the current plan, so no change is
155
+ * recommended), or nobody.
156
+ */
157
+ winnerSource: 'milp' | 'candidate' | 'incumbent' | 'none';
158
+ winnerCandidateId: string | null;
159
+ winnerLabel: string | null;
160
+ /**
161
+ * The recommended per-year schedule (exact-ledger executed amounts; empty
162
+ * when winnerSource is 'none'; the plan's current executed conversions when
163
+ * winnerSource is 'incumbent').
164
+ */
165
+ winnerConversions: {
166
+ year: number;
167
+ amount: number;
168
+ }[];
169
+ /** Exact comparison for the winner; null for 'incumbent' (a plan's delta vs itself is zero) and 'none'. */
170
+ winnerValidation: ExactLedgerValidation | null;
171
+ /** Candidate's exact estate delta over the displaced MILP schedule; 0 when no MILP comparison was made. */
172
+ marginOverMilpDollars: number;
173
+ /** True when Phase 4 local search improved the winning candidate schedule. */
174
+ searchRefined: boolean;
175
+ /** Exact-ledger simulations spent by local search (0 when search was off or skipped). */
176
+ searchSimulations: number;
177
+ }
178
+ /**
179
+ * Exact-ledger evaluations of a small fixed set of simple conversion
180
+ * strategies (post-processor plan Phase 5; validation plan "candidate strategy
181
+ * comparisons"). Each candidate is one deterministic `simulatePlan` run of the
182
+ * plan with a fill-to-target Roth strategy over the whole horizon, priced with
183
+ * the same summary metrics the recommendation gate uses.
184
+ */
185
+ export declare function evaluateSimpleConversionCandidates(plan: Plan, baselineResult: ProjectionResult, simulateOptions: SimulateOptions): SimpleCandidateEvaluation[];
186
+ /**
187
+ * The bounded exact-ledger tournament (post-processor plan Phase 5): compare
188
+ * the post-processed MILP schedule against the simple candidates and recommend
189
+ * whichever wins on the exact after-tax estate. The MILP's linearisation can
190
+ * over-convert past its own objective's break-even (its bracket, IRMAA, and
191
+ * liquidity approximations diverge over long horizons), and on trad-heavy
192
+ * fixtures a plain bracket fill beats the cleaned schedule by 2× — so the
193
+ * winner, not the solver, supplies the recommendation. A candidate must beat
194
+ * a recommendable MILP by a material margin (default $1k) to avoid churn on
195
+ * ties, must never shorten money-lasts relative to the schedule it would
196
+ * replace, and its schedule is exact-ledger executed amounts by construction.
197
+ * Cost: six extra deterministic `simulatePlan` runs.
198
+ */
199
+ export declare function runExactLedgerTournament(plan: Plan, baselineResult: ProjectionResult, postProcessed: ExactLedgerPostProcessing | null, simulateOptions: SimulateOptions, options?: {
200
+ switchMarginDollars?: number;
201
+ search?: ExactLedgerSearchOptions | false;
202
+ policy?: ObjectivePolicy;
203
+ }): ExactLedgerTournament;
204
+ export type ExactLedgerRecommendationState = 'beneficial' | 'neutral' | 'rejected' | 'unexecutable';
205
+ export type ExactLedgerRecommendationSchedule = 'cleaned' | 'none';
206
+ export interface ExactLedgerValidationOptions {
207
+ /** Dollars around zero treated as matching the current exact plan. */
208
+ neutralToleranceDollars?: number;
209
+ /** Minimum total requested conversions before execution-ratio diagnostics matter. */
210
+ minimumRequestedConversionDollars?: number;
211
+ /** Absolute shortfall before requested-vs-executed mismatch is material. */
212
+ materialConversionShortfallDollars?: number;
213
+ /** Percent shortfall before requested-vs-executed mismatch is material. */
214
+ materialConversionShortfallPct?: number;
215
+ }
216
+ export interface ExactLedgerValidation {
217
+ baseline: ProjectionSummary;
218
+ candidate: ProjectionSummary;
219
+ afterTaxEstateDelta: number;
220
+ endingNetWorthDelta: number;
221
+ lifetimeTaxDelta: number;
222
+ moneyLastsYearsDelta: number;
223
+ requestedConversionTotal: number;
224
+ executedConversionTotal: number;
225
+ executedConversionRatio: number;
226
+ firstMateriallyUnexecutedYear: number | null;
227
+ traditionalDepletionYear: number | null;
228
+ recommendationState: ExactLedgerRecommendationState;
229
+ }
230
+ export interface ExactLedgerScheduleAdjustment {
231
+ year: number;
232
+ requested: number;
233
+ executed: number;
234
+ cleaned: number;
235
+ reason: 'ledger-capped' | 'dropped-zero' | 'estate-pruned' | 'rounding';
236
+ }
237
+ export interface ExactLedgerPostProcessingOptions extends ExactLedgerValidationOptions {
238
+ /** Maximum raw -> executed -> rerun stabilization passes. */
239
+ maxIterations?: number;
240
+ /** Maximum trailing-year prune candidates evaluated (one exact simulation each). */
241
+ maxPruneIterations?: number;
242
+ }
243
+ export interface ExactLedgerPostProcessing {
244
+ rawSchedule: OptimizedSchedule;
245
+ cleanedSchedule: OptimizedSchedule;
246
+ rawValidation: ExactLedgerValidation;
247
+ cleanedValidation: ExactLedgerValidation;
248
+ rawResult: ProjectionResult;
249
+ cleanedResult: ProjectionResult;
250
+ adjustments: ExactLedgerScheduleAdjustment[];
251
+ stabilized: boolean;
252
+ iterationCount: number;
253
+ /** Trailing-year prune candidates evaluated (0 when the prune pass did not run). */
254
+ pruneIterationCount: number;
255
+ recommendationSchedule: ExactLedgerRecommendationSchedule;
256
+ }
257
+ /**
258
+ * Compare a proposed conversion schedule with the exact projection ledger.
259
+ *
260
+ * The solver can request conversions that the ledger later caps because
261
+ * spending, RMDs, or prior conversions drained the own traditional balance.
262
+ * Since the decision engine landed this is a thin adapter over the shared
263
+ * `evaluateCandidate` core (Phase 1): the exact candidate run stays
264
+ * authoritative, classified by after-tax estate first, with execution mismatch
265
+ * as a separate blocking state ('diagnostic' in engine terms, 'unexecutable'
266
+ * here for the optimizer UI contract).
267
+ */
268
+ export declare function evaluateExactLedgerSchedule(plan: Plan, requestedConversions: {
269
+ year: number;
270
+ amount: number;
271
+ }[], baselineResult: ProjectionResult, candidateResult: ProjectionResult, options?: ExactLedgerValidationOptions): ExactLedgerValidation;
272
+ /**
273
+ * Turn a raw optimizer schedule into the schedule the exact ledger can execute.
274
+ *
275
+ * The MILP is useful as a candidate generator, but only the ledger knows the
276
+ * real account balances, withdrawals, taxes, ACA/IRMAA effects, and conversion
277
+ * caps. This pass trims raw requested conversions to exact execution, reruns the
278
+ * ledger, and returns the cleaned schedule plus diagnostics.
279
+ */
280
+ export declare function postProcessExactLedgerSchedule(plan: Plan, rawSchedule: OptimizedSchedule, baselineResult: ProjectionResult, simulateOptions: SimulateOptions, options?: ExactLedgerPostProcessingOptions): ExactLedgerPostProcessing;
281
+ /** Run the optimizer end-to-end on a plan: MILP → (convergence loop) → post-processing → tournament. */
282
+ export declare function optimizePlan(plan: Plan, opts: OptimizePlanOptions): Promise<OptimizePlanResult>;
283
+ export interface ClaimAgeCoOptimization {
284
+ /** True when claim-age co-optimization actually ran. */
285
+ enabled: boolean;
286
+ /** Claim combinations optimized, including the current claim (1 when off). */
287
+ combinationsEvaluated: number;
288
+ /** Label of the winning claim change, or null when the current claim won. */
289
+ winningClaimLabel: string | null;
290
+ /**
291
+ * The plan patch (new incomes array) that produced the joint optimum, or null
292
+ * when the current claim ages won. Callers apply this to recommend the claim
293
+ * change alongside the returned conversion schedule.
294
+ */
295
+ winningClaimPatch: {
296
+ incomes: Plan['incomes'];
297
+ } | null;
298
+ /** Exact after-tax estate of the joint (claim, schedule) optimum. */
299
+ jointExactEstate: number;
300
+ /** Exact after-tax estate of the current-claim optimum (the comparison floor). */
301
+ currentClaimExactEstate: number;
302
+ }
303
+ export interface OptimizePlanWithClaimResult extends OptimizePlanResult {
304
+ /** The plan whose optimum is returned — the current plan, or a claim-patched copy. */
305
+ optimizedPlan: Plan;
306
+ claimAge: ClaimAgeCoOptimization;
307
+ }
308
+ /**
309
+ * Co-optimized SS claim age (Step 5). Alternate-minimize: run the full
310
+ * conversion optimum (convergence loop + tournament) at the current claim ages
311
+ * and at each bounded claim candidate from `socialSecurityClaimGenerator`, then
312
+ * keep the (claim, schedule) pair with the best *absolute* exact-ledger after-tax
313
+ * estate. A claim switch must clear a small margin to avoid churn. The grid is
314
+ * bounded (≤ 2 streams × 3 canonical ages), so the cost is a small multiple of a
315
+ * single optimize; the exact ledger prices every pair, so the tournament remains
316
+ * the guardrail for each. Returns the winning plan (current or claim-patched) and
317
+ * its optimizer result plus a diagnostic of the joint decision.
318
+ */
319
+ export declare function optimizePlanCoOptimizingClaimAge(plan: Plan, opts: OptimizePlanOptions): Promise<OptimizePlanWithClaimResult>;
320
+ /**
321
+ * Return a copy of the plan with the optimizer's conversions installed as an
322
+ * `optimized` strategy. The UI's "accept as manual" simply rewrites the same
323
+ * conversions under the `manual` mode.
324
+ */
325
+ export declare function withOptimizedConversions(plan: Plan, conversions: {
326
+ year: number;
327
+ amount: number;
328
+ }[], optimizedAtIso?: string): Plan;