@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.
- package/LICENSE +661 -0
- package/README.md +79 -0
- package/dist/allocation/assetClasses.d.ts +94 -0
- package/dist/allocation/assetClasses.js +215 -0
- package/dist/decisions/annuitization.d.ts +72 -0
- package/dist/decisions/annuitization.js +184 -0
- package/dist/decisions/decisionFixtures.d.ts +48 -0
- package/dist/decisions/decisionFixtures.js +253 -0
- package/dist/decisions/evaluateCandidate.d.ts +61 -0
- package/dist/decisions/evaluateCandidate.js +193 -0
- package/dist/decisions/generators.d.ts +110 -0
- package/dist/decisions/generators.js +667 -0
- package/dist/decisions/index.d.ts +17 -0
- package/dist/decisions/index.js +17 -0
- package/dist/decisions/insightsAdapter.d.ts +27 -0
- package/dist/decisions/insightsAdapter.js +51 -0
- package/dist/decisions/objectives.d.ts +96 -0
- package/dist/decisions/objectives.js +319 -0
- package/dist/decisions/pensionElection.d.ts +112 -0
- package/dist/decisions/pensionElection.js +239 -0
- package/dist/decisions/search.d.ts +51 -0
- package/dist/decisions/search.js +110 -0
- package/dist/decisions/spendingSolver.d.ts +70 -0
- package/dist/decisions/spendingSolver.js +180 -0
- package/dist/decisions/spiaQuotes.d.ts +21 -0
- package/dist/decisions/spiaQuotes.js +43 -0
- package/dist/decisions/stochastic.d.ts +11 -0
- package/dist/decisions/stochastic.js +66 -0
- package/dist/decisions/swrComparator.d.ts +54 -0
- package/dist/decisions/swrComparator.js +89 -0
- package/dist/decisions/tournament.d.ts +53 -0
- package/dist/decisions/tournament.js +104 -0
- package/dist/decisions/types.d.ts +125 -0
- package/dist/decisions/types.js +12 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/insights/detectors/annuitizationHeadroom.d.ts +11 -0
- package/dist/insights/detectors/annuitizationHeadroom.js +77 -0
- package/dist/insights/detectors/assetLocation.d.ts +9 -0
- package/dist/insights/detectors/assetLocation.js +86 -0
- package/dist/insights/detectors/hecmBufferCandidate.d.ts +12 -0
- package/dist/insights/detectors/hecmBufferCandidate.js +76 -0
- package/dist/insights/detectors/incomeFloorFunded.d.ts +8 -0
- package/dist/insights/detectors/incomeFloorFunded.js +52 -0
- package/dist/insights/detectors/irmaaTierEdge.d.ts +2 -0
- package/dist/insights/detectors/irmaaTierEdge.js +102 -0
- package/dist/insights/detectors/pensionElectionPending.d.ts +11 -0
- package/dist/insights/detectors/pensionElectionPending.js +62 -0
- package/dist/insights/detectors/qcdEfficiency.d.ts +2 -0
- package/dist/insights/detectors/qcdEfficiency.js +62 -0
- package/dist/insights/detectors/rothBridgeHeadroom.d.ts +2 -0
- package/dist/insights/detectors/rothBridgeHeadroom.js +77 -0
- package/dist/insights/detectors/spendingGuardrails.d.ts +2 -0
- package/dist/insights/detectors/spendingGuardrails.js +77 -0
- package/dist/insights/detectors/spendingHeadroom.d.ts +12 -0
- package/dist/insights/detectors/spendingHeadroom.js +92 -0
- package/dist/insights/detectors/ssBridgeGap.d.ts +9 -0
- package/dist/insights/detectors/ssBridgeGap.js +92 -0
- package/dist/insights/detectors/stateRelocation.d.ts +12 -0
- package/dist/insights/detectors/stateRelocation.js +99 -0
- package/dist/insights/detectors/widowsPenalty.d.ts +12 -0
- package/dist/insights/detectors/widowsPenalty.js +145 -0
- package/dist/insights/registry.d.ts +4 -0
- package/dist/insights/registry.js +62 -0
- package/dist/insights/runInsights.d.ts +6 -0
- package/dist/insights/runInsights.js +15 -0
- package/dist/insights/types.d.ts +70 -0
- package/dist/insights/types.js +1 -0
- package/dist/ladder/bridge.d.ts +72 -0
- package/dist/ladder/bridge.js +59 -0
- package/dist/ladder/fedInvest.d.ts +55 -0
- package/dist/ladder/fedInvest.js +77 -0
- package/dist/ladder/fundedRatio.d.ts +41 -0
- package/dist/ladder/fundedRatio.js +46 -0
- package/dist/ladder/ladderMath.d.ts +88 -0
- package/dist/ladder/ladderMath.js +140 -0
- package/dist/longevity/ssaPeriod2022.d.ts +10 -0
- package/dist/longevity/ssaPeriod2022.js +51 -0
- package/dist/longevity/types.d.ts +39 -0
- package/dist/longevity/types.js +1 -0
- package/dist/model/migrations.d.ts +20 -0
- package/dist/model/migrations.js +67 -0
- package/dist/model/plan.d.ts +3108 -0
- package/dist/model/plan.js +1514 -0
- package/dist/montecarlo/frontiers.d.ts +22 -0
- package/dist/montecarlo/frontiers.js +61 -0
- package/dist/montecarlo/historicalReturns.d.ts +27 -0
- package/dist/montecarlo/historicalReturns.js +124 -0
- package/dist/montecarlo/historicalSuites.d.ts +45 -0
- package/dist/montecarlo/historicalSuites.js +102 -0
- package/dist/montecarlo/ltcShock.d.ts +42 -0
- package/dist/montecarlo/ltcShock.js +62 -0
- package/dist/montecarlo/marketModels.d.ts +248 -0
- package/dist/montecarlo/marketModels.js +672 -0
- package/dist/montecarlo/mortality.d.ts +31 -0
- package/dist/montecarlo/mortality.js +61 -0
- package/dist/montecarlo/riskBasedGuardrails.d.ts +93 -0
- package/dist/montecarlo/riskBasedGuardrails.js +200 -0
- package/dist/montecarlo/rng.d.ts +18 -0
- package/dist/montecarlo/rng.js +44 -0
- package/dist/montecarlo/run.d.ts +210 -0
- package/dist/montecarlo/run.js +353 -0
- package/dist/montecarlo/sharedPaths.d.ts +43 -0
- package/dist/montecarlo/sharedPaths.js +28 -0
- package/dist/montecarlo/survival.d.ts +49 -0
- package/dist/montecarlo/survival.js +126 -0
- package/dist/params/data/realYieldCurve2026.d.ts +17 -0
- package/dist/params/data/realYieldCurve2026.js +26 -0
- package/dist/params/data/year2026.d.ts +14 -0
- package/dist/params/data/year2026.js +199 -0
- package/dist/params/index.d.ts +63 -0
- package/dist/params/index.js +134 -0
- package/dist/params/provenance.d.ts +29 -0
- package/dist/params/provenance.js +123 -0
- package/dist/params/state/data/year2026.d.ts +18 -0
- package/dist/params/state/data/year2026.js +581 -0
- package/dist/params/state/index.d.ts +13 -0
- package/dist/params/state/index.js +26 -0
- package/dist/params/state/types.d.ts +73 -0
- package/dist/params/state/types.js +10 -0
- package/dist/params/types.d.ts +201 -0
- package/dist/params/types.js +7 -0
- package/dist/projection/annuityForms.d.ts +62 -0
- package/dist/projection/annuityForms.js +87 -0
- package/dist/projection/compare.d.ts +96 -0
- package/dist/projection/compare.js +216 -0
- package/dist/projection/flatTax.d.ts +7 -0
- package/dist/projection/flatTax.js +14 -0
- package/dist/projection/optimizePlan.d.ts +328 -0
- package/dist/projection/optimizePlan.js +1039 -0
- package/dist/projection/relocation.d.ts +149 -0
- package/dist/projection/relocation.js +317 -0
- package/dist/projection/simulate.d.ts +61 -0
- package/dist/projection/simulate.js +2754 -0
- package/dist/projection/types.d.ts +381 -0
- package/dist/projection/types.js +9 -0
- package/dist/rmd/jointLifeTable.d.ts +8 -0
- package/dist/rmd/jointLifeTable.js +663 -0
- package/dist/rmd/rmd.d.ts +29 -0
- package/dist/rmd/rmd.js +34 -0
- package/dist/scenarios/scenarios.d.ts +70 -0
- package/dist/scenarios/scenarios.js +130 -0
- package/dist/socialSecurity/benefitFactor.d.ts +13 -0
- package/dist/socialSecurity/benefitFactor.js +40 -0
- package/dist/socialSecurity/claimFactor.d.ts +33 -0
- package/dist/socialSecurity/claimFactor.js +58 -0
- package/dist/socialSecurity/disability.d.ts +36 -0
- package/dist/socialSecurity/disability.js +42 -0
- package/dist/socialSecurity/familyMaximum.d.ts +38 -0
- package/dist/socialSecurity/familyMaximum.js +48 -0
- package/dist/socialSecurity/maritalBenefits.d.ts +57 -0
- package/dist/socialSecurity/maritalBenefits.js +87 -0
- package/dist/socialSecurity/nra.d.ts +30 -0
- package/dist/socialSecurity/nra.js +83 -0
- package/dist/socialSecurity/piaFromEarnings.d.ts +100 -0
- package/dist/socialSecurity/piaFromEarnings.js +202 -0
- package/dist/socialSecurity/ssaWageData.d.ts +68 -0
- package/dist/socialSecurity/ssaWageData.js +306 -0
- package/dist/socialSecurity/survivorBenefit.d.ts +61 -0
- package/dist/socialSecurity/survivorBenefit.js +61 -0
- package/dist/spending/abw.d.ts +57 -0
- package/dist/spending/abw.js +80 -0
- package/dist/spending/flexibleGoals.d.ts +62 -0
- package/dist/spending/flexibleGoals.js +136 -0
- package/dist/spending/guardrails.d.ts +78 -0
- package/dist/spending/guardrails.js +100 -0
- package/dist/spending/layers.d.ts +81 -0
- package/dist/spending/layers.js +67 -0
- package/dist/spending/shapePresets.d.ts +42 -0
- package/dist/spending/shapePresets.js +77 -0
- package/dist/strategies/accountEligibility.d.ts +81 -0
- package/dist/strategies/accountEligibility.js +98 -0
- package/dist/strategies/inheritedIra.d.ts +45 -0
- package/dist/strategies/inheritedIra.js +43 -0
- package/dist/strategies/iraBasis.d.ts +48 -0
- package/dist/strategies/iraBasis.js +46 -0
- package/dist/strategies/optimizer.d.ts +307 -0
- package/dist/strategies/optimizer.js +551 -0
- package/dist/strategies/rothBasis.d.ts +67 -0
- package/dist/strategies/rothBasis.js +83 -0
- package/dist/strategies/rothConversion.d.ts +55 -0
- package/dist/strategies/rothConversion.js +83 -0
- package/dist/strategies/sepp.d.ts +38 -0
- package/dist/strategies/sepp.js +53 -0
- package/dist/tax/aca.d.ts +40 -0
- package/dist/tax/aca.js +82 -0
- package/dist/tax/federalTax.d.ts +110 -0
- package/dist/tax/federalTax.js +249 -0
- package/dist/tax/medicare.d.ts +20 -0
- package/dist/tax/medicare.js +36 -0
- package/dist/tax/propertySale.d.ts +46 -0
- package/dist/tax/propertySale.js +35 -0
- package/dist/tax/stateTax.d.ts +66 -0
- package/dist/tax/stateTax.js +198 -0
- package/dist/testing/money.d.ts +7 -0
- package/dist/testing/money.js +17 -0
- package/dist/testing/planFixtures.d.ts +29 -0
- package/dist/testing/planFixtures.js +141 -0
- 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
|
+
}
|