@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,353 @@
1
+ /**
2
+ * Monte Carlo path runner + aggregation (roadmap V4, feature catalog §11).
3
+ *
4
+ * Each path is the same deterministic annual ledger (engine/projection)
5
+ * driven by one stochastic MarketSeries — never a separate model. Per-path
6
+ * seeds derive from (seed, pathIndex), so a run is reproducible regardless
7
+ * of how paths are partitioned across workers. Aggregation produces the
8
+ * headline outputs: success probability, the per-year percentile fan,
9
+ * the ending-balance distribution, and the depletion-age histogram.
10
+ */
11
+ import { summarizeProjection } from '../projection/compare.js';
12
+ import { simulatePlan } from '../projection/simulate.js';
13
+ import { sampleCareEvents } from './ltcShock.js';
14
+ import { MAX_AGE, sampleDeathAge } from './mortality.js';
15
+ import { createRng, derivePathSeed } from './rng.js';
16
+ /** Dollar threshold below which a shortfall is treated as rounding, not a miss. */
17
+ const SHORTFALL_EPSILON = 0.5;
18
+ export function runMonteCarloPaths(plan, opts) {
19
+ const first = opts.firstPathIndex ?? 0;
20
+ const paths = [];
21
+ let startYear = opts.startYear;
22
+ let endYear = opts.startYear;
23
+ // In stochastic-longevity mode, run every path to a fixed wide horizon (the
24
+ // youngest person reaching the table's max age) so the year grids align even
25
+ // though sampled death ages differ; otherwise the horizon is plan-determined.
26
+ const horizonEndYear = opts.stochasticLongevity
27
+ ? Math.max(...plan.household.people.map((p) => Number(p.dob.slice(0, 4)) + MAX_AGE))
28
+ : undefined;
29
+ for (let i = 0; i < opts.pathCount; i++) {
30
+ const rng = createRng(derivePathSeed(opts.seed, first + i));
31
+ // 120 years comfortably covers any plan horizon; simulate clamps reads.
32
+ const market = opts.model.generatePath(rng, 120);
33
+ let pathPlan = plan;
34
+ let deathAgeByPersonId;
35
+ if (opts.stochasticLongevity) {
36
+ deathAgeByPersonId = {};
37
+ for (const p of plan.household.people) {
38
+ deathAgeByPersonId[p.id] = sampleDeathAge(rng, opts.startYear - Number(p.dob.slice(0, 4)), p.sex);
39
+ }
40
+ }
41
+ if (opts.ltcShock) {
42
+ const sampled = sampleCareEvents(rng, plan.household.people, opts.startYear, opts.ltcShock);
43
+ if (sampled.length > 0)
44
+ pathPlan = { ...plan, careEvents: [...plan.careEvents, ...sampled] };
45
+ }
46
+ const result = simulatePlan(pathPlan, {
47
+ startYear: opts.startYear,
48
+ taxCalculator: opts.taxCalculator,
49
+ market,
50
+ deathAgeByPersonId,
51
+ horizonEndYear,
52
+ });
53
+ const projectionSummary = summarizeProjection(pathPlan, result);
54
+ startYear = result.startYear;
55
+ endYear = result.endYear;
56
+ const investableByYear = new Float64Array(result.years.length);
57
+ let requiredFloorMet = true;
58
+ let targetLifestyleMet = true;
59
+ let totalShortfall = 0;
60
+ let totalRequiredShortfall = 0;
61
+ let totalTargetShortfall = 0;
62
+ let targetIntended = 0;
63
+ let targetFunded = 0;
64
+ let targetShortfallTotal = 0;
65
+ let yearsBelowTarget = 0;
66
+ let idealIntended = 0;
67
+ let idealFunded = 0;
68
+ let excessIntended = 0;
69
+ let excessFunded = 0;
70
+ const flexibleGoals = { funded: 0, partiallyFunded: 0, deferred: 0, skipped: 0, fundedAmount: 0, unfundedAmount: 0 };
71
+ const guardrailActionCounts = { cut: 0, raise: 0, hold: 0 };
72
+ let guardrailCutYears = 0;
73
+ let longestGuardrailCutSpellYears = 0;
74
+ let currentCutSpellYears = 0;
75
+ let maxGuardrailCutDepth = 0;
76
+ for (let y = 0; y < result.years.length; y++) {
77
+ const yr = result.years[y];
78
+ investableByYear[y] = yr.investableTotal;
79
+ const guardrailFactor = yr.expenses.guardrailFactor;
80
+ if (guardrailFactor < 1 - 1e-9) {
81
+ guardrailCutYears++;
82
+ currentCutSpellYears++;
83
+ if (currentCutSpellYears > longestGuardrailCutSpellYears)
84
+ longestGuardrailCutSpellYears = currentCutSpellYears;
85
+ if (1 - guardrailFactor > maxGuardrailCutDepth)
86
+ maxGuardrailCutDepth = 1 - guardrailFactor;
87
+ }
88
+ else {
89
+ currentCutSpellYears = 0;
90
+ }
91
+ totalShortfall += yr.shortfall;
92
+ totalRequiredShortfall += yr.requiredShortfall;
93
+ totalTargetShortfall += yr.targetShortfall;
94
+ if (yr.requiredShortfall > SHORTFALL_EPSILON)
95
+ requiredFloorMet = false;
96
+ if (yr.targetShortfall > SHORTFALL_EPSILON)
97
+ targetLifestyleMet = false;
98
+ targetIntended += yr.expenses.targetSpending;
99
+ targetFunded += Math.max(0, yr.expenses.targetSpending - yr.targetShortfall);
100
+ targetShortfallTotal += yr.targetShortfall;
101
+ if (yr.targetShortfall > SHORTFALL_EPSILON)
102
+ yearsBelowTarget++;
103
+ idealIntended += yr.expenses.idealSpending;
104
+ idealFunded += Math.max(0, yr.expenses.idealSpending - yr.idealShortfall);
105
+ excessIntended += yr.expenses.excessSpending;
106
+ excessFunded += Math.max(0, yr.expenses.excessSpending - yr.excessShortfall);
107
+ flexibleGoals.funded += yr.flexibleGoals.funded;
108
+ flexibleGoals.partiallyFunded += yr.flexibleGoals.partiallyFunded;
109
+ flexibleGoals.deferred += yr.flexibleGoals.deferred;
110
+ flexibleGoals.skipped += yr.flexibleGoals.skipped;
111
+ flexibleGoals.fundedAmount += yr.flexibleGoals.fundedAmount;
112
+ flexibleGoals.unfundedAmount += yr.flexibleGoals.unfundedAmount;
113
+ guardrailActionCounts[yr.guardrailAction]++;
114
+ }
115
+ // Bequest target is entered in today's dollars; the ending estate is
116
+ // nominal at path end, so the target is inflated across the path horizon
117
+ // using this path's *realized* inflation series (mirroring the ledger's
118
+ // cumulative factor, with the plan assumption as the past-series fallback).
119
+ const bequestTarget = pathPlan.expenses.bequestTargetDollars ?? 0;
120
+ let pathInflationFactor = 1;
121
+ if (bequestTarget > 0) {
122
+ const series = market.inflationPct;
123
+ for (let y = 0; y < result.endYear - result.startYear; y++) {
124
+ const pct = series && series.length > 0
125
+ ? (series[Math.min(y, series.length - 1)] ?? pathPlan.assumptions.inflationPct)
126
+ : pathPlan.assumptions.inflationPct;
127
+ pathInflationFactor *= 1 + pct / 100;
128
+ }
129
+ }
130
+ const endingAboveBequestTarget = bequestTarget > 0 ? projectionSummary.endingAfterTaxEstate >= bequestTarget * pathInflationFactor : null;
131
+ paths.push({
132
+ investableByYear,
133
+ endingInvestable: result.endingInvestable,
134
+ endingNetWorth: result.endingNetWorth,
135
+ endingAfterTaxEstate: projectionSummary.endingAfterTaxEstate,
136
+ depletionYear: result.depletionYear,
137
+ totalShortfall,
138
+ totalRequiredShortfall,
139
+ totalTargetShortfall,
140
+ requiredFloorMet,
141
+ targetLifestyleMet,
142
+ targetAttainmentPct: targetIntended > 0 ? Math.min(1, targetFunded / targetIntended) : 1,
143
+ averageAnnualTargetShortfall: result.years.length > 0 ? targetShortfallTotal / result.years.length : 0,
144
+ yearsBelowTarget,
145
+ idealIntended,
146
+ idealFunded,
147
+ excessIntended,
148
+ excessFunded,
149
+ flexibleGoals,
150
+ guardrailActionCounts,
151
+ guardrailCutYears,
152
+ longestGuardrailCutSpellYears,
153
+ maxGuardrailCutDepth,
154
+ endingAboveBequestTarget,
155
+ });
156
+ opts.onPathDone?.(i + 1);
157
+ }
158
+ return { startYear, endYear, paths };
159
+ }
160
+ function percentile(sorted, p) {
161
+ if (sorted.length === 0)
162
+ return 0;
163
+ const idx = (p / 100) * (sorted.length - 1);
164
+ const lo = Math.floor(idx);
165
+ const hi = Math.ceil(idx);
166
+ const frac = idx - lo;
167
+ return sorted[lo] * (1 - frac) + sorted[hi] * frac;
168
+ }
169
+ function percentileSet(sorted) {
170
+ return {
171
+ p10: percentile(sorted, 10),
172
+ p25: percentile(sorted, 25),
173
+ p50: percentile(sorted, 50),
174
+ p75: percentile(sorted, 75),
175
+ p90: percentile(sorted, 90),
176
+ };
177
+ }
178
+ function histogramFor(sorted, histogramBins) {
179
+ const min = sorted[0] ?? 0;
180
+ const max = sorted[sorted.length - 1] ?? 0;
181
+ const binWidth = max > min ? (max - min) / histogramBins : 1;
182
+ const counts = new Array(histogramBins).fill(0);
183
+ for (const v of sorted) {
184
+ counts[Math.min(histogramBins - 1, Math.floor((v - min) / binWidth))]++;
185
+ }
186
+ return { min, binWidth, counts };
187
+ }
188
+ export function aggregateMonteCarlo(result, histogramBins = 30) {
189
+ const { paths, startYear } = result;
190
+ const yearCount = paths[0]?.investableByYear.length ?? 0;
191
+ const fan = [];
192
+ const column = new Array(paths.length);
193
+ for (let y = 0; y < yearCount; y++) {
194
+ for (let i = 0; i < paths.length; i++)
195
+ column[i] = paths[i].investableByYear[y];
196
+ column.sort((a, b) => a - b);
197
+ fan.push({ year: startYear + y, ...percentileSet(column) });
198
+ }
199
+ const endings = paths.map((p) => p.endingInvestable).sort((a, b) => a - b);
200
+ const endingNetWorths = paths.map((p) => p.endingNetWorth).sort((a, b) => a - b);
201
+ const endingAfterTaxEstates = paths.map((p) => p.endingAfterTaxEstate).sort((a, b) => a - b);
202
+ const depletionMap = new Map();
203
+ let successes = 0;
204
+ let requiredFloorSuccesses = 0;
205
+ let targetLifestyleSuccesses = 0;
206
+ let averageAnnualTargetShortfallTotal = 0;
207
+ let yearsBelowTargetTotal = 0;
208
+ let idealIntendedTotal = 0;
209
+ let idealFundedTotal = 0;
210
+ let excessIntendedTotal = 0;
211
+ let excessFundedTotal = 0;
212
+ let totalShortfallTotal = 0;
213
+ let totalRequiredShortfallTotal = 0;
214
+ let totalTargetShortfallTotal = 0;
215
+ let failingPathShortfallTotal = 0;
216
+ let failingPathRequiredShortfallTotal = 0;
217
+ let failingPathTargetShortfallTotal = 0;
218
+ let failingPathCount = 0;
219
+ const totalShortfalls = [];
220
+ const targetAttainments = [];
221
+ const averageTargetShortfalls = [];
222
+ const flexibleGoals = { funded: 0, partiallyFunded: 0, deferred: 0, skipped: 0, fundedAmount: 0, unfundedAmount: 0 };
223
+ const guardrailActionCounts = { cut: 0, raise: 0, hold: 0 };
224
+ const cutDepths = [];
225
+ const cutYearCounts = [];
226
+ let cutSpellTotal = 0;
227
+ let pathsWithCut = 0;
228
+ let pathsWithRaise = 0;
229
+ let surplusPaths = 0;
230
+ let bequestTargetPaths = 0;
231
+ let aboveBequestTargetPaths = 0;
232
+ for (const p of paths) {
233
+ if (p.depletionYear === null)
234
+ successes++;
235
+ else {
236
+ depletionMap.set(p.depletionYear, (depletionMap.get(p.depletionYear) ?? 0) + 1);
237
+ failingPathCount++;
238
+ failingPathShortfallTotal += p.totalShortfall;
239
+ failingPathRequiredShortfallTotal += p.totalRequiredShortfall;
240
+ failingPathTargetShortfallTotal += p.totalTargetShortfall;
241
+ }
242
+ totalShortfalls.push(p.totalShortfall);
243
+ totalShortfallTotal += p.totalShortfall;
244
+ totalRequiredShortfallTotal += p.totalRequiredShortfall;
245
+ totalTargetShortfallTotal += p.totalTargetShortfall;
246
+ if (p.requiredFloorMet)
247
+ requiredFloorSuccesses++;
248
+ if (p.targetLifestyleMet)
249
+ targetLifestyleSuccesses++;
250
+ targetAttainments.push(p.targetAttainmentPct);
251
+ averageTargetShortfalls.push(p.averageAnnualTargetShortfall);
252
+ averageAnnualTargetShortfallTotal += p.averageAnnualTargetShortfall;
253
+ yearsBelowTargetTotal += p.yearsBelowTarget;
254
+ idealIntendedTotal += p.idealIntended;
255
+ idealFundedTotal += p.idealFunded;
256
+ excessIntendedTotal += p.excessIntended;
257
+ excessFundedTotal += p.excessFunded;
258
+ flexibleGoals.funded += p.flexibleGoals.funded;
259
+ flexibleGoals.partiallyFunded += p.flexibleGoals.partiallyFunded;
260
+ flexibleGoals.deferred += p.flexibleGoals.deferred;
261
+ flexibleGoals.skipped += p.flexibleGoals.skipped;
262
+ flexibleGoals.fundedAmount += p.flexibleGoals.fundedAmount;
263
+ flexibleGoals.unfundedAmount += p.flexibleGoals.unfundedAmount;
264
+ guardrailActionCounts.cut += p.guardrailActionCounts.cut;
265
+ guardrailActionCounts.raise += p.guardrailActionCounts.raise;
266
+ guardrailActionCounts.hold += p.guardrailActionCounts.hold;
267
+ if (p.guardrailCutYears > 0) {
268
+ pathsWithCut++;
269
+ cutDepths.push(p.maxGuardrailCutDepth);
270
+ cutYearCounts.push(p.guardrailCutYears);
271
+ cutSpellTotal += p.longestGuardrailCutSpellYears;
272
+ }
273
+ if (p.guardrailActionCounts.raise > 0)
274
+ pathsWithRaise++;
275
+ if (p.endingAfterTaxEstate > 0)
276
+ surplusPaths++;
277
+ if (p.endingAboveBequestTarget !== null) {
278
+ bequestTargetPaths++;
279
+ if (p.endingAboveBequestTarget)
280
+ aboveBequestTargetPaths++;
281
+ }
282
+ }
283
+ const depletionYearCounts = [...depletionMap.entries()]
284
+ .map(([year, count]) => ({ year, count }))
285
+ .sort((a, b) => a.year - b.year);
286
+ const share = (n) => (paths.length === 0 ? 0 : n / paths.length);
287
+ let cumulative = 0;
288
+ const depletionProbabilityByYear = depletionYearCounts.map(({ year, count }) => {
289
+ const probability = share(count);
290
+ cumulative += probability;
291
+ return { year, count, probability, cumulativeProbability: cumulative };
292
+ });
293
+ targetAttainments.sort((a, b) => a - b);
294
+ averageTargetShortfalls.sort((a, b) => a - b);
295
+ totalShortfalls.sort((a, b) => a - b);
296
+ cutDepths.sort((a, b) => a - b);
297
+ cutYearCounts.sort((a, b) => a - b);
298
+ const adjustments = {
299
+ pathsWithCut: share(pathsWithCut),
300
+ pathsWithRaise: share(pathsWithRaise),
301
+ medianMaxCutDepth: percentile(cutDepths, 50),
302
+ p90MaxCutDepth: percentile(cutDepths, 90),
303
+ averageCutYears: pathsWithCut === 0 ? 0 : cutYearCounts.reduce((a, b) => a + b, 0) / pathsWithCut,
304
+ p90CutYears: percentile(cutYearCounts, 90),
305
+ averageLongestCutSpellYears: pathsWithCut === 0 ? 0 : cutSpellTotal / pathsWithCut,
306
+ probEndingSurplus: share(surplusPaths),
307
+ probEndingAboveBequestTarget: bequestTargetPaths === 0 ? null : aboveBequestTargetPaths / bequestTargetPaths,
308
+ };
309
+ return {
310
+ pathCount: paths.length,
311
+ successRate: share(successes),
312
+ requiredFloorSuccessRate: share(requiredFloorSuccesses),
313
+ targetLifestyleSuccessRate: share(targetLifestyleSuccesses),
314
+ targetAttainmentPct: percentileSet(targetAttainments),
315
+ averageAnnualTargetShortfall: paths.length === 0 ? 0 : averageAnnualTargetShortfallTotal / paths.length,
316
+ p90AverageAnnualTargetShortfall: percentile(averageTargetShortfalls, 90),
317
+ averageYearsBelowTarget: paths.length === 0 ? 0 : yearsBelowTargetTotal / paths.length,
318
+ idealFundingRate: idealIntendedTotal > 0 ? idealFundedTotal / idealIntendedTotal : 1,
319
+ excessFundingRate: excessIntendedTotal > 0 ? excessFundedTotal / excessIntendedTotal : 1,
320
+ flexibleGoals,
321
+ guardrailActionCounts,
322
+ adjustments,
323
+ downsideRisk: {
324
+ failureRate: share(failingPathCount),
325
+ failingPathCount,
326
+ expectedShortfallDollars: failingPathCount === 0 ? 0 : failingPathShortfallTotal / failingPathCount,
327
+ expectedRequiredShortfallDollars: failingPathCount === 0 ? 0 : failingPathRequiredShortfallTotal / failingPathCount,
328
+ expectedTargetShortfallDollars: failingPathCount === 0 ? 0 : failingPathTargetShortfallTotal / failingPathCount,
329
+ p90TotalShortfallDollars: percentile(totalShortfalls, 90),
330
+ },
331
+ spendingShortfall: {
332
+ averageTotalShortfallDollars: paths.length === 0 ? 0 : totalShortfallTotal / paths.length,
333
+ averageRequiredShortfallDollars: paths.length === 0 ? 0 : totalRequiredShortfallTotal / paths.length,
334
+ averageTargetShortfallDollars: paths.length === 0 ? 0 : totalTargetShortfallTotal / paths.length,
335
+ p90TotalShortfallDollars: percentile(totalShortfalls, 90),
336
+ },
337
+ fan,
338
+ endingInvestable: { percentiles: percentileSet(endings), histogram: histogramFor(endings, histogramBins) },
339
+ endingNetWorth: { percentiles: percentileSet(endingNetWorths), histogram: histogramFor(endingNetWorths, histogramBins) },
340
+ endingAfterTaxEstate: {
341
+ percentiles: percentileSet(endingAfterTaxEstates),
342
+ histogram: histogramFor(endingAfterTaxEstates, histogramBins),
343
+ },
344
+ depletionYearCounts,
345
+ depletionProbabilityByYear,
346
+ };
347
+ }
348
+ /** Merge per-worker partial results (path order does not affect aggregation). */
349
+ export function mergePathResults(parts) {
350
+ const nonEmpty = parts.filter((p) => p.paths.length > 0);
351
+ const first = nonEmpty[0] ?? parts[0] ?? { startYear: 0, endYear: 0, paths: [] };
352
+ return { startYear: first.startYear, endYear: first.endYear, paths: nonEmpty.flatMap((p) => p.paths) };
353
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Shared-path stochastic comparisons.
3
+ *
4
+ * Every plan variant is evaluated with the same seed, path count, and market
5
+ * model config, so path index N means the same market history for every row.
6
+ * This is the reusable primitive for scenario/candidate stochastic deltas.
7
+ */
8
+ import type { Plan } from '../model/plan.js';
9
+ import type { TaxCalculator } from '../projection/types.js';
10
+ import type { LtcShockParams } from './ltcShock.js';
11
+ import type { MarketModelConfig } from './marketModels.js';
12
+ import { type MonteCarloSummary } from './run.js';
13
+ export interface SharedPathPlan {
14
+ id: string;
15
+ label: string;
16
+ plan: Plan;
17
+ /**
18
+ * Per-entry tax stack, when variants need different calculators (e.g. the
19
+ * relocation compare's per-candidate local rates). Falls back to the shared
20
+ * `opts.taxCalculator`.
21
+ */
22
+ taxCalculator?: TaxCalculator;
23
+ }
24
+ export interface SharedPathComparisonOptions {
25
+ startYear: number;
26
+ taxCalculator: TaxCalculator;
27
+ model: MarketModelConfig;
28
+ pathCount: number;
29
+ seed: number;
30
+ stochasticLongevity?: boolean;
31
+ ltcShock?: LtcShockParams | null;
32
+ }
33
+ export interface SharedPathComparisonRow {
34
+ id: string;
35
+ label: string;
36
+ summary: MonteCarloSummary;
37
+ }
38
+ export interface SharedPathComparison {
39
+ seed: number;
40
+ pathCount: number;
41
+ rows: SharedPathComparisonRow[];
42
+ }
43
+ export declare function comparePlansOnSharedMarketPaths(plans: SharedPathPlan[], opts: SharedPathComparisonOptions): SharedPathComparison;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared-path stochastic comparisons.
3
+ *
4
+ * Every plan variant is evaluated with the same seed, path count, and market
5
+ * model config, so path index N means the same market history for every row.
6
+ * This is the reusable primitive for scenario/candidate stochastic deltas.
7
+ */
8
+ import { createMarketModel } from './marketModels.js';
9
+ import { aggregateMonteCarlo, runMonteCarloPaths } from './run.js';
10
+ export function comparePlansOnSharedMarketPaths(plans, opts) {
11
+ return {
12
+ seed: opts.seed,
13
+ pathCount: opts.pathCount,
14
+ rows: plans.map((entry) => ({
15
+ id: entry.id,
16
+ label: entry.label,
17
+ summary: aggregateMonteCarlo(runMonteCarloPaths(entry.plan, {
18
+ startYear: opts.startYear,
19
+ taxCalculator: entry.taxCalculator ?? opts.taxCalculator,
20
+ model: createMarketModel(opts.model),
21
+ seed: opts.seed,
22
+ pathCount: opts.pathCount,
23
+ stochasticLongevity: opts.stochasticLongevity,
24
+ ltcShock: opts.ltcShock,
25
+ })),
26
+ })),
27
+ };
28
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Survival-percentile planning ages (spending-paths & SWR-lenses plan, Goal 4).
3
+ *
4
+ * "Plan to the age you have a 25% (or 10%) chance of reaching" is standard
5
+ * longevity-risk advice — the refreshed Actuaries Longevity Illustrator is the
6
+ * canonical tool that operationalizes it. These helpers compute that age from
7
+ * the same SSA 2022 q(x) derivation the stochastic-longevity Monte Carlo uses
8
+ * (mortality.ts), so every surface quotes one mortality table.
9
+ *
10
+ * The optional health adjustment is a proportional-hazards transform: annual
11
+ * survival p(x) is raised to a hazard power h (q' = 1 − (1−q)^h; h > 1 = worse
12
+ * health). `hazardForExpectancyMultiplier` converts the longevity wizard's
13
+ * remaining-years multiplier (its sourced smoking/health/activity factors,
14
+ * see ../../longevity/factors.ts) into that hazard power, so the percentile
15
+ * picker can reuse the questionnaire instead of a second unsourced factor set.
16
+ */
17
+ import { type Sex } from './mortality.js';
18
+ /**
19
+ * Probability someone `currentAge` today is still alive at `targetAge`
20
+ * (product of one-year survivals over integer ages). 1 for targetAge ≤ current.
21
+ */
22
+ export declare function survivalProbabilityTo(currentAge: number, sex: Sex, targetAge: number, hazard?: number): number;
23
+ /**
24
+ * The oldest integer age this person still reaches with probability ≥ pct/100 —
25
+ * "the age I have a `pct`% chance of reaching". Monotone: smaller pct ⇒ older
26
+ * age. Always ≥ the current age (you have already reached it) and ≤ MAX_AGE + 1
27
+ * (the table forces death by then).
28
+ */
29
+ export declare function survivalPercentileAge(currentAge: number, sex: Sex, pct: number, hazard?: number): number;
30
+ export interface SurvivalPerson {
31
+ age: number;
32
+ sex: Sex;
33
+ hazard?: number;
34
+ }
35
+ /**
36
+ * Joint ("either of us") percentile age on the primary person's age clock: the
37
+ * oldest primary age A such that P(at least one member alive when the primary
38
+ * would be A) ≥ pct/100, assuming independent lifetimes —
39
+ * P = 1 − (1 − S_primary)(1 − S_partner). Always ≥ the single-life answer.
40
+ */
41
+ export declare function jointSurvivalPercentileAge(primary: SurvivalPerson, partner: SurvivalPerson, pct: number): number;
42
+ /**
43
+ * Convert a remaining-years multiplier `m` (the longevity questionnaire's
44
+ * applied multiplier: m < 1 = shorter expectancy) into the proportional-hazards
45
+ * power whose adjusted expectancy at this age is m × the SSA baseline, solved
46
+ * by bisection. m = 1 returns ~1 (identity); results are clamped to a sane
47
+ * hazard range so extreme questionnaire answers cannot degenerate the curve.
48
+ */
49
+ export declare function hazardForExpectancyMultiplier(age: number, sex: Sex, m: number): number;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Survival-percentile planning ages (spending-paths & SWR-lenses plan, Goal 4).
3
+ *
4
+ * "Plan to the age you have a 25% (or 10%) chance of reaching" is standard
5
+ * longevity-risk advice — the refreshed Actuaries Longevity Illustrator is the
6
+ * canonical tool that operationalizes it. These helpers compute that age from
7
+ * the same SSA 2022 q(x) derivation the stochastic-longevity Monte Carlo uses
8
+ * (mortality.ts), so every surface quotes one mortality table.
9
+ *
10
+ * The optional health adjustment is a proportional-hazards transform: annual
11
+ * survival p(x) is raised to a hazard power h (q' = 1 − (1−q)^h; h > 1 = worse
12
+ * health). `hazardForExpectancyMultiplier` converts the longevity wizard's
13
+ * remaining-years multiplier (its sourced smoking/health/activity factors,
14
+ * see ../../longevity/factors.ts) into that hazard power, so the percentile
15
+ * picker can reuse the questionnaire instead of a second unsourced factor set.
16
+ */
17
+ import { baselineRemainingYears } from '../longevity/ssaPeriod2022.js';
18
+ import { annualMortality, MAX_AGE } from './mortality.js';
19
+ /** One-year survival probability at integer age, under a hazard power. */
20
+ function annualSurvival(age, sex, hazard) {
21
+ const q = annualMortality(age, sex);
22
+ if (q >= 1)
23
+ return 0;
24
+ return Math.pow(1 - q, hazard);
25
+ }
26
+ /**
27
+ * Probability someone `currentAge` today is still alive at `targetAge`
28
+ * (product of one-year survivals over integer ages). 1 for targetAge ≤ current.
29
+ */
30
+ export function survivalProbabilityTo(currentAge, sex, targetAge, hazard = 1) {
31
+ const from = Math.floor(Math.max(currentAge, 0));
32
+ const to = Math.floor(targetAge);
33
+ let s = 1;
34
+ for (let age = from; age < to; age++) {
35
+ s *= annualSurvival(age, sex, hazard);
36
+ if (s <= 0)
37
+ return 0;
38
+ }
39
+ return s;
40
+ }
41
+ /**
42
+ * The oldest integer age this person still reaches with probability ≥ pct/100 —
43
+ * "the age I have a `pct`% chance of reaching". Monotone: smaller pct ⇒ older
44
+ * age. Always ≥ the current age (you have already reached it) and ≤ MAX_AGE + 1
45
+ * (the table forces death by then).
46
+ */
47
+ export function survivalPercentileAge(currentAge, sex, pct, hazard = 1) {
48
+ const threshold = Math.min(Math.max(pct, 0.1), 100) / 100;
49
+ const from = Math.floor(Math.max(currentAge, 0));
50
+ let s = 1;
51
+ let best = from;
52
+ for (let age = from; age <= MAX_AGE; age++) {
53
+ s *= annualSurvival(age, sex, hazard);
54
+ if (s >= threshold)
55
+ best = age + 1;
56
+ else
57
+ break;
58
+ }
59
+ return best;
60
+ }
61
+ /**
62
+ * Joint ("either of us") percentile age on the primary person's age clock: the
63
+ * oldest primary age A such that P(at least one member alive when the primary
64
+ * would be A) ≥ pct/100, assuming independent lifetimes —
65
+ * P = 1 − (1 − S_primary)(1 − S_partner). Always ≥ the single-life answer.
66
+ */
67
+ export function jointSurvivalPercentileAge(primary, partner, pct) {
68
+ const threshold = Math.min(Math.max(pct, 0.1), 100) / 100;
69
+ const from = Math.floor(Math.max(primary.age, 0));
70
+ const partnerFrom = Math.floor(Math.max(partner.age, 0));
71
+ let sPrimary = 1;
72
+ let sPartner = 1;
73
+ let best = from;
74
+ // Walk both survival curves on the primary's clock; the partner's own clock
75
+ // is offset by the age difference.
76
+ for (let t = 0; from + t <= MAX_AGE + 1; t++) {
77
+ const eitherAlive = 1 - (1 - sPrimary) * (1 - sPartner);
78
+ if (eitherAlive >= threshold)
79
+ best = from + t;
80
+ else
81
+ break;
82
+ sPrimary *= annualSurvival(from + t, primary.sex, primary.hazard ?? 1);
83
+ sPartner *= annualSurvival(partnerFrom + t, partner.sex, partner.hazard ?? 1);
84
+ if (sPrimary <= 0 && sPartner <= 0)
85
+ break;
86
+ }
87
+ return best;
88
+ }
89
+ /** Curtate-style remaining life expectancy under a hazard power: 0.5 + Σ S(t). */
90
+ function expectancyUnderHazard(age, sex, hazard) {
91
+ const from = Math.floor(Math.max(age, 0));
92
+ let s = 1;
93
+ let e = 0.5;
94
+ for (let a = from; a <= MAX_AGE; a++) {
95
+ s *= annualSurvival(a, sex, hazard);
96
+ e += s;
97
+ if (s <= 1e-12)
98
+ break;
99
+ }
100
+ return e;
101
+ }
102
+ /**
103
+ * Convert a remaining-years multiplier `m` (the longevity questionnaire's
104
+ * applied multiplier: m < 1 = shorter expectancy) into the proportional-hazards
105
+ * power whose adjusted expectancy at this age is m × the SSA baseline, solved
106
+ * by bisection. m = 1 returns ~1 (identity); results are clamped to a sane
107
+ * hazard range so extreme questionnaire answers cannot degenerate the curve.
108
+ */
109
+ export function hazardForExpectancyMultiplier(age, sex, m) {
110
+ const target = Math.max(0.1, m) * baselineRemainingYears(age, sex);
111
+ let lo = 0.2; // far healthier than the table
112
+ let hi = 8; // far sicker than the table
113
+ // Expectancy is strictly decreasing in the hazard power.
114
+ if (expectancyUnderHazard(age, sex, lo) <= target)
115
+ return lo;
116
+ if (expectancyUnderHazard(age, sex, hi) >= target)
117
+ return hi;
118
+ for (let i = 0; i < 40; i++) {
119
+ const mid = (lo + hi) / 2;
120
+ if (expectancyUnderHazard(age, sex, mid) > target)
121
+ lo = mid;
122
+ else
123
+ hi = mid;
124
+ }
125
+ return (lo + hi) / 2;
126
+ }
@@ -0,0 +1,17 @@
1
+ import type { RealYieldCurve } from '../types.js';
2
+ /**
3
+ * Embedded TIPS real-yield curve snapshot (par real yields, percent per year).
4
+ *
5
+ * Source: U.S. Treasury "Daily Treasury Par Real Yield Curve Rates"
6
+ * (https://home.treasury.gov/resource-center/data-chart-center/interest-rates),
7
+ * end-of-June 2026 readings rounded to 5bp. The Treasury publishes 5-, 7-, 10-,
8
+ * 20-, and 30-year par real yields; the ladder engine interpolates between
9
+ * points and holds the endpoints flat outside them.
10
+ *
11
+ * This snapshot is the app's offline posture: every ladder quote and funded
12
+ * ratio works without a network call. Refresh cadence: annually with the
13
+ * parameter packs (see DOCS/maintenance-schedule.md), or opt-in per-session
14
+ * via the FedInvest live-price fetch (engine/ladder/fedInvest.ts), which
15
+ * never replaces this embedded default.
16
+ */
17
+ export declare const REAL_YIELD_CURVE_2026: RealYieldCurve;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Embedded TIPS real-yield curve snapshot (par real yields, percent per year).
3
+ *
4
+ * Source: U.S. Treasury "Daily Treasury Par Real Yield Curve Rates"
5
+ * (https://home.treasury.gov/resource-center/data-chart-center/interest-rates),
6
+ * end-of-June 2026 readings rounded to 5bp. The Treasury publishes 5-, 7-, 10-,
7
+ * 20-, and 30-year par real yields; the ladder engine interpolates between
8
+ * points and holds the endpoints flat outside them.
9
+ *
10
+ * This snapshot is the app's offline posture: every ladder quote and funded
11
+ * ratio works without a network call. Refresh cadence: annually with the
12
+ * parameter packs (see DOCS/maintenance-schedule.md), or opt-in per-session
13
+ * via the FedInvest live-price fetch (engine/ladder/fedInvest.ts), which
14
+ * never replaces this embedded default.
15
+ */
16
+ export const REAL_YIELD_CURVE_2026 = {
17
+ asOfIso: '2026-06-30',
18
+ source: 'U.S. Treasury Daily Par Real Yield Curve Rates',
19
+ points: [
20
+ { maturityYears: 5, realYieldPct: 1.85 },
21
+ { maturityYears: 7, realYieldPct: 2.05 },
22
+ { maturityYears: 10, realYieldPct: 2.25 },
23
+ { maturityYears: 20, realYieldPct: 2.55 },
24
+ { maturityYears: 30, realYieldPct: 2.7 },
25
+ ],
26
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Tax year 2026 parameter pack (post-OBBBA current law).
3
+ *
4
+ * Sources (verified June 2026) — see DOCS/domain/domain-rules-reference.md:
5
+ * - Brackets/deductions/AMT: IRS inflation adjustments; Tax Foundation 2026 tables
6
+ * - Capital gains/NIIT: IRS; Kiplinger 2026 LTCG thresholds
7
+ * - Limits: IRS IR news release (401(k) $24,500; IRA $7,500)
8
+ * - RMD: IRS Pub 590-B Uniform Lifetime Table (2022+); QCD $111,000 (2026)
9
+ * - Medicare: CMS 2026 ($202.90 standard Part B); IRMAA tiers per statute
10
+ * - Social Security: SSA 2026 COLA fact sheet
11
+ * - FPL: HHS 2025 guidelines (apply to 2026 ACA coverage year)
12
+ */
13
+ import type { ParameterPack } from '../types.js';
14
+ export declare const year2026: ParameterPack;