@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,136 @@
1
+ /**
2
+ * Flexible one-time-goal scheduler. One scheduler instance belongs to one
3
+ * projection path, so goal resolution is stateful and deterministic: a goal can
4
+ * be funded, partially funded, or skipped at most once.
5
+ */
6
+ const CLASSIFICATION_RANK = {
7
+ required: 0,
8
+ target: 1,
9
+ ideal: 2,
10
+ excess: 3,
11
+ };
12
+ const EPSILON = 0.005;
13
+ /** Resolve a stored goal's optional flexibility fields to their defaults. */
14
+ export function toSchedulableGoal(goal, index) {
15
+ const targetYear = goal.year;
16
+ const classification = goal.classification ?? 'target';
17
+ const flexibility = goal.flexibility ?? 'fixed';
18
+ const earliestYear = Math.min(goal.earliestYear ?? targetYear, targetYear);
19
+ const latestYear = Math.max(goal.latestYear ?? targetYear, targetYear);
20
+ const defaultMinFundingPct = flexibility === 'skippable' ? 0 : 100;
21
+ return {
22
+ id: goal.id,
23
+ classification,
24
+ flexibility,
25
+ earliestYear,
26
+ targetYear,
27
+ latestYear,
28
+ priority: goal.priority ?? index,
29
+ order: index,
30
+ amountTodayDollars: goal.amount,
31
+ minFundingPct: goal.minFundingPct ?? defaultMinFundingPct,
32
+ allowPartialFunding: goal.allowPartialFunding ?? false,
33
+ };
34
+ }
35
+ function result(goal, ctx, outcome, fundedNominal) {
36
+ const amountNominal = goal.amountTodayDollars * ctx.inflFactor;
37
+ return {
38
+ id: goal.id,
39
+ classification: goal.classification,
40
+ outcome,
41
+ amountNominal,
42
+ fundedNominal,
43
+ unfundedNominal: Math.max(0, amountNominal - fundedNominal),
44
+ };
45
+ }
46
+ function defaultBudget(ctx) {
47
+ if (ctx.availableBudget !== undefined)
48
+ return ctx.availableBudget;
49
+ return ctx.cutting ? 0 : null;
50
+ }
51
+ function canFullyFund(budget, amount) {
52
+ return budget === null || budget + EPSILON >= amount;
53
+ }
54
+ function consume(budget, amount) {
55
+ return budget === null ? null : Math.max(0, budget - amount);
56
+ }
57
+ function partialAmount(goal, budget, amount) {
58
+ if (!goal.allowPartialFunding || budget === null || budget <= EPSILON)
59
+ return 0;
60
+ const minimum = amount * Math.max(0, Math.min(100, goal.minFundingPct)) / 100;
61
+ if (budget + EPSILON < minimum)
62
+ return 0;
63
+ return Math.min(amount, budget);
64
+ }
65
+ function resolveBudget(ctx, beforeTarget, remainingBudget) {
66
+ if (beforeTarget || ctx.cutting)
67
+ return remainingBudget;
68
+ return null;
69
+ }
70
+ /**
71
+ * Build a scheduler for a plan's goals. Flexible goals are evaluated by
72
+ * classification, then explicit priority, then original list order so a target
73
+ * car replacement cannot be crowded out by a lower-layer ideal remodel.
74
+ */
75
+ export function createGoalScheduler(goals) {
76
+ const ordered = [...goals].sort((a, b) => {
77
+ const classDelta = CLASSIFICATION_RANK[a.classification] - CLASSIFICATION_RANK[b.classification];
78
+ if (classDelta !== 0)
79
+ return classDelta;
80
+ const priorityDelta = a.priority - b.priority;
81
+ if (priorityDelta !== 0)
82
+ return priorityDelta;
83
+ return a.order - b.order;
84
+ });
85
+ const resolved = new Set();
86
+ return {
87
+ isResolved(id) {
88
+ return resolved.has(id);
89
+ },
90
+ planYear(year, ctx) {
91
+ const results = [];
92
+ let remainingBudget = defaultBudget(ctx);
93
+ for (const goal of ordered) {
94
+ if (resolved.has(goal.id))
95
+ continue;
96
+ if (goal.flexibility === 'fixed') {
97
+ if (year === goal.targetYear) {
98
+ const funded = goal.amountTodayDollars * ctx.inflFactor;
99
+ resolved.add(goal.id);
100
+ results.push(result(goal, ctx, 'funded', funded));
101
+ }
102
+ continue;
103
+ }
104
+ if (year < goal.earliestYear || year > goal.latestYear)
105
+ continue;
106
+ const beforeTarget = year < goal.targetYear;
107
+ if (beforeTarget && !ctx.canPullForward)
108
+ continue;
109
+ const amount = goal.amountTodayDollars * ctx.inflFactor;
110
+ const effectiveBudget = resolveBudget(ctx, beforeTarget, remainingBudget);
111
+ if (canFullyFund(effectiveBudget, amount)) {
112
+ if (effectiveBudget !== null)
113
+ remainingBudget = consume(remainingBudget, amount);
114
+ resolved.add(goal.id);
115
+ results.push(result(goal, ctx, 'funded', amount));
116
+ continue;
117
+ }
118
+ const partial = partialAmount(goal, effectiveBudget, amount);
119
+ if (partial > EPSILON) {
120
+ remainingBudget = consume(remainingBudget, partial);
121
+ resolved.add(goal.id);
122
+ results.push(result(goal, ctx, 'partiallyFunded', partial));
123
+ continue;
124
+ }
125
+ if (year < goal.latestYear) {
126
+ results.push(result(goal, ctx, 'deferred', 0));
127
+ }
128
+ else {
129
+ resolved.add(goal.id);
130
+ results.push(result(goal, ctx, 'skipped', 0));
131
+ }
132
+ }
133
+ return { results, remainingBudget };
134
+ },
135
+ };
136
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Withdrawal-rate spending guardrails (planning-depth roadmap §4, Guyton–Klinger
3
+ * style). A path-level decision rule that rations the *discretionary* spending
4
+ * layer year by year: when the current withdrawal rate runs too far above the
5
+ * plan's starting rate the discretionary layer is cut, and when it falls back
6
+ * below the starting rate it is restored — the required floor is never touched.
7
+ *
8
+ * These functions are pure and deterministic. The ledger owns the per-year state
9
+ * (the running discretionary multiplier) and applies the funded spending through
10
+ * the same tax/withdrawal/estate mechanics as every other expense; nothing here
11
+ * builds its own cash-flow model.
12
+ *
13
+ * The withdrawal-rate signal is `targetSpending / startOfYearPortfolio`, compared
14
+ * to the same ratio in the first projection year. This is a documented
15
+ * simplification: it uses gross target spending (not net-of-guaranteed-income
16
+ * portfolio draws) as the withdrawal proxy, so it is well defined before the
17
+ * tax/withdrawal fixed point runs and cannot become circular with the very
18
+ * spending it is deciding.
19
+ */
20
+ export interface GuardrailPolicy {
21
+ /**
22
+ * Which signal triggers adjustments. 'withdrawal-rate' (or absent) compares
23
+ * the current withdrawal rate to the starting rate (Guyton–Klinger style);
24
+ * 'risk-based' compares the real portfolio balance to solver-derived dollar
25
+ * thresholds that correspond to a target probability-of-success band.
26
+ */
27
+ mode?: 'withdrawal-rate' | 'risk-based';
28
+ /** Cut when current rate exceeds this % of the starting rate. Absent ⇒ 120. */
29
+ upperGuardrailPct?: number;
30
+ /** Raise when current rate falls below this % of the starting rate. Absent ⇒ 80. */
31
+ lowerGuardrailPct?: number;
32
+ /** Risk-based: cut when the real balance falls below this % of the starting portfolio. */
33
+ lowerBalanceThresholdPct?: number;
34
+ /** Risk-based: raise when the real balance rises above this % of the starting portfolio. */
35
+ upperBalanceThresholdPct?: number;
36
+ /** Each cut/raise moves the discretionary layer by this % of its full size. Absent ⇒ 10. */
37
+ adjustmentPct?: number;
38
+ /** Permit raises above the target lifestyle into ideal/excess layers. */
39
+ allowRaisesAboveTarget?: boolean;
40
+ }
41
+ export declare const DEFAULT_UPPER_GUARDRAIL_PCT = 120;
42
+ export declare const DEFAULT_LOWER_GUARDRAIL_PCT = 80;
43
+ export declare const DEFAULT_ADJUSTMENT_PCT = 10;
44
+ export type GuardrailAction = 'hold' | 'cut' | 'raise';
45
+ export interface GuardrailDecision {
46
+ /** Discretionary multiplier in [0, 1] after this year's decision. */
47
+ multiplier: number;
48
+ action: GuardrailAction;
49
+ }
50
+ /**
51
+ * One year's guardrail decision. `startingRate` and `currentRate` are the
52
+ * target-spending / start-of-year-portfolio ratios for the first projection year
53
+ * and this year respectively.
54
+ *
55
+ * - ratio above the upper band ⇒ cut the discretionary layer by `adjustmentPct`,
56
+ * - ratio below the lower band ⇒ restore it by `adjustmentPct`,
57
+ * - otherwise hold.
58
+ *
59
+ * The multiplier is clamped to [0, 1] so a cut never reaches below the required
60
+ * floor and a raise never overshoots full discretionary spending. `action` is
61
+ * reported as 'hold' when the multiplier does not actually move (already at a
62
+ * bound), so downstream counts reflect real adjustments only.
63
+ */
64
+ export declare function nextGuardrailMultiplier(prevMultiplier: number, currentRate: number, startingRate: number, policy?: GuardrailPolicy, maxMultiplier?: number): GuardrailDecision;
65
+ /**
66
+ * One year's risk-based guardrail decision. The signal is the real (deflated)
67
+ * start-of-year portfolio balance against dollar thresholds expressed as a
68
+ * percent of the starting portfolio. The thresholds come from the shared-path
69
+ * probability solver (engine/montecarlo/riskBasedGuardrails.ts): the balance
70
+ * levels at which the plan's probability of success leaves the user's target
71
+ * band. Below the lower threshold ⇒ cut the discretionary layer one
72
+ * `adjustmentPct` step; above the upper threshold ⇒ restore/raise one step.
73
+ *
74
+ * Missing or degenerate thresholds hold: a risk-based policy whose thresholds
75
+ * have not been solved yet adjusts nothing (it behaves like fixed target), so
76
+ * the mode can never act on made-up numbers.
77
+ */
78
+ export declare function nextBalanceGuardrailMultiplier(prevMultiplier: number, currentRealBalance: number, startingBalance: number, policy?: GuardrailPolicy, maxMultiplier?: number): GuardrailDecision;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Withdrawal-rate spending guardrails (planning-depth roadmap §4, Guyton–Klinger
3
+ * style). A path-level decision rule that rations the *discretionary* spending
4
+ * layer year by year: when the current withdrawal rate runs too far above the
5
+ * plan's starting rate the discretionary layer is cut, and when it falls back
6
+ * below the starting rate it is restored — the required floor is never touched.
7
+ *
8
+ * These functions are pure and deterministic. The ledger owns the per-year state
9
+ * (the running discretionary multiplier) and applies the funded spending through
10
+ * the same tax/withdrawal/estate mechanics as every other expense; nothing here
11
+ * builds its own cash-flow model.
12
+ *
13
+ * The withdrawal-rate signal is `targetSpending / startOfYearPortfolio`, compared
14
+ * to the same ratio in the first projection year. This is a documented
15
+ * simplification: it uses gross target spending (not net-of-guaranteed-income
16
+ * portfolio draws) as the withdrawal proxy, so it is well defined before the
17
+ * tax/withdrawal fixed point runs and cannot become circular with the very
18
+ * spending it is deciding.
19
+ */
20
+ export const DEFAULT_UPPER_GUARDRAIL_PCT = 120;
21
+ export const DEFAULT_LOWER_GUARDRAIL_PCT = 80;
22
+ export const DEFAULT_ADJUSTMENT_PCT = 10;
23
+ function clampRange(x, max) {
24
+ const hi = Math.max(0, Number.isFinite(max) ? max : 1);
25
+ if (!Number.isFinite(x))
26
+ return Math.min(1, hi);
27
+ return Math.min(hi, Math.max(0, x));
28
+ }
29
+ /**
30
+ * One year's guardrail decision. `startingRate` and `currentRate` are the
31
+ * target-spending / start-of-year-portfolio ratios for the first projection year
32
+ * and this year respectively.
33
+ *
34
+ * - ratio above the upper band ⇒ cut the discretionary layer by `adjustmentPct`,
35
+ * - ratio below the lower band ⇒ restore it by `adjustmentPct`,
36
+ * - otherwise hold.
37
+ *
38
+ * The multiplier is clamped to [0, 1] so a cut never reaches below the required
39
+ * floor and a raise never overshoots full discretionary spending. `action` is
40
+ * reported as 'hold' when the multiplier does not actually move (already at a
41
+ * bound), so downstream counts reflect real adjustments only.
42
+ */
43
+ export function nextGuardrailMultiplier(prevMultiplier, currentRate, startingRate, policy = {}, maxMultiplier = 1) {
44
+ const upper = (policy.upperGuardrailPct ?? DEFAULT_UPPER_GUARDRAIL_PCT) / 100;
45
+ const lower = (policy.lowerGuardrailPct ?? DEFAULT_LOWER_GUARDRAIL_PCT) / 100;
46
+ const adj = (policy.adjustmentPct ?? DEFAULT_ADJUSTMENT_PCT) / 100;
47
+ const prev = clampRange(prevMultiplier, maxMultiplier);
48
+ // An undefined signal (no starting portfolio, non-finite current rate) holds:
49
+ // there is nothing to compare against, so spending is left where it is.
50
+ if (!(startingRate > 0) || !Number.isFinite(currentRate) || currentRate < 0) {
51
+ return { multiplier: prev, action: 'hold' };
52
+ }
53
+ const ratio = currentRate / startingRate;
54
+ if (ratio > upper) {
55
+ const multiplier = clampRange(prev - adj, maxMultiplier);
56
+ return { multiplier, action: multiplier < prev - 1e-9 ? 'cut' : 'hold' };
57
+ }
58
+ if (ratio < lower) {
59
+ const multiplier = clampRange(prev + adj, maxMultiplier);
60
+ return { multiplier, action: multiplier > prev + 1e-9 ? 'raise' : 'hold' };
61
+ }
62
+ return { multiplier: prev, action: 'hold' };
63
+ }
64
+ /**
65
+ * One year's risk-based guardrail decision. The signal is the real (deflated)
66
+ * start-of-year portfolio balance against dollar thresholds expressed as a
67
+ * percent of the starting portfolio. The thresholds come from the shared-path
68
+ * probability solver (engine/montecarlo/riskBasedGuardrails.ts): the balance
69
+ * levels at which the plan's probability of success leaves the user's target
70
+ * band. Below the lower threshold ⇒ cut the discretionary layer one
71
+ * `adjustmentPct` step; above the upper threshold ⇒ restore/raise one step.
72
+ *
73
+ * Missing or degenerate thresholds hold: a risk-based policy whose thresholds
74
+ * have not been solved yet adjusts nothing (it behaves like fixed target), so
75
+ * the mode can never act on made-up numbers.
76
+ */
77
+ export function nextBalanceGuardrailMultiplier(prevMultiplier, currentRealBalance, startingBalance, policy = {}, maxMultiplier = 1) {
78
+ const adj = (policy.adjustmentPct ?? DEFAULT_ADJUSTMENT_PCT) / 100;
79
+ const prev = clampRange(prevMultiplier, maxMultiplier);
80
+ const lowerPct = policy.lowerBalanceThresholdPct;
81
+ const upperPct = policy.upperBalanceThresholdPct;
82
+ if (!(startingBalance > 0) || !Number.isFinite(currentRealBalance) || currentRealBalance < 0) {
83
+ return { multiplier: prev, action: 'hold' };
84
+ }
85
+ const lower = lowerPct !== undefined && lowerPct > 0 ? (lowerPct / 100) * startingBalance : null;
86
+ const upper = upperPct !== undefined && upperPct > 0 ? (upperPct / 100) * startingBalance : null;
87
+ // An inverted pair (lower ≥ upper) is degenerate; hold rather than oscillate.
88
+ if (lower !== null && upper !== null && lower >= upper) {
89
+ return { multiplier: prev, action: 'hold' };
90
+ }
91
+ if (lower !== null && currentRealBalance < lower) {
92
+ const multiplier = clampRange(prev - adj, maxMultiplier);
93
+ return { multiplier, action: multiplier < prev - 1e-9 ? 'cut' : 'hold' };
94
+ }
95
+ if (upper !== null && currentRealBalance > upper) {
96
+ const multiplier = clampRange(prev + adj, maxMultiplier);
97
+ return { multiplier, action: multiplier > prev + 1e-9 ? 'raise' : 'hold' };
98
+ }
99
+ return { multiplier: prev, action: 'hold' };
100
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Spending-layer helpers (planning-depth roadmap §4). Pure functions the ledger
3
+ * uses to split annual lifestyle spending into a required floor and a
4
+ * discretionary layer, and to attribute a portfolio shortfall to the required
5
+ * vs. target layers. Keeping these out of `simulate.ts` makes the layer math
6
+ * unit-testable without running a full projection.
7
+ *
8
+ * System-computed costs (healthcare, debt service, property carrying costs,
9
+ * insurance premiums, net long-term-care) are treated as required by the ledger
10
+ * and are not modeled here; this file owns only the lifestyle split and the
11
+ * shortfall attribution, which operate on the whole spending stack.
12
+ */
13
+ export interface LifestyleSplit {
14
+ /** Nominal required-floor lifestyle spending. */
15
+ requiredLifestyle: number;
16
+ /** Nominal discretionary lifestyle spending (the layer a guardrail can cut). */
17
+ discretionaryLifestyle: number;
18
+ }
19
+ /**
20
+ * Split a year's nominal lifestyle spending into required and discretionary
21
+ * layers. The required floor is clamped to the target so the two layers always
22
+ * sum back to `baseAnnualNominal` (the migrated fixed-target total).
23
+ */
24
+ export declare function splitLifestyle(baseAnnualNominal: number, requiredAnnualNominal: number): LifestyleSplit;
25
+ export interface AnnualSpendingLayers extends LifestyleSplit {
26
+ /** Nominal target-lifestyle layer above the required floor. */
27
+ targetLifestyle: number;
28
+ /** Nominal annual ideal layer above the target lifestyle. */
29
+ idealLifestyle: number;
30
+ /** Nominal annual excess/opportunistic layer above ideal. */
31
+ excessLifestyle: number;
32
+ }
33
+ /**
34
+ * Split annual lifestyle spending into the four modeled layers. `baseAnnual`
35
+ * remains the target lifestyle for compatibility; ideal/excess are explicit
36
+ * upside layers above that target and default to zero for older saved plans.
37
+ */
38
+ export declare function splitAnnualSpendingLayers(input: {
39
+ baseAnnualNominal: number;
40
+ requiredAnnualNominal: number;
41
+ idealAnnualNominal?: number;
42
+ excessAnnualNominal?: number;
43
+ }): AnnualSpendingLayers;
44
+ export interface ShortfallAttributionInput {
45
+ /** Must-fund spending this year (system costs + required lifestyle + required goals). */
46
+ requiredSpending: number;
47
+ /** Full target spending with no guardrail cut (required + target lifestyle/goals). */
48
+ targetSpending: number;
49
+ /** Incremental ideal spending above the target lifestyle. */
50
+ idealSpending?: number;
51
+ /** Incremental excess/opportunistic spending above ideal. */
52
+ excessSpending?: number;
53
+ /** Spending actually attempted after any guardrail cut (`expenses.total`). */
54
+ fundedSpending: number;
55
+ /** Dollars the withdrawal waterfall could not produce for the funded need. */
56
+ withdrawalShortfall: number;
57
+ }
58
+ export interface ShortfallAttribution {
59
+ /** Required-floor spending the portfolio could not cover — the serious failure signal. */
60
+ requiredShortfall: number;
61
+ /** Target-lifestyle miss: the guardrail's deliberate cut plus any shortfall that ate discretionary. */
62
+ targetShortfall: number;
63
+ /** Ideal-layer miss after target spending has first claim on funded dollars. */
64
+ idealShortfall: number;
65
+ /** Excess-layer miss after required, target, and ideal have first claim. */
66
+ excessShortfall: number;
67
+ }
68
+ /**
69
+ * Attribute a portfolio shortfall across the spending layers.
70
+ *
71
+ * The target-lifestyle miss is a superset of the required-floor miss: it is
72
+ * everything spent below the full target, i.e. the guardrail's deliberate
73
+ * discretionary cut plus every dollar the portfolio could not produce.
74
+ *
75
+ * The required-floor miss is only the part of the withdrawal shortfall that eats
76
+ * below the floor — a genuine shortfall is charged to funded discretionary
77
+ * spending first and reaches the floor only once discretionary is exhausted — so
78
+ * `requiredShortfall` stays a clean "ran out of money for essentials" signal.
79
+ * Consequently `targetShortfall ≥ requiredShortfall` always.
80
+ */
81
+ export declare function attributeShortfall(input: ShortfallAttributionInput): ShortfallAttribution;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Spending-layer helpers (planning-depth roadmap §4). Pure functions the ledger
3
+ * uses to split annual lifestyle spending into a required floor and a
4
+ * discretionary layer, and to attribute a portfolio shortfall to the required
5
+ * vs. target layers. Keeping these out of `simulate.ts` makes the layer math
6
+ * unit-testable without running a full projection.
7
+ *
8
+ * System-computed costs (healthcare, debt service, property carrying costs,
9
+ * insurance premiums, net long-term-care) are treated as required by the ledger
10
+ * and are not modeled here; this file owns only the lifestyle split and the
11
+ * shortfall attribution, which operate on the whole spending stack.
12
+ */
13
+ /**
14
+ * Split a year's nominal lifestyle spending into required and discretionary
15
+ * layers. The required floor is clamped to the target so the two layers always
16
+ * sum back to `baseAnnualNominal` (the migrated fixed-target total).
17
+ */
18
+ export function splitLifestyle(baseAnnualNominal, requiredAnnualNominal) {
19
+ const requiredLifestyle = Math.min(Math.max(0, requiredAnnualNominal), Math.max(0, baseAnnualNominal));
20
+ return {
21
+ requiredLifestyle,
22
+ discretionaryLifestyle: Math.max(0, baseAnnualNominal - requiredLifestyle),
23
+ };
24
+ }
25
+ /**
26
+ * Split annual lifestyle spending into the four modeled layers. `baseAnnual`
27
+ * remains the target lifestyle for compatibility; ideal/excess are explicit
28
+ * upside layers above that target and default to zero for older saved plans.
29
+ */
30
+ export function splitAnnualSpendingLayers(input) {
31
+ const { requiredLifestyle, discretionaryLifestyle } = splitLifestyle(input.baseAnnualNominal, input.requiredAnnualNominal);
32
+ return {
33
+ requiredLifestyle,
34
+ discretionaryLifestyle,
35
+ targetLifestyle: discretionaryLifestyle,
36
+ idealLifestyle: Math.max(0, input.idealAnnualNominal ?? 0),
37
+ excessLifestyle: Math.max(0, input.excessAnnualNominal ?? 0),
38
+ };
39
+ }
40
+ /**
41
+ * Attribute a portfolio shortfall across the spending layers.
42
+ *
43
+ * The target-lifestyle miss is a superset of the required-floor miss: it is
44
+ * everything spent below the full target, i.e. the guardrail's deliberate
45
+ * discretionary cut plus every dollar the portfolio could not produce.
46
+ *
47
+ * The required-floor miss is only the part of the withdrawal shortfall that eats
48
+ * below the floor — a genuine shortfall is charged to funded discretionary
49
+ * spending first and reaches the floor only once discretionary is exhausted — so
50
+ * `requiredShortfall` stays a clean "ran out of money for essentials" signal.
51
+ * Consequently `targetShortfall ≥ requiredShortfall` always.
52
+ */
53
+ export function attributeShortfall(input) {
54
+ const requiredSpending = Math.max(0, input.requiredSpending);
55
+ const targetSpending = Math.max(requiredSpending, input.targetSpending);
56
+ const idealSpending = Math.max(0, input.idealSpending ?? 0);
57
+ const excessSpending = Math.max(0, input.excessSpending ?? 0);
58
+ const actualFunded = Math.max(0, input.fundedSpending - Math.max(0, input.withdrawalShortfall));
59
+ const idealFunded = Math.max(0, Math.min(idealSpending, actualFunded - targetSpending));
60
+ const excessFunded = Math.max(0, Math.min(excessSpending, actualFunded - targetSpending - idealSpending));
61
+ return {
62
+ requiredShortfall: Math.max(0, requiredSpending - actualFunded),
63
+ targetShortfall: Math.max(0, targetSpending - actualFunded),
64
+ idealShortfall: Math.max(0, idealSpending - idealFunded),
65
+ excessShortfall: Math.max(0, excessSpending - excessFunded),
66
+ };
67
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Spending-shape presets (spending-paths & SWR-lenses plan, Goal 1).
3
+ *
4
+ * Named, research-sourced spending shapes that compile to ordinary
5
+ * `expenses.phases` rows — the anti-drift rule from the shipped spending
6
+ * profiles: presets generate visible, editable phase rows at creation time and
7
+ * never live-couple saved plans to research constants.
8
+ *
9
+ * Shapes (multipliers on the primary person's age clock, always vs. the
10
+ * inflation-adjusted baseline):
11
+ * - constant-real: no phases — spending holds level in real terms (the classic
12
+ * Bengen assumption).
13
+ * - smile: Blanchett's *average* retiree — a real decline through
14
+ * mid-retirement that late-life healthcare partly reverses. The shipped
15
+ * two-step calibration (−10% at 75, −20% at 85) approximates that path's
16
+ * overall level without an explicit late step-up; edit the generated rows
17
+ * to add one if desired.
18
+ * - smirk: Blanchett's 2025–26 finding for the *median* retiree — a steady
19
+ * ~1%/yr real decline with no late uptick (only the sickest tail drives the
20
+ * average's uptick). Shape-aware plans support meaningfully higher initial
21
+ * withdrawals than constant-real; the solver-per-shape view quantifies that
22
+ * on the user's own plan.
23
+ * - custom annual real delta: the same compilation at a user-chosen %/yr.
24
+ *
25
+ * A steady per-year drift is compiled as 5-year steps (the compounded
26
+ * multiplier at each step age) so the generated rows stay reviewable — a
27
+ * documented approximation, editable like any hand-entered phase.
28
+ */
29
+ import type { ExpensePhase } from '../model/plan.js';
30
+ export type SpendingShapeId = 'flat' | 'smile' | 'frontLoaded' | 'smirk';
31
+ /** Blanchett-median real spending drift used by the smirk preset (%/yr). */
32
+ export declare const SMIRK_ANNUAL_REAL_DELTA_PCT = -1;
33
+ /**
34
+ * Compile a steady annual real spending drift into 5-year phase steps from
35
+ * retirement to age 100: at each step age the multiplier is the fully
36
+ * compounded (1 + delta)^(yearsSinceRetirement), rounded to 2 decimals and
37
+ * clamped to the phase schema's [0, 3] range. A zero delta compiles to no
38
+ * phases (constant-real).
39
+ */
40
+ export declare function annualDeltaPhases(deltaPct: number, retirementAge: number): ExpensePhase[];
41
+ /** Compile a named shape to phase rows (see module docs for each calibration). */
42
+ export declare function spendingShapePhases(shape: SpendingShapeId, retirementAge: number): ExpensePhase[];
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Spending-shape presets (spending-paths & SWR-lenses plan, Goal 1).
3
+ *
4
+ * Named, research-sourced spending shapes that compile to ordinary
5
+ * `expenses.phases` rows — the anti-drift rule from the shipped spending
6
+ * profiles: presets generate visible, editable phase rows at creation time and
7
+ * never live-couple saved plans to research constants.
8
+ *
9
+ * Shapes (multipliers on the primary person's age clock, always vs. the
10
+ * inflation-adjusted baseline):
11
+ * - constant-real: no phases — spending holds level in real terms (the classic
12
+ * Bengen assumption).
13
+ * - smile: Blanchett's *average* retiree — a real decline through
14
+ * mid-retirement that late-life healthcare partly reverses. The shipped
15
+ * two-step calibration (−10% at 75, −20% at 85) approximates that path's
16
+ * overall level without an explicit late step-up; edit the generated rows
17
+ * to add one if desired.
18
+ * - smirk: Blanchett's 2025–26 finding for the *median* retiree — a steady
19
+ * ~1%/yr real decline with no late uptick (only the sickest tail drives the
20
+ * average's uptick). Shape-aware plans support meaningfully higher initial
21
+ * withdrawals than constant-real; the solver-per-shape view quantifies that
22
+ * on the user's own plan.
23
+ * - custom annual real delta: the same compilation at a user-chosen %/yr.
24
+ *
25
+ * A steady per-year drift is compiled as 5-year steps (the compounded
26
+ * multiplier at each step age) so the generated rows stay reviewable — a
27
+ * documented approximation, editable like any hand-entered phase.
28
+ */
29
+ /** Blanchett-median real spending drift used by the smirk preset (%/yr). */
30
+ export const SMIRK_ANNUAL_REAL_DELTA_PCT = -1;
31
+ const STEP_YEARS = 5;
32
+ const LAST_STEP_AGE = 100;
33
+ /**
34
+ * Compile a steady annual real spending drift into 5-year phase steps from
35
+ * retirement to age 100: at each step age the multiplier is the fully
36
+ * compounded (1 + delta)^(yearsSinceRetirement), rounded to 2 decimals and
37
+ * clamped to the phase schema's [0, 3] range. A zero delta compiles to no
38
+ * phases (constant-real).
39
+ */
40
+ export function annualDeltaPhases(deltaPct, retirementAge) {
41
+ if (deltaPct === 0)
42
+ return [];
43
+ // Phase rows require fromAge in [40, 110]; start stepping after retirement.
44
+ const startAge = Math.min(Math.max(Math.round(retirementAge), 40), 105);
45
+ const phases = [];
46
+ for (let age = startAge + STEP_YEARS; age <= LAST_STEP_AGE; age += STEP_YEARS) {
47
+ const multiplier = Math.pow(1 + deltaPct / 100, age - startAge);
48
+ phases.push({ fromAge: age, multiplier: Math.min(3, Math.max(0, Math.round(multiplier * 100) / 100)) });
49
+ }
50
+ return phases;
51
+ }
52
+ /** Compile a named shape to phase rows (see module docs for each calibration). */
53
+ export function spendingShapePhases(shape, retirementAge) {
54
+ switch (shape) {
55
+ case 'flat':
56
+ return [];
57
+ case 'smile':
58
+ return [
59
+ { fromAge: 75, multiplier: 0.9 },
60
+ { fromAge: 85, multiplier: 0.8 },
61
+ ];
62
+ case 'smirk':
63
+ return annualDeltaPhases(SMIRK_ANNUAL_REAL_DELTA_PCT, retirementAge);
64
+ case 'frontLoaded': {
65
+ const boostFrom = Math.min(Math.max(retirementAge, 40), 100);
66
+ // The +10% travel boost only covers the pre-75 window and always settles
67
+ // back to baseline at 75. If retirement starts at/after 75 there is no
68
+ // early window to boost, so stay flat (matches the UI label and doc).
69
+ if (boostFrom >= 75)
70
+ return [];
71
+ return [
72
+ { fromAge: boostFrom, multiplier: 1.1 },
73
+ { fromAge: 75, multiplier: 1 },
74
+ ];
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Account eligibility service (account/HSA/fixed-asset depth plan, step 1).
3
+ *
4
+ * One pure module answering, per account (and where relevant, per year/owner):
5
+ * can it accept contributions? can it be converted to Roth? does it follow the
6
+ * owner's own RMDs? is its balance spendable this year? and what early-
7
+ * withdrawal penalty rate applies? The ledger (`projection/simulate.ts`), the
8
+ * optimizer input builder (`projection/optimizePlan.ts`), and the decision
9
+ * engine (`decisions/*`) all consume these helpers, so every recommended
10
+ * account movement is explainable by a rule that exists in exactly one place —
11
+ * the roadmap §7 acceptance criterion.
12
+ *
13
+ * Rules encoded here (each documented in DOCS/domain/domain-rules-reference.md):
14
+ * - Inherited traditional accounts (SECURE Act 10-year rule) cannot receive
15
+ * contributions, cannot be converted to Roth, are exempt from the owner's
16
+ * Uniform-Lifetime RMDs (they follow their own forced-distribution schedule),
17
+ * and are never subject to the 10% early-withdrawal penalty.
18
+ * - Cliff-vesting equity compensation is not spendable before its vest year.
19
+ * - Traditional withdrawals before 59½ (approximated as age < 60) carry a 10%
20
+ * penalty unless the Rule of 55 applies (employer plan, separation in/after
21
+ * the year the owner turned 55, approximated by the owner's retirement age).
22
+ * 72(t) SEPP and inherited distributions are taken outside the need-based
23
+ * flow and are penalty-free by construction.
24
+ * - HSA non-qualified withdrawals before 65 carry a 20% penalty; from 65 on
25
+ * they are penalty-free (ordinary income only).
26
+ */
27
+ import type { Account } from '../model/plan.js';
28
+ export type TraditionalAccount = Extract<Account, {
29
+ type: 'traditional';
30
+ }>;
31
+ export type EquityCompAccount = Extract<Account, {
32
+ type: 'equityComp';
33
+ }>;
34
+ /** Traditional pre-59½ early-withdrawal penalty rate (age approximated annually). */
35
+ export declare const TRADITIONAL_EARLY_PENALTY_RATE = 0.1;
36
+ /** HSA non-qualified withdrawal penalty rate before age 65 (IRC §223(f)(4)). */
37
+ export declare const HSA_NON_QUALIFIED_PENALTY_RATE = 0.2;
38
+ /** Can this account receive new contributions? (Inherited accounts cannot.) */
39
+ export declare function acceptsContributions(account: Account): boolean;
40
+ /**
41
+ * Can this account's balance be converted to Roth? Only an owned (non-
42
+ * inherited) traditional account qualifies: inherited accounts follow the
43
+ * 10-year rule and are never convertible by a non-spouse beneficiary.
44
+ */
45
+ export declare function isConvertibleToRoth(account: Account): account is TraditionalAccount;
46
+ /**
47
+ * Does this account follow the owner's own age-based RMDs (Uniform Lifetime /
48
+ * Joint Life)? Inherited accounts are exempt — they follow the beneficiary
49
+ * 10-year schedule in `strategies/inheritedIra.ts` instead.
50
+ */
51
+ export declare function followsOwnerRmds(account: Account): account is TraditionalAccount;
52
+ /**
53
+ * Does this account participate in the owner's Form-8606 IRA aggregation for
54
+ * the nondeductible-basis pro-rata rule? Only the owner's own traditional
55
+ * IRAs aggregate: employer plans track after-tax money separately, and a
56
+ * beneficiary's inherited IRA has its own separate Form 8606.
57
+ */
58
+ export declare function isAggregatedIra(account: Account): account is TraditionalAccount;
59
+ /** Is a cliff-vesting equity-comp account vested (available for spending) in `year`? */
60
+ export declare function isEquityCompVested(account: EquityCompAccount, year: number): boolean;
61
+ /** Is this account's balance available for need-based spending withdrawals in `year`? */
62
+ export declare function isSpendableInYear(account: Account, year: number): boolean;
63
+ export interface EarlyWithdrawalContext {
64
+ /** Owner's age attained this calendar year. */
65
+ ownerAgeAttained: number;
66
+ /** Owner's plan retirement age (the separation-from-service proxy), or null. */
67
+ ownerRetirementAge: number | null;
68
+ }
69
+ /**
70
+ * Penalty rate on a need-based traditional withdrawal this year: 10% before
71
+ * 59½ (approximated as age < 60), waived for inherited accounts (never
72
+ * penalized), and waived by the Rule of 55 for an EMPLOYER plan the owner
73
+ * separated from in/after the year they turned 55 (IRAs never qualify).
74
+ */
75
+ export declare function traditionalWithdrawalPenaltyRate(account: TraditionalAccount, ctx: EarlyWithdrawalContext): number;
76
+ /**
77
+ * Penalty rate on the NON-QUALIFIED portion of an HSA withdrawal this year:
78
+ * 20% before age 65, none after. Qualified (medical) withdrawals are never
79
+ * penalized; the HSA subledger decides how much of a withdrawal is qualified.
80
+ */
81
+ export declare function hsaNonQualifiedPenaltyRate(ownerAgeAttained: number): number;