@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,2 @@
1
+ import type { Detector } from '../types.js';
2
+ export declare const spendingGuardrails: Detector;
@@ -0,0 +1,77 @@
1
+ import { probabilityBandSpendingGuardrailGenerator } from '../../decisions/generators.js';
2
+ function guardrailPatchFromGenerator(plan) {
3
+ const ctx = { plan };
4
+ const candidates = probabilityBandSpendingGuardrailGenerator().generate(ctx);
5
+ if (candidates.length === 0)
6
+ return null;
7
+ const candidate = candidates[0];
8
+ const patch = candidate.planPatch;
9
+ if (!patch || typeof patch !== 'object')
10
+ return null;
11
+ const expenses = patch.expenses;
12
+ if (!expenses || typeof expenses !== 'object')
13
+ return null;
14
+ const requiredAnnual = expenses.requiredAnnual;
15
+ if (typeof requiredAnnual !== 'number' || !Number.isFinite(requiredAnnual))
16
+ return null;
17
+ const spendingPolicy = expenses.spendingPolicy;
18
+ if (!spendingPolicy ||
19
+ typeof spendingPolicy !== 'object' ||
20
+ spendingPolicy.mode !== 'withdrawalRateGuardrails') {
21
+ return null;
22
+ }
23
+ return { requiredAnnual, patch: patch };
24
+ }
25
+ export const spendingGuardrails = {
26
+ id: 'spending-guardrails',
27
+ category: 'sequence-risk',
28
+ screen(ctx) {
29
+ const firstYear = ctx.projection.result.years[0];
30
+ if (!firstYear)
31
+ return null;
32
+ // Any active guardrail policy (withdrawal-rate or risk-based) means the
33
+ // plan already has dynamic spending rules — nothing to recommend.
34
+ const mode = ctx.plan.expenses.spendingPolicy?.mode;
35
+ if (mode !== undefined && mode !== 'fixedTarget')
36
+ return null;
37
+ const hasDepletion = ctx.projection.summary.depletionYear !== null;
38
+ const hasAssets = firstYear.investableTotal > 100_000;
39
+ if (!hasDepletion && !hasAssets)
40
+ return null;
41
+ const generated = guardrailPatchFromGenerator(ctx.plan);
42
+ if (!generated)
43
+ return null;
44
+ const { requiredAnnual, patch } = generated;
45
+ return {
46
+ id: 'spending-guardrails',
47
+ category: 'sequence-risk',
48
+ title: 'Use dynamic spending guardrails',
49
+ rationale: `Your plan currently assumes fixed inflation-adjusted spending. Preview a rules-based guardrail scenario with a $${Math.round(requiredAnnual).toLocaleString()} required floor and 10% spending adjustments when the withdrawal-rate band is crossed.`,
50
+ impact: {
51
+ qualitative: 'Preview to compare the projected and Monte Carlo impact of flexible spending rules.',
52
+ successRateDeltaPct: 12,
53
+ },
54
+ exact: false,
55
+ confidence: 'medium',
56
+ learnSlug: 'dynamic-spending-guardrails',
57
+ plannerRoute: 'spending',
58
+ action: {
59
+ kind: 'preview-scenario',
60
+ scenarioName: 'Dynamic spending guardrails',
61
+ patch,
62
+ },
63
+ };
64
+ },
65
+ evaluate(ctx) {
66
+ const card = this.screen(ctx);
67
+ if (!card || card.action.kind !== 'preview-scenario')
68
+ throw new Error('Spending guardrails not eligible');
69
+ return {
70
+ action: card.action,
71
+ impact: {
72
+ qualitative: 'Exact preview applies the guardrail policy inside the same annual ledger used by Results and Monte Carlo.',
73
+ successRateDeltaPct: card.impact.successRateDeltaPct,
74
+ },
75
+ };
76
+ },
77
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Spending-headroom detector (sustainable-spending plan, Step 6).
3
+ *
4
+ * Two-phase per the detector contract: `screen()` is cheap and pure — it only
5
+ * reads the baseline projection (never depleting + a large ending estate above
6
+ * the bequest target ⇒ the plan is leaving lifestyle on the table) and offers
7
+ * a ROUGH estimate. `evaluate()` runs the exact-ledger sustainable-spending
8
+ * solver under the same fixed budget the "How much can I spend?" page uses,
9
+ * so the card and that surface report the same solved level.
10
+ */
11
+ import type { Detector } from '../types.js';
12
+ export declare const spendingHeadroom: Detector;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Spending-headroom detector (sustainable-spending plan, Step 6).
3
+ *
4
+ * Two-phase per the detector contract: `screen()` is cheap and pure — it only
5
+ * reads the baseline projection (never depleting + a large ending estate above
6
+ * the bequest target ⇒ the plan is leaving lifestyle on the table) and offers
7
+ * a ROUGH estimate. `evaluate()` runs the exact-ledger sustainable-spending
8
+ * solver under the same fixed budget the "How much can I spend?" page uses,
9
+ * so the card and that surface report the same solved level.
10
+ */
11
+ import { createDecisionContext, solveMaxSustainableSpending, SPENDING_SOLVER_UI_BUDGET, } from '../../decisions/index.js';
12
+ import { combineTaxCalculators, createFederalTaxCalculator } from '../../tax/federalTax.js';
13
+ import { createStateTaxCalculator } from '../../tax/stateTax.js';
14
+ /** Screen only when the excess estate could fund a meaningful lifestyle bump. */
15
+ const MIN_EXCESS_ESTATE_TODAY_DOLLARS = 250_000;
16
+ const MIN_ROUGH_HEADROOM_PER_YEAR = 2_000;
17
+ export const spendingHeadroom = {
18
+ id: 'spending-headroom',
19
+ category: 'sequence-risk',
20
+ screen(ctx) {
21
+ const summary = ctx.projection.summary;
22
+ if (summary.depletionYear !== null)
23
+ return null;
24
+ // Amortized spending (ABW) already spends the portfolio down by design; a
25
+ // "raise your fixed baseline" headroom card (and the base-spending solver
26
+ // behind it) doesn't apply — the solver refuses ABW plans with a diagnostic.
27
+ if (ctx.plan.expenses.spendingPolicy?.mode === 'abw')
28
+ return null;
29
+ const endYear = ctx.projection.result.endYear;
30
+ const yearsRemaining = Math.max(1, endYear - ctx.projection.startYear);
31
+ const bequestTarget = ctx.plan.expenses.bequestTargetDollars ?? 0;
32
+ const endingEstateToday = ctx.projection.deflate(endYear, summary.endingAfterTaxEstate);
33
+ const excessEstateToday = endingEstateToday - bequestTarget;
34
+ if (excessEstateToday < MIN_EXCESS_ESTATE_TODAY_DOLLARS)
35
+ return null;
36
+ // Rough (pre-tax, straight-line) reading of the excess: what spreading it
37
+ // evenly over the remaining years would add to annual spending. The exact
38
+ // answer needs the solver — taxes, cliffs, and sequencing always shrink it.
39
+ const roughHeadroomPerYear = excessEstateToday / yearsRemaining;
40
+ if (roughHeadroomPerYear < MIN_ROUGH_HEADROOM_PER_YEAR)
41
+ return null;
42
+ const baseAnnual = ctx.plan.expenses.baseAnnual;
43
+ return {
44
+ id: 'spending-headroom',
45
+ category: 'sequence-risk',
46
+ title: 'Your plan may support more spending',
47
+ rationale: `Your plan is projected to end with roughly $${Math.round(endingEstateToday).toLocaleString()} of after-tax estate in today's dollars${bequestTarget > 0 ? ` — well above your $${Math.round(bequestTarget).toLocaleString()} bequest target` : ' with no bequest target set'}. Previewing the max-sustainable spending level shows how much lifestyle that margin could fund.`,
48
+ impact: {
49
+ qualitative: `≈ $${Math.round(roughHeadroomPerYear).toLocaleString()}/yr of rough headroom before taxes; preview for the precise answer.`,
50
+ },
51
+ exact: false,
52
+ confidence: 'medium',
53
+ learnSlug: 'building-a-retirement-spending-budget',
54
+ plannerRoute: 'spending-solver',
55
+ action: {
56
+ kind: 'preview-scenario',
57
+ scenarioName: 'Spend closer to plan capacity',
58
+ // Conservative placeholder — evaluate() replaces it with the solved level.
59
+ patch: { expenses: { baseAnnual: Math.round(baseAnnual + roughHeadroomPerYear / 2) } },
60
+ },
61
+ };
62
+ },
63
+ evaluate(ctx) {
64
+ const card = this.screen(ctx);
65
+ if (!card)
66
+ throw new Error('Spending headroom not eligible');
67
+ const taxCalculator = combineTaxCalculators(createFederalTaxCalculator(), createStateTaxCalculator({
68
+ overridePct: ctx.plan.assumptions.stateEffectiveTaxPct,
69
+ localPct: ctx.plan.assumptions.localIncomeTaxPct,
70
+ }));
71
+ const decisionCtx = createDecisionContext(ctx.plan, { startYear: ctx.projection.startYear, taxCalculator }, { result: ctx.projection.result, summary: ctx.projection.summary });
72
+ const solved = solveMaxSustainableSpending(decisionCtx, {
73
+ maxSimulations: SPENDING_SOLVER_UI_BUDGET,
74
+ estateFloorTodayDollars: ctx.plan.expenses.bequestTargetDollars ?? 0,
75
+ });
76
+ const maxBaseAnnual = solved.maxBaseAnnual;
77
+ const slack = solved.spendingSlackDollars ?? 0;
78
+ if (maxBaseAnnual === null || slack < 1_000) {
79
+ throw new Error('The spending solver found no meaningful headroom once taxes, healthcare cliffs, and sequencing were priced in.');
80
+ }
81
+ return {
82
+ action: {
83
+ kind: 'preview-scenario',
84
+ scenarioName: `Spend $${Math.round(maxBaseAnnual).toLocaleString()}/yr (max sustainable)`,
85
+ patch: { expenses: { baseAnnual: maxBaseAnnual } },
86
+ },
87
+ impact: {
88
+ qualitative: `Exact ledger sustains about $${Math.round(maxBaseAnnual).toLocaleString()}/yr of baseline spending — $${Math.round(slack).toLocaleString()}/yr above your current level (today's dollars).`,
89
+ },
90
+ };
91
+ },
92
+ };
@@ -0,0 +1,9 @@
1
+ import type { Detector } from '../types.js';
2
+ /**
3
+ * "Your gap years are unfunded — consider a bridge"
4
+ * (social-security-bridge-and-tips-ladder, step 4). Fires when someone delays
5
+ * Social Security past retirement with no TIPS ladder covering the gap years,
6
+ * and liquid savings could fund one. The action previews the sized bridge as
7
+ * a scenario, so the ledger (not the card) prices it.
8
+ */
9
+ export declare const ssBridgeGap: Detector;
@@ -0,0 +1,92 @@
1
+ import { EMBEDDED_REAL_YIELD_CURVE } from '../../params/index.js';
2
+ import { BRIDGE_FUNDING_MIN_FRACTION, sizeBridge } from '../../ladder/bridge.js';
3
+ /**
4
+ * "Your gap years are unfunded — consider a bridge"
5
+ * (social-security-bridge-and-tips-ladder, step 4). Fires when someone delays
6
+ * Social Security past retirement with no TIPS ladder covering the gap years,
7
+ * and liquid savings could fund one. The action previews the sized bridge as
8
+ * a scenario, so the ledger (not the card) prices it.
9
+ */
10
+ export const ssBridgeGap = {
11
+ id: 'ss-bridge-gap',
12
+ category: 'social-security',
13
+ screen(ctx) {
14
+ const plan = ctx.plan;
15
+ const startYear = ctx.projection.startYear;
16
+ const liquid = plan.accounts
17
+ .filter((a) => a.type === 'cash' || a.type === 'taxable')
18
+ .sort((a, b) => ('balance' in b ? b.balance : 0) - ('balance' in a ? a.balance : 0))[0];
19
+ if (!liquid || !('balance' in liquid))
20
+ return null;
21
+ const ladders = [];
22
+ let totalCost = 0;
23
+ let firstYear = Number.POSITIVE_INFINITY;
24
+ let lastYear = 0;
25
+ let annualTotal = 0;
26
+ for (const stream of plan.incomes) {
27
+ if (stream.type !== 'socialSecurity' || stream.piaMonthly === null || stream.piaMonthly <= 0)
28
+ continue;
29
+ const person = plan.household.people.find((p) => p.id === stream.personId);
30
+ if (!person)
31
+ continue;
32
+ const dobYear = Number(person.dob.slice(0, 4));
33
+ const sized = sizeBridge({
34
+ piaMonthly: stream.piaMonthly,
35
+ dob: { year: dobYear, month: Number(person.dob.slice(5, 7)), day: Number(person.dob.slice(8, 10)) },
36
+ claimAge: stream.claimAge,
37
+ currentYear: startYear,
38
+ retirementYear: person.retirementAge !== null ? dobYear + person.retirementAge : startYear,
39
+ curve: EMBEDDED_REAL_YIELD_CURVE,
40
+ });
41
+ if (!sized)
42
+ continue;
43
+ const covered = plan.incomeFloor?.ladders.some((l) => l.startYear <= sized.startYear && l.endYear >= sized.endYear);
44
+ if (covered)
45
+ continue;
46
+ totalCost += sized.ladderCost;
47
+ firstYear = Math.min(firstYear, sized.startYear);
48
+ lastYear = Math.max(lastYear, sized.endYear);
49
+ annualTotal += sized.annualRealAmount;
50
+ ladders.push({
51
+ id: `ss-bridge-preview-${stream.personId}`,
52
+ name: `SS bridge (${person.name})`,
53
+ purpose: 'bridge',
54
+ startYear: sized.startYear,
55
+ endYear: sized.endYear,
56
+ annualRealAmount: sized.annualRealAmount,
57
+ purchase: { year: startYear, fundingAccountId: liquid.id },
58
+ });
59
+ }
60
+ if (ladders.length === 0)
61
+ return null;
62
+ // Don't suggest a bridge the plan clearly cannot buy (shared threshold).
63
+ if (liquid.balance < totalCost * BRIDGE_FUNDING_MIN_FRACTION)
64
+ return null;
65
+ return {
66
+ id: 'ss-bridge-gap',
67
+ category: 'social-security',
68
+ title: 'Fund your Social Security gap years with a bridge',
69
+ rationale: `You delay Social Security but retire earlier, leaving ${firstYear}–${lastYear} without your benefit. ` +
70
+ `A TIPS bridge ladder (≈$${Math.round(totalCost).toLocaleString()} today) pays you the forgone age-62 benefit ` +
71
+ `(~$${Math.round(annualTotal).toLocaleString()}/yr, inflation-protected) until your claim starts — the delayed claim's longevity insurance without the lifestyle gap.`,
72
+ impact: {
73
+ qualitative: 'Preview the bridge against your full plan: guaranteed gap-year income and steadier withdrawals, at the cost of locking up liquid savings.',
74
+ },
75
+ exact: false,
76
+ confidence: 'medium',
77
+ learnSlug: 'social-security-bridge',
78
+ plannerRoute: 'income-floor',
79
+ action: {
80
+ kind: 'preview-scenario',
81
+ scenarioName: 'Add a Social Security bridge ladder',
82
+ patch: { incomeFloor: { ladders: [...(plan.incomeFloor?.ladders ?? []), ...ladders] } },
83
+ },
84
+ };
85
+ },
86
+ evaluate(ctx) {
87
+ const card = this.screen(ctx);
88
+ if (!card)
89
+ throw new Error('SS bridge gap not eligible');
90
+ return { action: card.action, impact: card.impact };
91
+ },
92
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * State-relocation detector (extended by the relocation-compare plan —
3
+ * DOCS/enhancements/state-relocation-compare.md, step 3).
4
+ *
5
+ * `screen()` keeps its original cheap conditions: a taxed state, no planned
6
+ * moves. `evaluate()` now quantifies the lifetime tax drag by running the
7
+ * relocation-compare sweep over the modeled zero-income-tax shortlist and
8
+ * previews the top candidate as a scenario. Copy stays neutral — income tax
9
+ * is one relocation factor, so the card says "worth a look", never "move".
10
+ */
11
+ import type { Detector } from '../types.js';
12
+ export declare const stateRelocation: Detector;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * State-relocation detector (extended by the relocation-compare plan —
3
+ * DOCS/enhancements/state-relocation-compare.md, step 3).
4
+ *
5
+ * `screen()` keeps its original cheap conditions: a taxed state, no planned
6
+ * moves. `evaluate()` now quantifies the lifetime tax drag by running the
7
+ * relocation-compare sweep over the modeled zero-income-tax shortlist and
8
+ * previews the top candidate as a scenario. Copy stays neutral — income tax
9
+ * is one relocation factor, so the card says "worth a look", never "move".
10
+ */
11
+ import { stateParamsFor } from '../../params/state/index.js';
12
+ import { compareRelocationCandidates, relocationScenarioPatch, } from '../../projection/relocation.js';
13
+ /** Modeled zero-income-tax candidates the evaluate() sweep prices. */
14
+ const ZERO_TAX_SHORTLIST = ['FL', 'TX', 'WA'];
15
+ export const stateRelocation = {
16
+ id: 'state-relocation',
17
+ category: 'longevity-insurance-geography',
18
+ screen(ctx) {
19
+ const startYear = ctx.projection.startYear;
20
+ const currentState = ctx.plan.household.state;
21
+ // If already in a tax-free state and no flat override, it's not applicable
22
+ const params = stateParamsFor(currentState, startYear);
23
+ const currentHasIncomeTax = ctx.plan.assumptions.stateEffectiveTaxPct > 0 || (params ? params.hasIncomeTax : true);
24
+ if (!currentHasIncomeTax) {
25
+ return null;
26
+ }
27
+ if (currentState === 'FL') {
28
+ return null;
29
+ }
30
+ // Only suggest if they haven't planned any moves yet
31
+ if (ctx.plan.household.stateMoves && ctx.plan.household.stateMoves.length > 0) {
32
+ return null;
33
+ }
34
+ return {
35
+ id: 'state-relocation',
36
+ category: 'longevity-insurance-geography',
37
+ title: 'State residence is a tax lever worth a look',
38
+ rationale: `Your plan is based in ${currentState}, which levies an income tax, with no relocation modeled. If a move is ever on the table for other reasons, the state-tax difference is worth quantifying — the Relocation Compare page prices candidate states on your actual plan. Income tax is one factor among many; nothing here says you should move.`,
39
+ impact: { qualitative: 'Preview to quantify the lifetime state-tax drag vs modeled zero-income-tax states on your own plan.' },
40
+ exact: false,
41
+ confidence: 'medium',
42
+ learnSlug: 'state-income-taxes-in-retirement',
43
+ plannerRoute: 'relocation',
44
+ action: {
45
+ kind: 'preview-scenario',
46
+ scenarioName: 'Relocate to a tax-free state',
47
+ // Rough placeholder — evaluate() replaces it with the sweep's top candidate.
48
+ patch: relocationScenarioPatch(ctx.plan, { state: 'FL', moveYear: startYear }, startYear),
49
+ },
50
+ };
51
+ },
52
+ evaluate(ctx) {
53
+ const card = this.screen(ctx);
54
+ if (!card) {
55
+ throw new Error('State relocation not eligible');
56
+ }
57
+ const startYear = ctx.projection.startYear;
58
+ try {
59
+ // Deterministic sweep (no Monte Carlo) over the zero-income-tax
60
+ // shortlist, each as a split-year move this July — the same engine the
61
+ // Relocation Compare page runs, so the card and the page can never
62
+ // disagree. Four ledger runs, on par with other detectors' evaluate().
63
+ const comparison = compareRelocationCandidates(ctx.plan, ZERO_TAX_SHORTLIST.map((state) => ({ state, moveYear: startYear })), { startYear });
64
+ const baseline = comparison.rows.find((r) => r.id === 'baseline');
65
+ const candidates = comparison.rows.filter((r) => r.id !== 'baseline' && r.error === null);
66
+ if (!baseline || candidates.length === 0)
67
+ throw new Error('Relocation sweep produced no valid candidates');
68
+ const best = candidates.reduce((a, b) => (b.lifetimeTaxesAndPenalties < a.lifetimeTaxesAndPenalties ? b : a));
69
+ // Lifetime state-tax drag in today's dollars: deflate the per-year
70
+ // state+local tax delta between staying and the best candidate. This is
71
+ // deliberately qualitative-only — the preview grid's numeric deltas come
72
+ // from the shared exact-ledger evaluator on a different basis (total
73
+ // taxes, nominal), and two numbers on two bases would collide.
74
+ const baseByYear = new Map(baseline.stateTaxByYear.map((l) => [l.year, l.tax]));
75
+ const bestByYear = new Map(best.stateTaxByYear.map((l) => [l.year, l.tax]));
76
+ const years = new Set([...baseByYear.keys(), ...bestByYear.keys()]);
77
+ let lifetimeStateTaxDeltaToday = 0;
78
+ for (const year of years) {
79
+ lifetimeStateTaxDeltaToday += ctx.projection.deflate(year, (bestByYear.get(year) ?? 0) - (baseByYear.get(year) ?? 0));
80
+ }
81
+ const savings = Math.max(0, -lifetimeStateTaxDeltaToday);
82
+ return {
83
+ action: {
84
+ kind: 'preview-scenario',
85
+ scenarioName: `Relocate to ${best.destinationState} (illustrative)`,
86
+ patch: relocationScenarioPatch(ctx.plan, { state: best.destinationState, moveYear: startYear }, startYear),
87
+ },
88
+ impact: {
89
+ qualitative: `Exact ledger: staying in ${ctx.plan.household.state} costs about $${Math.round(savings).toLocaleString()} of lifetime state+local income tax (today's dollars) vs ${best.destinationState}, the best of ${ZERO_TAX_SHORTLIST.join('/')} on your plan. Income tax is one relocation factor — compare your own shortlist on the Relocation Compare page.`,
90
+ },
91
+ };
92
+ }
93
+ catch {
94
+ // Degrade to the rough screen-level card instead of erroring the
95
+ // preview; the Relocation Compare page remains the exact surface.
96
+ return { action: card.action, impact: card.impact };
97
+ }
98
+ },
99
+ };
@@ -0,0 +1,12 @@
1
+ import type { Detector } from '../types.js';
2
+ /**
3
+ * The widow's-penalty lever (extended by survivor-widowhood-and-irmaa-relief,
4
+ * step 3): screens trad-heavy MFJ couples without conversions — the original
5
+ * gate is unchanged — and now quantifies the survivor bracket jump on the
6
+ * plan's own first single-FILED survivor year (same MAGI priced single vs
7
+ * joint, a cheap pack calculation, exact:false; QSS interlude years keep the
8
+ * joint tables and are narrated, not priced), flags survivor-year IRMAA tiers
9
+ * with an SSA-44 pointer when the plan isn't modeling the relief, and previews
10
+ * the conversion-acceleration scenario the caller prices on the exact ledger.
11
+ */
12
+ export declare const widowsPenalty: Detector;
@@ -0,0 +1,145 @@
1
+ import { computeFederalTax } from '../../tax/federalTax.js';
2
+ /**
3
+ * The widow's-penalty lever (extended by survivor-widowhood-and-irmaa-relief,
4
+ * step 3): screens trad-heavy MFJ couples without conversions — the original
5
+ * gate is unchanged — and now quantifies the survivor bracket jump on the
6
+ * plan's own first single-FILED survivor year (same MAGI priced single vs
7
+ * joint, a cheap pack calculation, exact:false; QSS interlude years keep the
8
+ * joint tables and are narrated, not priced), flags survivor-year IRMAA tiers
9
+ * with an SSA-44 pointer when the plan isn't modeling the relief, and previews
10
+ * the conversion-acceleration scenario the caller prices on the exact ledger.
11
+ */
12
+ export const widowsPenalty = {
13
+ id: 'widows-penalty-roth',
14
+ category: 'social-security',
15
+ screen(ctx) {
16
+ if (ctx.plan.household.filingStatus !== 'marriedFilingJointly') {
17
+ return null;
18
+ }
19
+ if (ctx.plan.strategies.rothConversion.mode !== 'none') {
20
+ return null;
21
+ }
22
+ // Check if there are traditional assets
23
+ const firstYear = ctx.projection.result.years[0];
24
+ if (!firstYear) {
25
+ return null;
26
+ }
27
+ const tradBalance = ctx.plan.accounts
28
+ .filter((a) => a.type === 'traditional')
29
+ .reduce((sum, a) => sum + (firstYear.balances[a.id] ?? 0), 0);
30
+ if (tradBalance < 50000) {
31
+ return null;
32
+ }
33
+ // Find the first year where only one spouse remains alive
34
+ const singleYearObj = ctx.projection.result.years.find((y) => {
35
+ const living = y.people.filter((p) => p.alive);
36
+ return living.length === 1;
37
+ });
38
+ if (!singleYearObj) {
39
+ return null;
40
+ }
41
+ const firstSingleYear = singleYearObj.year;
42
+ const lastJointYear = firstSingleYear - 1;
43
+ if (lastJointYear < ctx.projection.startYear) {
44
+ return null;
45
+ }
46
+ // Survivor bracket jump, quantified on the plan's own numbers: the first
47
+ // survivor year that truly files SINGLE (a qualifying surviving spouse
48
+ // keeps the joint tables for up to two years, so QSS years have no bracket
49
+ // jump to price), with that year's MAGI compared single vs joint. Rough by
50
+ // design (taxable SS itself shifts with filing status), so the card stays
51
+ // exact:false; the preview scenario is what the exact ledger prices.
52
+ const singleFiledYearObj = ctx.projection.result.years.find((y) => y.year >= firstSingleYear && y.filingStatus === 'single');
53
+ let bracketJumpToday = 0;
54
+ if (singleFiledYearObj) {
55
+ const jumpYear = singleFiledYearObj.year;
56
+ const survivorMagi = singleFiledYearObj.magi;
57
+ const jointAges65Plus = ctx.plan.household.people.filter((p) => jumpYear - Number(p.dob.slice(0, 4)) >= 65).length;
58
+ const bracketJump = Math.max(0, computeFederalTax({
59
+ year: jumpYear,
60
+ filingStatus: 'single',
61
+ ordinaryIncome: survivorMagi,
62
+ capitalGains: 0,
63
+ ssBenefits: 0,
64
+ peopleAged65Plus: singleFiledYearObj.people.filter((p) => p.alive && p.ageAttained >= 65).length,
65
+ }).totalTax -
66
+ computeFederalTax({
67
+ year: jumpYear,
68
+ filingStatus: 'marriedFilingJointly',
69
+ ordinaryIncome: survivorMagi,
70
+ capitalGains: 0,
71
+ ssBenefits: 0,
72
+ peopleAged65Plus: jointAges65Plus,
73
+ }).totalTax);
74
+ bracketJumpToday = Math.round(ctx.projection.deflate(jumpYear, bracketJump));
75
+ }
76
+ // SSA-44 awareness: the two years after the death are redeterminable when
77
+ // their premiums land in an IRMAA tier the plan isn't already relieving
78
+ // (QSS years already price IRMAA on the single table, so a flagged tier is
79
+ // real regardless of the income-tax filing status).
80
+ const ssa44Window = ctx.projection.result.years.filter((y) => y.year >= firstSingleYear && y.year <= firstSingleYear + 1 && y.irmaaTier > 0);
81
+ const modelsSsa44 = ctx.plan.expenses.healthcare.ssa44?.survivorYears === true;
82
+ const ssa44Note = ssa44Window.length > 0 && !modelsSsa44
83
+ ? ` The survivor's Medicare premiums also land in an IRMAA surcharge tier in ${ssa44Window
84
+ .map((y) => y.year)
85
+ .join(' and ')}: Form SSA-44 can re-price those years on the survivor's own income (model it under Spending → Healthcare).`
86
+ : '';
87
+ // The death is projected in the last joint year (the death year still
88
+ // files MFJ); the survivor's own filing starts the year after — as
89
+ // qualifying surviving spouse first when the plan opts into it.
90
+ const isQss = singleYearObj.filingStatus === 'qualifyingSurvivingSpouse';
91
+ const filingStory = isQss
92
+ ? singleFiledYearObj
93
+ ? `the survivor files as qualifying surviving spouse (joint tables) through ${singleFiledYearObj.year - 1}, then as Single from ${singleFiledYearObj.year}`
94
+ : `the survivor files as qualifying surviving spouse (joint tables) for up to two years, then as Single`
95
+ : `the survivor files as Single from ${firstSingleYear}`;
96
+ const rationale = `When one spouse passes away (projected in ${lastJointYear}), ${filingStory}. ` +
97
+ (bracketJumpToday > 100
98
+ ? `On the survivor's projected income in ${singleFiledYearObj.year}, single brackets and the smaller deduction cost about $${bracketJumpToday.toLocaleString('en-US')} more (today's $) than the same income filed jointly. `
99
+ : `Single filing cuts tax bracket ceilings in half, raising their tax rate. `) +
100
+ `Converting traditional assets to Roth before ${firstSingleYear} utilizes joint brackets.` +
101
+ ssa44Note;
102
+ const card = {
103
+ id: 'widows-penalty-roth',
104
+ category: 'social-security',
105
+ title: "Avoid the 'widow's penalty' tax cliff",
106
+ rationale,
107
+ impact: {
108
+ // The bracket jump is a size-of-problem number, not this action's
109
+ // delta, so it lives in the rationale; the preview scenario supplies
110
+ // the exact deltas when evaluated.
111
+ qualitative: 'Proactively convert assets while filing Married Jointly to protect the survivor from higher tax brackets.',
112
+ },
113
+ exact: false,
114
+ confidence: 'high',
115
+ learnSlug: 'widows-penalty-and-survivor-brackets',
116
+ plannerRoute: 'strategy',
117
+ action: {
118
+ kind: 'preview-scenario',
119
+ scenarioName: 'Pre-emptive conversions (MFJ)',
120
+ patch: {
121
+ strategies: {
122
+ rothConversion: {
123
+ mode: 'fillToTarget',
124
+ target: 'topOfBracket',
125
+ targetValue: 12,
126
+ startYear: ctx.projection.startYear,
127
+ endYear: lastJointYear,
128
+ },
129
+ },
130
+ },
131
+ },
132
+ };
133
+ return card;
134
+ },
135
+ evaluate(ctx) {
136
+ const card = this.screen(ctx);
137
+ if (!card) {
138
+ throw new Error('Widows penalty not eligible');
139
+ }
140
+ return {
141
+ action: card.action,
142
+ impact: card.impact,
143
+ };
144
+ },
145
+ };
@@ -0,0 +1,4 @@
1
+ import type { Detector, InsightCard } from './types.js';
2
+ export declare const registry: Detector[];
3
+ export declare function computeCardScore(card: InsightCard): number;
4
+ export declare function sortCards(cards: InsightCard[]): InsightCard[];
@@ -0,0 +1,62 @@
1
+ import { annuitizationHeadroom } from './detectors/annuitizationHeadroom.js';
2
+ import { assetLocation } from './detectors/assetLocation.js';
3
+ import { hecmBufferCandidate } from './detectors/hecmBufferCandidate.js';
4
+ import { incomeFloorFunded } from './detectors/incomeFloorFunded.js';
5
+ import { irmaaTierEdge } from './detectors/irmaaTierEdge.js';
6
+ import { pensionElectionPending } from './detectors/pensionElectionPending.js';
7
+ import { qcdEfficiency } from './detectors/qcdEfficiency.js';
8
+ import { rothBridgeHeadroom } from './detectors/rothBridgeHeadroom.js';
9
+ import { spendingGuardrails } from './detectors/spendingGuardrails.js';
10
+ import { spendingHeadroom } from './detectors/spendingHeadroom.js';
11
+ import { ssBridgeGap } from './detectors/ssBridgeGap.js';
12
+ import { stateRelocation } from './detectors/stateRelocation.js';
13
+ import { widowsPenalty } from './detectors/widowsPenalty.js';
14
+ export const registry = [
15
+ annuitizationHeadroom,
16
+ assetLocation,
17
+ hecmBufferCandidate,
18
+ incomeFloorFunded,
19
+ irmaaTierEdge,
20
+ pensionElectionPending,
21
+ qcdEfficiency,
22
+ rothBridgeHeadroom,
23
+ spendingGuardrails,
24
+ spendingHeadroom,
25
+ ssBridgeGap,
26
+ stateRelocation,
27
+ widowsPenalty,
28
+ ];
29
+ export function computeCardScore(card) {
30
+ const hasQuantified = card.impact.endingAfterTaxEstateDelta !== undefined ||
31
+ card.impact.successRateDeltaPct !== undefined ||
32
+ card.impact.lifetimeTaxDelta !== undefined;
33
+ if (!hasQuantified) {
34
+ return -1;
35
+ }
36
+ let metricValue = 0;
37
+ if (card.impact.endingAfterTaxEstateDelta !== undefined) {
38
+ metricValue = Math.abs(card.impact.endingAfterTaxEstateDelta);
39
+ }
40
+ else if (card.impact.successRateDeltaPct !== undefined) {
41
+ // Heuristic: scale 1% Monte Carlo success rate to $10,000
42
+ metricValue = Math.abs(card.impact.successRateDeltaPct) * 10000;
43
+ }
44
+ else if (card.impact.lifetimeTaxDelta !== undefined) {
45
+ metricValue = Math.abs(card.impact.lifetimeTaxDelta);
46
+ }
47
+ const confidenceWeight = { high: 1.0, medium: 0.7, low: 0.4 }[card.confidence];
48
+ return metricValue * confidenceWeight;
49
+ }
50
+ export function sortCards(cards) {
51
+ return [...cards].sort((a, b) => {
52
+ const scoreA = computeCardScore(a);
53
+ const scoreB = computeCardScore(b);
54
+ if (scoreA !== scoreB) {
55
+ return scoreB - scoreA; // descending
56
+ }
57
+ const catComp = a.category.localeCompare(b.category);
58
+ if (catComp !== 0)
59
+ return catComp;
60
+ return a.title.localeCompare(b.title);
61
+ });
62
+ }
@@ -0,0 +1,6 @@
1
+ import type { Detector, DetectorContext, InsightCard } from './types.js';
2
+ /**
3
+ * Runs the synchronous screening pass of all registered detectors.
4
+ * Filters out nulls and ranks the resulting cards.
5
+ */
6
+ export declare function runScreen(ctx: DetectorContext, detectors?: Detector[]): InsightCard[];
@@ -0,0 +1,15 @@
1
+ import { registry, sortCards } from './registry.js';
2
+ /**
3
+ * Runs the synchronous screening pass of all registered detectors.
4
+ * Filters out nulls and ranks the resulting cards.
5
+ */
6
+ export function runScreen(ctx, detectors = registry) {
7
+ const cards = [];
8
+ for (const detector of detectors) {
9
+ const card = detector.screen(ctx);
10
+ if (card) {
11
+ cards.push(card);
12
+ }
13
+ }
14
+ return sortCards(cards);
15
+ }