@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,149 @@
1
+ /**
2
+ * Relocation compare ("where should I retire?" on your real plan) — see
3
+ * DOCS/enhancements/state-relocation-compare.md.
4
+ *
5
+ * Runs the user's actual plan once per candidate state and ranks the results.
6
+ * A candidate is expressed as a scenario patch over the existing split-year
7
+ * state-move fields (no new persistence), so a candidate row is byte-identical
8
+ * to manually editing the plan's state, and "Add as scenario" round-trips to
9
+ * exactly what the sweep ran.
10
+ *
11
+ * Scope is income tax only: property tax, sales tax, cost of living, and
12
+ * healthcare quality are out of the model. The optional local-rate and
13
+ * flat-spending-delta knobs are the user's blunt approximations for those,
14
+ * and both apply plan-wide (documented in the UI).
15
+ */
16
+ import type { Plan } from '../model/plan.js';
17
+ import type { MarketModelConfig } from '../montecarlo/marketModels.js';
18
+ import { type StateRetirementExclusion } from '../params/state/index.js';
19
+ /** Hard cap on candidate states per sweep (each one is a full plan run + Monte Carlo). */
20
+ export declare const MAX_RELOCATION_CANDIDATES = 5;
21
+ export interface RelocationCandidate {
22
+ /** Two-letter destination state code. */
23
+ state: string;
24
+ /**
25
+ * Calendar year the move happens (July 1 by default, split-year taxed).
26
+ * Omitted ⇒ the plan is priced as if already resident in the candidate
27
+ * state from the start (the plan's base state is replaced).
28
+ */
29
+ moveYear?: number;
30
+ /** Move month for the split year (1–12); defaults to July like the planner. */
31
+ moveMonth?: number;
32
+ /**
33
+ * Flat local income-tax rate (percent) in the destination, replacing the
34
+ * plan's `assumptions.localIncomeTaxPct`. Applies plan-wide — exact for a
35
+ * from-the-start candidate, an approximation across a mid-plan move.
36
+ * **Omitted means 0**: the destination's local rate starts clean, so the
37
+ * origin's locality tax never silently follows the household. Pass the
38
+ * plan's own rate explicitly to keep it.
39
+ */
40
+ localRatePct?: number;
41
+ /**
42
+ * Flat cost-of-living knob: percent change applied to baseline lifestyle
43
+ * spending (`expenses.baseAnnual`), e.g. -10 for a cheaper metro. Applies
44
+ * plan-wide, not just after the move — a deliberate v1 simplification.
45
+ */
46
+ spendingDeltaPct?: number;
47
+ }
48
+ /**
49
+ * The scenario patch a candidate writes — only existing plan fields (household
50
+ * state/moves, the flat-override/local-rate assumptions, baseline spending).
51
+ * Applying it via `applyScenarioPatch` produces the exact plan the sweep runs,
52
+ * so "Add as scenario" and the compare table can never disagree.
53
+ *
54
+ * When the plan's flat state-rate override is set (> 0) it would mask modeled
55
+ * per-state packs entirely, so candidate patches clear it (the UI calls the
56
+ * masking out).
57
+ */
58
+ export declare function relocationScenarioPatch(plan: Plan, candidate: RelocationCandidate, startYear: number): Record<string, unknown>;
59
+ /** Facts about a state's big levers, straight from the modeled pack. */
60
+ export interface RelocationDriverFacts {
61
+ state: string;
62
+ stateName: string;
63
+ hasIncomeTax: boolean;
64
+ taxesSocialSecurity: boolean;
65
+ retirementPrivate: StateRetirementExclusion;
66
+ retirementPublic: StateRetirementExclusion;
67
+ /** One shared all-retirement rule (no separate public-pension law). */
68
+ retirementRuleShared: boolean;
69
+ capitalGainsAsOrdinary: boolean;
70
+ /** Percent of net capital gain in the state base. */
71
+ capitalGainsTaxablePct: number;
72
+ capitalLossCarryforwardConformity: 'federal' | 'currentYearOnly';
73
+ /** Top marginal bracket rate (married filing jointly), percent. */
74
+ topRatePct: number;
75
+ }
76
+ /**
77
+ * Lifetime driver attribution for one row's destination state, computed by
78
+ * re-pricing every recorded ledger tax year with one state feature
79
+ * neutralized at a time (the identical code path the ledger used, so the
80
+ * unmodified recomputation reconciles with the ledger's state-tax lines
81
+ * exactly). Each "savings" is the extra lifetime state+local tax the plan
82
+ * would have paid without that feature — 0 when the feature doesn't apply.
83
+ */
84
+ export interface RelocationDrivers {
85
+ /** Pack facts for the destination state; null when unmodeled or a flat override priced the row. */
86
+ facts: RelocationDriverFacts | null;
87
+ /** Lifetime state+local tax the ledger actually charged (sum of the per-year lines). */
88
+ totalStateLocalTax: number;
89
+ /** Saved because the state leaves the federally-taxable part of Social Security out of its base. */
90
+ ssTreatmentSavings: number;
91
+ /** Saved by the state's retirement-income exclusion(s), both buckets together. */
92
+ retirementExclusionSavings: number;
93
+ /** Portion attributable to a separate public-pension law; 0 when the state has one shared rule. */
94
+ publicPensionExclusionSavings: number;
95
+ /** Saved vs a benchmark state that taxes 100% of net gains as ordinary income (can be negative for nonconforming states). */
96
+ capitalGainsTreatmentSavings: number;
97
+ }
98
+ export interface RelocationCandidateRow {
99
+ /** 'baseline' for the stay-put row, else 'candidate-N'. */
100
+ id: string;
101
+ label: string;
102
+ /** null for the baseline row. */
103
+ candidate: RelocationCandidate | null;
104
+ /** Patch-application/validation failure; when set, all metrics are absent. */
105
+ error: string | null;
106
+ /** Destination state that drives the drill-down (final residence state for the baseline). */
107
+ destinationState: string;
108
+ /** False when any residence state in the row has no modeled pack (tax treated as $0) or a flat override priced it. */
109
+ modeled: boolean;
110
+ lifetimeStateLocalTax: number;
111
+ lifetimeTaxesAndPenalties: number;
112
+ endingAfterTaxEstate: number;
113
+ endingNetWorth: number;
114
+ depletionYear: number | null;
115
+ endYear: number;
116
+ /** Monte Carlo success on shared market paths; null when the sweep ran deterministic-only. */
117
+ successRate: number | null;
118
+ drivers: RelocationDrivers | null;
119
+ /** The ledger's per-year state+local tax lines (nominal). */
120
+ stateTaxByYear: Array<{
121
+ year: number;
122
+ tax: number;
123
+ }>;
124
+ warnings: string[];
125
+ }
126
+ export interface RelocationComparison {
127
+ startYear: number;
128
+ /** Baseline first, then candidates in input order (the UI ranks). */
129
+ rows: RelocationCandidateRow[];
130
+ monteCarlo: {
131
+ pathCount: number;
132
+ seed: number;
133
+ } | null;
134
+ }
135
+ export interface RelocationCompareOptions {
136
+ startYear: number;
137
+ /** When present, each row also gets a success rate on shared market paths. */
138
+ monteCarlo?: {
139
+ model: MarketModelConfig;
140
+ pathCount: number;
141
+ seed: number;
142
+ } | null;
143
+ }
144
+ /**
145
+ * Deterministic N-state sweep (plus optional shared-path Monte Carlo).
146
+ * Pure and synchronous — run it in a worker for the UI. Reproducible per
147
+ * (plan, candidates, startYear, monteCarlo config).
148
+ */
149
+ export declare function compareRelocationCandidates(plan: Plan, candidates: RelocationCandidate[], opts: RelocationCompareOptions): RelocationComparison;
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Relocation compare ("where should I retire?" on your real plan) — see
3
+ * DOCS/enhancements/state-relocation-compare.md.
4
+ *
5
+ * Runs the user's actual plan once per candidate state and ranks the results.
6
+ * A candidate is expressed as a scenario patch over the existing split-year
7
+ * state-move fields (no new persistence), so a candidate row is byte-identical
8
+ * to manually editing the plan's state, and "Add as scenario" round-trips to
9
+ * exactly what the sweep ran.
10
+ *
11
+ * Scope is income tax only: property tax, sales tax, cost of living, and
12
+ * healthcare quality are out of the model. The optional local-rate and
13
+ * flat-spending-delta knobs are the user's blunt approximations for those,
14
+ * and both apply plan-wide (documented in the UI).
15
+ */
16
+ import { stateForYear } from '../model/plan.js';
17
+ import { applyScenarioPatch } from '../scenarios/scenarios.js';
18
+ import { comparePlansOnSharedMarketPaths } from '../montecarlo/sharedPaths.js';
19
+ import { stateParamsFor, } from '../params/state/index.js';
20
+ import { combineTaxCalculators, createFederalTaxCalculator } from '../tax/federalTax.js';
21
+ import { computeStateTaxYearTotal, createStateTaxCalculator } from '../tax/stateTax.js';
22
+ import { summarizeProjection } from './compare.js';
23
+ import { simulatePlan } from './simulate.js';
24
+ /** Hard cap on candidate states per sweep (each one is a full plan run + Monte Carlo). */
25
+ export const MAX_RELOCATION_CANDIDATES = 5;
26
+ /**
27
+ * The scenario patch a candidate writes — only existing plan fields (household
28
+ * state/moves, the flat-override/local-rate assumptions, baseline spending).
29
+ * Applying it via `applyScenarioPatch` produces the exact plan the sweep runs,
30
+ * so "Add as scenario" and the compare table can never disagree.
31
+ *
32
+ * When the plan's flat state-rate override is set (> 0) it would mask modeled
33
+ * per-state packs entirely, so candidate patches clear it (the UI calls the
34
+ * masking out).
35
+ */
36
+ export function relocationScenarioPatch(plan, candidate, startYear) {
37
+ const state = candidate.state.toUpperCase();
38
+ const patch = {};
39
+ if (candidate.moveYear !== undefined && candidate.moveYear >= startYear) {
40
+ // Future (or current-year) move: keep the base state, replace any planned
41
+ // moves with this one split-year move. Arrays replace wholesale in
42
+ // scenario patches, so prior stateMoves cannot leak through.
43
+ patch.household = {
44
+ stateMoves: [{ fromYear: candidate.moveYear, fromMonth: candidate.moveMonth ?? 7, state }],
45
+ };
46
+ }
47
+ else {
48
+ // Resident from the start: swap the base state and drop planned moves.
49
+ patch.household = { state, stateMoves: [] };
50
+ }
51
+ const assumptions = {};
52
+ if (plan.assumptions.stateEffectiveTaxPct > 0)
53
+ assumptions.stateEffectiveTaxPct = 0;
54
+ // Destination local rate starts clean unless specified (omitted = 0); only
55
+ // written when it actually changes the plan, to keep scenario diffs tidy.
56
+ const localRatePct = Math.max(0, candidate.localRatePct ?? 0);
57
+ if (localRatePct !== plan.assumptions.localIncomeTaxPct)
58
+ assumptions.localIncomeTaxPct = localRatePct;
59
+ if (Object.keys(assumptions).length > 0)
60
+ patch.assumptions = assumptions;
61
+ if (candidate.spendingDeltaPct !== undefined && candidate.spendingDeltaPct !== 0) {
62
+ patch.expenses = {
63
+ baseAnnual: Math.max(0, Math.round(plan.expenses.baseAnnual * (1 + candidate.spendingDeltaPct / 100))),
64
+ };
65
+ }
66
+ return patch;
67
+ }
68
+ /** The app-standard per-plan tax stack, with the state side recorded per year. */
69
+ function recordingTaxStack(plan) {
70
+ const lines = new Map();
71
+ const stateCalculator = createStateTaxCalculator({
72
+ overridePct: plan.assumptions.stateEffectiveTaxPct,
73
+ localPct: plan.assumptions.localIncomeTaxPct,
74
+ });
75
+ const recordingState = {
76
+ compute(input) {
77
+ const tax = stateCalculator.compute(input);
78
+ // The ledger converges tax iteratively within each year and never
79
+ // revisits a finished year, so the last computation per year is the
80
+ // ledger's final state-tax line.
81
+ lines.set(input.year, { input, tax });
82
+ return tax;
83
+ },
84
+ };
85
+ return { taxCalculator: combineTaxCalculators(createFederalTaxCalculator(), recordingState), lines };
86
+ }
87
+ function taxStackFor(plan) {
88
+ return combineTaxCalculators(createFederalTaxCalculator(), createStateTaxCalculator({
89
+ overridePct: plan.assumptions.stateEffectiveTaxPct,
90
+ localPct: plan.assumptions.localIncomeTaxPct,
91
+ }));
92
+ }
93
+ function driverFacts(state, year) {
94
+ const params = stateParamsFor(state, year);
95
+ if (!params)
96
+ return null;
97
+ const brackets = params.brackets.marriedFilingJointly;
98
+ return {
99
+ state: params.code,
100
+ stateName: params.name,
101
+ hasIncomeTax: params.hasIncomeTax,
102
+ taxesSocialSecurity: params.taxesSocialSecurity,
103
+ retirementPrivate: params.retirementPrivate,
104
+ retirementPublic: params.retirementPublic,
105
+ retirementRuleShared: params.retirementRuleShared ?? false,
106
+ capitalGainsAsOrdinary: params.capitalGainsAsOrdinary,
107
+ capitalGainsTaxablePct: params.capitalGainsTaxablePct ?? (params.capitalGainsAsOrdinary ? 100 : 0),
108
+ capitalLossCarryforwardConformity: params.capitalLossCarryforwardConformity ?? 'federal',
109
+ topRatePct: brackets.length > 0 ? Math.max(...brackets.map((b) => b.ratePct)) : 0,
110
+ };
111
+ }
112
+ /** Apply `map` to the destination state's params only, leaving other residence states untouched. */
113
+ function forState(state, map) {
114
+ return (p) => (p.code === state ? map(p) : p);
115
+ }
116
+ function computeDrivers(plan, destinationState, startYear, lines, warnings) {
117
+ const opts = { overridePct: plan.assumptions.stateEffectiveTaxPct, localPct: plan.assumptions.localIncomeTaxPct };
118
+ let total = 0;
119
+ for (const { tax } of lines.values())
120
+ total += tax;
121
+ // A flat override masks modeled packs; drivers can't be attributed.
122
+ if (plan.assumptions.stateEffectiveTaxPct > 0) {
123
+ return {
124
+ facts: null,
125
+ totalStateLocalTax: total,
126
+ ssTreatmentSavings: 0,
127
+ retirementExclusionSavings: 0,
128
+ publicPensionExclusionSavings: 0,
129
+ capitalGainsTreatmentSavings: 0,
130
+ };
131
+ }
132
+ const facts = driverFacts(destinationState, startYear);
133
+ const sumVariant = (map) => {
134
+ let sum = 0;
135
+ for (const { input } of lines.values()) {
136
+ sum += computeStateTaxYearTotal(input, map ? { ...opts, mapParams: forState(destinationState, map) } : opts);
137
+ }
138
+ return sum;
139
+ };
140
+ // Acceptance guard: re-pricing every recorded year unmodified must equal the
141
+ // ledger's state-tax lines — the drivers below are only meaningful if it does.
142
+ const reconciled = sumVariant(undefined);
143
+ if (Math.abs(reconciled - total) > 0.01) {
144
+ warnings.push('Driver attribution could not reconcile with the ledger state-tax lines; drill-down figures were suppressed.');
145
+ // facts: null keeps the UI on its "attribution unavailable" message
146
+ // instead of rendering a drivers table of misleading $0 rows.
147
+ return {
148
+ facts: null,
149
+ totalStateLocalTax: total,
150
+ ssTreatmentSavings: 0,
151
+ retirementExclusionSavings: 0,
152
+ publicPensionExclusionSavings: 0,
153
+ capitalGainsTreatmentSavings: 0,
154
+ };
155
+ }
156
+ if (!facts) {
157
+ return {
158
+ facts: null,
159
+ totalStateLocalTax: total,
160
+ ssTreatmentSavings: 0,
161
+ retirementExclusionSavings: 0,
162
+ publicPensionExclusionSavings: 0,
163
+ capitalGainsTreatmentSavings: 0,
164
+ };
165
+ }
166
+ const noExclusion = { kind: 'none' };
167
+ // "Savings" = tax with the feature neutralized − tax as modeled.
168
+ const ssTreatmentSavings = facts.taxesSocialSecurity
169
+ ? 0
170
+ : sumVariant((p) => ({ ...p, taxesSocialSecurity: true })) - total;
171
+ const retirementExclusionSavings = sumVariant((p) => ({ ...p, retirementPrivate: noExclusion, retirementPublic: noExclusion })) - total;
172
+ const publicPensionExclusionSavings = facts.retirementRuleShared
173
+ ? 0
174
+ : sumVariant((p) => ({ ...p, retirementPublic: noExclusion })) - total;
175
+ const capitalGainsTreatmentSavings = sumVariant((p) => ({
176
+ ...p,
177
+ capitalGainsAsOrdinary: true,
178
+ capitalGainsTaxablePct: 100,
179
+ capitalLossCarryforwardConformity: 'federal',
180
+ })) - total;
181
+ return {
182
+ facts,
183
+ totalStateLocalTax: total,
184
+ ssTreatmentSavings,
185
+ retirementExclusionSavings,
186
+ publicPensionExclusionSavings,
187
+ capitalGainsTreatmentSavings,
188
+ };
189
+ }
190
+ function candidateLabel(candidate) {
191
+ const state = candidate.state.toUpperCase();
192
+ return candidate.moveYear !== undefined ? `${state} (move ${candidate.moveYear})` : state;
193
+ }
194
+ function runRow(id, label, candidate, plan, startYear) {
195
+ const endYearFallback = startYear;
196
+ if (candidate) {
197
+ const applied = applyScenarioPatch(plan, relocationScenarioPatch(plan, candidate, startYear));
198
+ if (!applied.ok) {
199
+ return {
200
+ row: {
201
+ id,
202
+ label,
203
+ candidate,
204
+ error: `Candidate is invalid: ${applied.issues.join('; ')}`,
205
+ destinationState: candidate.state.toUpperCase(),
206
+ modeled: false,
207
+ lifetimeStateLocalTax: 0,
208
+ lifetimeTaxesAndPenalties: 0,
209
+ endingAfterTaxEstate: 0,
210
+ endingNetWorth: 0,
211
+ depletionYear: null,
212
+ endYear: endYearFallback,
213
+ successRate: null,
214
+ drivers: null,
215
+ stateTaxByYear: [],
216
+ warnings: [],
217
+ },
218
+ plan: null,
219
+ };
220
+ }
221
+ plan = applied.plan;
222
+ }
223
+ const { taxCalculator, lines } = recordingTaxStack(plan);
224
+ const result = simulatePlan(plan, { startYear, taxCalculator });
225
+ const summary = summarizeProjection(plan, result);
226
+ // The state the drill-down attributes: the plan's ACTUAL final residence.
227
+ // For a candidate whose move year falls beyond the horizon the ledger never
228
+ // relocates, so labeling the requested state would misattribute a baseline
229
+ // run — attribute the real residence and say so.
230
+ const destinationState = stateForYear(plan.household, result.endYear);
231
+ const warnings = [];
232
+ if (candidate && destinationState !== candidate.state.toUpperCase()) {
233
+ warnings.push(`The planned move to ${candidate.state.toUpperCase()}${candidate.moveYear !== undefined ? ` in ${candidate.moveYear}` : ''} falls after the plan horizon (${result.endYear}), so this row never becomes a ${candidate.state.toUpperCase()} resident — it prices the unmoved plan.`);
234
+ }
235
+ const drivers = computeDrivers(plan, destinationState, startYear, lines, warnings);
236
+ // Modeled = every residence year priced through a modeled pack (or a flat
237
+ // override deliberately in charge). An unmodeled state contributes $0 and
238
+ // the ledger already warns about it; surface that as modeled:false.
239
+ const overrideActive = plan.assumptions.stateEffectiveTaxPct > 0;
240
+ let allModeled = true;
241
+ if (!overrideActive) {
242
+ for (const y of result.years) {
243
+ if (!stateParamsFor(stateForYear(plan.household, y.year), y.year)) {
244
+ allModeled = false;
245
+ break;
246
+ }
247
+ }
248
+ }
249
+ const stateTaxByYear = [...lines.entries()]
250
+ .filter(([year]) => year >= result.startYear && year <= result.endYear)
251
+ .sort((a, b) => a[0] - b[0])
252
+ .map(([year, { tax }]) => ({ year, tax }));
253
+ return {
254
+ row: {
255
+ id,
256
+ label,
257
+ candidate,
258
+ error: null,
259
+ destinationState,
260
+ modeled: allModeled && !overrideActive,
261
+ lifetimeStateLocalTax: drivers.totalStateLocalTax,
262
+ lifetimeTaxesAndPenalties: summary.lifetimeTaxesAndPenalties,
263
+ endingAfterTaxEstate: summary.endingAfterTaxEstate,
264
+ endingNetWorth: summary.endingNetWorth,
265
+ depletionYear: result.depletionYear,
266
+ endYear: result.endYear,
267
+ successRate: null,
268
+ drivers,
269
+ stateTaxByYear,
270
+ warnings: [...warnings, ...result.warnings],
271
+ },
272
+ plan,
273
+ };
274
+ }
275
+ /**
276
+ * Deterministic N-state sweep (plus optional shared-path Monte Carlo).
277
+ * Pure and synchronous — run it in a worker for the UI. Reproducible per
278
+ * (plan, candidates, startYear, monteCarlo config).
279
+ */
280
+ export function compareRelocationCandidates(plan, candidates, opts) {
281
+ const capped = candidates.slice(0, MAX_RELOCATION_CANDIDATES);
282
+ // The baseline label names the plan's own residency path, including any
283
+ // planned moves ("CA → FL"), so "stay" never misdescribes a moving plan.
284
+ const finalState = stateForYear(plan.household, Number.MAX_SAFE_INTEGER);
285
+ const baselineLabel = finalState === plan.household.state
286
+ ? `Stay in ${plan.household.state}`
287
+ : `Your plan (${plan.household.state} → ${finalState})`;
288
+ const runs = [
289
+ runRow('baseline', baselineLabel, null, plan, opts.startYear),
290
+ ...capped.map((candidate, i) => runRow(`candidate-${i}`, candidateLabel(candidate), candidate, plan, opts.startYear)),
291
+ ];
292
+ const mc = opts.monteCarlo ?? null;
293
+ if (mc) {
294
+ const entries = runs
295
+ .filter((r) => r.plan !== null)
296
+ .map((r) => ({ id: r.row.id, label: r.row.label, plan: r.plan, taxCalculator: taxStackFor(r.plan) }));
297
+ const comparison = comparePlansOnSharedMarketPaths(entries, {
298
+ startYear: opts.startYear,
299
+ // Unused fallback — every entry carries its own per-candidate stack.
300
+ taxCalculator: taxStackFor(plan),
301
+ model: mc.model,
302
+ pathCount: mc.pathCount,
303
+ seed: mc.seed,
304
+ });
305
+ const byId = new Map(comparison.rows.map((row) => [row.id, row.summary.successRate]));
306
+ for (const r of runs) {
307
+ const successRate = byId.get(r.row.id);
308
+ if (successRate !== undefined)
309
+ r.row.successRate = successRate;
310
+ }
311
+ }
312
+ return {
313
+ startYear: opts.startYear,
314
+ rows: runs.map((r) => r.row),
315
+ monteCarlo: mc ? { pathCount: mc.pathCount, seed: mc.seed } : null,
316
+ };
317
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Deterministic annual-ledger simulation (roadmap V1).
3
+ *
4
+ * Year ordering: ages → income → expenses (incl. debt service) → capped
5
+ * contributions → fixed-point tax/withdrawal iteration → apply flows →
6
+ * property events → growth → snapshot. All amounts are nominal dollars;
7
+ * today's-dollar display is a render-time transform.
8
+ *
9
+ * V1 simplifications (each lifts in a later roadmap phase):
10
+ * - Wages, contributions, base spending, and goals inflate at the general rate;
11
+ * wages stop in the year the person attains retirement age.
12
+ * - SS COLA compounds from the projection start, and first-year benefits are
13
+ * prorated by claim months only (no birthday-month precision). PIA comes
14
+ * from the stream directly or from its earnings history (AIME → bend
15
+ * points). The earnings test withholds own, spousal, and survivor benefits
16
+ * annually ($1/$2 below FRA, $1/$3 in the FRA year) and credits the withheld
17
+ * months back at FRA (ARF, annual approximation). Spousal benefits apply the
18
+ * retirement/survivor family maximum; survivors step up to the deceased's
19
+ * benefit with the early-claim widow(er) reduction and RIB-LIM widow's-limit cap.
20
+ * - RMDs are forced from traditional accounts at SECURE 2.0 start ages
21
+ * (Uniform Lifetime Table; no April-1 first-year deferral). QCDs route
22
+ * charitable dollars out of the RMD (age 70½ ≈ age attained 71).
23
+ * Early-withdrawal penalties: 10% traditional pre-59½ (≈ age < 60), 20%
24
+ * HSA non-medical pre-65. Healthcare expenses: ACA-credited marketplace
25
+ * premiums pre-65 (credit vs prior-year MAGI; 400% FPL cliff), Medicare
26
+ * Part B + IRMAA (MAGI 2-year lookback) + Part D surcharge + extras from
27
+ * 65. Roth conversions run after RMDs (manual amounts or fill-to-target
28
+ * sized against the federal engine; conversion taxes ride the normal
29
+ * withdrawal flow, so they come from cash/taxable first and are never
30
+ * penalized). Annuities end at owner death; pensions pay survivorPct to a
31
+ * surviving spouse once payments have started.
32
+ * - Contribution limits beyond the latest parameter pack are indexed forward
33
+ * at the assumed inflation rate (statutory limits are inflation-indexed).
34
+ */
35
+ import type { Plan } from '../model/plan.js';
36
+ import { type MarketSeries, type OptimizerYearProbe, type ProjectionResult, type TaxCalculator } from './types.js';
37
+ export interface SimulateOptions {
38
+ startYear: number;
39
+ taxCalculator: TaxCalculator;
40
+ /**
41
+ * Per-year stochastic returns/inflation (Monte Carlo, roadmap V4). Omitted =
42
+ * deterministic run using the plan's assumptions every year.
43
+ */
44
+ market?: MarketSeries;
45
+ /**
46
+ * Per-person age at death (last full year alive), overriding longevity.planningAge.
47
+ * Used by stochastic-longevity Monte Carlo (roadmap V6); omitted = use planningAge.
48
+ */
49
+ deathAgeByPersonId?: Record<string, number>;
50
+ /**
51
+ * Force the projection's end year, independent of lifespans, so every Monte
52
+ * Carlo path shares a year grid even when sampled death ages differ.
53
+ */
54
+ horizonEndYear?: number;
55
+ /**
56
+ * Optional sink for the V8 optimizer's per-year linearization inputs. A no-op
57
+ * when omitted, so a normal projection is unaffected. @see OptimizerYearProbe
58
+ */
59
+ captureOptimizerInputs?: (probe: OptimizerYearProbe) => void;
60
+ }
61
+ export declare function simulatePlan(plan: Plan, opts: SimulateOptions): ProjectionResult;