@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,1039 @@
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 { packForYear } from '../params/index.js';
18
+ import { stateParamsFor } from '../params/state/index.js';
19
+ import { DECISION_MINIMUM_REQUESTED_CONVERSION_DOLLARS, DECISION_NEUTRAL_TOLERANCE_DOLLARS, attachStochasticMetrics, dedupeCandidates, evaluateCandidate, lastsThroughYear, rankEvaluations, refineConversionSchedule, simpleRothConversionGenerator, socialSecurityClaimGenerator, } from '../decisions/index.js';
20
+ import { applyScenarioPatch } from '../scenarios/scenarios.js';
21
+ import { optimizeSchedule, } from '../strategies/optimizer.js';
22
+ import { expectedAccountReturnPct } from '../allocation/assetClasses.js';
23
+ import { buildLognormalModelConfigForPlan } from '../montecarlo/marketModels.js';
24
+ import { summarizeProjection } from './compare.js';
25
+ import { simulatePlan } from './simulate.js';
26
+ const OTHER_TYPES = new Set(['cash', 'taxable', 'equityComp', 'roth', 'hsa']);
27
+ /**
28
+ * Single preferential LTCG rate the optimizer uses to price taxable-bucket gains
29
+ * inside the solve (Step 2). 15% is the modal federal preferential bracket for
30
+ * retirees; a single rate linearizes the 0/15/20% stack and the exact ledger
31
+ * refines it. State capital-gains treatment is not added inside the solve.
32
+ */
33
+ const LP_LTCG_RATE = 0.15;
34
+ /**
35
+ * Convex state income-tax bracket PWL for a year (Step 3), mirroring the
36
+ * federal `bracketSegments`: ascending segment widths + rates over the state's
37
+ * brackets for the filing status. Returns undefined for no-income-tax states,
38
+ * unknown codes, or a missing state — those keep the flat state term only.
39
+ */
40
+ function stateBracketSegmentsFor(state, year, status) {
41
+ if (!state)
42
+ return undefined;
43
+ const params = stateParamsFor(state, year);
44
+ if (!params || !params.hasIncomeTax)
45
+ return undefined;
46
+ const brackets = params.brackets[status];
47
+ if (!brackets || brackets.length === 0)
48
+ return undefined;
49
+ return brackets.map((b, i) => ({
50
+ width: i + 1 < brackets.length ? brackets[i + 1].lowerBound - b.lowerBound : null,
51
+ rate: b.ratePct / 100,
52
+ }));
53
+ }
54
+ function isInvestable(a) {
55
+ return a.type === 'cash' || a.type === 'taxable' || a.type === 'equityComp' || a.type === 'traditional' || a.type === 'roth' || a.type === 'hsa';
56
+ }
57
+ /** Blended nominal growth across investable accounts, weighted by balance. */
58
+ function blendedGrowth(plan, startYear) {
59
+ let weighted = 0;
60
+ let total = 0;
61
+ for (const a of plan.accounts) {
62
+ if (!isInvestable(a))
63
+ continue;
64
+ const rate = expectedAccountReturnPct(a, plan.assumptions, startYear) / 100;
65
+ weighted += a.balance * rate;
66
+ total += a.balance;
67
+ }
68
+ return total > 0 ? weighted / total : plan.assumptions.defaultReturnPct / 100;
69
+ }
70
+ /**
71
+ * Build the optimizer input from a probe projection.
72
+ *
73
+ * By default the probe is a conversion-free baseline, so the LP's exogenous
74
+ * inputs (taxable SS, RMD divisors, spending, healthcare/IRMAA) are taken at
75
+ * their no-conversion levels and held fixed for the solve. The exact-ledger
76
+ * convergence loop (Step 1) passes the *incumbent* schedule as `probeSourcePlan`
77
+ * so those same inputs are recaptured at the schedule the solver last emitted —
78
+ * the SLP re-linearization that turns the solver's optimum into the exact-ledger
79
+ * optimum by iteration (see `optimizePlan`).
80
+ */
81
+ export function buildOptimizerInput(plan, opts, probeSourcePlan) {
82
+ // Strip conversions so the probe reflects no-conversion income/RMD/spending,
83
+ // unless the caller supplies an incumbent-schedule plan to re-linearize around.
84
+ const probeSource = probeSourcePlan ?? { ...plan, strategies: { ...plan.strategies, rothConversion: { mode: 'none' } } };
85
+ const probes = [];
86
+ simulatePlan(probeSource, {
87
+ startYear: opts.startYear,
88
+ taxCalculator: opts.taxCalculator,
89
+ captureOptimizerInputs: (p) => probes.push(p),
90
+ });
91
+ let openingTrad = 0;
92
+ let openingInheritedTrad = 0;
93
+ let openingOther = 0; // tax-free bucket: cash + roth + hsa
94
+ let openingTaxable = 0; // taxable brokerage + equity-comp
95
+ let openingTaxableBasis = 0;
96
+ for (const a of plan.accounts) {
97
+ if (a.type === 'traditional') {
98
+ if (!a.inherited)
99
+ openingTrad += a.balance;
100
+ else
101
+ openingInheritedTrad += a.balance;
102
+ }
103
+ else if (a.type === 'taxable' || a.type === 'equityComp') {
104
+ // Step 2: the taxable bucket is split out so its withdrawals realize LTCG.
105
+ openingTaxable += a.balance;
106
+ openingTaxableBasis += a.costBasis ?? a.balance;
107
+ }
108
+ else if (OTHER_TYPES.has(a.type)) {
109
+ openingOther += a.balance;
110
+ }
111
+ }
112
+ // Aggregate opening basis fraction; gain fraction = 1 − this. A single opening
113
+ // ratio is the v1 linearization (the exact ledger prices true depletion).
114
+ const taxableBasisRatio = openingTaxable > 0 ? Math.min(1, Math.max(0, openingTaxableBasis / openingTaxable)) : 1;
115
+ const growth = blendedGrowth(plan, opts.startYear);
116
+ const infl = plan.assumptions.inflationPct / 100;
117
+ const filingStatus = plan.household.filingStatus;
118
+ // Step 3: a flat `stateEffectiveTaxPct` override carries the whole state tax;
119
+ // otherwise the flat term is local-only and the state's progressive brackets
120
+ // are modeled as a PWL (`stateBrackets`). Retirement-income exclusions are
121
+ // left to the exact ledger to refine.
122
+ const stateOverridePct = plan.assumptions.stateEffectiveTaxPct;
123
+ const localPct = plan.assumptions.localIncomeTaxPct;
124
+ const stateRate = (stateOverridePct > 0 ? stateOverridePct + localPct : localPct) / 100;
125
+ const useStateBrackets = stateOverridePct <= 0;
126
+ const years = probes.map((p) => {
127
+ const { pack } = packForYear(p.year);
128
+ return {
129
+ year: p.year,
130
+ pack,
131
+ filingStatus,
132
+ stateBrackets: useStateBrackets ? stateBracketSegmentsFor(plan.household.state, p.year, filingStatus) : undefined,
133
+ ordinaryIncomeBase: p.ordinaryIncomeBase,
134
+ spendingNeed: p.spendingNeed,
135
+ exogenousCash: p.exogenousCash,
136
+ // Recover the divisor from the baseline ratio (startTrad / RMD) so the LP's
137
+ // floor (trad ÷ divisor) reproduces the baseline RMD on the baseline balance.
138
+ rmdDivisor: p.rmd > 0 && p.startTraditional > 0 ? p.startTraditional / p.rmd : null,
139
+ // Baseline forced RMD, so saturation skips (senior deduction) see the
140
+ // year's true no-conversion MAGI, not just the non-withdrawal base.
141
+ baselineRmd: p.rmd,
142
+ inheritedDistribution: p.inheritedDistribution,
143
+ inheritedDistributionDivisor: p.inheritedDistribution > 0 && p.startInheritedTraditional > 0
144
+ ? p.startInheritedTraditional / p.inheritedDistribution
145
+ : null,
146
+ peopleAged65Plus: p.peopleAged65Plus,
147
+ inflationScale: Math.pow(1 + infl, p.year - pack.year),
148
+ growth,
149
+ stateRate,
150
+ tradInflow: p.traditionalInflow,
151
+ otherInflow: p.otherInflow,
152
+ taxableInflow: p.taxableInflow,
153
+ // Step 3: in-solve taxable-SS PWL so the solver sees the marginal tax
154
+ // torpedo instead of the probe-time constant; baseline gains/dividends
155
+ // feed provisional income and the IRMAA MAGI base.
156
+ ssTaxability: p.ssBenefits > 0 ? { ssBenefits: p.ssBenefits, taxableSsBase: p.taxableSsBase } : undefined,
157
+ capitalGainsBase: p.capitalGainsBase,
158
+ // SSA-44 (opt-in): shift this premium year's IRMAA trigger to (t−1)'s
159
+ // MAGI so the solve prices the redetermination the exact ledger applies.
160
+ ssa44Redetermination: p.ssa44IrmaaRedetermination || undefined,
161
+ };
162
+ });
163
+ const liquidationRate = (opts.liquidationRatePct ?? plan.assumptions.heirTaxRatePct) / 100;
164
+ return {
165
+ years,
166
+ openingTrad,
167
+ openingInheritedTrad,
168
+ openingOther,
169
+ openingTaxable,
170
+ taxableBasisRatio,
171
+ // Single preferential LTCG rate for the solve — the modal 15% bracket. A
172
+ // single rate linearizes the 0/15/20% stack; the exact ledger refines it.
173
+ ltcgRate: openingTaxable > 0 ? LP_LTCG_RATE : 0,
174
+ // Step 4: price IRMAA on the exact ledger's 2-year MAGI lookback in-solve.
175
+ irmaaLookback: true,
176
+ // Ground-truth 2026 law sync Step 2: price the OBBBA senior deduction and
177
+ // its 6% MAGI phase-out in-solve so 65+ conversions see the marginal-rate
178
+ // spike the exact ledger charges.
179
+ seniorDeduction: true,
180
+ liquidationRate,
181
+ realDollarFactor: 1 / Math.pow(1 + infl, years.length),
182
+ options: opts.solver,
183
+ };
184
+ }
185
+ /** A candidate only replaces the MILP schedule when it wins by more than this. */
186
+ const DEFAULT_TOURNAMENT_SWITCH_MARGIN_DOLLARS = 1_000;
187
+ /**
188
+ * The plan's currently-installed conversion strategy as exact-ledger executed
189
+ * amounts, or null when the plan doesn't convert. When neither the MILP nor
190
+ * any candidate beats the baseline — which *includes* these conversions — the
191
+ * incumbent is the best known schedule and the tournament reports it as the
192
+ * holder instead of a scary "nothing recommended" diagnostic (the common case
193
+ * right after applying a winning schedule and re-running the optimizer).
194
+ */
195
+ function incumbentExecutedConversions(plan, baselineResult) {
196
+ if (plan.strategies.rothConversion.mode === 'none')
197
+ return null;
198
+ const conversions = baselineResult.years
199
+ .filter((year) => year.rothConversion > 1)
200
+ .map((year) => ({ year: year.year, amount: roundDollars(year.rothConversion) }));
201
+ return conversions.length > 0 ? conversions : null;
202
+ }
203
+ function incumbentLabel(plan) {
204
+ switch (plan.strategies.rothConversion.mode) {
205
+ case 'optimized':
206
+ return 'your applied optimizer schedule';
207
+ case 'manual':
208
+ return 'your manual conversion schedule';
209
+ default:
210
+ return 'your current conversion strategy';
211
+ }
212
+ }
213
+ function decisionContext(plan, baselineResult, simulateOptions) {
214
+ return {
215
+ plan,
216
+ baselineResult,
217
+ baselineSummary: summarizeProjection(plan, baselineResult),
218
+ simulateOptions,
219
+ };
220
+ }
221
+ function buildRichCandidates(plan, baselineResult, simulateOptions) {
222
+ const ctx = decisionContext(plan, baselineResult, simulateOptions);
223
+ return dedupeCandidates(simpleRothConversionGenerator.generate(ctx)).map((candidate) => {
224
+ const evaluation = evaluateCandidate(ctx, candidate);
225
+ return {
226
+ evaluation: {
227
+ id: candidate.id,
228
+ label: candidate.label,
229
+ executedConversionTotal: evaluation.candidateResult.years.reduce((sum, year) => sum + year.rothConversion, 0),
230
+ afterTaxEstateDelta: evaluation.deltas.endingAfterTaxEstate,
231
+ lifetimeTaxDelta: evaluation.deltas.lifetimeTax,
232
+ moneyLastsYearsDelta: evaluation.deltas.moneyLastsYears,
233
+ },
234
+ fullEvaluation: evaluation,
235
+ conversions: evaluation.candidateResult.years
236
+ .filter((year) => year.rothConversion > 1)
237
+ .map((year) => ({ year: year.year, amount: roundDollars(year.rothConversion) })),
238
+ result: evaluation.candidateResult,
239
+ };
240
+ });
241
+ }
242
+ /**
243
+ * Exact-ledger evaluations of a small fixed set of simple conversion
244
+ * strategies (post-processor plan Phase 5; validation plan "candidate strategy
245
+ * comparisons"). Each candidate is one deterministic `simulatePlan` run of the
246
+ * plan with a fill-to-target Roth strategy over the whole horizon, priced with
247
+ * the same summary metrics the recommendation gate uses.
248
+ */
249
+ export function evaluateSimpleConversionCandidates(plan, baselineResult, simulateOptions) {
250
+ return buildRichCandidates(plan, baselineResult, simulateOptions).map((candidate) => candidate.evaluation);
251
+ }
252
+ /**
253
+ * The bounded exact-ledger tournament (post-processor plan Phase 5): compare
254
+ * the post-processed MILP schedule against the simple candidates and recommend
255
+ * whichever wins on the exact after-tax estate. The MILP's linearisation can
256
+ * over-convert past its own objective's break-even (its bracket, IRMAA, and
257
+ * liquidity approximations diverge over long horizons), and on trad-heavy
258
+ * fixtures a plain bracket fill beats the cleaned schedule by 2× — so the
259
+ * winner, not the solver, supplies the recommendation. A candidate must beat
260
+ * a recommendable MILP by a material margin (default $1k) to avoid churn on
261
+ * ties, must never shorten money-lasts relative to the schedule it would
262
+ * replace, and its schedule is exact-ledger executed amounts by construction.
263
+ * Cost: six extra deterministic `simulatePlan` runs.
264
+ */
265
+ export function runExactLedgerTournament(plan, baselineResult, postProcessed, simulateOptions, options = {}) {
266
+ // A non-default objective re-ranks the same evaluations through the shared
267
+ // ranker instead of the estate-delta arbitration below.
268
+ if (options.policy && options.policy.id !== 'max-after-tax-estate') {
269
+ return runPolicyRankedTournament(plan, baselineResult, postProcessed, simulateOptions, options.policy);
270
+ }
271
+ const margin = options.switchMarginDollars ?? DEFAULT_TOURNAMENT_SWITCH_MARGIN_DOLLARS;
272
+ const rich = buildRichCandidates(plan, baselineResult, simulateOptions);
273
+ const candidates = rich.map((candidate) => candidate.evaluation);
274
+ const milpRecommended = postProcessed !== null && postProcessed.recommendationSchedule === 'cleaned' ? postProcessed : null;
275
+ const milpDelta = milpRecommended ? milpRecommended.cleanedValidation.afterTaxEstateDelta : 0;
276
+ const guardrailResult = milpRecommended?.cleanedResult ?? baselineResult;
277
+ const guardrailLastsThroughYear = lastsThroughYear(guardrailResult);
278
+ // Rank guardrail-passing candidates by exact estate delta. Search refines the
279
+ // top two (not just the winner): the runner-up can sit in a different basin —
280
+ // e.g. a lower bracket fill whose refined taper beats the raw winner — and
281
+ // coordinate descent cannot cross basins from the winner alone.
282
+ const eligible = rich
283
+ .filter((candidate) => lastsThroughYear(candidate.result) >= guardrailLastsThroughYear)
284
+ .sort((a, b) => b.evaluation.afterTaxEstateDelta - a.evaluation.afterTaxEstateDelta);
285
+ const best = eligible[0] ?? null;
286
+ if (best !== null && best.conversions.length > 0) {
287
+ const winnerValidation = evaluateExactLedgerSchedule(plan, best.conversions, baselineResult, best.result);
288
+ const clearsMilpComparison = milpRecommended
289
+ ? best.evaluation.afterTaxEstateDelta > milpDelta + margin
290
+ : winnerValidation.recommendationState === 'beneficial';
291
+ if (winnerValidation.recommendationState === 'beneficial' && clearsMilpComparison) {
292
+ let winner = {
293
+ candidate: best,
294
+ conversions: best.conversions,
295
+ validation: winnerValidation,
296
+ estateDelta: best.evaluation.afterTaxEstateDelta,
297
+ };
298
+ let searchRefined = false;
299
+ let searchSimulations = 0;
300
+ // Phase 4 refinement: coordinate descent from the top candidates,
301
+ // adopting a result only when the exact ledger prices it beneficial,
302
+ // it improves the winner's estate delta, and the money-lasts guardrail
303
+ // still holds against whatever the winner displaced.
304
+ if (options.search) {
305
+ const ctx = decisionContext(plan, baselineResult, simulateOptions);
306
+ const seeds = eligible.slice(0, 2).filter((candidate) => candidate.conversions.length > 0);
307
+ for (const seed of seeds) {
308
+ const refined = refineConversionSchedule(ctx, seed.conversions, {
309
+ maxSimulations: options.search.maxSimulations,
310
+ });
311
+ searchSimulations += refined.simulationCount;
312
+ if (!refined.improved || lastsThroughYear(refined.bestEvaluation.candidateResult) < guardrailLastsThroughYear)
313
+ continue;
314
+ // Snap to exact-ledger executed amounts so the recommended schedule
315
+ // stays executable-by-construction, like every other winner.
316
+ const executed = refined.bestEvaluation.conversionExecution?.executedByYear ?? refined.bestConversions;
317
+ const refinedValidation = evaluateExactLedgerSchedule(plan, executed, baselineResult, refined.bestEvaluation.candidateResult);
318
+ if (refinedValidation.recommendationState === 'beneficial' &&
319
+ refinedValidation.afterTaxEstateDelta > winner.estateDelta) {
320
+ winner = {
321
+ candidate: seed,
322
+ conversions: executed,
323
+ validation: refinedValidation,
324
+ estateDelta: refinedValidation.afterTaxEstateDelta,
325
+ };
326
+ searchRefined = true;
327
+ }
328
+ }
329
+ }
330
+ // Keep the candidate table consistent with the recommendation: after
331
+ // refinement the winner's row shows the delta its refined schedule
332
+ // actually delivers — otherwise a refined runner-up can display a LOWER
333
+ // delta than a row it beat (the rows are raw evaluations, the winner is
334
+ // post-search).
335
+ const displayCandidates = searchRefined
336
+ ? candidates.map((row) => row.id === winner.candidate.evaluation.id ? { ...row, afterTaxEstateDelta: winner.estateDelta } : row)
337
+ : candidates;
338
+ return {
339
+ policyId: 'max-after-tax-estate',
340
+ candidates: displayCandidates,
341
+ winnerSource: 'candidate',
342
+ winnerCandidateId: winner.candidate.evaluation.id,
343
+ winnerLabel: winner.candidate.evaluation.label,
344
+ winnerConversions: winner.conversions,
345
+ winnerValidation: winner.validation,
346
+ marginOverMilpDollars: milpRecommended ? winner.estateDelta - milpDelta : 0,
347
+ searchRefined,
348
+ searchSimulations,
349
+ };
350
+ }
351
+ }
352
+ if (milpRecommended) {
353
+ // Phase 4 refinement for a MILP winner too (previously only candidate
354
+ // winners were search-polished): coordinate descent from the cleaned
355
+ // schedule, adopted under the same rules — exact-ledger beneficial, a real
356
+ // estate-delta improvement, and the money-lasts guardrail intact.
357
+ let winnerConversions = milpRecommended.cleanedSchedule.conversions;
358
+ let winnerValidation = milpRecommended.cleanedValidation;
359
+ let searchRefined = false;
360
+ let searchSimulations = 0;
361
+ if (options.search && winnerConversions.length > 0) {
362
+ const ctx = decisionContext(plan, baselineResult, simulateOptions);
363
+ const refined = refineConversionSchedule(ctx, winnerConversions, {
364
+ maxSimulations: options.search.maxSimulations,
365
+ });
366
+ searchSimulations = refined.simulationCount;
367
+ if (refined.improved && lastsThroughYear(refined.bestEvaluation.candidateResult) >= guardrailLastsThroughYear) {
368
+ const executed = refined.bestEvaluation.conversionExecution?.executedByYear ?? refined.bestConversions;
369
+ const refinedValidation = evaluateExactLedgerSchedule(plan, executed, baselineResult, refined.bestEvaluation.candidateResult);
370
+ if (refinedValidation.recommendationState === 'beneficial' &&
371
+ refinedValidation.afterTaxEstateDelta > milpRecommended.cleanedValidation.afterTaxEstateDelta) {
372
+ winnerConversions = executed;
373
+ winnerValidation = refinedValidation;
374
+ searchRefined = true;
375
+ }
376
+ }
377
+ }
378
+ return {
379
+ policyId: 'max-after-tax-estate',
380
+ candidates,
381
+ winnerSource: 'milp',
382
+ winnerCandidateId: null,
383
+ winnerLabel: null,
384
+ winnerConversions,
385
+ winnerValidation,
386
+ marginOverMilpDollars: 0,
387
+ searchRefined,
388
+ searchSimulations,
389
+ };
390
+ }
391
+ return fallbackTournament(plan, baselineResult, candidates, 'max-after-tax-estate');
392
+ }
393
+ /** Shared incumbent/none fallback when nothing evaluated beats the current plan. */
394
+ function fallbackTournament(plan, baselineResult, candidates, policyId) {
395
+ const incumbent = incumbentExecutedConversions(plan, baselineResult);
396
+ if (incumbent) {
397
+ return {
398
+ policyId,
399
+ candidates,
400
+ winnerSource: 'incumbent',
401
+ winnerCandidateId: null,
402
+ winnerLabel: incumbentLabel(plan),
403
+ winnerConversions: incumbent,
404
+ winnerValidation: null,
405
+ marginOverMilpDollars: 0,
406
+ searchRefined: false,
407
+ searchSimulations: 0,
408
+ };
409
+ }
410
+ return {
411
+ policyId,
412
+ candidates,
413
+ winnerSource: 'none',
414
+ winnerCandidateId: null,
415
+ winnerLabel: null,
416
+ winnerConversions: [],
417
+ winnerValidation: null,
418
+ marginOverMilpDollars: 0,
419
+ searchRefined: false,
420
+ searchSimulations: 0,
421
+ };
422
+ }
423
+ /**
424
+ * Policy-ranked tournament variant (sustainable-spending plan, Step 5): under
425
+ * a non-default objective, the simple candidates and the post-processed MILP
426
+ * schedule are ranked *together* by the shared `rankEvaluations` — the same
427
+ * ranker every other decision surface uses — under the policy's own primary
428
+ * metric and hard constraints, instead of the default estate-delta
429
+ * arbitration. No new simulations: every evaluation being ranked was already
430
+ * produced by the exact ledger. Phase 4 search refinement is skipped here —
431
+ * it climbs the estate objective specifically.
432
+ */
433
+ function runPolicyRankedTournament(plan, baselineResult, postProcessed, simulateOptions, policy) {
434
+ const ctx = decisionContext(plan, baselineResult, simulateOptions);
435
+ const rich = buildRichCandidates(plan, baselineResult, simulateOptions);
436
+ const candidates = rich.map((candidate) => candidate.evaluation);
437
+ const milpRecommended = postProcessed !== null && postProcessed.recommendationSchedule === 'cleaned' ? postProcessed : null;
438
+ const evaluations = rich.map((candidate) => candidate.fullEvaluation);
439
+ let milpEvaluation = null;
440
+ if (milpRecommended) {
441
+ // Synthesize the MILP schedule's evaluation from post-processing artifacts
442
+ // (its exact run and deltas already exist) so it competes in the same
443
+ // ranking rather than holding a privileged slot.
444
+ milpEvaluation = {
445
+ candidate: {
446
+ id: 'milp-cleaned-schedule',
447
+ source: 'milp',
448
+ category: 'roth',
449
+ label: "the solver's cleaned schedule",
450
+ explanation: 'Post-processed MILP schedule re-ranked under the selected objective.',
451
+ conversions: milpRecommended.cleanedSchedule.conversions,
452
+ },
453
+ baselineSummary: ctx.baselineSummary,
454
+ candidateSummary: summarizeProjection(plan, milpRecommended.cleanedResult),
455
+ candidateResult: milpRecommended.cleanedResult,
456
+ deltas: {
457
+ endingAfterTaxEstate: milpRecommended.cleanedValidation.afterTaxEstateDelta,
458
+ endingNetWorth: milpRecommended.cleanedValidation.endingNetWorthDelta,
459
+ lifetimeTax: milpRecommended.cleanedValidation.lifetimeTaxDelta,
460
+ moneyLastsYears: milpRecommended.cleanedValidation.moneyLastsYearsDelta,
461
+ },
462
+ conversionExecution: null,
463
+ traditionalDepletionYear: null,
464
+ diagnostics: [],
465
+ recommendationState: milpRecommended.cleanedValidation.recommendationState === 'beneficial' ? 'beneficial' : 'neutral',
466
+ };
467
+ evaluations.push(milpEvaluation);
468
+ }
469
+ if (policy.id === 'max-downside-resilience') {
470
+ attachStochasticMetrics(ctx, evaluations, {
471
+ startYear: simulateOptions.startYear,
472
+ taxCalculator: simulateOptions.taxCalculator,
473
+ model: buildLognormalModelConfigForPlan(plan, 12),
474
+ seed: 0x5eeded,
475
+ pathCount: 200,
476
+ });
477
+ }
478
+ // Dollar-scale metrics keep the $1 default bar; the years-scale durability
479
+ // metric clears at any whole-year improvement.
480
+ const minimumImprovement = policy.id === 'max-spending-durability' ? 0.5 : 1;
481
+ const { winner } = rankEvaluations(evaluations, ctx, policy, minimumImprovement);
482
+ if (winner && milpEvaluation && winner.evaluation === milpEvaluation && milpRecommended) {
483
+ return {
484
+ policyId: policy.id,
485
+ candidates,
486
+ winnerSource: 'milp',
487
+ winnerCandidateId: null,
488
+ winnerLabel: null,
489
+ winnerConversions: milpRecommended.cleanedSchedule.conversions,
490
+ winnerValidation: milpRecommended.cleanedValidation,
491
+ marginOverMilpDollars: 0,
492
+ searchRefined: false,
493
+ searchSimulations: 0,
494
+ };
495
+ }
496
+ if (winner) {
497
+ const richWinner = rich.find((candidate) => candidate.fullEvaluation === winner.evaluation);
498
+ if (richWinner && richWinner.conversions.length > 0) {
499
+ const winnerValidation = evaluateExactLedgerSchedule(plan, richWinner.conversions, baselineResult, richWinner.result);
500
+ return {
501
+ policyId: policy.id,
502
+ candidates,
503
+ winnerSource: 'candidate',
504
+ winnerCandidateId: richWinner.evaluation.id,
505
+ winnerLabel: richWinner.evaluation.label,
506
+ winnerConversions: richWinner.conversions,
507
+ winnerValidation,
508
+ marginOverMilpDollars: milpRecommended
509
+ ? richWinner.evaluation.afterTaxEstateDelta - milpRecommended.cleanedValidation.afterTaxEstateDelta
510
+ : 0,
511
+ searchRefined: false,
512
+ searchSimulations: 0,
513
+ };
514
+ }
515
+ }
516
+ return fallbackTournament(plan, baselineResult, candidates, policy.id);
517
+ }
518
+ // Shared with the decision engine so every surface classifies schedules identically.
519
+ const DEFAULT_NEUTRAL_TOLERANCE_DOLLARS = DECISION_NEUTRAL_TOLERANCE_DOLLARS;
520
+ const DEFAULT_MINIMUM_REQUESTED_CONVERSION_DOLLARS = DECISION_MINIMUM_REQUESTED_CONVERSION_DOLLARS;
521
+ const DEFAULT_MAX_POST_PROCESSING_ITERATIONS = 3;
522
+ // Each prune candidate costs one exact simulatePlan run (single-digit ms on
523
+ // test plans, low tens of ms on long-horizon plans), so the default bound keeps
524
+ // worst-case prune overhead in the same ballpark as the solver itself.
525
+ const DEFAULT_MAX_PRUNE_ITERATIONS = 24;
526
+ function aggregateConversions(conversions) {
527
+ const byYear = new Map();
528
+ for (const conversion of conversions) {
529
+ byYear.set(conversion.year, (byYear.get(conversion.year) ?? 0) + conversion.amount);
530
+ }
531
+ return byYear;
532
+ }
533
+ function roundDollars(amount) {
534
+ return Math.round(amount * 100) / 100;
535
+ }
536
+ function conversionsFromYearMap(byYear, toleranceDollars) {
537
+ return [...byYear.entries()]
538
+ .filter(([, amount]) => amount > toleranceDollars)
539
+ .sort(([a], [b]) => a - b)
540
+ .map(([year, amount]) => ({ year, amount: roundDollars(amount) }));
541
+ }
542
+ function scheduleWithConversions(schedule, conversions) {
543
+ const byYear = aggregateConversions(conversions);
544
+ return {
545
+ ...schedule,
546
+ conversions,
547
+ schedule: schedule.schedule.map((year) => ({
548
+ ...year,
549
+ conversion: roundDollars(byYear.get(year.year) ?? 0),
550
+ })),
551
+ };
552
+ }
553
+ /**
554
+ * Compare a proposed conversion schedule with the exact projection ledger.
555
+ *
556
+ * The solver can request conversions that the ledger later caps because
557
+ * spending, RMDs, or prior conversions drained the own traditional balance.
558
+ * Since the decision engine landed this is a thin adapter over the shared
559
+ * `evaluateCandidate` core (Phase 1): the exact candidate run stays
560
+ * authoritative, classified by after-tax estate first, with execution mismatch
561
+ * as a separate blocking state ('diagnostic' in engine terms, 'unexecutable'
562
+ * here for the optimizer UI contract).
563
+ */
564
+ export function evaluateExactLedgerSchedule(plan, requestedConversions, baselineResult, candidateResult, options = {}) {
565
+ // Both projections are precomputed, so the context never simulates; the
566
+ // throwing calculator makes any accidental re-simulation loud.
567
+ const ctx = {
568
+ plan,
569
+ baselineResult,
570
+ baselineSummary: summarizeProjection(plan, baselineResult),
571
+ simulateOptions: {
572
+ startYear: baselineResult.startYear,
573
+ taxCalculator: () => {
574
+ throw new Error('evaluateExactLedgerSchedule reuses precomputed projections and never simulates');
575
+ },
576
+ },
577
+ };
578
+ const evaluation = evaluateCandidate(ctx, {
579
+ id: 'exact-ledger-schedule',
580
+ source: 'milp',
581
+ category: 'roth',
582
+ label: 'Conversion schedule validation',
583
+ explanation: 'Requested conversion schedule compared with the exact ledger.',
584
+ conversions: requestedConversions,
585
+ }, { ...options, candidateResult });
586
+ const execution = evaluation.conversionExecution;
587
+ return {
588
+ baseline: evaluation.baselineSummary,
589
+ candidate: evaluation.candidateSummary,
590
+ afterTaxEstateDelta: evaluation.deltas.endingAfterTaxEstate,
591
+ endingNetWorthDelta: evaluation.deltas.endingNetWorth,
592
+ lifetimeTaxDelta: evaluation.deltas.lifetimeTax,
593
+ moneyLastsYearsDelta: evaluation.deltas.moneyLastsYears,
594
+ requestedConversionTotal: execution.requestedTotal,
595
+ executedConversionTotal: execution.executedTotal,
596
+ executedConversionRatio: execution.executedRatio,
597
+ firstMateriallyUnexecutedYear: execution.firstMateriallyUnexecutedYear,
598
+ traditionalDepletionYear: evaluation.traditionalDepletionYear,
599
+ recommendationState: evaluation.recommendationState === 'diagnostic' ? 'unexecutable' : evaluation.recommendationState,
600
+ };
601
+ }
602
+ function conversionsMatch(requested, executedByYear, toleranceDollars) {
603
+ const requestedByYear = aggregateConversions(requested);
604
+ const years = new Set([...requestedByYear.keys(), ...executedByYear.keys()]);
605
+ for (const year of years) {
606
+ const requestedAmount = requestedByYear.get(year) ?? 0;
607
+ const executedAmount = executedByYear.get(year) ?? 0;
608
+ if (Math.abs(requestedAmount - executedAmount) > toleranceDollars)
609
+ return false;
610
+ }
611
+ return true;
612
+ }
613
+ function buildCleanedConversionsFromExecution(requestedConversions, candidateResult, toleranceDollars) {
614
+ const requestedByYear = aggregateConversions(requestedConversions);
615
+ const executedByYear = new Map(candidateResult.years.map((year) => [year.year, year.rothConversion]));
616
+ const cleanedByYear = new Map();
617
+ const adjustments = [];
618
+ for (const [year, requested] of [...requestedByYear.entries()].sort(([a], [b]) => a - b)) {
619
+ const executed = Math.max(0, executedByYear.get(year) ?? 0);
620
+ const cleaned = executed > toleranceDollars ? Math.min(requested, executed) : 0;
621
+ if (cleaned > toleranceDollars)
622
+ cleanedByYear.set(year, cleaned);
623
+ const roundedRequested = roundDollars(requested);
624
+ const roundedExecuted = roundDollars(executed);
625
+ const roundedCleaned = roundDollars(cleaned);
626
+ const hasMaterialDifference = Math.abs(roundedRequested - roundedCleaned) > toleranceDollars;
627
+ if (!hasMaterialDifference)
628
+ continue;
629
+ adjustments.push({
630
+ year,
631
+ requested: roundedRequested,
632
+ executed: roundedExecuted,
633
+ cleaned: roundedCleaned,
634
+ reason: roundedCleaned <= toleranceDollars ? 'dropped-zero' : 'ledger-capped',
635
+ });
636
+ }
637
+ return { conversions: conversionsFromYearMap(cleanedByYear, toleranceDollars), adjustments };
638
+ }
639
+ /**
640
+ * Turn a raw optimizer schedule into the schedule the exact ledger can execute.
641
+ *
642
+ * The MILP is useful as a candidate generator, but only the ledger knows the
643
+ * real account balances, withdrawals, taxes, ACA/IRMAA effects, and conversion
644
+ * caps. This pass trims raw requested conversions to exact execution, reruns the
645
+ * ledger, and returns the cleaned schedule plus diagnostics.
646
+ */
647
+ export function postProcessExactLedgerSchedule(plan, rawSchedule, baselineResult, simulateOptions, options = {}) {
648
+ const neutralToleranceDollars = options.neutralToleranceDollars ?? DEFAULT_NEUTRAL_TOLERANCE_DOLLARS;
649
+ const maxIterations = options.maxIterations ?? DEFAULT_MAX_POST_PROCESSING_ITERATIONS;
650
+ const rawPlan = withOptimizedConversions(plan, rawSchedule.conversions);
651
+ const rawResult = simulatePlan(rawPlan, simulateOptions);
652
+ const rawValidation = evaluateExactLedgerSchedule(plan, rawSchedule.conversions, baselineResult, rawResult, options);
653
+ let currentConversions = rawSchedule.conversions;
654
+ let currentResult = rawResult;
655
+ const adjustmentsByYear = new Map();
656
+ let stabilized = false;
657
+ let iterationCount = 0;
658
+ for (let i = 0; i < maxIterations; i++) {
659
+ iterationCount = i + 1;
660
+ const { conversions, adjustments } = buildCleanedConversionsFromExecution(currentConversions, currentResult, neutralToleranceDollars);
661
+ for (const adjustment of adjustments)
662
+ adjustmentsByYear.set(adjustment.year, adjustment);
663
+ const executedByYear = new Map(currentResult.years.map((year) => [year.year, year.rothConversion]));
664
+ if (conversionsMatch(currentConversions, executedByYear, neutralToleranceDollars)) {
665
+ currentConversions = conversions;
666
+ stabilized = true;
667
+ break;
668
+ }
669
+ currentConversions = conversions;
670
+ const cleanedPlan = withOptimizedConversions(plan, currentConversions);
671
+ currentResult = simulatePlan(cleanedPlan, simulateOptions);
672
+ }
673
+ let cleanedSchedule = scheduleWithConversions(rawSchedule, currentConversions);
674
+ let cleanedResult = simulatePlan(withOptimizedConversions(plan, cleanedSchedule.conversions), simulateOptions);
675
+ let cleanedValidation = evaluateExactLedgerSchedule(plan, cleanedSchedule.conversions, baselineResult, cleanedResult, options);
676
+ // Pass 3 – estate-preserving prune. A schedule can be fully executable yet
677
+ // still lower the exact after-tax estate because its late conversions land in
678
+ // years where they only add tax (RMD relief already gone, brackets already
679
+ // full, IRMAA triggered). Dropping trailing years never changes earlier
680
+ // years' execution — flows are chronological — so each candidate stays
681
+ // executable by construction and costs exactly one exact-ledger run. The
682
+ // best candidate is adopted only when the exact ledger prices it beneficial;
683
+ // otherwise the original cleaned schedule and its rejected/neutral state
684
+ // stand, per the plan rule that pruning repairs overshoot but never rescues
685
+ // a schedule the ledger says is harmful throughout.
686
+ let pruneIterationCount = 0;
687
+ if (stabilized &&
688
+ cleanedSchedule.conversions.length > 1 &&
689
+ (cleanedValidation.recommendationState === 'rejected' || cleanedValidation.recommendationState === 'neutral')) {
690
+ const maxPruneIterations = options.maxPruneIterations ?? DEFAULT_MAX_PRUNE_ITERATIONS;
691
+ const rawRequestedByYear = aggregateConversions(rawSchedule.conversions);
692
+ let best = null;
693
+ let candidate = cleanedSchedule.conversions;
694
+ while (candidate.length > 1 && pruneIterationCount < maxPruneIterations) {
695
+ candidate = candidate.slice(0, -1);
696
+ pruneIterationCount++;
697
+ const prunedResult = simulatePlan(withOptimizedConversions(plan, candidate), simulateOptions);
698
+ const prunedValidation = evaluateExactLedgerSchedule(plan, candidate, baselineResult, prunedResult, options);
699
+ const bestDelta = best?.validation.afterTaxEstateDelta ?? cleanedValidation.afterTaxEstateDelta;
700
+ if (prunedValidation.afterTaxEstateDelta > bestDelta) {
701
+ best = { conversions: candidate, result: prunedResult, validation: prunedValidation };
702
+ }
703
+ }
704
+ if (best !== null && best.validation.recommendationState === 'beneficial') {
705
+ const keptYears = new Set(best.conversions.map((conversion) => conversion.year));
706
+ for (const conversion of cleanedSchedule.conversions) {
707
+ if (keptYears.has(conversion.year))
708
+ continue;
709
+ adjustmentsByYear.set(conversion.year, {
710
+ year: conversion.year,
711
+ requested: roundDollars(rawRequestedByYear.get(conversion.year) ?? conversion.amount),
712
+ executed: roundDollars(conversion.amount),
713
+ cleaned: 0,
714
+ reason: 'estate-pruned',
715
+ });
716
+ }
717
+ cleanedSchedule = scheduleWithConversions(rawSchedule, best.conversions);
718
+ cleanedResult = best.result;
719
+ cleanedValidation = best.validation;
720
+ }
721
+ }
722
+ const minimumRequestedConversionDollars = options.minimumRequestedConversionDollars ?? DEFAULT_MINIMUM_REQUESTED_CONVERSION_DOLLARS;
723
+ const cleanedConversionTotal = cleanedSchedule.conversions.reduce((sum, conversion) => sum + conversion.amount, 0);
724
+ const recommendationSchedule = stabilized &&
725
+ cleanedConversionTotal >= minimumRequestedConversionDollars &&
726
+ cleanedValidation.recommendationState !== 'rejected' &&
727
+ cleanedValidation.recommendationState !== 'unexecutable'
728
+ ? 'cleaned'
729
+ : 'none';
730
+ return {
731
+ rawSchedule,
732
+ cleanedSchedule,
733
+ rawValidation,
734
+ cleanedValidation,
735
+ rawResult,
736
+ cleanedResult,
737
+ adjustments: [...adjustmentsByYear.values()].sort((a, b) => a.year - b.year),
738
+ stabilized,
739
+ iterationCount,
740
+ pruneIterationCount,
741
+ recommendationSchedule,
742
+ };
743
+ }
744
+ // Exact-ledger convergence loop defaults (Step 1). The cap bounds worst-case
745
+ // runtime at cap × (solve + a couple of sims); 4 outer solves is ample on the
746
+ // fixture matrix (taxable-SS/IRMAA feedback settles in 2–3 re-linearizations).
747
+ const DEFAULT_CONVERGENCE_ITERATIONS = 4;
748
+ const DEFAULT_CONVERGENCE_SCHEDULE_TOLERANCE_DOLLARS = 250;
749
+ const DEFAULT_CONVERGENCE_OBJECTIVE_TOLERANCE_DOLLARS = 100;
750
+ const DEFAULT_CONVERGENCE_MAX_YEAR_STEP_DOLLARS = 250_000;
751
+ /**
752
+ * Effective outer-iteration cap: the configured value floored to an integer and
753
+ * bounded below at 1. Shared by the enable guard and the loop so a fractional
754
+ * cap (e.g. 1.5 → 1) can't pass the guard yet run zero iterations.
755
+ */
756
+ function normalizedConvergenceIterations(cfg) {
757
+ const raw = cfg.maxIterations ?? DEFAULT_CONVERGENCE_ITERATIONS;
758
+ return Number.isFinite(raw) ? Math.max(1, Math.floor(raw)) : DEFAULT_CONVERGENCE_ITERATIONS;
759
+ }
760
+ /**
761
+ * Price a requested conversion schedule on the exact ledger and snap it to the
762
+ * amounts the ledger actually executed. The ledger silently caps requests that
763
+ * exceed the available traditional balance, so a raw request and its executed
764
+ * schedule can differ wildly while pricing identically — any loop that adopts
765
+ * raw requests can therefore inflate them for free and drift into
766
+ * unexecutable-junk schedules (they price fine, then post-process terribly).
767
+ * Snapping keeps every adopted step executable-by-construction, the same
768
+ * discipline the post-processor and tournament use.
769
+ */
770
+ function priceExecutedSchedule(plan, requestedConversions, simulateOptions) {
771
+ const withConversions = withOptimizedConversions(plan, requestedConversions);
772
+ const result = simulatePlan(withConversions, simulateOptions);
773
+ return {
774
+ estate: summarizeProjection(withConversions, result).endingAfterTaxEstate,
775
+ executed: result.years
776
+ .filter((year) => year.rothConversion > 1)
777
+ .map((year) => ({ year: year.year, amount: roundDollars(year.rothConversion) })),
778
+ };
779
+ }
780
+ /** Largest absolute per-year conversion difference between two schedules. */
781
+ function maxYearConversionMove(a, b) {
782
+ const byYearA = aggregateConversions(a);
783
+ const byYearB = aggregateConversions(b);
784
+ let max = 0;
785
+ for (const year of new Set([...byYearA.keys(), ...byYearB.keys()])) {
786
+ max = Math.max(max, Math.abs((byYearA.get(year) ?? 0) - (byYearB.get(year) ?? 0)));
787
+ }
788
+ return max;
789
+ }
790
+ /**
791
+ * SLP trust-region step: move each year's conversion toward the freshly-solved
792
+ * value by `dampingFactor`, then clamp the change to `maxYearStepDollars`. This
793
+ * damps the oscillation SLP can otherwise show when a big conversion swings the
794
+ * next iteration's taxable-SS/IRMAA recapture the other way.
795
+ */
796
+ function dampConvergenceStep(previous, solved, cfg) {
797
+ // Sanitize the tuning knobs: they can arrive over the worker boundary, and a
798
+ // NaN/Infinity/negative value would break the clamp (a negative step limit
799
+ // flips it and forces movement). Damping is a fraction in [0, 1]; the step
800
+ // limit is a finite non-negative dollar bound.
801
+ const rawDamping = cfg.dampingFactor ?? 1;
802
+ const damping = Number.isFinite(rawDamping) ? Math.min(1, Math.max(0, rawDamping)) : 1;
803
+ const rawStepLimit = cfg.maxYearStepDollars ?? DEFAULT_CONVERGENCE_MAX_YEAR_STEP_DOLLARS;
804
+ const stepLimit = Number.isFinite(rawStepLimit) && rawStepLimit >= 0 ? rawStepLimit : DEFAULT_CONVERGENCE_MAX_YEAR_STEP_DOLLARS;
805
+ const previousByYear = aggregateConversions(previous);
806
+ const solvedByYear = aggregateConversions(solved);
807
+ const out = [];
808
+ for (const year of [...new Set([...previousByYear.keys(), ...solvedByYear.keys()])].sort((x, y) => x - y)) {
809
+ const prev = previousByYear.get(year) ?? 0;
810
+ const target = solvedByYear.get(year) ?? 0;
811
+ const dampedDelta = damping * (target - prev);
812
+ const clampedDelta = Math.max(-stepLimit, Math.min(stepLimit, dampedDelta));
813
+ const next = roundDollars(Math.max(0, prev + clampedDelta));
814
+ if (next > 0)
815
+ out.push({ year, amount: next });
816
+ }
817
+ return out;
818
+ }
819
+ /**
820
+ * Exact-ledger convergence loop (optimizer-exact-ledger-convergence Track 1,
821
+ * Step 1). Re-solve the MILP
822
+ * against exogenous inputs recaptured from the exact-ledger run of the incumbent
823
+ * schedule, iterating until the schedule and its exact after-tax estate settle
824
+ * within tolerance (or a hard iteration cap). Each accepted step must not lower
825
+ * the exact-ledger estate — the real ledger, not the LP's own number, is the
826
+ * monotone guard — so the loop can only sharpen the recommendation, never
827
+ * degrade it, and the tournament still prices/gates whatever it produces.
828
+ */
829
+ async function convergeSchedule(plan, opts, firstSolve, firstInput, cfg, simulateOptions) {
830
+ const maxIterations = normalizedConvergenceIterations(cfg);
831
+ const scheduleTolerance = cfg.scheduleToleranceDollars ?? DEFAULT_CONVERGENCE_SCHEDULE_TOLERANCE_DOLLARS;
832
+ const objectiveTolerance = cfg.objectiveToleranceDollars ?? DEFAULT_CONVERGENCE_OBJECTIVE_TOLERANCE_DOLLARS;
833
+ // Snap the incumbent to executed amounts up front: the loop's internal state
834
+ // must live in executed space, or the LP can inflate raw requests for free
835
+ // (the ledger caps them silently) and drift into schedules that price fine
836
+ // but post-process terribly. The *returned* schedule stays the untouched
837
+ // first solve until a step is actually adopted.
838
+ const first = priceExecutedSchedule(plan, firstSolve.conversions, simulateOptions);
839
+ const firstEstate = first.estate;
840
+ let schedule = firstSolve;
841
+ let input = firstInput; // anchors to the first solve until the loop adopts a better step
842
+ let currentConversions = first.executed;
843
+ let currentEstate = firstEstate;
844
+ let iterations = 1;
845
+ let converged = false;
846
+ let finalMove = 0;
847
+ for (let i = 1; i < maxIterations; i++) {
848
+ // Re-linearize around the incumbent schedule: recapture taxable SS, IRMAA/
849
+ // ACA-priced spending, and RMD divisors at the schedule the ledger actually
850
+ // executes, then re-solve.
851
+ const incumbentPlan = withOptimizedConversions(plan, currentConversions);
852
+ const reInput = buildOptimizerInput(plan, opts, incumbentPlan);
853
+ const solved = await optimizeSchedule(reInput);
854
+ iterations = i + 1;
855
+ if (solved.status === 'infeasible')
856
+ break;
857
+ const damped = dampConvergenceStep(currentConversions, solved.conversions, cfg);
858
+ const candidate = priceExecutedSchedule(plan, damped, simulateOptions);
859
+ // Monotone guard: never adopt a step the exact ledger prices worse than the
860
+ // incumbent. A regression means the linearization overshot; stop and keep
861
+ // the best schedule so far (bounded, deterministic, never harmful).
862
+ if (candidate.estate + 0.01 < currentEstate)
863
+ break;
864
+ const move = maxYearConversionMove(currentConversions, candidate.executed);
865
+ const improvement = candidate.estate - currentEstate;
866
+ schedule = scheduleWithConversions(solved, candidate.executed);
867
+ input = reInput;
868
+ currentConversions = candidate.executed;
869
+ currentEstate = candidate.estate;
870
+ finalMove = move;
871
+ if (move <= scheduleTolerance || improvement <= objectiveTolerance) {
872
+ converged = true;
873
+ break;
874
+ }
875
+ }
876
+ return {
877
+ schedule,
878
+ input,
879
+ diagnostics: {
880
+ enabled: true,
881
+ iterations,
882
+ converged,
883
+ estateGainOverFirstSolveDollars: roundDollars(currentEstate - firstEstate),
884
+ finalMaxYearMoveDollars: roundDollars(finalMove),
885
+ },
886
+ };
887
+ }
888
+ const DISABLED_CONVERGENCE = {
889
+ enabled: false,
890
+ iterations: 1,
891
+ converged: false,
892
+ estateGainOverFirstSolveDollars: 0,
893
+ finalMaxYearMoveDollars: 0,
894
+ };
895
+ /** Run the optimizer end-to-end on a plan: MILP → (convergence loop) → post-processing → tournament. */
896
+ export async function optimizePlan(plan, opts) {
897
+ const simulateOptions = { startYear: opts.startYear, taxCalculator: opts.taxCalculator };
898
+ let input = buildOptimizerInput(plan, opts);
899
+ let schedule = await optimizeSchedule(input);
900
+ // Exact-ledger convergence loop (Step 1). Only runs when explicitly enabled
901
+ // with a cap above 1 and the first solve produced an actionable schedule; the
902
+ // default path is a single solve, byte-identical to the pre-loop behavior.
903
+ //
904
+ // Gated to the after-tax-estate objective: the loop's monotone guard adopts
905
+ // steps by exact after-tax estate (the MILP's own objective), so under a
906
+ // non-default policy — where the tournament re-ranks candidates by lifetime
907
+ // tax, durability, etc. — an estate-improving re-linearization could displace
908
+ // the schedule that policy would have preferred (the policy-ranked tournament
909
+ // only sees the converged MILP schedule, not the first solve). Co-optimizing
910
+ // the loop against non-estate policies is Step 5 work; until then, non-estate
911
+ // objectives keep today's single-solve behavior exactly.
912
+ const policyIsEstateObjective = !opts.policy || opts.policy.id === 'max-after-tax-estate';
913
+ const convergenceCfg = opts.convergence && policyIsEstateObjective ? opts.convergence : null;
914
+ let convergence = DISABLED_CONVERGENCE;
915
+ const firstSchedule = schedule;
916
+ const firstInput = input;
917
+ if (convergenceCfg &&
918
+ normalizedConvergenceIterations(convergenceCfg) > 1 &&
919
+ schedule.status !== 'infeasible' &&
920
+ schedule.conversions.length > 0) {
921
+ const converged = await convergeSchedule(plan, opts, schedule, input, convergenceCfg, simulateOptions);
922
+ schedule = converged.schedule;
923
+ input = converged.input;
924
+ convergence = converged.diagnostics;
925
+ }
926
+ const baselineResult = simulatePlan(plan, simulateOptions);
927
+ let postProcessed = schedule.status !== 'infeasible' && schedule.conversions.length > 0
928
+ ? postProcessExactLedgerSchedule(plan, schedule, baselineResult, simulateOptions)
929
+ : null;
930
+ // Pipeline-level monotone guard: the loop's step guard is raw-schedule-vs-
931
+ // raw-schedule, but the pipeline's true incumbent is the *post-processed*
932
+ // first solve — trim + estate-prune can rescue a bad raw schedule into a
933
+ // better cleaned one than the converged raw schedule cleans to (SLP converges
934
+ // to a local optimum; the post-processor explores a different neighborhood).
935
+ // Post-process both and keep whichever cleaned schedule the exact ledger
936
+ // prices higher, so convergence-enabled can never do worse than disabled.
937
+ if (convergence.enabled && schedule !== firstSchedule) {
938
+ const firstPostProcessed = postProcessExactLedgerSchedule(plan, firstSchedule, baselineResult, simulateOptions);
939
+ const convergedDelta = postProcessed && postProcessed.recommendationSchedule === 'cleaned'
940
+ ? postProcessed.cleanedValidation.afterTaxEstateDelta
941
+ : -Infinity;
942
+ const firstDelta = firstPostProcessed.recommendationSchedule === 'cleaned'
943
+ ? firstPostProcessed.cleanedValidation.afterTaxEstateDelta
944
+ : -Infinity;
945
+ if (firstDelta > convergedDelta) {
946
+ schedule = firstSchedule;
947
+ input = firstInput;
948
+ postProcessed = firstPostProcessed;
949
+ convergence = { ...convergence, keptSchedule: 'first-solve' };
950
+ }
951
+ else {
952
+ convergence = { ...convergence, keptSchedule: 'converged' };
953
+ }
954
+ }
955
+ // The tournament runs even when the MILP is infeasible or empty — simple
956
+ // candidates can still surface a beneficial exact-ledger schedule there.
957
+ const tournament = runExactLedgerTournament(plan, baselineResult, postProcessed, simulateOptions, {
958
+ search: opts.search ?? false,
959
+ policy: opts.policy,
960
+ });
961
+ return { schedule, input, postProcessed, tournament, convergence };
962
+ }
963
+ /** A claim candidate must beat the current-claim optimum by this to switch. */
964
+ const DEFAULT_CLAIM_SWITCH_MARGIN_DOLLARS = 1_000;
965
+ /** Exact after-tax estate of a plan run with its tournament-recommended conversions installed. */
966
+ function winnerExactEstate(plan, tournament, simulateOptions) {
967
+ return priceExecutedSchedule(plan, tournament.winnerConversions, simulateOptions).estate;
968
+ }
969
+ /**
970
+ * Co-optimized SS claim age (Step 5). Alternate-minimize: run the full
971
+ * conversion optimum (convergence loop + tournament) at the current claim ages
972
+ * and at each bounded claim candidate from `socialSecurityClaimGenerator`, then
973
+ * keep the (claim, schedule) pair with the best *absolute* exact-ledger after-tax
974
+ * estate. A claim switch must clear a small margin to avoid churn. The grid is
975
+ * bounded (≤ 2 streams × 3 canonical ages), so the cost is a small multiple of a
976
+ * single optimize; the exact ledger prices every pair, so the tournament remains
977
+ * the guardrail for each. Returns the winning plan (current or claim-patched) and
978
+ * its optimizer result plus a diagnostic of the joint decision.
979
+ */
980
+ export async function optimizePlanCoOptimizingClaimAge(plan, opts) {
981
+ const simulateOptions = { startYear: opts.startYear, taxCalculator: opts.taxCalculator };
982
+ const switchMargin = DEFAULT_CLAIM_SWITCH_MARGIN_DOLLARS;
983
+ // Current-claim optimum is the floor every claim candidate must beat.
984
+ const baseResult = await optimizePlan(plan, opts);
985
+ const baseEstate = winnerExactEstate(plan, baseResult.tournament, simulateOptions);
986
+ const ctx = decisionContext(plan, simulatePlan(plan, simulateOptions), simulateOptions);
987
+ const candidates = socialSecurityClaimGenerator.generate(ctx);
988
+ let bestPlan = plan;
989
+ let bestResult = baseResult;
990
+ let bestEstate = baseEstate;
991
+ let winningLabel = null;
992
+ let winningPatch = null;
993
+ let evaluated = 1;
994
+ for (const candidate of candidates) {
995
+ if (!candidate.planPatch)
996
+ continue;
997
+ const applied = applyScenarioPatch(plan, candidate.planPatch);
998
+ if (!applied.ok)
999
+ continue;
1000
+ const patchedPlan = applied.plan;
1001
+ const result = await optimizePlan(patchedPlan, opts);
1002
+ const estate = winnerExactEstate(patchedPlan, result.tournament, simulateOptions);
1003
+ evaluated++;
1004
+ // The churn margin is charged against the FIXED current-claim floor, not
1005
+ // the running best — otherwise the winner is generator-order-dependent (a
1006
+ // slightly-worse candidate adopted first could lock out the true optimum
1007
+ // by raising the bar). Among margin-clearing candidates, strictly best wins.
1008
+ if (estate > baseEstate + switchMargin && estate > bestEstate) {
1009
+ bestPlan = patchedPlan;
1010
+ bestResult = result;
1011
+ bestEstate = estate;
1012
+ winningLabel = candidate.label;
1013
+ winningPatch = { incomes: patchedPlan.incomes };
1014
+ }
1015
+ }
1016
+ return {
1017
+ ...bestResult,
1018
+ optimizedPlan: bestPlan,
1019
+ claimAge: {
1020
+ enabled: true,
1021
+ combinationsEvaluated: evaluated,
1022
+ winningClaimLabel: winningLabel,
1023
+ winningClaimPatch: winningPatch,
1024
+ jointExactEstate: bestEstate,
1025
+ currentClaimExactEstate: baseEstate,
1026
+ },
1027
+ };
1028
+ }
1029
+ /**
1030
+ * Return a copy of the plan with the optimizer's conversions installed as an
1031
+ * `optimized` strategy. The UI's "accept as manual" simply rewrites the same
1032
+ * conversions under the `manual` mode.
1033
+ */
1034
+ export function withOptimizedConversions(plan, conversions, optimizedAtIso) {
1035
+ return {
1036
+ ...plan,
1037
+ strategies: { ...plan.strategies, rothConversion: { mode: 'optimized', conversions, optimizedAtIso } },
1038
+ };
1039
+ }