@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,43 @@
1
+ /**
2
+ * Sourced default SPIA payout-rate table (annuity-pension-and-home-equity
3
+ * decisions, step 2).
4
+ *
5
+ * Approximate single-life immediate-annuity annual payout rates (fraction of
6
+ * premium per year, life-only) by start age — compiled from public quote
7
+ * aggregators (Blueprint-Income-style annuity marketplaces and insurer
8
+ * calculators, mid-2026 rates). Planning defaults only: RetireGolden never
9
+ * fetches live quotes; a user-entered quote always wins. Linear interpolation
10
+ * between anchors; clamped outside the table.
11
+ *
12
+ * Refresh with the annual parameter packs (DOCS/maintenance-schedule.md).
13
+ */
14
+ const SPIA_PAYOUT_POINTS = [
15
+ [60, 0.055],
16
+ [65, 0.061],
17
+ [70, 0.069],
18
+ [75, 0.079],
19
+ [80, 0.093],
20
+ [85, 0.11],
21
+ ];
22
+ /** Annual life-only SPIA payout rate (fraction of premium) at `startAge`. */
23
+ export function spiaPayoutRate(startAge) {
24
+ const points = SPIA_PAYOUT_POINTS;
25
+ if (startAge <= points[0][0])
26
+ return points[0][1];
27
+ const last = points[points.length - 1];
28
+ if (startAge >= last[0])
29
+ return last[1];
30
+ for (let i = 0; i < points.length - 1; i++) {
31
+ const [a0, r0] = points[i];
32
+ const [a1, r1] = points[i + 1];
33
+ if (startAge >= a0 && startAge <= a1)
34
+ return r0 + ((r1 - r0) * (startAge - a0)) / (a1 - a0);
35
+ }
36
+ return last[1];
37
+ }
38
+ /**
39
+ * Approximate deferred-QLAC annual payout rate (fraction of premium) for a
40
+ * purchase today starting at 80–85: deferral compounds mortality credits well
41
+ * above immediate rates. Same public-quote provenance as the SPIA table.
42
+ */
43
+ export const QLAC_DEFERRED_PAYOUT_RATE = 0.16;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Optional stochastic attachments for exact-ledger decision evaluations.
3
+ *
4
+ * Deterministic evaluation remains the default authority. Callers opt into this
5
+ * helper when a policy explicitly ranks candidates by same-path Monte Carlo
6
+ * resilience.
7
+ */
8
+ import { type SharedPathComparisonOptions } from '../montecarlo/sharedPaths.js';
9
+ import type { DecisionContext, ExactDecisionEvaluation } from './types.js';
10
+ export type AttachStochasticMetricsOptions = SharedPathComparisonOptions;
11
+ export declare function attachStochasticMetrics(ctx: DecisionContext, evaluations: ExactDecisionEvaluation[], opts: AttachStochasticMetricsOptions): ExactDecisionEvaluation[];
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Optional stochastic attachments for exact-ledger decision evaluations.
3
+ *
4
+ * Deterministic evaluation remains the default authority. Callers opt into this
5
+ * helper when a policy explicitly ranks candidates by same-path Monte Carlo
6
+ * resilience.
7
+ */
8
+ import { comparePlansOnSharedMarketPaths, } from '../montecarlo/sharedPaths.js';
9
+ import { planForCandidate } from './evaluateCandidate.js';
10
+ function metricsFromSummary(summary, seed) {
11
+ return {
12
+ pathCount: summary.pathCount,
13
+ seed,
14
+ successRate: summary.successRate,
15
+ requiredFloorSuccessRate: summary.requiredFloorSuccessRate,
16
+ targetLifestyleSuccessRate: summary.targetLifestyleSuccessRate,
17
+ p10EndingAfterTaxEstate: summary.endingAfterTaxEstate.percentiles.p10,
18
+ medianEndingAfterTaxEstate: summary.endingAfterTaxEstate.percentiles.p50,
19
+ expectedShortfallDollars: summary.downsideRisk.expectedShortfallDollars,
20
+ averageTargetShortfallDollars: summary.spendingShortfall.averageTargetShortfallDollars,
21
+ };
22
+ }
23
+ function attachment(baseline, candidate) {
24
+ return {
25
+ baseline,
26
+ candidate,
27
+ deltas: {
28
+ successRate: candidate.successRate - baseline.successRate,
29
+ requiredFloorSuccessRate: candidate.requiredFloorSuccessRate - baseline.requiredFloorSuccessRate,
30
+ targetLifestyleSuccessRate: candidate.targetLifestyleSuccessRate - baseline.targetLifestyleSuccessRate,
31
+ p10EndingAfterTaxEstate: candidate.p10EndingAfterTaxEstate - baseline.p10EndingAfterTaxEstate,
32
+ medianEndingAfterTaxEstate: candidate.medianEndingAfterTaxEstate - baseline.medianEndingAfterTaxEstate,
33
+ expectedShortfallDollars: candidate.expectedShortfallDollars - baseline.expectedShortfallDollars,
34
+ averageTargetShortfallDollars: candidate.averageTargetShortfallDollars - baseline.averageTargetShortfallDollars,
35
+ },
36
+ };
37
+ }
38
+ export function attachStochasticMetrics(ctx, evaluations, opts) {
39
+ const materialized = [
40
+ { evaluation: null, entry: { id: 'baseline', label: 'Current plan', plan: ctx.plan } },
41
+ ];
42
+ for (const evaluation of evaluations) {
43
+ const built = planForCandidate(ctx.plan, evaluation.candidate);
44
+ if (!built.ok)
45
+ continue;
46
+ materialized.push({
47
+ evaluation,
48
+ entry: { id: evaluation.candidate.id, label: evaluation.candidate.label, plan: built.plan },
49
+ });
50
+ }
51
+ const comparison = comparePlansOnSharedMarketPaths(materialized.map((item) => item.entry), opts);
52
+ const baselineSummary = comparison.rows.find((row) => row.id === 'baseline')?.summary;
53
+ if (!baselineSummary)
54
+ return evaluations;
55
+ const baseline = metricsFromSummary(baselineSummary, opts.seed);
56
+ const rowById = new Map(comparison.rows.map((row) => [row.id, row.summary]));
57
+ for (const item of materialized) {
58
+ if (!item.evaluation)
59
+ continue;
60
+ const summary = rowById.get(item.entry.id);
61
+ if (!summary)
62
+ continue;
63
+ item.evaluation.stochastic = attachment(baseline, metricsFromSummary(summary, opts.seed));
64
+ }
65
+ return evaluations;
66
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * "Whose 4% rule?" comparator (spending-paths & SWR-lenses plan, Goal 3).
3
+ *
4
+ * Runs the currently-argued published safe-withdrawal-rate parameterizations
5
+ * on the user's own plan — not a generic 60/40 backtest — through the same
6
+ * deterministic exact ledger Results uses, so every rule prices taxes, ACA/
7
+ * IRMAA cliffs, healthcare, debts, and survivor years identically (same-path
8
+ * by construction). Presented as "published rules of thumb vs this plan's
9
+ * solver": each rule sets a constant-real initial spending level from the
10
+ * starting investable balance, which is the rules' own definition; none of
11
+ * them is "the answer" — the plan's solver is the plan-specific number.
12
+ *
13
+ * Parameterizations (each per its citation):
14
+ * - Bengen 2025: 4.7% — William Bengen, *A Richer Retirement* (Aug 2025);
15
+ * SAFEMAX raised from 4.15% to ~4.7% with a seven-asset-class portfolio.
16
+ * - Morningstar 2026: 3.9% — Morningstar's *State of Retirement Income*
17
+ * (2025 edition, for 2026 retirees; 30-year horizon, 90% success).
18
+ * - ERN CAPE: 1.75% + 0.5 × CAEY — Early Retirement Now's CAPE-based rule
19
+ * (SWR series part 18), CAEY = 100/CAPE; conditions the rate on valuations.
20
+ */
21
+ import type { Plan } from '../model/plan.js';
22
+ import { type SimulateOptions } from '../projection/simulate.js';
23
+ export type SwrRuleId = 'bengen-2025' | 'morningstar-2026' | 'ern-cape';
24
+ /** Default CAPE for the ERN rule; matches the CAPE market-model default. */
25
+ export declare const SWR_DEFAULT_CAPE = 25;
26
+ export interface SwrRuleSpec {
27
+ id: SwrRuleId;
28
+ label: string;
29
+ /** One-line source citation shown with the number. */
30
+ citation: string;
31
+ initialRatePct: (cape: number) => number;
32
+ }
33
+ export declare const SWR_RULES: readonly SwrRuleSpec[];
34
+ export interface SwrRuleResult {
35
+ id: SwrRuleId;
36
+ label: string;
37
+ citation: string;
38
+ initialRatePct: number;
39
+ /** rate × starting investable balance — the rule's spending level (today's $). */
40
+ initialAnnualSpend: number;
41
+ /** Exact-ledger outcome of spending that level constant-real on this plan. */
42
+ depletionYear: number | null;
43
+ endYear: number;
44
+ endingAfterTaxEstate: number;
45
+ lifetimeTaxesAndPenalties: number;
46
+ }
47
+ /**
48
+ * Price every published rule on the user's plan with one deterministic ledger
49
+ * run each. The variant plan spends the rule's level constant-real (phases
50
+ * cleared — the rules are defined constant-real) with any dynamic spending
51
+ * policy removed; everything else (accounts, taxes, healthcare, goals,
52
+ * horizon) is the user's own.
53
+ */
54
+ export declare function compareSwrRules(plan: Plan, opts: SimulateOptions, cape?: number): SwrRuleResult[];
@@ -0,0 +1,89 @@
1
+ /**
2
+ * "Whose 4% rule?" comparator (spending-paths & SWR-lenses plan, Goal 3).
3
+ *
4
+ * Runs the currently-argued published safe-withdrawal-rate parameterizations
5
+ * on the user's own plan — not a generic 60/40 backtest — through the same
6
+ * deterministic exact ledger Results uses, so every rule prices taxes, ACA/
7
+ * IRMAA cliffs, healthcare, debts, and survivor years identically (same-path
8
+ * by construction). Presented as "published rules of thumb vs this plan's
9
+ * solver": each rule sets a constant-real initial spending level from the
10
+ * starting investable balance, which is the rules' own definition; none of
11
+ * them is "the answer" — the plan's solver is the plan-specific number.
12
+ *
13
+ * Parameterizations (each per its citation):
14
+ * - Bengen 2025: 4.7% — William Bengen, *A Richer Retirement* (Aug 2025);
15
+ * SAFEMAX raised from 4.15% to ~4.7% with a seven-asset-class portfolio.
16
+ * - Morningstar 2026: 3.9% — Morningstar's *State of Retirement Income*
17
+ * (2025 edition, for 2026 retirees; 30-year horizon, 90% success).
18
+ * - ERN CAPE: 1.75% + 0.5 × CAEY — Early Retirement Now's CAPE-based rule
19
+ * (SWR series part 18), CAEY = 100/CAPE; conditions the rate on valuations.
20
+ */
21
+ import { startingInvestableOf } from '../montecarlo/riskBasedGuardrails.js';
22
+ import { summarizeProjection } from '../projection/compare.js';
23
+ import { simulatePlan } from '../projection/simulate.js';
24
+ /** Default CAPE for the ERN rule; matches the CAPE market-model default. */
25
+ export const SWR_DEFAULT_CAPE = 25;
26
+ export const SWR_RULES = [
27
+ {
28
+ id: 'bengen-2025',
29
+ label: 'Bengen 4.7%',
30
+ citation: 'William Bengen, A Richer Retirement (2025): SAFEMAX ≈ 4.7% with seven asset classes, 30-year horizon.',
31
+ initialRatePct: () => 4.7,
32
+ },
33
+ {
34
+ id: 'morningstar-2026',
35
+ label: 'Morningstar 3.9%',
36
+ citation: 'Morningstar, State of Retirement Income (2025, for 2026 retirees): 3.9% starting rate, 30 years, 90% success.',
37
+ initialRatePct: () => 3.9,
38
+ },
39
+ {
40
+ id: 'ern-cape',
41
+ label: 'ERN CAPE rule',
42
+ citation: 'Early Retirement Now, SWR series part 18: SWR = 1.75% + 0.5 × (100 ÷ CAPE).',
43
+ initialRatePct: (cape) => 1.75 + 0.5 * (100 / cape),
44
+ },
45
+ ];
46
+ /**
47
+ * Price every published rule on the user's plan with one deterministic ledger
48
+ * run each. The variant plan spends the rule's level constant-real (phases
49
+ * cleared — the rules are defined constant-real) with any dynamic spending
50
+ * policy removed; everything else (accounts, taxes, healthcare, goals,
51
+ * horizon) is the user's own.
52
+ */
53
+ export function compareSwrRules(plan, opts, cape = SWR_DEFAULT_CAPE) {
54
+ const startingInvestable = startingInvestableOf(plan);
55
+ return SWR_RULES.map((rule) => {
56
+ const ratePct = rule.initialRatePct(cape);
57
+ const initialAnnualSpend = (ratePct / 100) * startingInvestable;
58
+ const variant = {
59
+ ...plan,
60
+ expenses: {
61
+ ...plan.expenses,
62
+ baseAnnual: initialAnnualSpend,
63
+ phases: [],
64
+ spendingPolicy: undefined,
65
+ // The annual upside layers fund on top of baseAnnual in fixed-target
66
+ // mode; a rule advertised as spending exactly rate × portfolio must
67
+ // not inherit them or it simulates rule + upside and reads worse.
68
+ // requiredAnnual is cleared too: it never adds spending, but clearing
69
+ // it makes the variant's lifestyle stack exactly the rule level.
70
+ idealAnnual: undefined,
71
+ excessAnnual: undefined,
72
+ requiredAnnual: undefined,
73
+ },
74
+ };
75
+ const result = simulatePlan(variant, opts);
76
+ const summary = summarizeProjection(variant, result);
77
+ return {
78
+ id: rule.id,
79
+ label: rule.label,
80
+ citation: rule.citation,
81
+ initialRatePct: ratePct,
82
+ initialAnnualSpend,
83
+ depletionYear: summary.depletionYear,
84
+ endYear: result.endYear,
85
+ endingAfterTaxEstate: summary.endingAfterTaxEstate,
86
+ lifetimeTaxesAndPenalties: summary.lifetimeTaxesAndPenalties,
87
+ };
88
+ });
89
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Generalized exact-ledger tournament (ledger-native decision engine, Phase 3).
3
+ *
4
+ * 1. gather candidates from the selected generators,
5
+ * 2. dedupe candidates with equivalent patches/schedules,
6
+ * 3. evaluate each through the exact ledger,
7
+ * 4. rank by the objective policy's primary metric and hard constraints,
8
+ * 5. return the ranked field, the winner, and why each loser lost.
9
+ *
10
+ * Deterministic: generation order, dedupe (first occurrence wins), stable
11
+ * ranking with candidate-id tie-breaks, and a fixed evaluation bound.
12
+ */
13
+ import { type EvaluateCandidateOptions } from './evaluateCandidate.js';
14
+ import { type ObjectivePolicy } from './objectives.js';
15
+ import type { CandidateGenerator, DecisionCandidate, DecisionContext, ExactDecisionEvaluation } from './types.js';
16
+ /** Equivalence key for a candidate's concrete change (patch + normalized schedule). */
17
+ export declare function candidateEquivalenceKey(candidate: DecisionCandidate): string;
18
+ /** Drop candidates whose patch/schedule is equivalent to an earlier one (first wins). */
19
+ export declare function dedupeCandidates(candidates: DecisionCandidate[]): DecisionCandidate[];
20
+ export interface RankedDecision {
21
+ evaluation: ExactDecisionEvaluation;
22
+ /** Policy primary metric (higher is better). */
23
+ primaryValue: number;
24
+ /** Hard-constraint violations; non-empty ⇒ ineligible to win. */
25
+ constraintViolations: string[];
26
+ eligible: boolean;
27
+ /** Why this candidate is not the recommendation (null for the winner). */
28
+ lossReason: string | null;
29
+ }
30
+ export interface DecisionTournamentResult {
31
+ policyId: ObjectivePolicy['id'];
32
+ /** Every evaluated candidate, best first (ineligible candidates rank below eligible ones). */
33
+ ranked: RankedDecision[];
34
+ /** Best eligible candidate that actually improves the primary metric, or null. */
35
+ winner: RankedDecision | null;
36
+ /** Exact-ledger simulations spent (one per unique candidate). */
37
+ simulationCount: number;
38
+ }
39
+ export interface DecisionTournamentOptions {
40
+ policy?: ObjectivePolicy;
41
+ /** Safety bound on exact evaluations; candidates beyond it are dropped deterministically. */
42
+ maxCandidates?: number;
43
+ /** Minimum primary-metric improvement before anything is recommended. */
44
+ minimumImprovement?: number;
45
+ evaluation?: EvaluateCandidateOptions;
46
+ }
47
+ /** Rank pre-computed evaluations under a policy (shared by tournament and tests). */
48
+ export declare function rankEvaluations(evaluations: ExactDecisionEvaluation[], ctx: DecisionContext, policy?: ObjectivePolicy, minimumImprovement?: number): {
49
+ ranked: RankedDecision[];
50
+ winner: RankedDecision | null;
51
+ };
52
+ /** Gather → dedupe → exact-evaluate → rank. */
53
+ export declare function runDecisionTournament(ctx: DecisionContext, generators: CandidateGenerator[], options?: DecisionTournamentOptions): DecisionTournamentResult;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Generalized exact-ledger tournament (ledger-native decision engine, Phase 3).
3
+ *
4
+ * 1. gather candidates from the selected generators,
5
+ * 2. dedupe candidates with equivalent patches/schedules,
6
+ * 3. evaluate each through the exact ledger,
7
+ * 4. rank by the objective policy's primary metric and hard constraints,
8
+ * 5. return the ranked field, the winner, and why each loser lost.
9
+ *
10
+ * Deterministic: generation order, dedupe (first occurrence wins), stable
11
+ * ranking with candidate-id tie-breaks, and a fixed evaluation bound.
12
+ */
13
+ import { evaluateCandidate } from './evaluateCandidate.js';
14
+ import { maximizeAfterTaxEstate } from './objectives.js';
15
+ /** Stable stringify: object keys sorted recursively so patch key order never matters. */
16
+ function canonicalJson(value) {
17
+ if (Array.isArray(value))
18
+ return `[${value.map(canonicalJson).join(',')}]`;
19
+ if (typeof value === 'object' && value !== null) {
20
+ const entries = Object.entries(value)
21
+ .filter(([, v]) => v !== undefined)
22
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
23
+ .map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
24
+ return `{${entries.join(',')}}`;
25
+ }
26
+ return JSON.stringify(value);
27
+ }
28
+ /** Equivalence key for a candidate's concrete change (patch + normalized schedule). */
29
+ export function candidateEquivalenceKey(candidate) {
30
+ const conversions = candidate.conversions
31
+ ? [...candidate.conversions]
32
+ .map((c) => ({ year: c.year, amount: Math.round(c.amount * 100) / 100 }))
33
+ .filter((c) => c.amount > 0)
34
+ .sort((a, b) => a.year - b.year)
35
+ : null;
36
+ return canonicalJson({ patch: candidate.planPatch ?? null, conversions });
37
+ }
38
+ /** Drop candidates whose patch/schedule is equivalent to an earlier one (first wins). */
39
+ export function dedupeCandidates(candidates) {
40
+ const seen = new Set();
41
+ const unique = [];
42
+ for (const candidate of candidates) {
43
+ const key = candidateEquivalenceKey(candidate);
44
+ if (seen.has(key))
45
+ continue;
46
+ seen.add(key);
47
+ unique.push(candidate);
48
+ }
49
+ return unique;
50
+ }
51
+ const DEFAULT_MAX_CANDIDATES = 32;
52
+ const DEFAULT_MINIMUM_IMPROVEMENT = 1;
53
+ /** Rank pre-computed evaluations under a policy (shared by tournament and tests). */
54
+ export function rankEvaluations(evaluations, ctx, policy = maximizeAfterTaxEstate, minimumImprovement = DEFAULT_MINIMUM_IMPROVEMENT) {
55
+ const rows = evaluations.map((evaluation) => {
56
+ const constraintViolations = policy.constraintViolations(evaluation, ctx);
57
+ return {
58
+ evaluation,
59
+ primaryValue: policy.primaryMetric(evaluation, ctx),
60
+ constraintViolations,
61
+ eligible: constraintViolations.length === 0,
62
+ lossReason: null,
63
+ };
64
+ });
65
+ const tieBreak = (row) => policy.tieBreaker ? policy.tieBreaker(row.evaluation, ctx) : row.evaluation.deltas.endingAfterTaxEstate;
66
+ rows.sort((a, b) => {
67
+ if (a.eligible !== b.eligible)
68
+ return a.eligible ? -1 : 1;
69
+ if (a.primaryValue !== b.primaryValue)
70
+ return b.primaryValue - a.primaryValue;
71
+ const tie = tieBreak(b) - tieBreak(a);
72
+ if (tie !== 0)
73
+ return tie;
74
+ return a.evaluation.candidate.id < b.evaluation.candidate.id ? -1 : 1;
75
+ });
76
+ const winner = rows.find((row) => row.eligible && row.primaryValue > minimumImprovement) ?? null;
77
+ for (const row of rows) {
78
+ if (row === winner)
79
+ continue;
80
+ if (!row.eligible) {
81
+ row.lossReason = `violates ${policy.label.toLowerCase()} constraints: ${row.constraintViolations.join('; ')}`;
82
+ }
83
+ else if (winner) {
84
+ row.lossReason = `${policy.primaryMetricLabel} trails the winner by ${Math.round(winner.primaryValue - row.primaryValue).toLocaleString()}`;
85
+ }
86
+ else {
87
+ row.lossReason = `${policy.primaryMetricLabel} does not improve on the current plan`;
88
+ }
89
+ }
90
+ return { ranked: rows, winner };
91
+ }
92
+ /** Gather → dedupe → exact-evaluate → rank. */
93
+ export function runDecisionTournament(ctx, generators, options = {}) {
94
+ const policy = options.policy ?? maximizeAfterTaxEstate;
95
+ const maxCandidates = options.maxCandidates ?? DEFAULT_MAX_CANDIDATES;
96
+ const minimumImprovement = options.minimumImprovement ?? DEFAULT_MINIMUM_IMPROVEMENT;
97
+ const gathered = [];
98
+ for (const generator of generators)
99
+ gathered.push(...generator.generate(ctx));
100
+ const unique = dedupeCandidates(gathered).slice(0, maxCandidates);
101
+ const evaluations = unique.map((candidate) => evaluateCandidate(ctx, candidate, options.evaluation));
102
+ const { ranked, winner } = rankEvaluations(evaluations, ctx, policy, minimumImprovement);
103
+ return { policyId: policy.id, ranked, winner, simulationCount: evaluations.length };
104
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Ledger-native decision engine — shared candidate/evaluation contract
3
+ * (DOCS/enhancements/ledger-native-decision-engine.md, Phase 1).
4
+ *
5
+ * A candidate is a concrete, bounded change to the plan (a scenario-style
6
+ * patch, a Roth conversion schedule, or both). Candidates are proposed by
7
+ * generators (MILP, detectors, heuristics, search); they are never trusted
8
+ * directly. `evaluateCandidate` runs every candidate through the exact ledger
9
+ * (`simulatePlan`) against a shared baseline, and only that exact comparison
10
+ * decides whether a candidate is beneficial.
11
+ */
12
+ import type { Plan } from '../model/plan.js';
13
+ import type { ProjectionSummary } from '../projection/compare.js';
14
+ import type { SimulateOptions } from '../projection/simulate.js';
15
+ import type { ProjectionResult, TaxCalculator } from '../projection/types.js';
16
+ export type DecisionSource = 'milp' | 'detector' | 'heuristic' | 'scenario-sweep' | 'search';
17
+ export type DecisionCategory = 'roth' | 'withdrawal' | 'social-security' | 'tax-cliff' | 'spending' | 'insurance' | 'geography' | 'asset-location' | 'guaranteed-income';
18
+ export interface DecisionCandidate {
19
+ id: string;
20
+ source: DecisionSource;
21
+ category: DecisionCategory;
22
+ label: string;
23
+ explanation: string;
24
+ /**
25
+ * Scenario-style deep-override of the plan (same merge semantics as
26
+ * `applyScenarioPatch`: objects merge, arrays/primitives replace).
27
+ */
28
+ planPatch?: Record<string, unknown>;
29
+ /**
30
+ * Explicit Roth conversion schedule, installed as an `optimized` strategy
31
+ * after any planPatch is applied. Requested amounts — the exact ledger may
32
+ * execute less (balance caps), which evaluation reports as diagnostics.
33
+ */
34
+ conversions?: Array<{
35
+ year: number;
36
+ amount: number;
37
+ }>;
38
+ metadata?: Record<string, unknown>;
39
+ }
40
+ /** Everything a generator or evaluator needs; baseline is computed once and shared. */
41
+ export interface DecisionContext {
42
+ plan: Plan;
43
+ baselineResult: ProjectionResult;
44
+ baselineSummary: ProjectionSummary;
45
+ simulateOptions: SimulateOptions;
46
+ /**
47
+ * Build the tax stack from a candidate's own (patched) plan instead of
48
+ * pricing it with `simulateOptions.taxCalculator`. Needed when a candidate
49
+ * patch changes tax assumptions (flat state-rate override, local rate) —
50
+ * e.g. relocation previews — so the evaluation matches the surface that
51
+ * proposed it. Absent ⇒ the shared calculator, as before.
52
+ */
53
+ taxCalculatorForPlan?: (plan: Plan) => TaxCalculator;
54
+ }
55
+ export interface DecisionDeltas {
56
+ endingAfterTaxEstate: number;
57
+ endingNetWorth: number;
58
+ lifetimeTax: number;
59
+ moneyLastsYears: number;
60
+ }
61
+ export interface StochasticDecisionMetrics {
62
+ pathCount: number;
63
+ seed: number;
64
+ successRate: number;
65
+ requiredFloorSuccessRate: number;
66
+ targetLifestyleSuccessRate: number;
67
+ p10EndingAfterTaxEstate: number;
68
+ medianEndingAfterTaxEstate: number;
69
+ expectedShortfallDollars: number;
70
+ averageTargetShortfallDollars: number;
71
+ }
72
+ export interface StochasticDecisionAttachment {
73
+ baseline: StochasticDecisionMetrics;
74
+ candidate: StochasticDecisionMetrics;
75
+ deltas: {
76
+ successRate: number;
77
+ requiredFloorSuccessRate: number;
78
+ targetLifestyleSuccessRate: number;
79
+ p10EndingAfterTaxEstate: number;
80
+ medianEndingAfterTaxEstate: number;
81
+ expectedShortfallDollars: number;
82
+ averageTargetShortfallDollars: number;
83
+ };
84
+ }
85
+ /**
86
+ * 'beneficial' | 'neutral' | 'rejected' come from the exact after-tax-estate
87
+ * comparison. 'diagnostic' marks evaluations that must not become
88
+ * recommendations for structural reasons: an invalid patch, or a conversion
89
+ * schedule the ledger could not materially execute as requested.
90
+ */
91
+ export type DecisionRecommendationState = 'beneficial' | 'neutral' | 'rejected' | 'diagnostic';
92
+ /** Requested-vs-executed accounting for conversion-schedule candidates. */
93
+ export interface ConversionExecution {
94
+ requestedTotal: number;
95
+ executedTotal: number;
96
+ /** min(1, executed/requested); 1 when nothing was requested. */
97
+ executedRatio: number;
98
+ firstMateriallyUnexecutedYear: number | null;
99
+ /** Per-year amounts the exact ledger actually executed (years > $1 only). */
100
+ executedByYear: Array<{
101
+ year: number;
102
+ amount: number;
103
+ }>;
104
+ }
105
+ export interface ExactDecisionEvaluation {
106
+ candidate: DecisionCandidate;
107
+ baselineSummary: ProjectionSummary;
108
+ candidateSummary: ProjectionSummary;
109
+ /** Full exact-ledger run for the candidate; not structured-clone-light — strip before posting to workers/UI. */
110
+ candidateResult: ProjectionResult;
111
+ deltas: DecisionDeltas;
112
+ /** Present when the candidate carries (or its patch produces) a conversion schedule. */
113
+ conversionExecution: ConversionExecution | null;
114
+ /** First year the candidate's own (non-inherited) traditional balance hits zero, when the plan has any. */
115
+ traditionalDepletionYear: number | null;
116
+ /** Optional same-path Monte Carlo attachment for opt-in robust ranking. */
117
+ stochastic?: StochasticDecisionAttachment;
118
+ diagnostics: string[];
119
+ recommendationState: DecisionRecommendationState;
120
+ }
121
+ export interface CandidateGenerator {
122
+ id: string;
123
+ /** Pure and bounded: no simulate() calls, small fixed candidate count. */
124
+ generate(ctx: DecisionContext): DecisionCandidate[];
125
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Ledger-native decision engine — shared candidate/evaluation contract
3
+ * (DOCS/enhancements/ledger-native-decision-engine.md, Phase 1).
4
+ *
5
+ * A candidate is a concrete, bounded change to the plan (a scenario-style
6
+ * patch, a Roth conversion schedule, or both). Candidates are proposed by
7
+ * generators (MILP, detectors, heuristics, search); they are never trusted
8
+ * directly. `evaluateCandidate` runs every candidate through the exact ledger
9
+ * (`simulatePlan`) against a shared baseline, and only that exact comparison
10
+ * decides whether a candidate is beneficial.
11
+ */
12
+ export {};
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Root entry — the core loop: validate a plan, run the ledger.
3
+ *
4
+ * Deep subpath imports are the primary API surface
5
+ * (`@retiregolden/engine/projection/simulate`,
6
+ * `@retiregolden/engine/montecarlo/run`, `@retiregolden/engine/params`, …);
7
+ * this root re-exports just enough to parse a plan and run a projection.
8
+ */
9
+ export { CURRENT_PLAN_SCHEMA_VERSION, planSchema, type Plan } from './model/plan.js';
10
+ export { migratePlanToCurrent } from './model/migrations.js';
11
+ export { simulatePlan, type SimulateOptions } from './projection/simulate.js';
12
+ export type { ProjectionResult, YearResult } from './projection/types.js';
13
+ export { summarizeProjection } from './projection/compare.js';
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Root entry — the core loop: validate a plan, run the ledger.
3
+ *
4
+ * Deep subpath imports are the primary API surface
5
+ * (`@retiregolden/engine/projection/simulate`,
6
+ * `@retiregolden/engine/montecarlo/run`, `@retiregolden/engine/params`, …);
7
+ * this root re-exports just enough to parse a plan and run a projection.
8
+ */
9
+ export { CURRENT_PLAN_SCHEMA_VERSION, planSchema } from './model/plan.js';
10
+ export { migratePlanToCurrent } from './model/migrations.js';
11
+ export { simulatePlan } from './projection/simulate.js';
12
+ export { summarizeProjection } from './projection/compare.js';
@@ -0,0 +1,11 @@
1
+ import type { Detector } from '../types.js';
2
+ /**
3
+ * "You plan for a long life but hold no guaranteed income — annuitization
4
+ * headroom" (annuity-pension-and-home-equity, step 5). Fires for
5
+ * longevity-anxious plans (a planning age of 95+) with meaningful liquid
6
+ * savings, no annuity on the books, and no pension income covering the same
7
+ * ground. The action previews a cover-the-floor SPIA as a scenario so the
8
+ * ledger — not the card — prices the liquidity/estate tradeoff; the Monte
9
+ * Carlo annuitization sweep is the full solver view.
10
+ */
11
+ export declare const annuitizationHeadroom: Detector;