Calculation catalog › Spending and withdrawals
Spending base annual
Base living spending modeled for a projection year.
Field: Base spending Unit: US dollars Basis: nominal dollars Computed by: Engine Status: Complete chain
Appears in: chart (base); csv (baseSpending); cash-flow-drilldown (requiredLifestyle / targetLifestyle use lines (layered))
Calculation records
-
Amortization-based withdrawal, growing annuity-due payment Formula record, justified by derivation
Purpose: This year's ABW payment from a start-of-year balance over the remaining horizon.
Statement: Given a start-of-year balance B, an expected return r and a planned payment growth g (both in percent per year, as abwAnnualPayment takes them, so the worksheet's 10% is passed as 10) and a remaining horizon in years including the current year, the function first truncates that horizon to whole years, n = floor(remainingYears), so a fractional horizon such as 4.5 years is amortized over 4. The beginning-of-period payment is P = B(1−x)/(1−x^n) where x = (1 + g/100)/(1 + r/100). P = B/n instead under either guard the code tests: `!Number.isFinite(x) || x <= 0` (x is not finite or not positive; r > −100 and g > −100 keep it finite and positive) and `Math.abs(x - 1) < 1e-9` (x = 1 within that tolerance). Payments are withdrawn before growth. Under the ABW spending policy P is the year's base spending before guardrail adjustments: annualLifestyleLayers writes it as baseAnnualNominal, which annualExpenseSummary publishes as YearResult.expenses.baseSpending. Domain: finite B > 0 (a non-finite or non-positive balance pays 0) and n ≥ 1; n ≤ 1 spends the whole balance. Rounding: the horizon is floored on input; the payment itself is not rounded (the production function returns a binary float).
Formula:
n = floor(remainingYears); x = (1 + g/100)/(1 + r/100); P = B(1-x)/(1-x^n), or P = B/n when x is not finite, x <= 0, or |x - 1| < 1e-9-
P: This year's beginning-of-period payment (unit: usd) (domain: P ≥ 0) -
B: Start-of-year portfolio balance (unit: usd) (domain: B > 0 and finite (otherwise the payment is 0)) -
r: Expected return over the period, in percent (10% is 10) (unit: percent) (domain: r > -100, so that 1 + r/100 > 0 and x is finite and positive) -
g: Planned payment growth over the period, in percent (0% is 0) (unit: percent) (domain: g > -100, so that 1 + g/100 > 0 and x is positive) -
n: Remaining periods including the current one, n = floor(remainingYears): a fractional horizon is truncated to its whole years before amortizing (unit: count) (domain: integer n ≥ 1 (the floored value); n ≤ 1 pays the whole balance) -
x: Payment-growth ratio (1 + g/100)/(1 + r/100) (unit: 1) (domain: finite x > 0 with |x − 1| ≥ 1e-9 for the closed form; otherwise P = B/n)
Timing: beginning of period, annual
Rounding: none on the payment, a binary float; the horizon is truncated to whole years on input, n = floor(remainingYears)
Limits:
- The chosen expected return is an assumption, not proven by the identity
- The payment is the ABW policy's base spending before guardrail adjustments, written by annualLifestyleLayers as baseAnnualNominal; under other spending policies baseSpending is not this formula
Provenance: derived by
claude-orchestrator, implemented bygrok.Independent review:
cursorGates (6 of 6 pass): fixture registers a test: pass; pins resolve: pass; families exist: pass; worksheet exists: pass; mutation receipt exists: pass; provenance independent: pass
Worksheet: abw-annuity-due-payment.md Mutation receipt: abw-annuity-due-payment.mutation.md
Evidence tests: abw.evidence.test.ts
Implemented in: abw.ts; annualLifestyleLayers.ts
Is the number of: spending-base-annual
-
-
Annual base spending after the guardrail cut Composition record, justified by derivation
Purpose: Recurring lifestyle spending actually intended once the guardrail has rationed the target layer.
Statement: projection/internal/annualGuardrailFunding.ts#annualGuardrailFundingPlan computes targetLifestyleFunded as targetLifestyle x min(1, discretionaryMultiplier) while guardrails are active, and the full targetLifestyle otherwise; projection/internal/annualExpenseSummary.ts#annualExpenseSummary then publishes projection/internal/types/yearLedger.ts#YearExpenses.baseSpending as requiredLifestyle + targetLifestyleFunded + idealLifestyleFunded + excessLifestyleFunded. The cap keeps a multiplier above one from inflating the target layer; upside instead reaches the separately funded ideal and excess layers. One-time goals and the system-computed costs are excluded; expenses.total adds them. The same multiplier is published unchanged as expenses.guardrailFactor. Units: nominal USD per year. Rounding: none.
Formula:
baseSpending = requiredLifestyle + targetLifestyle * min(1, guardrailFactor) + idealLifestyleFunded + excessLifestyleFunded-
requiredLifestyle: Required-floor lifestyle layer, never cut (unit: usd/year) (domain: nonnegative) -
targetLifestyle: Full target lifestyle layer before the cut (unit: usd/year) (domain: nonnegative) -
guardrailFactor: This year's discretionary multiplier (unit: 1) (domain: nonnegative; capped at 1 for this term) -
idealLifestyleFunded, excessLifestyleFunded: Upside layers funded this year (unit: usd/year) (domain: nonnegative)
Timing: once per projection year, after the guardrail decision and before the funding fixed point
Rounding: none
Limits:
- The same published field is also the output of abw-annuity-due-payment, which computes it under the ABW spending policy; this record is the guardrail-policy composition of the same field, so the family carries two records rather than one
- The worksheet's two cases are states annualGuardrailFundingPlan produces, and the evidence runs the real phase for a live household: a cutting year holding an incoming 0.75 (its withdrawal rate at its starting rate, so the policy holds, as in any year after a cut), where the phase funds the target layer at the factor and no upside because the upside budget is max(0, multiplier - 1) x the guardrail step basis; and a year with no policy active, where the full target layer and both upside layers fund. The first derivation paired a 0.75 cut with $4,000 of funded ideal and $1,500 of funded excess, which the YearExpenses.baseSpending comment rules out; it was re-derived on 2026-09-22 and re-approved by the reviewer, and the min(1, factor) cap is also asserted at a held multiplier of 1.4
- The $8,000 one-time goal is passed to annualExpenseSummary beside the phase's funded layers and asserted to be absent from baseSpending while present in expenses.total, which is the worksheet's second wrong reading
Provenance: derived by
codex, implemented byclaude.Independent review:
cursorGates (6 of 6 pass): fixture registers a test: pass; pins resolve: pass; families exist: pass; worksheet exists: pass; mutation receipt exists: pass; provenance independent: pass
Worksheet: spending-base-annual.md Mutation receipt: spending-base-annual.mutation.md
Evidence tests: annualExpenseSummary.evidence.test.ts
Implemented in: annualExpenseSummary.ts; annualGuardrailFunding.ts; yearLedger.ts
Is the number of: spending-base-annual
Feeds: spending-total-annual
-
Records that feed this number
-
ABW expected real return: fixed, TIPS yield, or CAPE-blended Model record, justified by derivation
Purpose: The expected real return an amortization-based withdrawal policy amortizes at, by return source.
Statement: For an ABW policy, or undefined: source = policy.returnSource ?? "fixed"; b = policy.bondRealYieldPct ?? 2.0. "tips" returns b. "cape" returns (100/CAPE) x s + b x (1 - s), with CAPE = policy.startingCape ?? 25 and s = (policy.equitySharePct ?? 60)/100. "fixed" returns policy.fixedRealReturnPct ?? 3.8. Units: percent per year, real. Rounding: none.
Formula:
r = b (tips); r = s (100/CAPE) + (1 - s) b (cape); r = fixedRealReturnPct (fixed); defaults b = 2.0, CAPE = 25, s = 0.6, fixed = 3.8-
CAPE: Starting cyclically adjusted price-earnings ratio (unit: ratio) (domain: schema 5..60) -
s: Equity share of the portfolio (unit: 1) (domain: 0 <= s <= 1 (schema: percent 0..100)) -
b: Real bond or TIPS yield (unit: percent/year) (domain: schema -2..8) -
r: Expected real return the payment is amortized at (unit: percent/year) (domain: finite)
Timing: resolved once per simulation; every year amortizes at the same r
Rounding: none
Limits:
- The CAPE branch treats the cyclically adjusted earnings yield as the equity real return, an ERN/TPAW-style planning convention rather than a forecast
- Defaults come from ABW_DEFAULTS; an undefined policy resolves to the fixed 3.8% (the VPW global 60/40 rate)
- The schema bounds CAPE, the equity share and the bond yield before the function runs; the function itself does not validate
- The worksheet says none yet: no census family carries the expected return. The traced consumer is the ledger, which resolves it once (simulate.ts) and amortizes every year's payment at it (annualLifestyleLayers.ts, abwAnnualPayment), so spending-base-annual is named as the nearest fed family
Provenance: derived by
codex, implemented byclaude-subagent.Independent review:
cursorGates (6 of 6 pass): fixture registers a test: pass; pins resolve: pass; families exist: pass; worksheet exists: pass; mutation receipt exists: pass; provenance independent: pass
Worksheet: abw-expected-real-return.md Mutation receipt: abw-expected-real-return.mutation.md
Evidence tests: abw.evidence.test.ts
Implemented in: abw.ts
Feeds: spending-base-annual
-
-
Joint (either-survives) percentile age on the primary's age clock Formula record, justified by derivation
Purpose: The couple's planning age: the oldest primary age at which at least one of two independent lives is alive with probability at least pct/100.
Statement: With from = floor(max(primary.age, 0)), partnerFrom = floor(max(partner.age, 0)) and threshold = min(max(p, 0.1), 100)/100: for t = 0, 1, ... while from + t <= 120 or partnerFrom + t <= 120 (either person still inside the table; decision D-PEOPLE-ORDER, rule R6), let P(t) = 1 - (1 - S_primary(t))(1 - S_partner(t)), where each S(t) is that person's survival curve from their own floored age under their hazard (default 1), read t years on (survival-probability-product; for 'average' the mixture). Record best = from + t while P(t) >= threshold; stop at the first t below the threshold, or once both survivals are 0. Returns best; P(0) = 1, so the result is always >= from and never below the primary's single-life answer. The result is on the primary's clock and can exceed 120 when the partner is much younger; the calendar year it names, primary's birth year + best, is the same whichever person is passed as primary (a male 70 with a female 35 at 25 percent: 126 on his clock, 2082; stopped at his table end the walk returned 120, 2076). Units: integer primary age. Rounding: none.
Formula:
age* = max{ from + t : 1 - (1 - S_primary(t))(1 - S_partner(t)) >= p/100 }-
from, partnerFrom: Floored current ages of primary and partner (unit: years) (domain: integer >= 0) -
t: Years elapsed on the primary's clock (unit: years) (domain: integer >= 0, from + t <= 120 or partnerFrom + t <= 120) -
S_primary(t), S_partner(t): Each life's survival over t more years (survival-probability-product) (unit: 1) (domain: [0, 1]) -
p: Percent chance that at least one is alive, clamped to [0.1, 100] (unit: percent) (domain: 0.1 <= p <= 100)
Timing: annual; both curves advance one year per step on the primary's clock
Rounding: none
Limits:
- Independent lifetimes; the either-alive probability is the complement of both being dead, 1 - (1 - S_a)(1 - S_b)
- The partner's clock is offset by the age difference and advances with the primary's; the answer is a primary age, not a partner age
- The percentage is clamped to [0.1, 100] before use, a bound the signature comment does not state
- Each person's hazard power applies to that person's own curve; a missing hazard is 1
- Two 'average' people are independent mixtures, so P(t) is the average over the four equally likely sex pairings (survival-probability-product)
- The year-by-year projection reads this age for a couple, at hazard 1, as the horizon a plan's amortization-based spending amortizes to when it is set to the age at least one of them reaches with a 25 or 10 percent chance (projection/simulate.ts), worked out again on every projection: example-couple on the 10 percent horizon ends with an after-tax estate of $1,378,752 on the 2022 table and $1,659,513 on 2023
Provenance: derived by
claude, implemented byclaude.Independent review:
codexGates (6 of 6 pass): fixture registers a test: pass; pins resolve: pass; families exist: pass; worksheet exists: pass; mutation receipt exists: pass; provenance independent: pass
Worksheet: joint-survival-percentile-age.md Mutation receipt: joint-survival-percentile-age.mutation.md
Evidence tests: survival.evidence.test.ts
Implemented in: survival.ts
Is the number of: longevity-survival-percentile-age
Feeds: spending-base-annual
-
-
One-year death probability: SSA's published q(x) Formula record, justified by derivation
Purpose: The annual death probability of a man or a woman that every survival curve and stochastic-longevity draw reads, straight from SSA's published table.
Statement: montecarlo/deathProbability.ts#annualMortality(age, sex) (re-exported by montecarlo/mortality.ts), for a man or a woman of age a with x = floor(a): the probability of dying within one year is the death probability SSA publishes for that sex and exact age x in Table 4C6 (ssa-period-life-table), for x from 0 to 118. At 119 and above it is 1: the table's last row is closed, so a life at 119 dies during that year, although SSA prints 0.926604 there. Below 0 it is 0. A person whose sex is 'average' has no single death probability, and the function refuses 'average' with a RangeError; the survival curve builds 'average' as the mixture of the two sexes (survival-probability-product). Units: probability. Rounding: none; the published six decimals.
Formula:
q(a) = q_SSA(sex, floor(a)) for 0 <= floor(a) <= 118; q(a) = 1 for floor(a) >= 119; q(a) = 0 for floor(a) < 0-
a: Age as passed (unit: years) (domain: real) -
q_SSA(sex, x): SSA's published probability of dying within one year at exact age x, male or female column (ssa-period-life-table) (unit: 1) (domain: [0, 1], six decimals) -
q(a): Probability of dying between ages floor(a) and floor(a) + 1 (unit: 1) (domain: 0 <= q(a) <= 1)
Timing: one-year transition between consecutive integer ages
Rounding: none
Limits:
- The table end: SSA prints q(119) = 0.926604 for both sexes, and e(119) = 0.58, which counts life past 120; the engine closes the table at 119 instead, as the Monte Carlo horizon and the expected-value loops assume. Reading the printed q(119) and closing the table at 120 would change the chance of being alive at 120 from 0 to 6.8e-12 for a man and 2.3e-11 for a woman of 65, 2.8e-7 at 110, 0.00138 at 117 and 0.0734 for someone already 119, and would change no survival probability to an age below 120
- The period table is applied unchanged to the person's whole remaining life: it describes 2023's death rates, not an individual's risk or future mortality improvement
- No interpolation within a year of age: a fractional age reads the row of its whole years
- A negative age returns 0 and is not flagged
Provenance: derived by
claude, implemented byclaude.Independent review:
codexGates (6 of 6 pass): fixture registers a test: pass; pins resolve: pass; families exist: pass; worksheet exists: pass; mutation receipt exists: pass; provenance independent: pass
Worksheet: mortality-published-death-probability.md Mutation receipt: mortality-published-death-probability.mutation.md
Evidence tests: mortality.evidence.test.ts
Implemented in: deathProbability.ts
Feeds: longevity-survival-percentile-age, monte-carlo-success-rate, monte-carlo-ending-investable-histogram, social-security-expected-present-value, social-security-survivor-switch-pv, social-security-fica-return-ratio, income-annuity-annual, spending-base-annual
-
-
Whose age the spending phases follow Model record, justified by derivation
Purpose: The phase multiplier follows the person the plan names, never whoever is listed first.
Statement: projection/internal/annualLifestyleLayers.ts#annualLifestyleLayers multiplies the base lifestyle by the multiplier of the last phase, phases stably sorted by fromAge, whose fromAge is at or below the attained age of the person expenses.phasesAgeOf names (annualExpenseAssemblyPhase passes phasesPersonId = phasesAgeOf, else the only person); the phases keep that person's age after that person dies (decision D-PEOPLE-ORDER, rule R1). No law says whose age a household's spending shape follows, so the household names the person and the Spending page shows the name; schema v7 requires it in a two-person plan with phases, and plans saved earlier are given the person then listed first, whose age their phases already followed, so no stored figure moves. Units: a factor on baseAnnual. Rounding: none.
Formula:
multiplier = phases sorted by fromAge; last p with age(phasesAgeOf) >= p.fromAge; else 1-
age(phasesAgeOf): Attained age of the named person this year (unit: years) (domain: integer)
Timing: once per projection year, in the expense assembly
Rounding: none
Limits:
- A convention the household chooses, stated on the Spending page; the presets (spending-shape-preset-compilation, spending-shape-comparison) start at the named person's retirement age
- A JSON file whose people were reordered by another tool before it was ever loaded at v7 is read by position that one time: its intent was never stored
Provenance: derived by
claude, implemented byclaude.Independent review:
codexGates (6 of 6 pass): fixture registers a test: pass; pins resolve: pass; families exist: pass; worksheet exists: pass; mutation receipt exists: pass; provenance independent: pass
Worksheet: spending-phase-person.md Mutation receipt: spending-phase-person.mutation.md
Evidence tests: peopleNamed.evidence.test.ts
Implemented in: annualLifestyleLayers.ts; annualExpenseAssemblyPhase.ts
Feeds: spending-base-annual
-
-
Annual real spending drift compiled to five-year phase rows Model record, justified by derivation
Purpose: Turns a steady %/yr real spending change into the editable phase multipliers a plan stores.
Statement: deltaPct = 0 returns no phases. startAge = min(max(round(retirementAge), 40), 105). For age = startAge + 5, startAge + 10, ... while age <= 100: multiplier = (1 + deltaPct/100)^(age - startAge), rounded to two decimals as Math.round(x x 100)/100 and clamped into [0, 3]; each row is { fromAge: age, multiplier }. Units: fromAge in years of the person the phases follow (expenses.phasesAgeOf; spending-phase-person); multiplier a factor on baseAnnual. Rounding: two decimals per row, as stated.
Formula:
m(age) = clamp(round2((1 + d/100)^(age - a0)), 0, 3) for age = a0 + 5k <= 100, k >= 1, a0 = clamp(round(retirementAge), 40, 105)-
d: Annual real spending change (unit: percent/year) (domain: finite; 0 emits no rows) -
a0: Rounded, clamped retirement age the steps count from (unit: years) (domain: 40 <= a0 <= 105) -
age: A phase row's fromAge (unit: years) (domain: a0 + 5, a0 + 10, ... <= 100) -
m(age): Multiplier on baseAnnual from that age (unit: 1) (domain: 0 <= m <= 3, two decimals)
Timing: compiled once when the shape is chosen; the rows then hold flat between step ages
Rounding: two decimals per multiplier (Math.round(x x 100)/100)
Limits:
- On the amortization-based-withdrawal path the compiled phases do not reach spending-base-annual: annualLifestyleLayers scales the base by the phase multiplier and then, when ABW is active, replaces it with abwAnnualPayment and zeroes the other lifestyle layers. The feed holds on the ordinary lifestyle path, for spending-shape-delta-vs-flat and for the sustainable-spending solver, which run shapes as plan data.
- Five-year steps hold the fully compounded multiplier flat between step ages, an editable approximation to annual drift
- The first row is one step after retirement, never at retirement age itself
- Retirement age is rounded and clamped into [40, 105] so every row satisfies the phase schema (fromAge 40..110, multiplier 0..3)
- The rows are plan data: the planner-ui SpendingSection writes them into expenses.phases and the solver page runs the sustainable-spending solver per shape; no engine module calls this function, so the named families are fed through the plan the UI writes
Provenance: derived by
codex, implemented byclaude-subagent.Independent review:
cursorGates (6 of 6 pass): fixture registers a test: pass; pins resolve: pass; families exist: pass; worksheet exists: pass; mutation receipt exists: pass; provenance independent: pass
Worksheet: spending-shape-annual-delta-phases.md Mutation receipt: spending-shape-annual-delta-phases.mutation.md
Evidence tests: shapePresets.evidence.test.ts
Implemented in: shapePresets.ts
Feeds: spending-base-annual, spending-shape-delta-vs-flat, sustainable-spending-result-max-base-annual, sustainable-spending-result-spending-slack-dollars
-
-
Named spending shapes compiled to phase rows Model record, justified by derivation
Purpose: What phase rows the flat, smile, front-loaded and smirk presets write into a plan.
Statement: "flat" returns no phases. "smile" returns [{ fromAge: 75, multiplier: 0.9 }, { fromAge: 85, multiplier: 0.8 }] independent of retirement age. "smirk" returns annualDeltaPhases(SMIRK_ANNUAL_REAL_DELTA_PCT = -1, retirementAge). "frontLoaded": boostFrom = min(max(retirementAge, 40), 100); boostFrom >= 75 returns no phases, else [{ fromAge: boostFrom, multiplier: 1.1 }, { fromAge: 75, multiplier: 1 }]. Units: fromAge in years; multiplier a factor on baseAnnual. Rounding: none here; the smirk rows carry annualDeltaPhases's two-decimal rounding.
Formula:
flat: []; smile: [(75, 0.9), (85, 0.8)]; smirk: annualDeltaPhases(-1, retirementAge); frontLoaded: [(clamp(retirementAge, 40, 100), 1.1), (75, 1)] when that age < 75, else []-
shape: The preset id (unit: enum) (domain: flat | smile | frontLoaded | smirk) -
retirementAge: The retirement age of the person the phases follow (expenses.phasesAgeOf) (unit: years) (domain: finite) -
rows: The phase rows written into expenses.phases (unit: rows) (domain: each within the phase schema)
Timing: compiled once when the preset is chosen; never re-evaluated for a saved plan
Rounding: none (smirk inherits the two-decimal rows)
Limits:
- On the amortization-based-withdrawal path the compiled phases do not reach spending-base-annual: annualLifestyleLayers scales the base by the phase multiplier and then, when ABW is active, replaces it with abwAnnualPayment and zeroes the other lifestyle layers. The feed holds on the ordinary lifestyle path, for spending-shape-delta-vs-flat and for the sustainable-spending solver, which run shapes as plan data.
- Presets compile to visible phase rows at creation time and never live-link a saved plan to the research constants; changing SMIRK_ANNUAL_REAL_DELTA_PCT changes only newly compiled plans
- The smile calibration is a two-step approximation of Blanchett's average path without an explicit late-life step-up
- frontLoaded passes the retirement age through unrounded (clamped into [40, 100]) while the smirk path rounds it
- No engine module calls this function; the UI writes the rows into expenses.phases and the solver page evaluates each shape, so the named families are fed through plan data
Provenance: derived by
codex, implemented byclaude-subagent.Independent review:
cursorGates (6 of 6 pass): fixture registers a test: pass; pins resolve: pass; families exist: pass; worksheet exists: pass; mutation receipt exists: pass; provenance independent: pass
Worksheet: spending-shape-preset-compilation.md Mutation receipt: spending-shape-preset-compilation.mutation.md
Evidence tests: shapePresets.evidence.test.ts
Implemented in: shapePresets.ts
-
-
SSA period life table, 2023 (2026 Trustees Report) Data record, justified by derivation
Purpose: The death probabilities and life expectancies by age and sex that every survival figure and the longevity questionnaire read.
Statement: longevity/ssaPeriodLifeTable.ts#SSA_PERIOD_LIFE_TABLE carries SSA's Table 4C6 period life table for 2023, as used in the 2026 Trustees Report: the probability of dying within one year, q(x), and the period life expectancy, e(x), at each exact age x from 0 to 119, for men and for women, as printed (q to six decimals, e to two), read from SSA's page on 2026-09-27. Its source record names the page, the table's caption, the period and Trustees Report years, the read date, an Internet Archive capture of the page and the SHA-256 of the four columns in a canonical text, which the evidence rebuilds from the numbers. #LAST_TABLE_AGE is the last row, 119. #baselineRemainingYears reads e: the printed e(x) at a whole age, linear between the two rows at a fractional one, and for 'average' the mean of the male and female values; the longevity questionnaire prints it as its baseline. #CURRENT_LIFE_TABLE_EDITION names the edition a percentile pick or a saved questionnaire result records, and #storedLifeTableEdition reads a stored figure that names none as made on the 2022 period table of the 2025 Trustees Report, the table the engine carried before. Units: probability; years. Rounding: none; the printed figures.
Limits:
- The number-of-lives columns SSA also prints are not carried; nothing reads them
- A period table: 2023's death rates at every age, applied to a person's whole remaining life, not a cohort projection with future mortality improvement
- When SSA publishes the next edition, the embedded table trails it until a reviewed data change replaces it; the tax-rule record for the table's edition (SSA Table 4C6) names it and is re-verified yearly (annuallyIndexed, the rules:due queue and verify:quotes, whose quoted introduction and row 65 stop matching the page when the edition changes)
- The last row is closed: the engine reads q(119) as 1 (mortality-published-death-probability) although the table prints 0.926604 there for both sexes; the printed value is carried, not read
- Which projections read it. The year-by-year projection reads the table in two cases only: when a plan's amortization-based spending is set to amortize to the age reached with a 25 or 10 percent chance (projection/simulate.ts, through survival-percentile-age and joint-survival-percentile-age, worked out again on every projection), and when a plan holds a joint-and-survivor annuity whose exclusion ratio is fixed (projection/annuityForms.ts, through mortality-joint-last-survivor-expectancy). Such a plan gets new year-by-year figures when the table changes: under-saved-single with its spending amortized to the 25 percent age ends with an after-tax estate of $9,840 on the 2022 table and $212,538 on the 2023 table. The Social Security analysis page, the percentile picker, the questionnaire and Monte Carlo with longevity modelled read it on their own pages; a percentile pick is stored once with its edition and not recomputed
Provenance: derived by
claude, implemented byclaude.Independent review:
codexGates (6 of 6 pass): fixture registers a test: pass; pins resolve: pass; families exist: pass; worksheet exists: pass; mutation receipt exists: pass; provenance independent: pass
Worksheet: ssa-period-life-table.md Mutation receipt: ssa-period-life-table.mutation.md
Evidence tests: ssaPeriodLifeTable.evidence.test.ts
Implemented in: ssaPeriodLifeTable.ts
Feeds: longevity-survival-percentile-age, monte-carlo-success-rate, monte-carlo-ending-investable-histogram, social-security-expected-present-value, social-security-survivor-switch-pv, social-security-fica-return-ratio, income-annuity-annual, spending-base-annual
-
Survival-percentile planning age: oldest age reached with probability at least pct/100 Formula record, justified by derivation
Purpose: The single-life planning age the assumptions card previews: the age a person has a pct% chance of reaching.
Statement: For current age c, sex, percentage p and hazard power h (default 1): threshold = min(max(p, 0.1), 100)/100. With from = floor(max(c, 0)) and best = from, for t = 1, 2, ... while from + t <= 120, read S(t), the survival curve from `from` under h (survival-probability-product; for 'average' the mixture); while S(t) >= threshold record best = from + t, and stop at the first t whose S(t) falls below the threshold. Returns best: the oldest integer age whose conditional survival from c is at least the threshold, always >= from. Units: integer age. Rounding: none.
Formula:
age* = max{ g >= from : S(from -> g) >= p/100 }, with S(from -> from) = 1-
from: Floored current age (unit: years) (domain: integer >= 0) -
p: Percent chance of reaching the age, clamped to [0.1, 100] (unit: percent) (domain: 0.1 <= p <= 100) -
S(from -> g): Conditional survival to g, read off the survival curve (survival-probability-product) (unit: 1) (domain: [0, 1], nonincreasing in g) -
age*: Survival-percentile planning age (unit: years) (domain: from <= age* <= max(119, from))
Timing: annual survival steps over integer ages
Rounding: none
Limits:
- The percentage is clamped to [0.1, 100] before use, a bound the signature comment does not state; a value passed as a probability (0.97) is read as 0.97%
- Survival is nonincreasing, so the qualifying ages form an initial interval and the loop may stop at the first failure
- The signature comment bounds the result by MAX_AGE + 1 (120); the table's last row is closed (q(119) = 1, mortality-published-death-probability), so the survival to 120 is 0 and the largest value actually returned is 119, or the floored current age when that is older (a current age of 125 returns 125, which it has already reached)
- For 'average' the age is read off the mixture's curve and is not the mean of the male and female ages (at 65, 50/25/10 percent: 85/91/95, against 83/89/94 for a man and 86/92/96 for a woman)
- The planner-ui SurvivalPercentileModal clamps the result to 60..120 before it becomes the plan's planning age; that clamp is presentation and is not part of this record
- The year-by-year projection reads this age for a single, at hazard 1, as the horizon a plan's amortization-based spending amortizes to when it is set to the age reached with a 25 or 10 percent chance (projection/simulate.ts). Unlike a percentile pick, which is stored once with its edition, that horizon is worked out again on every projection, so the plan's figures move when the table changes: under-saved-single on the 25 percent horizon (age 90 on the 2022 table, 91 on 2023) ends with an after-tax estate of $9,840 on the 2022 table and $212,538 on 2023
Provenance: derived by
claude, implemented byclaude.Independent review:
codexGates (6 of 6 pass): fixture registers a test: pass; pins resolve: pass; families exist: pass; worksheet exists: pass; mutation receipt exists: pass; provenance independent: pass
Worksheet: survival-percentile-age.md Mutation receipt: survival-percentile-age.mutation.md
Evidence tests: survival.evidence.test.ts
Implemented in: survival.ts
Is the number of: longevity-survival-percentile-age
Feeds: spending-base-annual
-
-
The survival curve: product of hazard-adjusted one-year survivals, and the 50/50 mixture for 'average' Formula record, justified by derivation
Purpose: The probability someone alive today is still alive at a later integer age, on SSA's published death probabilities with an optional health hazard.
Statement: montecarlo/survival.ts#survivalCurve is the engine's one survival curve, for a whole starting age a (at least 0), sex and hazard power h (a positive finite number, default 1). For a man or a woman, survivalTo(t) = product over x = a..a+t-1 of (1 - q(x))^h (mortality-published-death-probability), multiplied left to right, where the factor is 0 when q(x) >= 1; 1 for t <= 0, and 0 once the running product reaches 0; deathProbabilityInYear(t) = survivalTo(t) x (1 - (1 - q(a + t))^h); and deathProbabilityGivenAlive(t) = 1 - (1 - q(a + t))^h, which at h = 1 is q(a + t) itself. For 'average', survivalTo(t) is the mean of the man's and the woman's survivalTo(t) from a, each under the power h: the chance for someone equally likely to be either; deathProbabilityInYear(t) = survivalTo(t) - survivalTo(t + 1); and deathProbabilityGivenAlive(t) = 1 - survivalTo(t + 1)/survivalTo(t), or 1 once survivalTo(t) is 0. Both death probabilities are 0 for t < 0. A fractional age or an invalid hazard is refused. #survivalProbabilityTo(c, sex, g, h) is a view of it: with from = floor(max(c, 0)) and to = floor(g), it returns 1 when to <= from and otherwise the curve from `from` at t = to - from, the same product in the same order. The percentile ages, the hazard solver, the death-age draw, the joint expectancy and the Social Security analysis models read the curve. Units: probability. Rounding: none.
Formula:
S(t) = prod_{x=a}^{a+t-1} (1 - q(x))^h for a man or a woman; S_average(t) = (S_male(t) + S_female(t))/2; S = 1 when t <= 0-
a, t: Whole starting age and whole years elapsed (unit: years) (domain: integer, a >= 0) -
q(x): SSA's published one-year death probability at x for the sex (mortality-published-death-probability) (unit: 1) (domain: [0, 1]) -
h: Proportional-hazards power (q' = 1 - (1 - q)^h; h > 1 is worse health), applied to each sex before mixing (unit: 1) (domain: h > 0) -
S: Probability of being alive t years on (unit: 1) (domain: [0, 1])
Timing: annual; one factor per integer age from the current age up to, not including, the target
Rounding: none
Limits:
- Ages are floored; a fractional current or target age is not interpolated
- A target at or below the current age returns exactly 1 without consulting the table
- survivalProbabilityTo does not validate the hazard power (a non-positive h is used as passed); survivalCurve refuses one
- annualSurvival is module-private; survivalCurve and survivalProbabilityTo read it, and the evidence reaches it through both
- 'average' is the 50/50 mixture of the two sexes' curves from the person's current age, not a q(x) table: its one-year chance of dying given alive depends on the starting age, because the survivors of a mixed group become more female with age (at 90: 0.14086 for a mixture formed at 22, 0.14155 at 65 and 0.14327 at 85, against the mean q 0.144317). Every survival probability, the life expectancy (0.5 plus the sum of the survivals) and every expected value linear in the curve are the means of the male and female ones; ages read off the curve and hazard powers solved on it are not (at 65 the ages reached with 50, 25 and 10 percent chance are 85, 91 and 95, against 83, 89 and 94 for a man and 86, 92 and 96 for a woman)
- The hazard power applies to each sex's curve before mixing: the person is one sex or the other, with that sex's death rates raised to h, and one power serves both halves
- Two 'average' people are independent mixtures: each is a man or a woman with probability 1/2, independently of the partner, so a couple's expected value, joint expectancy or either-alive probability is the average over the four equally likely sex pairings (man-man, man-woman, woman-man, woman-woman). It is not the reading "an opposite-sex couple, order unknown", which averages only the two mixed pairings; that reading differs by up to 0.135 percent of a benefits-only row on the examples' two couples of 'average' people (all-401k-no-bridge and brokerage-bridge-401k) and changes no top claim age at 0, 2 or 4 percent
Provenance: derived by
claude, implemented byclaude.Independent review:
codexGates (6 of 6 pass): fixture registers a test: pass; pins resolve: pass; families exist: pass; worksheet exists: pass; mutation receipt exists: pass; provenance independent: pass
Worksheet: survival-probability-product.md Mutation receipt: survival-probability-product.mutation.md
Evidence tests: survival.evidence.test.ts
Implemented in: survival.ts
Feeds: longevity-survival-percentile-age, social-security-expected-present-value, social-security-survivor-switch-pv, social-security-fica-return-ratio, income-annuity-annual, spending-base-annual
-
Engine source: engine/src/projection/internal/types/result.ts
(YearResult.expenses.baseSpending)
The evidence links on this page (each record's worksheet, mutation receipt, evidence tests
and implementation, and the source links) are pinned to engine commit
2624dbdba9fd, committed 2026-10-11.
The live calculation index and
output-family census links point at the repository's main
branch, so you can check this snapshot against them.