@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,551 @@
1
+ /**
2
+ * Multi-year withdrawal / Roth-conversion optimizer (roadmap V8, PR 1).
3
+ *
4
+ * A true multi-year optimizer — unlike the greedy per-year `sizeRothConversion`
5
+ * sizer, this reasons over the whole horizon at once, which is the entire point
6
+ * of conversion planning: convert more now (paying tax in a low bracket) to
7
+ * shrink later RMDs and leave more in the never-haircut Roth. It is "just
8
+ * another strategy provider" (V8 spec §1.3): it emits a per-year schedule that
9
+ * the existing `simulate` ledger consumes via the Roth-conversion `optimized`
10
+ * mode — it does NOT run a parallel engine.
11
+ *
12
+ * Method: a mixed-integer linear program solved client-side by HiGHS-WASM (the
13
+ * `highs` package), proven viable by the PR 0 spike (measurements recorded in
14
+ * DOCS/features/optimizer.md §"Spike findings (historical)"). The LP is a *linearised* view of the
15
+ * ledger; `simulate` stays the source of truth, so callers re-run the real
16
+ * (nonlinear) ledger with the emitted schedule for exact numbers and surface
17
+ * any gap (V8 §3.1).
18
+ *
19
+ * Modeled exactly (the "big levers", V8 §6):
20
+ * - Graduated federal ordinary tax as a CONVEX piecewise-linear cost from the
21
+ * pack's real brackets — minimising tax fills the cheap band first with no
22
+ * integer variables.
23
+ * - IRMAA tier surcharges as binary step thresholds (the non-convex part that
24
+ * makes this a MILP, not an LP).
25
+ * - RMD floors as a linear lower bound on each year's taxable distribution
26
+ * (floor = start-of-year traditional balance ÷ divisor).
27
+ * - Three-bucket balances (owner traditional, inherited traditional, and
28
+ * "other" = Roth + taxable + cash) with per-year growth and scheduled
29
+ * contribution / employer-match inflows from the baseline probe, so a
30
+ * plan whose solvency depends on future deposits is not misread as
31
+ * infeasible.
32
+ *
33
+ * The optimizer-exact-ledger-convergence plan (Track 1) closed the
34
+ * original v1 simplifications, each opt-in via `OptimizerInput` so absent
35
+ * fields reproduce the v1 LP byte-for-byte:
36
+ * - Taxable-gain realization (Step 2): `openingTaxable`/`taxableInflow` split
37
+ * the lumped bucket into a taxable brokerage bucket — whose withdrawals
38
+ * realize LTCG at `ltcgRate` on the opening gain fraction and lift IRMAA
39
+ * MAGI — and the tax-free Roth/cash/HSA bucket. A single LTCG rate
40
+ * linearizes the 0/15/20% stack; the exact ledger refines it.
41
+ * - Bracketed state tax (Step 3): `stateBrackets` adds a convex state PWL
42
+ * over the same `ti` base as the federal PWL, so progressive
43
+ * (non-flat-override) states are priced with real brackets rather than
44
+ * zero; a flat `stateEffectiveTaxPct` override keeps the flat `stateRate`.
45
+ * - Taxable-SS phase-in (Step 3): `ssTaxability` swaps the fixed taxable-SS
46
+ * constant for an in-solve convex PWL over provisional income, so the
47
+ * solver sees the *marginal* tax torpedo (see `taxss` constraints below).
48
+ * - IRMAA 2-year lookback (Step 4): `irmaaLookback` drives each premium
49
+ * year's binaries off MAGI(year−2), matching the ledger's causality.
50
+ * - OBBBA senior deduction (ground-truth 2026 law sync, Step 2):
51
+ * `seniorDeduction` adds the per-person 65+ deduction plus a convex
52
+ * phase-out floor over MAGI, so conversions in the phase-out band are
53
+ * priced at bracket rate × (1 + phase-out rate) in-solve instead of the
54
+ * deduction being ignored entirely.
55
+ *
56
+ * Remaining simplifications (refined when the schedule re-runs through
57
+ * `simulate`, and by the exact-ledger convergence loop — Step 1, in
58
+ * projection/optimizePlan.ts — which recaptures the exogenous inputs at the
59
+ * incumbent schedule and re-solves to a fixed point): a single LTCG rate and
60
+ * opening basis ratio for the taxable stack, the omitted 85%-of-benefit
61
+ * taxable-SS cap (conservative), unscaled stand-in packs for future years
62
+ * (matching the ledger's convention), and state retirement-income exclusions.
63
+ */
64
+ function fmt(coef) {
65
+ // Plain decimal (HiGHS LP reader dislikes exponential notation); trim noise.
66
+ return Number.isInteger(coef) ? String(coef) : coef.toFixed(8).replace(/\.?0+$/, '');
67
+ }
68
+ /** Render { x: 1.05, y: -2 } as a signed LP linear expression. */
69
+ function expr(terms) {
70
+ const parts = [];
71
+ for (const [name, coef] of Object.entries(terms)) {
72
+ if (coef === 0)
73
+ continue;
74
+ parts.push(`${coef < 0 ? '-' : '+'} ${fmt(Math.abs(coef))} ${name}`);
75
+ }
76
+ return parts.join(' ') || '0';
77
+ }
78
+ /** Convex piecewise-linear federal tax from a pack's ascending brackets. */
79
+ function bracketSegments(pack, filing) {
80
+ const brackets = pack.federalTax.brackets[filing];
81
+ return brackets.map((b, i) => ({
82
+ width: i + 1 < brackets.length ? brackets[i + 1].lowerBound - b.lowerBound : null, // last band open-ended
83
+ rate: b.ratePct / 100,
84
+ }));
85
+ }
86
+ /** Annual Part B + Part D IRMAA surcharge increments per tier (nominal). */
87
+ function irmaaIncrements(pack) {
88
+ const std = pack.medicare.partBStandardMonthly;
89
+ const tiers = pack.medicare.irmaaTiers;
90
+ return tiers.map((tier, i) => {
91
+ const prevPct = i === 0 ? 25 : tiers[i - 1].applicablePct;
92
+ // Part B increment over the previous tier + this tier's Part D surcharge.
93
+ const partB = std * ((tier.applicablePct - prevPct) / 25) * 12;
94
+ const partD = (tier.partDSurchargeMonthly ?? 0) * 12 - (i === 0 ? 0 : (tiers[i - 1].partDSurchargeMonthly ?? 0) * 12);
95
+ return { threshold: (f) => tier.magiOver[f], surcharge: Math.max(0, partB + partD) };
96
+ });
97
+ }
98
+ /** Build the multi-year MILP in CPLEX LP format. Exported for golden tests. */
99
+ export function buildOptimizerModel(input) {
100
+ const { years, liquidationRate } = input;
101
+ const n = years.length;
102
+ const maxConv = input.options?.maxConversionPerYear;
103
+ // Step 2 — taxable-gain realization. Split the lumped "other" bucket into a
104
+ // taxable bucket (withdrawals realize LTCG on the gain fraction) and the
105
+ // tax-free bucket (`openingOther`). Only materialized when there is a taxable
106
+ // balance or inflow, so plans without a taxable account emit the identical LP.
107
+ const openingTaxable = input.openingTaxable ?? 0;
108
+ const gainFraction = Math.min(1, Math.max(0, 1 - (input.taxableBasisRatio ?? 1)));
109
+ const ltcgRate = Number.isFinite(input.ltcgRate) ? Math.max(0, input.ltcgRate ?? 0) : 0;
110
+ const hasTaxable = openingTaxable > 0 || years.some((y) => (y.taxableInflow ?? 0) > 0);
111
+ // Net cash a taxable withdrawal dollar yields after its LTCG cost, and the
112
+ // MAGI weight of its realized gain (capital gains lift MAGI for IRMAA).
113
+ const taxableNetCash = 1 - ltcgRate * gainFraction;
114
+ const taxableGainWeight = gainFraction;
115
+ // Step 4 — drive each premium year's IRMAA binaries off year (t−2)'s MAGI.
116
+ const irmaaLookback = input.irmaaLookback ?? false;
117
+ // Safe big-M: no year's taxable income can exceed all assets plus all income.
118
+ // Scale by the PEAK cumulative growth factor, floored at 1 — with negative
119
+ // returns the end-of-horizon factor can be < 1, which would shrink big-M
120
+ // below early-year balances and let a relaxed IRMAA constraint wrongly bind.
121
+ let cumGrowth = 1;
122
+ let peakGrowth = 1;
123
+ for (const y of years) {
124
+ cumGrowth *= 1 + y.growth;
125
+ if (cumGrowth > peakGrowth)
126
+ peakGrowth = cumGrowth;
127
+ }
128
+ const incomeSum = years.reduce((a, y) => a + y.ordinaryIncomeBase, 0);
129
+ const inflowSum = years.reduce((a, y) => a + y.tradInflow + y.otherInflow, 0);
130
+ const bigM = (input.openingTrad + input.openingInheritedTrad + input.openingOther + (input.openingTaxable ?? 0) + inflowSum) *
131
+ peakGrowth +
132
+ incomeSum +
133
+ 1_000_000;
134
+ const constraints = [];
135
+ const binaries = [];
136
+ const segBounds = [];
137
+ // Per-year MAGI decomposition for the IRMAA constraints: a constant base plus
138
+ // variable terms (conversions/withdrawals, taxable gains, and — when the
139
+ // in-solve taxable-SS PWL is active — the taxable-SS variable). Stored so the
140
+ // 2-year lookback (Step 4) can reference an earlier year's MAGI.
141
+ const magiBase = [];
142
+ const magiTerms = [];
143
+ for (let t = 0; t < n; t++) {
144
+ const y = years[t];
145
+ const g = 1 + y.growth;
146
+ const conv = `conv${t}`;
147
+ const wt = `wt${t}`; // taxable traditional withdrawal
148
+ const wi = `wi${t}`; // taxable inherited-traditional withdrawal
149
+ const wo = `wo${t}`; // tax-free other-bucket withdrawal
150
+ const wtax = `wtax${t}`; // taxable-brokerage withdrawal (realizes LTCG on the gain fraction)
151
+ const save = `save${t}`; // surplus cash routed back to the tax-free bucket
152
+ const ti = `ti${t}`; // taxable ordinary income (post standard deduction, ≥0)
153
+ // OBBBA senior deduction in-solve (ground-truth 2026 law sync, Step 2).
154
+ // Eligible years add the full per-person deduction to the constant and a
155
+ // convex phase-out floor `srd ≥ rate·(MAGI − start)` (srd ≥ 0) that claws
156
+ // it back inside the solve — the marginal-rate spike the exact ledger
157
+ // prices via `seniorDeductionAmount`. Years already past full phase-out at
158
+ // baseline skip it exactly (conversions only raise MAGI, so the deduction
159
+ // stays zero); the baseline MAGI for that test must include the year's
160
+ // forced distributions (owner RMD + inherited) — they are re-decided as
161
+ // `wt`/`wi` so `ordinaryIncomeBase` excludes them, but the ledger's MAGI
162
+ // counts them, and a high-RMD year can be fully phased out on forced
163
+ // income alone. The concave full-phase-out cap is otherwise omitted — a
164
+ // conservative overstatement for cap-crossing conversions, like the SS 85%
165
+ // cap (a hard `srd ≤ base` bound would instead make cap-crossing MAGI
166
+ // infeasible against the floor constraint, and an exact cap needs a binary).
167
+ const srdRule = input.seniorDeduction ? y.pack.federalTax.seniorDeduction : null;
168
+ const srdEligible = srdRule !== null && y.peopleAged65Plus > 0 && y.year <= srdRule.lastApplicableYear;
169
+ const srdRate = srdRule && srdEligible ? srdRule.phaseOutRatePct / 100 : 0;
170
+ const srdStart = srdRule && srdEligible ? srdRule.magiPhaseOutStart[y.filingStatus] : 0;
171
+ const baselineMagi = y.ordinaryIncomeBase + (y.capitalGainsBase ?? 0) + (y.baselineRmd ?? 0) + y.inheritedDistribution;
172
+ const srdBaseRaw = srdRule && srdEligible ? srdRule.amountPerPerson * y.peopleAged65Plus : 0;
173
+ const srdFullyPhasedOut = srdRate > 0 && baselineMagi >= srdStart + srdBaseRaw / srdRate;
174
+ const srdBase = srdFullyPhasedOut ? 0 : srdBaseRaw;
175
+ const srdPwlActive = srdBase > 0 && srdRate > 0;
176
+ const srd = `srd${t}`;
177
+ const ded = pack65Deduction(y) + srdBase;
178
+ // Tax pieces: federal PWL segments + IRMAA binaries + flat state on `ti`.
179
+ const segs = bracketSegments(y.pack, y.filingStatus);
180
+ const segVars = segs.map((_, i) => `fseg${t}_${i}`);
181
+ const irmaa = irmaaIncrements(y.pack);
182
+ const irmaaVars = irmaa.map((_, k) => `irmaa${t}_${k}`);
183
+ // In-solve taxable-SS PWL (Step 3): active when the year carries SS inputs
184
+ // and the incumbent is not already saturated at the 85% cap (a saturated
185
+ // year's marginal effect is zero, so the fixed constant is exactly right).
186
+ // Convex max-affine underestimate of the 0/50/85% phase-in over provisional
187
+ // income PI = PI0 + x (x = conv + wt + wi + gain-weighted wtax); the 0.85·B
188
+ // cap is omitted (concave), so past the cap the LP overestimates the
189
+ // marginal cost — a conservative direction the exact ledger refines.
190
+ const ssB = y.ssTaxability;
191
+ const ssPwlActive = ssB !== undefined && ssB.ssBenefits > 1 && ssB.taxableSsBase < 0.845 * ssB.ssBenefits;
192
+ const taxSs = `taxss${t}`;
193
+ // Ordinary base with the probe's taxable-SS constant swapped out for the
194
+ // PWL variable when active.
195
+ const ordinaryBase = ssPwlActive ? y.ordinaryIncomeBase - ssB.taxableSsBase : y.ordinaryIncomeBase;
196
+ // (taxable ordinary) ti ≥ grossOrdinary − deduction, ti ≥ 0. Minimising tax
197
+ // pins ti to max(0, gross−ded) since every tax term is increasing in ti.
198
+ // gross = ordinaryBase + conv + wt + wi (+ taxSS variable when PWL active)
199
+ const tiFloor = { [ti]: 1, [conv]: -1, [wt]: -1, [wi]: -1 };
200
+ if (ssPwlActive)
201
+ tiFloor[taxSs] = -1;
202
+ // The phase-out variable raises the floor: ti ≥ gross − ded + srd.
203
+ if (srdPwlActive)
204
+ tiFloor[srd] = -1;
205
+ constraints.push(` tifloor${t}: ${expr(tiFloor)} >= ${fmt(ordinaryBase - ded)}`);
206
+ if (ssPwlActive) {
207
+ const B = ssB.ssBenefits;
208
+ const t1 = y.pack.ssBenefitTaxation.tier50Start[y.filingStatus];
209
+ const t2 = y.pack.ssBenefitTaxation.tier85Start[y.filingStatus];
210
+ // Provisional income: ordinary (excl. taxable SS) + gains + half the benefit.
211
+ const pi0 = ordinaryBase + (y.capitalGainsBase ?? 0) + 0.5 * B;
212
+ const piVars = { [conv]: 1, [wt]: 1, [wi]: 1 };
213
+ if (hasTaxable && taxableGainWeight > 0)
214
+ piVars[wtax] = taxableGainWeight;
215
+ // taxSS ≥ max(0, 0.5·(PI−T1), tier1Cap + 0.85·(PI−T2)) — the convex
216
+ // phase-in mirroring the ledger's `taxableSocialSecurity`: above T2 the
217
+ // 50% tier's contribution is capped at min(0.5·B, 0.5·(T2−T1)) and each
218
+ // marginal dollar adds 0.85 (never 0.5+0.85 — the tiers do not stack at
219
+ // the margin). Expressed as ≥-constraints (the tax terms are increasing
220
+ // in taxSS, so minimization pins taxSS to the max). The concave 0.85·B
221
+ // cap is deliberately NOT modeled: a binary per SS year makes the MILP
222
+ // intractably slow (measured), and omitting the cap only OVERSTATES the
223
+ // tax on conversions large enough to blow past it — a conservative
224
+ // direction. Mega-conversion shapes still reach the recommendation
225
+ // through the tournament candidates and local search, all priced on the
226
+ // exact ledger, and saturated-at-the-incumbent years skip the PWL
227
+ // entirely (their marginal effect is zero, so the constant is exact).
228
+ const tier1Cap = Math.min(0.5 * B, 0.5 * (t2 - t1));
229
+ const pieces = [
230
+ { slope: 0.5, constant: 0.5 * (pi0 - t1), name: 'a' },
231
+ { slope: 0.85, constant: tier1Cap + 0.85 * (pi0 - t2), name: 'b' },
232
+ ];
233
+ for (const piece of pieces) {
234
+ const terms = { [taxSs]: 1 };
235
+ for (const [v, c] of Object.entries(piVars))
236
+ terms[v] = -piece.slope * c;
237
+ constraints.push(` taxss${t}${piece.name}: ${expr(terms)} >= ${fmt(piece.constant)}`);
238
+ }
239
+ }
240
+ // MAGI decomposition for IRMAA: constant base + variable add-ons. The base
241
+ // includes the year's baseline capital gains / qualified dividends — the
242
+ // ledger's MAGI counts them, and the SS PWL above already does; omitting
243
+ // them here would let a conversion look tier-safe in-solve while the exact
244
+ // ledger prices the surcharge. Matches the pre-PWL behavior exactly when
245
+ // both are absent (constant taxable SS rides inside ordinaryIncomeBase).
246
+ magiBase.push(ordinaryBase + (y.capitalGainsBase ?? 0));
247
+ const myMagiTerms = { [conv]: 1, [wt]: 1, [wi]: 1 };
248
+ if (hasTaxable && taxableGainWeight > 0)
249
+ myMagiTerms[wtax] = taxableGainWeight;
250
+ if (ssPwlActive)
251
+ myMagiTerms[taxSs] = 1;
252
+ magiTerms.push(myMagiTerms);
253
+ // Senior-deduction phase-out floor: srd ≥ rate·(MAGI − start), srd ≥ 0.
254
+ // Tax is increasing in ti (hence in srd) and drains cash from the
255
+ // maximized terminal estate, so the optimum pins srd to
256
+ // max(0, rate·(MAGI − start)) — the ledger's claw-back, sans the cap.
257
+ if (srdPwlActive) {
258
+ const srdTerms = { [srd]: 1 };
259
+ for (const [v, c] of Object.entries(myMagiTerms))
260
+ srdTerms[v] = -srdRate * c;
261
+ constraints.push(` srd${t}: ${expr(srdTerms)} >= ${fmt(srdRate * (magiBase[t] - srdStart))}`);
262
+ }
263
+ // ti split across federal segments (convex; low bands fill first).
264
+ const split = { [ti]: 1 };
265
+ segVars.forEach((s) => (split[s] = -1));
266
+ constraints.push(` fsplit${t}: ${expr(split)} = 0`);
267
+ segs.forEach((seg, i) => {
268
+ if (seg.width !== null)
269
+ segBounds.push(` 0 <= ${segVars[i]} <= ${fmt(seg.width)}`);
270
+ });
271
+ // State PWL (Step 3): a second convex partition of the same `ti` into the
272
+ // state's brackets. Present only for progressive (non-flat-override) states;
273
+ // widths are UNSCALED, matching the ledger's stand-in convention (state
274
+ // packs apply at face value for future years, like the federal deduction
275
+ // and brackets). When absent the flat `stateRate` term below is the whole
276
+ // state tax (byte-identical LP).
277
+ const stateSegs = y.stateBrackets;
278
+ const stateSegVars = stateSegs?.map((_, i) => `sseg${t}_${i}`) ?? [];
279
+ if (stateSegs && stateSegs.length > 0) {
280
+ const ssplit = { [ti]: 1 };
281
+ stateSegVars.forEach((s) => (ssplit[s] = -1));
282
+ constraints.push(` ssplit${t}: ${expr(ssplit)} = 0`);
283
+ stateSegs.forEach((seg, i) => {
284
+ if (seg.width !== null)
285
+ segBounds.push(` 0 <= ${stateSegVars[i]} <= ${fmt(seg.width)}`);
286
+ });
287
+ }
288
+ // IRMAA: MAGI (≈ gross ordinary + realized taxable gains) over a tier
289
+ // threshold in the *premium* year forces its binary on. Same-year MAGI by
290
+ // default; under the two-year lookback (Step 4) the trigger is year (t−2)'s
291
+ // MAGI, matching the exact ledger's causality (a conversion at 63 raises the
292
+ // 65 premium) — shifted to year (t−1)'s MAGI when SSA-44 redetermination
293
+ // applies (see OptimizerYear.ssa44Redetermination). Premium years with no
294
+ // in-horizon source (t<2 under lookback) have exogenous premiums the ledger
295
+ // prices, so no binary is modeled.
296
+ const irmaaSrc = irmaaLookback ? (y.ssa44Redetermination ? t - 1 : t - 2) : t;
297
+ const hasIrmaaBinaries = irmaaSrc >= 0;
298
+ if (hasIrmaaBinaries) {
299
+ const srcTerms = magiTerms[irmaaSrc];
300
+ const srcBase = magiBase[irmaaSrc];
301
+ irmaa.forEach((tier, k) => {
302
+ const thr = tier.threshold(y.filingStatus) * y.inflationScale;
303
+ const step = { ...srcTerms, [irmaaVars[k]]: -bigM };
304
+ constraints.push(` irmaa${t}_${k}: ${expr(step)} <= ${fmt(thr - srcBase)}`);
305
+ binaries.push(irmaaVars[k]);
306
+ });
307
+ }
308
+ // tax_t = Σ rate·fseg + stateRate·ti + Σ surcharge·irmaaBinary (defined inline
309
+ // in the cash-flow constraint to avoid a separate variable).
310
+ const taxTerms = {};
311
+ segs.forEach((seg, i) => (taxTerms[segVars[i]] = seg.rate));
312
+ // Flat state/local rate on `ti` (override + local), plus the progressive
313
+ // state bracket PWL when supplied.
314
+ taxTerms[ti] = (taxTerms[ti] ?? 0) + y.stateRate;
315
+ if (stateSegs && stateSegs.length > 0)
316
+ stateSegs.forEach((seg, i) => (taxTerms[stateSegVars[i]] = seg.rate));
317
+ // The surcharge is a cash cost in the premium year t; its binary is whatever
318
+ // the trigger constraint above bound (same-year or the t−2 lookback source).
319
+ if (hasIrmaaBinaries)
320
+ irmaa.forEach((tier, k) => (taxTerms[irmaaVars[k]] = tier.surcharge));
321
+ // Cash flow: withdrawals + exogenous cash − tax = spendingNeed + save.
322
+ // wt + wi + wo + wtax·(1 − ltcgCost) + exoCash − tax = spendingNeed + save
323
+ // A taxable withdrawal yields less net cash than a tax-free one because its
324
+ // gain fraction is taxed at the LTCG rate — so the solver stops treating
325
+ // taxable draws as free and prefers the tax-free bucket where it can.
326
+ const cash = { [wt]: 1, [wi]: 1, [wo]: 1, [save]: -1 };
327
+ if (hasTaxable)
328
+ cash[wtax] = taxableNetCash;
329
+ for (const [v, c] of Object.entries(taxTerms))
330
+ cash[v] = (cash[v] ?? 0) - c;
331
+ constraints.push(` cash${t}: ${expr(cash)} = ${fmt(y.spendingNeed - y.exogenousCash)}`);
332
+ // RMD floor: discretionary+forced taxable distribution ≥ balance ÷ divisor.
333
+ if (y.rmdDivisor && y.rmdDivisor > 0) {
334
+ const rmd = { [wt]: 1, [`trad${t}`]: -1 / y.rmdDivisor };
335
+ constraints.push(` rmd${t}: ${expr(rmd)} >= 0`);
336
+ }
337
+ if (y.inheritedDistributionDivisor && y.inheritedDistributionDivisor > 0) {
338
+ const inheritedFloor = { [wi]: 1, [`inh${t}`]: -1 / y.inheritedDistributionDivisor };
339
+ constraints.push(` inhrmd${t}: ${expr(inheritedFloor)} >= 0`);
340
+ }
341
+ else if (y.inheritedDistribution > 0) {
342
+ constraints.push(` inhrmd${t}: + 1 ${wi} >= ${fmt(y.inheritedDistribution)}`);
343
+ }
344
+ if (maxConv !== undefined)
345
+ segBounds.push(` 0 <= ${conv} <= ${fmt(maxConv)}`);
346
+ // Balance recursions (next start = growth × (this start ± activity)).
347
+ // Scheduled contribution / employer-match inflows land in their bucket the
348
+ // same year and grow like the exact ledger's deposits (which are applied
349
+ // before end-of-year growth); their cash cost is already in spendingNeed.
350
+ const trad = { [`trad${t + 1}`]: 1, [`trad${t}`]: -g, [conv]: g, [wt]: g };
351
+ constraints.push(` trad${t}: ${expr(trad)} = ${fmt(g * y.tradInflow)}`);
352
+ const inherited = { [`inh${t + 1}`]: 1, [`inh${t}`]: -g, [wi]: g };
353
+ constraints.push(` inh${t}: ${expr(inherited)} = 0`);
354
+ // Tax-free bucket: `otherInflow` net of the taxable share lands here; the
355
+ // taxable share seeds the taxable bucket below. Surplus `save` and Roth
356
+ // conversions accrue to this tax-free bucket.
357
+ const taxFreeInflow = hasTaxable ? Math.max(0, y.otherInflow - (y.taxableInflow ?? 0)) : y.otherInflow;
358
+ const other = { [`other${t + 1}`]: 1, [`other${t}`]: -g, [conv]: -g, [wo]: g, [save]: -g };
359
+ constraints.push(` other${t}: ${expr(other)} = ${fmt(g * taxFreeInflow)}`);
360
+ if (hasTaxable) {
361
+ const taxable = { [`taxable${t + 1}`]: 1, [`taxable${t}`]: -g, [wtax]: g };
362
+ constraints.push(` taxable${t}: ${expr(taxable)} = ${fmt(g * (y.taxableInflow ?? 0))}`);
363
+ }
364
+ }
365
+ // Objective: ending other (whole) + ending traditional net of the haircut,
366
+ // expressed in today's dollars (a constant deflator; argmax unchanged). Plus a
367
+ // tiny per-conversion reward so that discretionary traditional-drainage — which
368
+ // a conversion and a taxable withdrawal-to-savings model identically here — is
369
+ // canonically reported as a CONVERSION (the only lever `simulate` can act on
370
+ // via the schedule). ε ≪ any bracket-rate difference, so it breaks genuine
371
+ // ties only and never drives an uneconomic conversion.
372
+ const deflate = input.realDollarFactor ?? 1;
373
+ const CONVERSION_TIE_BREAK = 1e-6;
374
+ const objTerms = {
375
+ [`other${n}`]: deflate,
376
+ [`trad${n}`]: deflate * (1 - liquidationRate),
377
+ [`inh${n}`]: deflate * (1 - liquidationRate),
378
+ };
379
+ // Ending taxable counts at full value (heirs get a basis step-up; the exact
380
+ // ledger prices the true estate treatment). Only present when split out.
381
+ if (hasTaxable)
382
+ objTerms[`taxable${n}`] = deflate;
383
+ for (let t = 0; t < n; t++)
384
+ objTerms[`conv${t}`] = CONVERSION_TIE_BREAK;
385
+ const objective = expr(objTerms);
386
+ const bounds = [
387
+ ` trad0 = ${fmt(input.openingTrad)}`,
388
+ ` inh0 = ${fmt(input.openingInheritedTrad)}`,
389
+ ` other0 = ${fmt(input.openingOther)}`,
390
+ ...(hasTaxable ? [` taxable0 = ${fmt(openingTaxable)}`] : []),
391
+ ...segBounds,
392
+ ];
393
+ const lp = [
394
+ 'Maximize',
395
+ ` obj: ${objective}`,
396
+ 'Subject To',
397
+ ...constraints,
398
+ 'Bounds',
399
+ ...bounds,
400
+ 'Binaries',
401
+ ` ${binaries.join(' ')}`,
402
+ 'End',
403
+ ].join('\n');
404
+ return { lp, binaryCount: binaries.length, bigM };
405
+ }
406
+ /**
407
+ * Standard deduction (+ age-65 addition) for the year — UNSCALED, matching the
408
+ * exact ledger's stand-in convention: `computeFederalTax` applies the latest
409
+ * pack's deduction (and brackets) at face value for future years, so the LP
410
+ * must too. Scaling it by inflation (as v1 did) overstated future deductions by
411
+ * up to ~2× at long horizons, under-taxing late-year conversions in-solve and
412
+ * driving systematic over-conversion the exact ledger then priced as harmful.
413
+ * IRMAA thresholds ARE inflation-scaled in both engines (`inflationScale`).
414
+ * The OBBBA senior deduction is handled separately in the model builder (it
415
+ * needs the MAGI-dependent phase-out, not a constant).
416
+ */
417
+ function pack65Deduction(y) {
418
+ const f = y.filingStatus;
419
+ const base = y.pack.federalTax.standardDeduction[f];
420
+ const age65 = y.peopleAged65Plus > 0 ? y.pack.federalTax.age65Addition[f] * y.peopleAged65Plus : 0;
421
+ return base + age65;
422
+ }
423
+ let highsPromise = null;
424
+ async function getHighs(locateFile) {
425
+ if (!highsPromise) {
426
+ // highs ships CJS with an `export default` in its types; under Node16
427
+ // resolution the namespace type isn't callable, but at runtime `.default`
428
+ // is the loader function in both Node ESM and bundler interop.
429
+ const loader = (await import('highs')).default;
430
+ highsPromise = loader(locateFile ? { locateFile } : undefined);
431
+ }
432
+ return highsPromise;
433
+ }
434
+ const STATUS_MAP = {
435
+ Optimal: 'optimal',
436
+ 'Time limit reached': 'timeout',
437
+ 'Iteration limit reached': 'timeout',
438
+ Infeasible: 'infeasible',
439
+ 'Primal infeasible or unbounded': 'infeasible',
440
+ };
441
+ /**
442
+ * Solve the multi-year conversion/withdrawal MILP. Returns a per-year schedule
443
+ * plus a conversions array ready to feed the `optimized` Roth strategy.
444
+ */
445
+ export async function optimizeSchedule(input) {
446
+ const model = buildOptimizerModel(input);
447
+ const solve = input.options?.solve ?? (await getHighs(input.options?.locateFile)).solve;
448
+ const t0 = globalThis.performance?.now?.() ?? Date.now();
449
+ const sol = solve(model.lp, {
450
+ output_flag: false,
451
+ time_limit: input.options?.timeLimitSec ?? 10,
452
+ });
453
+ const solveMs = (globalThis.performance?.now?.() ?? Date.now()) - t0;
454
+ const objectiveValue = typeof sol.ObjectiveValue === 'number' ? sol.ObjectiveValue : 0;
455
+ const columns = sol.Columns ?? {};
456
+ const status = STATUS_MAP[sol.Status] ?? (objectiveValue ? 'feasible' : 'infeasible');
457
+ const col = (name) => {
458
+ const c = columns[name];
459
+ return c && typeof c.Primal === 'number' ? c.Primal : 0;
460
+ };
461
+ // Gain fraction of a taxable withdrawal, for the LTCG readout (mirrors the model).
462
+ const gainFraction = Math.min(1, Math.max(0, 1 - (input.taxableBasisRatio ?? 1)));
463
+ const ltcgRate = Number.isFinite(input.ltcgRate) ? Math.max(0, input.ltcgRate ?? 0) : 0;
464
+ const schedule = [];
465
+ const conversions = [];
466
+ let lifetimeTax = 0;
467
+ for (let t = 0; t < input.years.length; t++) {
468
+ const y = input.years[t];
469
+ const conversion = round(col(`conv${t}`));
470
+ const withdrawTraditional = round(col(`wt${t}`));
471
+ const withdrawInheritedTraditional = round(col(`wi${t}`));
472
+ const withdrawTaxable = round(col(`wtax${t}`));
473
+ const taxableGainRealized = round(withdrawTaxable * gainFraction);
474
+ const taxableOrdinary = round(col(`ti${t}`));
475
+ // Reconstruct modeled tax for the readout (federal PWL + state + IRMAA + LTCG).
476
+ let tier = 0;
477
+ const irmaa = irmaaIncrements(y.pack);
478
+ irmaa.forEach((_, k) => {
479
+ if (col(`irmaa${t}_${k}`) > 0.5)
480
+ tier = k + 1;
481
+ });
482
+ lifetimeTax +=
483
+ federalTaxOn(taxableOrdinary, y) +
484
+ taxableOrdinary * y.stateRate +
485
+ stateBracketTaxOn(taxableOrdinary, y) +
486
+ irmaaSurchargeFor(y, tier) +
487
+ taxableGainRealized * ltcgRate;
488
+ if (conversion > 0.5)
489
+ conversions.push({ year: y.year, amount: conversion });
490
+ schedule.push({
491
+ year: y.year,
492
+ conversion,
493
+ withdrawTraditional,
494
+ withdrawInheritedTraditional,
495
+ withdrawOther: round(col(`wo${t}`)),
496
+ withdrawTaxable,
497
+ taxableGainRealized,
498
+ taxableOrdinary,
499
+ irmaaTier: tier,
500
+ endTrad: round(col(`trad${t + 1}`)),
501
+ endInheritedTrad: round(col(`inh${t + 1}`)),
502
+ endOther: round(col(`other${t + 1}`)),
503
+ endTaxable: round(col(`taxable${t + 1}`)),
504
+ });
505
+ }
506
+ return {
507
+ status,
508
+ endingAfterTax: round(objectiveValue),
509
+ lifetimeTax: round(lifetimeTax),
510
+ schedule,
511
+ conversions,
512
+ solveMs,
513
+ };
514
+ }
515
+ function round(x) {
516
+ return Math.round(x * 100) / 100;
517
+ }
518
+ function federalTaxOn(taxableOrdinary, y) {
519
+ let remaining = taxableOrdinary;
520
+ let tax = 0;
521
+ for (const seg of bracketSegments(y.pack, y.filingStatus)) {
522
+ if (remaining <= 0)
523
+ break;
524
+ const take = seg.width === null ? remaining : Math.min(remaining, seg.width);
525
+ tax += take * seg.rate;
526
+ remaining -= take;
527
+ }
528
+ return tax;
529
+ }
530
+ /** State bracket PWL tax on taxable ordinary income (0 when the year is flat-only). */
531
+ function stateBracketTaxOn(taxableOrdinary, y) {
532
+ if (!y.stateBrackets || y.stateBrackets.length === 0)
533
+ return 0;
534
+ let remaining = taxableOrdinary;
535
+ let tax = 0;
536
+ for (const seg of y.stateBrackets) {
537
+ if (remaining <= 0)
538
+ break;
539
+ const width = seg.width === null ? remaining : Math.min(remaining, seg.width);
540
+ tax += width * seg.rate;
541
+ remaining -= width;
542
+ }
543
+ return tax;
544
+ }
545
+ function irmaaSurchargeFor(y, tier) {
546
+ if (tier <= 0)
547
+ return 0;
548
+ return irmaaIncrements(y.pack)
549
+ .slice(0, tier)
550
+ .reduce((a, t) => a + t.surcharge, 0);
551
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Roth ordering + 5-year rules (roadmap V8, §4 tax depth).
3
+ *
4
+ * A Roth distribution comes out in a fixed IRS order, and each layer is taxed
5
+ * differently before the account is "qualified" (owner 59½+ and the account 5+
6
+ * years old):
7
+ * 1. Contributions — always tax- AND penalty-free, at any age.
8
+ * 2. Conversions — oldest first; already taxed at conversion, so never
9
+ * income-taxed again, BUT a 10% recapture penalty applies
10
+ * to any layer tapped within 5 years while under 59½.
11
+ * 3. Earnings — last out; before 59½ they are ordinary income PLUS the
12
+ * 10% penalty (a non-qualified distribution).
13
+ *
14
+ * The engine approximates the 59½ boundary as "age 60 attained," matching the
15
+ * traditional early-withdrawal penalty elsewhere in the simulation, and folds
16
+ * the account's own 5-year clock into that age proxy (an existing Roth is
17
+ * almost always open >5 years by 59½). Conversions performed during the
18
+ * projection each carry their own explicit 5-year clock, which is the case that
19
+ * actually matters for the early-retirement "conversion ladder."
20
+ */
21
+ /** The engine's 59½ proxy: no early-withdrawal penalty once age 60 is attained. */
22
+ export declare const ROTH_QUALIFIED_AGE = 60;
23
+ /** A conversion is "seasoned" (no recapture penalty) once this many years pass. */
24
+ export declare const ROTH_SEASONING_YEARS = 5;
25
+ export interface RothConversionLayer {
26
+ /** Calendar year the conversion occurred (starts its 5-year clock). */
27
+ year: number;
28
+ /** Remaining un-withdrawn converted principal in this layer (nominal $). */
29
+ amount: number;
30
+ /**
31
+ * Portion of `amount` that was included in income at conversion. The 10%
32
+ * recapture penalty on an unseasoned tap applies only to this taxable share —
33
+ * nondeductible IRA basis rolled in was never taxed, so it recaptures nothing
34
+ * (IRS Pub 590-B). The full `amount` still returns tax-free before earnings.
35
+ * Absent (or equal to `amount`) for a fully-taxable conversion.
36
+ */
37
+ taxableAmount: number;
38
+ }
39
+ export interface RothBasisState {
40
+ /** Direct-contribution basis remaining (nominal $); withdrawn first, always free. */
41
+ contributionBasis: number;
42
+ /** Conversion principal layers, oldest first; penalized if tapped <5y while pre-59½. */
43
+ conversionLayers: RothConversionLayer[];
44
+ }
45
+ export interface RothWithdrawalSplit {
46
+ contributions: number;
47
+ conversions: number;
48
+ earnings: number;
49
+ /** 10% early-withdrawal penalty on unseasoned conversions + pre-59½ earnings. */
50
+ penalty: number;
51
+ /** Earnings portion taxed as ordinary income (pre-59½ non-qualified distribution). */
52
+ taxableOrdinary: number;
53
+ /** Basis state after the withdrawal; the caller commits this only for the final plan. */
54
+ next: RothBasisState;
55
+ }
56
+ /** An empty (fresh) basis state, e.g. for a brand-new Roth account. */
57
+ export declare function emptyRothBasis(contributionBasis?: number): RothBasisState;
58
+ /**
59
+ * Split a Roth withdrawal into contribution / conversion / earnings buckets and
60
+ * the early-distribution tax + penalty it incurs. Pure: returns the post-
61
+ * withdrawal `next` state rather than mutating, so callers can probe a candidate
62
+ * withdrawal during the tax fixed-point and only commit once it converges.
63
+ *
64
+ * `amount` is assumed ≤ the account balance (the caller drains against available
65
+ * balance), so the remainder after contributions and conversions is earnings.
66
+ */
67
+ export declare function splitRothWithdrawal(state: RothBasisState, amount: number, year: number, age: number): RothWithdrawalSplit;