Calculation catalog › Monte Carlo
Monte carlo success rate
Share of simulated market paths on which investable assets never deplete before the plan horizon.
Field: Success probability Unit: probability Basis: no dollar basis Computed by: Engine Status: Complete chain
Appears in: monte-carlo-page (SuccessGauge percent and verdict heading); monte-carlo-page (Layered success tile "Any spending shortfall" (successRate)); monte-carlo-page (WhySuccessPanel "What it counts" percent); monte-carlo-page (Spending vs. success and Retirement age vs. success frontier charts (StochasticFrontierPoint.successRate)); monte-carlo-page (Annuitization frontier chart success line and glidepath-only dashed line (AnnuitizationPointMetrics.successRate)); results-headline (KpiBar "Market success" percent (useMcSuccessRateState headline run)); optimize-page ("Monte Carlo success rate with this claim change" and the success stat); scenarios-page (Risk table "Portfolio success rate"); relocation-page (Success column when Monte Carlo is enabled); ss-page (Bridge comparison and claim-age table success columns); insights (InsightCardView preview: the headline run (1,000 paths, or a published finer run) and the previewed plan's run at the same path count, behind the Monte Carlo delta line); mcp (run_monte_carlo.successRate)
Calculation records
-
Success, required-floor, target-lifestyle and failure rates Formula record, justified by derivation
Purpose: The headline shares: how often the money lasted, how often the essentials were funded, how often the full lifestyle was, and how often it ran out.
Statement: successRate is the share of paths whose depletionYear is null; requiredFloorSuccessRate the share with requiredFloorMet; targetLifestyleSuccessRate the share with targetLifestyleMet; downsideRisk.failureRate the share whose depletionYear is non-null, and downsideRisk.failingPathCount how many those are. Failure is the complement of success over the same depletion test. For the worksheet's five deliberately crossed paths these are 0.6, 0.6, 0.2, 0.4 and 2. Units: shares in [0, 1] and a path count. Rounding: none; absolute 1e-9 on the rates and the count exactly.
Formula:
successRate = |{p : depletionYear_p = null}| / N; failureRate = 1 - successRate; failingPathCount = N - |{p : depletionYear_p = null}|-
N: All paths (unit: paths) (domain: worksheet 5) -
depletionYear_p: Year a path first depleted; null when it never did (unit: calendar year or null) (domain: worksheet null, null, null, 2031, 2032) -
requiredFloorMet_p, targetLifestyleMet_p: Whether a path funded the required floor, and the full target, every year (unit: boolean) (domain: worksheet crossed against depletion)
Timing: once per aggregation, over the completed path set
Rounding: none
Limits:
- The three tests are distinct, and the shared 0.6 of successRate and requiredFloorSuccessRate is a coincidence of this path set; the evidence therefore also asserts each path's own three verdicts
- Depletion alone defines failure here: a path that missed its target every year without depleting still counts as a success, and treating any target miss as depletion would give failure rate 0.8 on four paths
- An empty path set gives 0 for every share rather than an undefined rate
- The evidence supplies depletionYear, requiredFloorMet and targetLifestyleMet only; every other MonteCarloPath field is at the test’s neutral (no shortfall, attainment 1, no goal or guardrail activity, zero balances, no bequest target)
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: monte-carlo-success-and-failure-rates.md Mutation receipt: monte-carlo-success-and-failure-rates.mutation.md
Evidence tests: run.evidence.test.ts
Implemented in: run.ts
Is the number of: monte-carlo-success-rate, monte-carlo-required-floor-success-rate, monte-carlo-target-lifestyle-success-rate, monte-carlo-failure-rate, monte-carlo-failing-path-count
-
Records that feed this number
-
Cholesky factor of a correlation matrix Formula record, justified by derivation
Purpose: The lower-triangular mix that turns independent Gaussians into correlated class shocks on an allocated Monte Carlo path.
Statement: For a symmetric matrix A, the lower-triangular L satisfies L L^T = A when A is positive definite: L_ij = (A_ij − sum_{k<j} L_ik L_jk) / L_jj for i > j, and L_ii = sqrt(A_ii − sum_{k<i} L_ik^2), with L_ij = 0 for j > i. When a diagonal pivot A_ii − sum_{k<i} L_ik^2 is not a positive number, A is not positive definite and no factor with a positive diagonal exists (the pivot is 0, as for a perfectly correlated pair, or negative, as for an indefinite matrix), so the matrix is refused with a message. For the 2×2 correlation [[1, r], [r, 1]], this is L = [[1, 0], [r, sqrt(1 − r^2)]]. Units: dimensionless. Rounding: none.
Formula:
L_ii = sqrt(A_ii − sum_{k<i} L_ik^2), refused unless that pivot is positive; L_ij = (A_ij − sum_{k<j} L_ik L_jk) / L_jj for i > j; L_ij = 0 for j > i-
A: Input matrix (a correlation matrix when used for class shocks) (unit: 1) (domain: square and positive definite, refused otherwise; symmetry is not checked) -
L: Lower-triangular factor (unit: 1) (domain: L_ij = 0 for j > i) -
r: Off-diagonal correlation in the 2×2 worksheet case (unit: 1) (domain: |r| < 1 for an exact factorization)
Timing: once per configured correlation matrix; reused every year of every path
Rounding: none
Limits:
- A matrix that is not positive definite (a diagonal pivot at or below 0, which includes a perfectly correlated pair of classes) is refused with a message; the shipped default matrix is positive definite
- A positive-definite matrix whose pivot is positive but below 1e-12 is now factored exactly; it used to have that pivot raised to 1e-12, so its class shocks differ from before (for [[1, r], [r, 1]] with r = 1 − 1e-13, L_22 is 4.4728311955343587e-7, not 1e-6). This is the one valid input whose factor changed
- The function neither checks symmetry nor rescales a correlation matrix to unit diagonal
- Class-shock models pass this factor a matrix over ASSET_CLASS_IDS order; a 2×2 is the worksheet case, not a live allocation
Provenance: derived by
codex, implemented byclaude-subagent.Independent review:
grokGates (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: allocation-cholesky-factor.md Mutation receipt: allocation-cholesky-factor.mutation.md
Evidence tests: assetClasses.evidence.test.ts
Implemented in: assetClasses.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles
-
-
Embedded annual stock, bond, and inflation series, 1928–2023 Data record, justified by dataset
Purpose: The offline 96-year Damodaran/Shiller snapshot every historical bootstrap and blend reads.
Statement: Ninety-six calendar-year rows, 1928 through 2023 inclusive, each storing S&P 500 total return, 10-year US Treasury total return, and CPI-U calendar-year inflation, all in percent, transcribed to one decimal. The dataset fact this record pins is the embedded table: 96 rows, those endpoints, column sums 1118.9 / 466.6 / 298.8 percentage points, and the sample rows 1928 43.8/0.8/−1.2, 1929 −8.3/4.2/0.6, 2023 26.1/3.9/3.4. Values are approximate at about 0.1–0.5 percentage point. Rounding: one decimal as stored.
Limits:
- Each value is approximate at about 0.1–0.5 percentage point; bootstrap sampling uses the joint distribution and sequencing, not basis-point precision
- Refresh cadence is annual with the published parameters
- The full table is not reproduced in the worksheet; the evidence pins row count, endpoints, column sums, and the three sample rows the worksheet names
Dataset: Aswath Damodaran / NYU Stern, “Historical Returns on Stocks, Bonds and Bills,” derived from Shiller annual series: S&P 500 total return, 10-year Treasury total return, and calendar-year CPI inflation , as of 2023-12-31 , retrieved 2026-09-14
Provenance: derived by
codex, 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: historical-market-series.md Mutation receipt: historical-market-series.mutation.md
Evidence tests: historicalReturns.evidence.test.ts
Implemented in: historicalReturns.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
Arithmetic mean of blended historical portfolio returns Formula record, justified by derivation
Purpose: The sample mean every centered historical bootstrap subtracts so the average additive shock is zero.
Statement: For equity weight w percent, mean = (1/96) sum_t [w/100 · stocks_t + (1 − w/100) · bonds_t] over the 96 embedded years. Equivalently w/100 · mean(stocks) + (1 − w/100) · mean(bonds). Arithmetic, not geometric, so the average additive shock is zero. For w = 60 the column sums 1118.9 and 466.6 give 8.93729166666667%. Units: percent per year, nominal. Rounding: none.
Formula:
mean(w) = (1/N) sum_t [ (w/100) stocks_t + (1 − w/100) bonds_t ], N = 96-
w: Equity weight (unit: percent) (domain: typically 0–100) -
stocks_t, bonds_t: Embedded year-t stock and bond total returns (unit: percent/year) (domain: finite) -
mean(w): Arithmetic mean blended return (unit: percent/year) (domain: finite)
Timing: once per configured equity weight; subtracted from every sampled year of a centered bootstrap
Rounding: none
Limits:
- Arithmetic mean is required to center additive shocks; a geometric mean would not make the average shock zero
- The mean is taken over the embedded 96-year window only; it is not a forecast of the plan expected return
Provenance: derived by
codex, 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: historical-portfolio-mean.md Mutation receipt: historical-portfolio-mean.mutation.md
Evidence tests: historicalReturns.evidence.test.ts
Implemented in: historicalReturns.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
One-year two-asset blended nominal return Formula record, justified by derivation
Purpose: The dollar-weighted stock/bond mix of one historical year, before centering.
Statement: For a year with stock return s percent and bond return b percent and equity weight w percent, the blended nominal return is (w/100)·s + (1 − w/100)·b, with no rounding. For 1928 (43.8, 0.8) at w = 60 this is 26.6%. Units: percent per year, nominal. Domain: finite series values; w is used as a fraction and is not clamped.
Formula:
R = (w/100) s + (1 − w/100) b-
s: S&P 500 total return for the year (unit: percent/year) (domain: finite) -
b: 10-year Treasury total return for the year (unit: percent/year) (domain: finite) -
w: Equity weight (unit: percent) (domain: typically 0–100; not clamped) -
R: Blended nominal portfolio return (unit: percent/year) (domain: finite)
Timing: one calendar year; one blend per sampled historical row
Rounding: none
Limits:
- A two-asset stock/bond mix; cash, international, and class-level sleeves are not in this blend
- The weight is not clamped to [0, 100]; a value outside that range is used as passed
Provenance: derived by
codex, 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: historical-portfolio-return-blend.md Mutation receipt: historical-portfolio-return-blend.mutation.md
Evidence tests: historicalReturns.evidence.test.ts
Implemented in: historicalReturns.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
Per-person paid-care episode draw Model record, justified by derivation
Purpose: Zero or one late-life care event per person on a Monte Carlo path, so existing care-event offsets apply.
Statement: For each person, draw one uniform U in [0, 1). If U >= incidence, emit no event for that person. If U < incidence, onset age is max(currentAge, minOnsetAge + nextInt(span + 1)) with span = max(0, maxOnsetAge − minOnsetAge), duration is drawn from the weighted discrete table, and annualCost is the configured today's-dollar cost. At most one episode per person. The worksheet case incidence = 0.5, U = 0.75 emits the empty list. Units: a care-event list. Rounding: onset is an integer age.
Formula:
if U >= incidence then no event; else onset = max(currentAge, minOnset + nextInt(span + 1)), duration ~ weighted table, cost = annualCost-
incidence: Probability a person has a paid-care episode (unit: 1) (domain: [0, 1]) -
U: First uniform draw for that person (unit: 1) (domain: [0, 1)) -
annualCost: Today's-dollar annual care cost of a sampled episode (unit: usd/year) (domain: >= 0)
Timing: once per person per path, before the path ledger runs
Rounding: onset age is an integer; cost is not rounded
Limits:
- Defaults are planning approximations (HHS/ASPE incidence, Genworth-range cost) and do not predict individual need, local cost, or insurance eligibility
- Onset is clamped no earlier than current age so the spike lands in the future
- The comparison is U >= incidence (no event); reversing it to U > incidence is the worksheet's first wrong reading
- One draw per person, not one household draw per path
Provenance: derived by
codex, 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: long-term-care-shock-sampling.md Mutation receipt: long-term-care-shock-sampling.mutation.md
Evidence tests: ltcShock.evidence.test.ts
Implemented in: ltcShock.ts
Feeds: spending-care-cost-gross-annual, long-term-care-benefit-annual, monte-carlo-success-rate, monte-carlo-ending-investable-histogram
-
-
AR(1) mean-reverting return shock Model record, justified by derivation
Purpose: A serially correlated, mean-zero additive return shock for one Monte Carlo path.
Statement: Let phi be the configured persistence (default 0.25; a number strictly between −1 and 1, where the process is stationary, and anything else is refused with a message) and sigma = returnVolPct/100. Starting from prev = 0, each year draws an innovation eps ~ N(0, 1) and sets shock = phi · prev + sigma · eps, then prev = shock; the published return shock is 100 · shock percentage points. With phi = 0.2, sigma = 0.10 and a first-year eps that produces a 10-point shock, the next two zero-innovation years are 2 then 0.4 points. Units: percentage points. Rounding: none.
Formula:
x_0 = 0; x_t = phi x_{t-1} + sigma eps_t; returnShockPct_t = 100 x_t-
phi: AR(1) persistence (unit: 1) (domain: strictly between −1 and 1, refused otherwise; worksheet case 0.2) -
sigma: Innovation scale, returnVolPct/100 (unit: 1) (domain: >= 0) -
eps_t: Standard-normal innovation (unit: 1) (domain: finite) -
returnShockPct_t: Additive return shock (unit: percentage points) (domain: finite)
Timing: annual; prev starts at 0 on every path
Rounding: none
Limits:
- A dynamics assumption, not evidence of a true return process
- The path always starts from prev = 0; a prior shock of 10 is realized as year 1 of the path, and the worksheet's "next two shocks" are years 2 and 3 with zero innovations
- The phi default comment was corrected to the code's 0.25 on 2026-09-18 (D-STUDENT-T-MIXTURE notes the related decision). The worksheet case passes 0.2 explicitly
- A phi of 1 or more in size, where the process is not stationary, is refused with a message; phi used to be clamped to [−0.9, 0.95] without a word, and values inside that range run exactly as before
- A return-inflation correlation outside [−1, 1], or not finite, is refused with a message rather than clamped
- A negative or non-finite returnVolPct or inflationVolPct is refused with a message
Provenance: derived by
codex, 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: market-model-ar1-shock.md Mutation receipt: market-model-ar1-shock.mutation.md
Evidence tests: marketModels.evidence.test.ts
Implemented in: marketModels.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
CAPE-conditioned shift of a mean-preserving lognormal shock Model record, justified by derivation
Purpose: Lowers the path mean when starting CAPE is above 20, on top of a lognormal shock.
Statement: The lognormal base shock is 100 (exp(sigma Z − sigma^2/2) − 1). The CAPE adjustment is adj = clamp(−(startingCape − 20) · sensitivity, −4, 2) percentage points, and the published shock is the base plus adj. For startingCape = 25, sensitivity = 0.15 and sigma = 0, adj = −0.75 and the shock is −0.75 points. Units: percentage points. Rounding: none.
Formula:
adj = clamp(−(CAPE − 20) · s, −4, 2); shock = 100 (exp(sigma Z − sigma^2/2) − 1) + adj-
CAPE: Starting cyclically adjusted P/E (unit: 1) (domain: positive; worksheet 25) -
s: Sensitivity, percentage points per CAPE point above 20 (unit: percentage points) (domain: worksheet 0.15) -
sigma: Lognormal volatility as a decimal (unit: 1) (domain: >= 0; worksheet 0) -
adj: Additive mean shift (unit: percentage points) (domain: [−4, 2])
Timing: annual; the CAPE adjustment is constant across years of the path
Rounding: none
Limits:
- A modeling hypothesis, not proof that CAPE causes the stated future-return change
- The adjustment is capped to [−4, 2] percentage points on purpose: the cap bounds a derived value, not an input the caller set; the worksheet case −0.75 does not bind
- The projection still adds the plan expected return separately; this record is the shock only
- A return-inflation correlation outside [−1, 1], or not finite, is refused with a message rather than clamped
- A negative or non-finite returnVolPct or inflationVolPct is refused with a message
Provenance: derived by
codex, 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: market-model-cape-conditioned.md Mutation receipt: market-model-cape-conditioned.mutation.md
Evidence tests: marketModels.evidence.test.ts
Implemented in: marketModels.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
Empirical historical shock, centered or raw Model record, justified by derivation
Purpose: Samples blended historical returns as shocks, optionally subtracting the historical mean.
Statement: Each year samples a HISTORICAL_YEARS row: the first nextInt(n) sets the cursor, then a uniform coin of 0.5 redraws iid when next() < 0.5 and otherwise keeps the cursor. At equity weight w, centered mode (the default) publishes blend(row, w) − meanPortfolioReturnPct(w); raw mode (centered === false) publishes the blend itself. Inflation is the row value as-is. For w = 60, nextInt(96) = 0 and next() = 0.6, year 1 is 1928: blend 26.599999999999998%, dataset mean 8.937291666666669%, centered shock 17.662708333333327, raw shock 26.599999999999998, inflation −1.2%. Units: percentage points and percent/year. Rounding: none; absolute tolerance 1e-12 on the non-integer shocks.
Formula:
shock_centered = R(row, w) − mean(w); shock_raw = R(row, w); inflation = row.inflationPct-
row: Sampled HISTORICAL_YEARS entry (unit: year) (domain: 1928–2023; worksheet 1928 via nextInt 0) -
w: Equity weight (unit: percent) (domain: worksheet 60) -
mean(w): Dataset blended mean at w, meanPortfolioReturnPct(w) (unit: percent/year) (domain: worksheet 8.937291666666669) -
R(row, w): Blended historical return of the sampled row (unit: percent/year) (domain: worksheet 26.599999999999998)
Timing: annual; one sampled row per path year
Rounding: none
Limits:
- Centered mode studies dispersion about the historical mean; raw mode preserves the empirical level but can double-count because the projection adds expected return separately
- Neither mode predicts future returns
- The sampler mixes sequence-step and iid (a 0.5 coin) rather than drawing one iid index per year
Provenance: derived by
codex, 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: market-model-empirical-history.md Mutation receipt: market-model-empirical-history.mutation.md
Evidence tests: marketModels.evidence.test.ts
Implemented in: marketModels.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
GARCH(1,1) variance recursion with variance targeting Model record, justified by derivation
Purpose: A clustered-volatility additive return shock whose long-run standard deviation equals returnVolPct.
Statement: With sigmaBar = returnVolPct/100 (default 12), alpha (default 0.1) and beta (default 0.85), omega = sigmaBar^2 · (1 − alpha − beta), so the long-run variance omega / (1 − alpha − beta) is sigmaBar^2 (Bollerslev 1986, with variance targeting as the Federal Reserve's stress-test model documentation of October 2025 states it, after Engle and Mezrich 1996; the defaults alpha and beta are the model's choices). The path starts at v_1 = sigmaBar^2. Each year t draws Z1_t = nextNormal, sets the innovation e_t = sqrt(v_t) · Z1_t and publishes returnShockPct = 100 · e_t; then Z2_t = nextNormal and inflation = inflationMeanPct + inflationVolPct · (rho · Z1_t + sqrt(1 − rho^2) · Z2_t); then v_{t+1} = omega + alpha · e_t^2 + beta · v_t, which feeds back the innovation itself. Because E[e_t^2 | past] = v_t and v_1 = sigmaBar^2, E[v_t] = sigmaBar^2 in every year, so every year's shock has mean 0 and unconditional standard deviation returnVolPct points. A negative or non-finite returnVolPct, a negative or non-finite alpha or beta, alpha + beta of 1 or more, and the retired keys omega (now derived) and returnVolScalePct (now returnVolPct) are refused with a message. For the defaults, zero inflation inputs and Z1 = 1, 0.5, −2, 0 (Z2 = 0), omega = 0.00072 and v = 0.0144, 0.0144, 0.01332, 0.01737, so the shocks are 12, 6, −23.082460874005616 and 0 points and inflation is 0. Units: percentage points. Rounding: none. Absolute tolerance 1e-12.
Formula:
v_1 = sigmaBar^2; e_t = sqrt(v_t) · Z1_t; shock_t = 100 · e_t; v_{t+1} = sigmaBar^2 (1 − alpha − beta) + alpha e_t^2 + beta v_t-
returnVolPct: Long-run standard deviation of the shock; sigmaBar = returnVolPct/100 (unit: percentage points) (domain: finite and >= 0, refused otherwise; worksheet 12) -
alpha, beta: Weights on the prior year's squared innovation and on its variance (unit: 1) (domain: finite, >= 0 and alpha + beta < 1, refused otherwise; worksheet 0.1 and 0.85) -
v_t: Conditional variance of year t (internal, not published) (unit: fraction squared) (domain: >= omega; worksheet 0.0144, 0.0144, 0.01332, 0.01737) -
Z1_t, Z2_t: The year's two standard-normal draws (unit: 1) (domain: worksheet Z1 = 1, 0.5, −2, 0 with Z2 = 0) -
shock_t: Published additive return shock (unit: percentage points) (domain: worksheet 12, 6, −23.082460874005616, 0)
Timing: annual; v persists along the path from v_1 = sigmaBar^2; draws are Z1, Z2, then the class normals
Rounding: none
Limits:
- alpha 0.1 and beta 0.85 are illustrative values, not fitted to annual return data; no source fixes annual GARCH parameters
- Innovations are Gaussian: the fat tails come only from the changing variance (a stationary kurtosis of 3.77 at the defaults)
- Allocated accounts' class shocks share one common variance with the market: each class keeps its configured long-run volatility and correlations, and each year every class is scaled by the same sqrt(v_t)/sigmaBar, so all classes cluster together rather than each following a variance of its own (at returnVolPct 0 the scale is 1)
- The variance v_t is internal and not published; only the shock, inflation and class series are
- An additive shock can take a year's return below −100 percent (the return is the plan's expected return plus the shock), and the ledger floors balances at zero (annualPostSolveAccountGrowth.ts); after a large shock the next year's variance, and with it that chance, is higher
- A return-inflation correlation outside [−1, 1], or not finite, is refused with a message rather than clamped
- A negative or non-finite inflationVolPct is refused with a message
Provenance: derived by
claude, implemented byclaude-subagent.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: market-model-garch-variance.md Mutation receipt: market-model-garch-variance.mutation.md
Evidence tests: marketModels.evidence.test.ts
Implemented in: marketModels.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
Additive Gaussian return shock Model record, justified by derivation
Purpose: A symmetric, mean-zero normal return shock scaled to the configured volatility.
Statement: Each year draws Z ~ N(0, 1) and emits returnShockPct = returnVolPct · Z (equivalently 100 · (returnVolPct/100) · Z). For volatility 12 and Z = −2 the shock is −24 percentage points. Inflation is normal and correlated via a Gaussian copula. Units: percentage points. Rounding: none. Exact for these inputs.
Formula:
returnShockPct = sigma_pct · Z-
sigma_pct: Annual return volatility (unit: percentage points) (domain: >= 0; worksheet 12) -
Z: Standard-normal draw (unit: 1) (domain: finite; worksheet −2) -
returnShockPct: Additive return shock (unit: percentage points) (domain: unbounded; can imply returns below −100%)
Timing: annual; iid given the draws
Rounding: none
Limits:
- The unbounded lower tail can imply returns below −100%, an explicit limitation of additive normal shocks
- This is simulated dispersion, not a forecast
- A return-inflation correlation outside [−1, 1], or not finite, is refused with a message rather than clamped
- A negative or non-finite returnVolPct or inflationVolPct is refused with a message
Provenance: derived by
codex, 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: market-model-gaussian-draw.md Mutation receipt: market-model-gaussian-draw.mutation.md
Evidence tests: marketModels.evidence.test.ts
Implemented in: marketModels.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
Centered historical bootstrap shock Model record, justified by derivation
Purpose: A mean-zero shock from a sampled historical stock/bond/inflation year at the configured equity weight.
Statement: A year is sampled iid, in a fixed-length wrapping block, or as a wrapping sequence. The return shock is portfolioReturnPct(row, w) − meanPortfolioReturnPct(w); inflation is the row's inflation. With mode iid, w = 60, and nextInt(96) = 0, path year 1 is the 1928 row: blend 26.599999999999998%, dataset mean 8.937291666666669%, centered shock 17.662708333333327 percentage points, inflation −1.2%. Units: percentage points and percent/year. Rounding: none; absolute tolerance 1e-12 on the shock.
Formula:
shock = R(row, w) − mean(w); inflation = row.inflationPct-
row: Sampled HISTORICAL_YEARS entry (unit: year) (domain: 1928–2023; worksheet 1928 via nextInt 0) -
w: Equity weight (unit: percent) (domain: worksheet 60) -
mean(w): Dataset blended mean at w, meanPortfolioReturnPct(w) (unit: percent/year) (domain: worksheet 8.937291666666669) -
shock: Centered return shock (unit: percentage points) (domain: worksheet 17.662708333333327)
Timing: annual; iid / block / sequence chooses the row
Rounding: none
Limits:
- Subtracting the sample-series mean makes the empirical shock mean zero; it does not claim history repeats or that a 60/40 blend fits every portfolio
- blockLengthYears (default 5) must be a whole number of at least 1, in every mode; anything else is refused with a message rather than rounded
Provenance: derived by
codex, 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: market-model-historical-centered-bootstrap.md Mutation receipt: market-model-historical-centered-bootstrap.mutation.md
Evidence tests: marketModels.evidence.test.ts
Implemented in: marketModels.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
Bernoulli high-inflation regime mix Model record, justified by derivation
Purpose: A two-regime inflation mean (normal vs high) with a lognormal return shock.
Statement: Each year a uniform U is compared with the high-inflation probability (from the low regime) or 0.7 (from the high regime) and the regime toggles on a hit. Inflation is the selected mean plus a 1.5-point copula term. For high-inflation probability 0.20, U = 0.10, high/base means 8/3 and zero innovations, the first year starts in the low regime, 0.10 < 0.20 toggles to high, and inflation is 8%. Units: percent/year. Rounding: none. Exact in this degenerate case.
Formula:
start low; if U < p_high then high; inflation = (high ? mu_high : mu_base) + 1.5 · copula(Z)-
p_high: Probability of switching into the high regime from low (unit: 1) (domain: a probability from 0 to 1, refused otherwise; worksheet 0.20) -
U: Regime uniform draw (unit: 1) (domain: [0, 1); worksheet 0.10) -
mu_high, mu_base: High and base inflation means (unit: percent/year) (domain: worksheet 8 and 3)
Timing: annual; the regime bit persists along the path
Rounding: none
Limits:
- A stress model, not a forecast of regime incidence
- Inflation volatility is hardcoded at 1.5 percentage points; zero-innovation inflation requires Z = 0, not a configured vol of 0
- A high-inflation probability outside [0, 1] is refused with a message; it used to be clamped to [0.01, 0.3] without a word, and values inside that range run exactly as before
- A return-inflation correlation outside [−1, 1], or not finite, is refused with a message rather than clamped
- A negative or non-finite returnVolPct is refused with a message
Provenance: derived by
codex, 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: market-model-inflation-regime.md Mutation receipt: market-model-inflation-regime.mutation.md
Evidence tests: marketModels.evidence.test.ts
Implemented in: marketModels.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
Mean-preserving lognormal return shock Model record, justified by derivation
Purpose: A multiplicative shock whose gross multiplier has mean one, plus correlated normal inflation.
Statement: Each year draws Z ~ N(0, 1) and emits returnShockPct = 100 (exp(sigma Z − sigma^2/2) − 1) with sigma = returnVolPct/100, so E[exp(sigma Z − sigma^2/2)] = 1. Inflation is inflationMeanPct + inflationVolPct · copula(Z). For return volatility 0 and inflation mean/vol 3/0 every year has return shock 0 and inflation 3%, regardless of Z. Units: percentage points and percent/year. Rounding: none. Exact apart from array representation.
Formula:
returnShockPct = 100 (exp(sigma Z − sigma^2/2) − 1); inflation = mu_i + vol_i · copula(Z)-
sigma: Return volatility as a decimal, returnVolPct/100 (unit: 1) (domain: >= 0; worksheet 0) -
Z: Standard-normal draw (unit: 1) (domain: finite; arbitrary when sigma = 0) -
mu_i, vol_i: Inflation mean and volatility (unit: percent/year) (domain: worksheet 3 / 0)
Timing: annual; iid given the draws
Rounding: none
Limits:
- Defines simulated dispersion, not a forecast or a guarantee that returns are lognormal
- The plan expected return is added by the projection; putting it inside this model would double-count
- A return-inflation correlation outside [−1, 1], or not finite, is refused with a message rather than clamped
- A negative or non-finite returnVolPct or inflationVolPct is refused with a message
Provenance: derived by
codex, 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: market-model-lognormal-draw.md Mutation receipt: market-model-lognormal-draw.mutation.md
Evidence tests: marketModels.evidence.test.ts
Implemented in: marketModels.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
Two-state bull/bear Markov return shock Model record, justified by derivation
Purpose: Persistent bull and bear mean deviations with a per-year switch probability.
Statement: The path starts bull if U_start > 0.5. Each year, if U_switch < p_switch the state flips. The shock is 100 · (mu_state + sigma_state · Z) percentage points, with mu_bull / mu_bear the configured deviations (default +4 / −4 percent as decimals 0.04 / −0.04). For bull/bear +4/−4, zero volatilities, current state bull, switch draw 0.9 and p_switch = 0.05, the state remains bull and the shock is +4. Units: percentage points. Rounding: none. Exact for the degenerate volatility case.
Formula:
stay if U >= p; shock = 100 (mu_state + sigma_state Z)-
mu_bull, mu_bear: State mean deviations as percents, divided by 100 in the recursion (unit: percent) (domain: worksheet +4 / −4) -
p: Annual switch probability (unit: 1) (domain: a probability from 0 to 1, refused otherwise; worksheet 0.05) -
U: Switch uniform draw (unit: 1) (domain: [0, 1); worksheet 0.9)
Timing: annual; the state bit persists along the path
Rounding: none
Limits:
- A scenario model, not evidence markets have two regimes
- Symmetric +a/−a deviations are near zero only under equal long-run state mass
- The start state is a coin flip (U > 0.5), so the worksheet's "current state bull" is realized by scripting that first draw
- A switch probability outside [0, 1] is refused with a message; it used to be clamped to [0.001, 0.5] without a word, and values inside that range run exactly as before
- A negative or non-finite bullVolPct, bearVolPct or inflationVolPct is refused with a message
Provenance: derived by
codex, 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: market-model-regime-switch.md Mutation receipt: market-model-regime-switch.mutation.md
Evidence tests: marketModels.evidence.test.ts
Implemented in: marketModels.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
Reversed-history window replay Model record, justified by derivation
Purpose: Replays a stochastically chosen historical window in reverse chronological order.
Statement: The window length L = windowLengthYears (default 10) must be a whole number of years from 5 to the historical row count n = 96; any other value is refused with a message and never clamped. The path draws start = nextInt(n − L + 1) once, then path year i replays row start + L − 1 − (i mod L). Each shock is the row's blend at equityWeightPct minus the dataset mean at that weight; inflation is the row value as-is. For windowLengthYears 5, w = 60, nextInt(92) = 72, and three path years, the replayed years are [2004, 2003, 2002], shocks [−0.7172916666666698, 8.26270833333333, −16.097291666666667], inflation [3.3, 1.9, 2.4]. Units: percentage points and percent/year. Rounding: none; absolute tolerance 1e-12 on the shocks.
Formula:
L = windowLengthYears (whole, 5 ≤ L ≤ n); idx_i = start + L − 1 − (i mod L); shock_i = R(row_idx, w) − mean(w)-
L: Window length, windowLengthYears (unit: years) (domain: whole number from 5 to n = 96, refused otherwise; worksheet 5) -
start: Drawn start index, nextInt(n − L + 1) (unit: 1) (domain: 0 to n − L; worksheet 72) -
w: Equity weight (unit: percent) (domain: worksheet 60)
Timing: annual; the reversed window repeats for longer paths
Rounding: none
Limits:
- A stress transformation that preserves marginal observations while changing sequence risk, not a historical claim
- A windowLengthYears that is not a whole number from 5 to 96 is refused with a message rather than run as a different window; the planner always sends 10
- Paths longer than L repeat the same reversed window
Provenance: derived by
claude, implemented byclaude-subagent.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: market-model-reversed-history.md Mutation receipt: market-model-reversed-history.mutation.md
Evidence tests: marketModels.evidence.test.ts
Implemented in: marketModels.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
Stationary (geometric-block) historical bootstrap Model record, justified by derivation
Purpose: Samples historical years in wrapping contiguous blocks whose length is geometric with mean L.
Statement: With L = meanBlockLength ?? 5 (a finite number of at least 2; anything else is refused with a message), each historical block draws one length remaining = floor(−ln(1 − U) · L) || 1 after cursor = nextInt(n) (U = next()). The path publishes that many contiguous rows with wrap, then draws a new start row and length only when remaining reaches 0. Draw order per block is nextInt then next. A per-year continuation coin with probability 1/L is a different law. For L = 5, equityWeightPct = 60, nextInt 72, U = 0.50, then at restart nextInt 0, U = 0.90, a five-year path replays 2000, 2001, 2002, 1928, 1929: inflation 3.4, 1.6, 2.4, −1.2, 0.6 and shocks −7.657291666666668, −13.837291666666669, −16.097291666666667, 17.662708333333327, −12.23729166666667. Units: percentage points and percent/year. Rounding: block length is floored and a zero floor becomes 1. Absolute tolerance 1e-12 on the shocks; inflation exact.
Formula:
at each block start: remaining = floor(−ln(1 − U) · L) || 1; each year publishes the row at cursor, then cursor = (cursor + 1) mod n and remaining decrements; when remaining reaches 0 a new cursor = nextInt(n) and a new length are drawn-
L: Mean block length (unit: years) (domain: finite and >= 2, refused otherwise; worksheet 5) -
U: Uniform length draw, once per block (unit: 1) (domain: [0, 1); worksheet 0.50 then 0.90) -
remaining: Years left in the current block (unit: years) (domain: >= 1 after the || 1 clamp; worksheet 3 then 11)
Timing: annual; a new block is drawn when remaining hits 0
Rounding: block length is floored; a zero floor becomes 1
Limits:
- Preserves observations while randomizing dependence lengths; it does not establish that historical dependence recurs
- One block-length draw per block: remaining = floor(−ln(1 − U) · L) || 1. A per-year continuation coin with p = 1/L is a different law even when its mean is near L
- A meanBlockLength below 2, or not finite, is refused with a message rather than raised to 2
- Draw order per block is nextInt(n) then next(); a new pair is drawn only when remaining reaches 0
- The || 1 is kept on purpose: it rejects an empty block, since at U = 0.01 and L = 5, floor(−ln(0.99) · 5) = 0 would otherwise be length 0
Provenance: derived by
codex, 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: market-model-stationary-bootstrap.md Mutation receipt: market-model-stationary-bootstrap.mutation.md
Evidence tests: marketModels.evidence.test.ts
Implemented in: marketModels.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
Student-t return shock scaled to the configured volatility Model record, justified by derivation
Purpose: A fat-tailed additive return shock whose standard deviation equals returnVolPct.
Statement: For degrees of freedom df > 2 (default 5) and sigma = returnVolPct/100, each year draws Z ~ N(0, 1) with nextNormal; then V ~ chi-square(df) as 2G with G ~ Gamma(df/2, 1) by the method of Marsaglia and Tsang (2000, ACM TOMS 26(3):363-372), which uses uniforms only (its normal is built by Box-Muller from its own two uniforms, cosine branch); then m = sqrt((df − 2)/V) and t = m · Z, and the published shock is returnShockPct = sigma · t · 100; then z2 = nextNormal and inflation = inflationMeanPct + inflationVolPct · (rho · t + sqrt(1 − rho^2) · z2). For an ideal chi-square V, E[1/V] = 1/(df − 2) and E[t^2] = 1, so the shock has mean 0 and standard deviation returnVolPct points, and Corr(shock, inflation) = rho when returnVolPct and inflationVolPct are both positive (undefined when either is 0); the implemented V, whose private normal is capped (a limit), makes these approximate rather than exact. A df of 2 or less, or not finite, is refused with a message. For df = 5, returnVolPct = 12, Z = 1 and uniforms 0.5, 0, 0.5: the method's normal is x = sqrt(2 ln 2) = 1.1774100225154747, d = 13/6, c = 1/sqrt(19.5), v = (1 + c x)^3 = 2.0321239789552172, and 0.5 is below the squeeze bound 1 − 0.0331 x^4 = 0.9363880209572302, so V = 2 d v = 8.805870575472607, m = 0.5836795510996967 and the shock is 7.00415461319636 points; inflation inputs are zero, so inflation is 0. Units: percentage points. Rounding: none. Absolute tolerance 1e-12.
Formula:
t = Z · sqrt((df − 2)/V), V ~ chi-square(df) by Marsaglia and Tsang; returnShockPct = returnVolPct · t-
df: Degrees of freedom of the t variate (unit: 1) (domain: finite and > 2, refused otherwise; worksheet 5) -
Z: The year's first standard-normal draw (unit: 1) (domain: worksheet 1) -
V: Chi-square(df) draw, from uniforms only (unit: 1) (domain: > 0; worksheet 8.805870575472607) -
m: Unit-variance mixing scale sqrt((df − 2)/V), also shared by the class shocks (unit: 1) (domain: > 0; worksheet 0.5836795510996967) -
returnVolPct: Standard deviation of the shock (unit: percentage points) (domain: worksheet 12) -
returnShockPct: Additive return shock (unit: percentage points) (domain: worksheet 7.00415461319636)
Timing: annual; draws are nextNormal, uniforms for V (2 per attempt rejected at s <= 0, else 3), nextNormal
Rounding: none
Limits:
- A tail model, not a claim that future returns follow a t distribution; the planner always uses df 5, and no source fixes df
- A df of 2 or less is refused, because the variance is infinite there; for df of 4 or less the fourth moment is infinite
- Allocated accounts' class shocks share V with the market, so the class vector is approximately a multivariate t (exactly one for an ideal chi-square V): large moves in magnitude co-occur across classes (tail dependence) even where the class correlation is low, while their direction follows the correlation; cash gets t tails too
- Inflation reads t rather than Z, so it inherits a little of the tail at the default correlation of −0.2; this keeps the configured correlation, for positive return and inflation volatilities and up to the capped normal inside V; with either volatility 0 the correlation is undefined
- An additive shock can take a year's return below −100 percent (the return is the plan's expected return plus the shock), and the ledger floors balances at zero (annualPostSolveAccountGrowth.ts). At df 5 the chance per year of a shock at or below −105 points is 4.8e-5 at 12 percent volatility, 5.3e-4 at 20 percent and 1.4e-3 at 25 percent; the earlier mixture was about 2.3e-3 at 25 percent
- The normal inside the chi-square draw floors its first uniform at 1e-12, as rng.ts does, which caps its size near 7.43, so V is a close approximation of chi-square(df), not exactly it: the exact t variance, correlation and multivariate t are the ideal draw's
- A return-inflation correlation outside [−1, 1], or not finite, is refused with a message rather than clamped
- A negative or non-finite returnVolPct or inflationVolPct is refused with a message
Provenance: derived by
claude, implemented byclaude-subagent.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: market-model-student-t-draw.md Mutation receipt: market-model-student-t-draw.mutation.md
Evidence tests: marketModels.evidence.test.ts
Implemented in: marketModels.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
One-year additive user shock on a lognormal base Model record, justified by derivation
Purpose: Applies a specified percentage-point shock in one 1-based path year and a mean-preserving lognormal base in the others.
Statement: Year indices are 1-based. In the shock year the return shock is the configured shockPct; in other years it is the zero-mean lognormal 100 (exp(sigma Z − sigma^2/2) − 1). For shock year 2, shock −20, base volatility 0 and three years, the vector is [0, −20, 0]. Units: percentage points. Rounding: none. Exact.
Formula:
returnShockPct_i = shockPct if i+1 = shockYear else 100 (exp(sigma Z_i − sigma^2/2) − 1)-
shockYear: 1-based path year of the user shock (unit: years) (domain: whole number >= 1, refused otherwise; worksheet 2) -
shockPct: Additive shock in that year (unit: percentage points) (domain: worksheet −20) -
sigma: Base lognormal volatility as a decimal (unit: 1) (domain: worksheet 0)
Timing: annual; the shock year is compared as i+1
Rounding: none
Limits:
- A what-if, not an assigned crash probability
- A shockYear that is not a whole number of at least 1 is refused with a message (a value below 1 used to be raised to 1, and a fractional one gave no shock year); treating it as zero-based is the worksheet's first wrong reading
- A negative or non-finite baseReturnVolPct is refused with a message
- The shock year still consumes the year's RNG draws; only the return-shock formula is replaced
Provenance: derived by
codex, 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: market-model-user-shock.md Mutation receipt: market-model-user-shock.mutation.md
Evidence tests: marketModels.evidence.test.ts
Implemented in: marketModels.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
Default Monte Carlo seed and the headline run's options Data record, justified by derivation
Purpose: The one base seed every plan's default Monte Carlo draws its markets from, and the headline configuration every host can match.
Statement: DEFAULT_MONTE_CARLO_SEED = 0x5eeded = 6,221,293 (decision D-MC-DEFAULT-SEED, 2026-09-28) is the base seed of every default Monte Carlo run, the same for every plan whatever its id: the headline success rate, the Monte Carlo page until the reader re-rolls (Re-roll replaces it for that page's runs only and is never saved), every comparison the planner pairs with the headline run (the Insight preview, Scenarios, the Social Security analysis, relocation, Optimize), the risk-based guardrail threshold solve, which stores the seed beside the thresholds, and the Optimize tournament's downside-resilience metric. headlineMonteCarloOptions(plan, startYear, pathCount = 1000) publishes { startYear, pathCount, seed: DEFAULT_MONTE_CARLO_SEED, model: buildLognormalModelConfigForPlan(plan, 12) }, so a host that uses it draws the app's markets. Path i is seeded with derivePathSeed(6,221,293, i): 2,931,854,348 for path 0 and 3,741,805,609 for path 1. Before the decision the planner hashed the plan id (FNV-1a), so Duplicate, Save to My Plans and import drew new markets and two plans compared side by side ran on independent markets. Units: unsigned 32-bit integer. Rounding: none.
Formula:
seed = 0x5eeded = 6,221,293 for every plan; pathSeed_i = derivePathSeed(seed, i); headline = { pathCount 1000, seed, lognormal(plan, 12) }-
seed: Base Monte Carlo seed of a default run (unit: uint32) (domain: 6,221,293) -
i: Zero-based path index (unit: 1) (domain: integer >= 0; worksheet 0 and 1)
Timing: once per run; the page's Re-roll replaces it for that page only
Rounding: none
Limits:
- Any fixed seed is one draw from the same sampling distribution: a headline rate on 1,000 paths still carries about 1.4 points of sampling error, which one seed for every plan does not remove; it removes the id's part of it and pairs every comparison
- The value was kept because it was already the tournament's seed, before any figure was measured on it; choosing a seed by its outcomes would bias every figure
- An allocated plan draws class shocks after each year's return and inflation draws, so an allocated plan and a single-return plan share only their first year's draws even on one seed
- RetireGolden-MCP adopted these options in 0.12.0: run_monte_carlo with no arguments runs headlineMonteCarloOptions (1,000 paths, this seed, the plan's lognormal model at 12 percent), where through 0.11.x it ran 200 paths on seed 42 with a plain lognormal model; an explicit seed, pathCount or returnVolPct replaces only its own default, and returnVolPct sets only the market factor, since an allocated account draws its classes' own volatilities
- Risk-based thresholds saved before schema v6 carry no stored seed and were solved on the plan-id seed; the Spending card says so and offers to solve again (guardrailThresholdSeedBasis)
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: monte-carlo-default-seed.md Mutation receipt: monte-carlo-default-seed.mutation.md
Evidence tests: headline.evidence.test.ts
Implemented in: rng.ts; headline.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year, insight-monte-carlo-success-delta, risk-based-guardrail-solved-balance-thresholds
-
-
The order a Monte Carlo path draws each person's death and care from Data record, justified by derivation
Purpose: Each path gives the people its random numbers in a canonical order, so listing the same household the other way round draws the same path.
Statement: montecarlo/run.ts#runMonteCarloPaths draws each path's death ages (stochastic longevity), then its care events (care shocks, a variable number of draws each), person by person in model/peopleOrder.ts#canonicalPeopleOrder: date of birth ascending (older first; ISO dates compare as strings), then sex in the written order female, male, average (CANONICAL_SEX_ORDER, never a locale comparison), then id by ordinal comparison; the sampled care events join the plan's in that order (decision D-PEOPLE-ORDER, rule R5). Before the decision the draws followed list order, so reversing a couple's people moved up to 1.5 points of success with longevity and 346 to 984 of 1,000 paths. Renaming ids moves a path only when two people share a birth date and a sex. Units: an order. Rounding: none.
Formula:
order = sort(people) by (dob ascending, index of sex in [female, male, average], id ordinal); draws: deaths in order, then care events in order-
people: The household's people (unit: records) (domain: one or two)
Timing: once per path, after the market path is drawn
Rounding: none
Limits:
- Any order is an equally valid sample; the canonical one is fixed so the published rate does not depend on which person takes the first random number, not because it is better
- Keying each person to a separate stream by id would also be order-free, but renaming ids would then move every path
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: monte-carlo-people-draw-order.md Mutation receipt: monte-carlo-people-draw-order.mutation.md
Evidence tests: peopleDrawOrder.evidence.test.ts
Implemented in: peopleOrder.ts; run.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
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
-
-
Sampled death age: a year-by-year walk down the survival curve Model record, justified by derivation
Purpose: The last full age alive a stochastic-longevity path draws for each person.
Statement: With x0 = floor(max(currentAge, 0)): if x0 >= 119 return 119 without a draw. Otherwise, for t = 0, 1, ... while x0 + t < 119, draw one uniform U in [0, 1) from the path RNG; if U < g(t) return x0 + t (alive through that age, dead before the next), otherwise advance. g(t) is the survival curve's probability of dying in year t given alive at its start (survival-probability-product): q(x0 + t) for a man or a woman, and for 'average' the mixture's 1 - S(t + 1)/S(t) from x0. Reaching 119 returns 119 without a further draw. One draw per year survived plus one in the year of death, in age order, so the result is deterministic for a fixed RNG stream, and the drawn age has the curve's distribution: P(death at x0 + t) = S(t) - S(t + 1), for 'average' the mixture's. Units: integer age. Rounding: the starting age is floored.
Formula:
death age = min{ x0 + t : U_t < g(t) }, or 119 when no such t with x0 + t < 119 exists; g(t) = P(dies in year t | alive at x0 + t)-
x0: Starting integer age, floor(max(currentAge, 0)) (unit: years) (domain: integer >= 0) -
U_t: Uniform draw consumed in year t (unit: 1) (domain: [0, 1)) -
g(t): The survival curve's probability of dying in year t given alive at its start: q(x0 + t) for a man or a woman (mortality-published-death-probability), 1 - S(t + 1)/S(t) for 'average' (survival-probability-product) (unit: 1) (domain: [0, 1])
Timing: annual; one draw per integer age from the current age until death
Rounding: starting age floored; the result is an integer
Limits:
- Period-table hazards are applied throughout the remaining lifetime; the draw does not predict an individual's death
- No proportional-hazards adjustment: sampled deaths use the curve at power 1, so the longevity questionnaire's multiplier does not reach the stochastic draw
- Exactly one RNG draw is consumed per year survived, so the death age shifts every later draw on the same path stream
- For 'average' the yearly probability is the mixture's from the current age, not a mean of the two q: the survivors of a mixed group become more female with age, so from 65 it is 0.0142164 at 66 against the mean q 0.014227 (the walk still takes one draw per year)
- The table endpoint (age 119) forces death; nobody is sampled past it (mortality-published-death-probability)
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-sampled-death-age.md Mutation receipt: mortality-sampled-death-age.mutation.md
Evidence tests: mortality.evidence.test.ts
Implemented in: mortality.ts
Feeds: monte-carlo-success-rate, monte-carlo-ending-investable-histogram
-
-
Starting investable: sum of listed account balances Formula record, justified by derivation
Purpose: The dollar base the risk-based guardrail fractions and SWR initial spend apply to.
Statement: Sum current balances of accounts whose type is taxable, equityComp, traditional, Roth, HSA, or cash. Property (home) and other noninvestable types are excluded, including when they carry a value rather than a balance. For taxable $100,000, cash $20,000 and home/property $300,000 the sum is $120,000. Units: dollars. Rounding: none. Exact dollars.
Formula:
B = sum { balance(a) : type(a) in {taxable, equityComp, traditional, roth, hsa, cash} }-
a: A plan account (unit: account) (domain: finite nonnegative balances) -
B: Starting investable (unit: usd) (domain: >= 0)
Timing: once at the start of a solve, on today's balances
Rounding: none
Limits:
- The listed investable set is the same dollar base market paths can fund; property is excluded by type and because it stores value rather than balance
- Basis fields are not added; only the balance field of an investable account contributes
Provenance: derived by
codex, 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: risk-based-starting-investable.md Mutation receipt: risk-based-starting-investable.mutation.md
Evidence tests: riskBasedGuardrails.evidence.test.ts
Implemented in: riskBasedGuardrails.ts
Feeds: display-guardrail-balance-thresholds, monte-carlo-success-rate, swr-rule-result-initial-annual-spend
-
-
SplitMix32-style per-path seed Model record, justified by derivation
Purpose: A 32-bit path seed from (base seed, zero-based path index) so a path's stream does not depend on how preceding paths consume draws.
Statement: A four-step 32-bit recurrence of the two arguments: h0 = (seed XOR imul(pathIndex + 1, 0x9e3779b9)) >>> 0; h1 = imul(h0 XOR (h0 >>> 16), 0x21f0aaad); h2 = imul(h1 XOR (h1 >>> 15), 0x735a2d97); result = (h2 XOR (h2 >>> 15)) >>> 0. For (42, 7), (42, 8) and (1, 0) the unsigned words are 1351098177, 2450979136 and 3950124170, and 1351098177 != 2450979136 so adjacent paths receive distinct seeds. Units: unsigned 32-bit integer. Rounding: none. Exact integer.
Formula:
h0 = (seed XOR imul(pathIndex+1, 0x9e3779b9)) >>> 0; h1 = imul(h0 XOR (h0>>>16), 0x21f0aaad); h2 = imul(h1 XOR (h1>>>15), 0x735a2d97); result = (h2 XOR (h2>>>15)) >>> 0-
seed: Base Monte Carlo seed (unit: uint32) (domain: worksheet 42 and 1) -
pathIndex: Zero-based path index (unit: 1) (domain: integer >= 0; worksheet 7, 8, 0) -
result: Derived path seed (unit: uint32) (domain: 0..2^32−1; worksheet 1351098177, 2450979136, 3950124170)
Timing: once per path, before that path's RNG is created
Rounding: none; 32-bit wrapping arithmetic
Limits:
- Hashing path identity rather than advancing one shared stream makes worker partitioning irrelevant
- Not cryptographic; adjacent indices are mixed by pathIndex+1 and the lowbias32 finalizer, not used as seed + index
Provenance: derived by
codex, implemented bygrok.Independent review:
grokGates (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: rng-derived-path-seed.md Mutation receipt: rng-derived-path-seed.mutation.md
Evidence tests: rng.evidence.test.ts
Implemented in: rng.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
Mulberry32 uniform reference stream Model record, justified by derivation
Purpose: The deterministic [0, 1) stream every Monte Carlo path draw is taken from.
Statement: State a is the seed as uint32. Each uniform draw: a = (a + 0x6d2b79f5) | 0; t = a; t = imul(t XOR (t >>> 15), t | 1); t ^= t + imul(t XOR (t >>> 7), t | 61); u = ((t XOR (t >>> 14)) >>> 0) / 2^32. For seed 1 the first five unsigned words are 2693262067, 11749833, 2265367787, 4213581821, 4159151403; dividing by 2^32 gives 0.6270739405881613, 0.002735721180215478, 0.5274470399599522, 0.9810509674716741, 0.9683778982143849. Standard-normal draws use Box–Muller (with a cached spare) and integers are floor(u · n) clamped into [0, n). Units: dimensionless in [0, 1). Rounding: none. Exact integer words; relative tolerance 1e-15 if doubles are compared.
Formula:
a ← (a + 0x6d2b79f5) | 0; avalanche; word = uint32(t); u = word / 2^32-
seed: Initial state (unit: uint32) (domain: worksheet 1) -
word: Unsigned 32-bit Mulberry32 output (unit: uint32) (domain: worksheet [2693262067, 11749833, 2265367787, 4213581821, 4159151403]) -
u: Uniform draw in [0, 1) (unit: 1) (domain: [0, 1))
Timing: one state advance per uniform; Box–Muller consumes two uniforms per pair of normals
Rounding: none; divide by 2^32 not 2^32−1
Limits:
- A small noncryptographic generator suitable for reproducible simulation sampling, not security
- Normal draws cache the paired Box–Muller value; the worksheet vector is the uniform stream, not the normal stream
Provenance: derived by
codex, 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: rng-mulberry32-reference-stream.md Mutation receipt: rng-mulberry32-reference-stream.mutation.md
Evidence tests: rng.evidence.test.ts
Implemented in: rng.ts
Feeds: monte-carlo-success-rate, monte-carlo-investable-fan-percentiles, monte-carlo-ending-investable-histogram, monte-carlo-ending-after-tax-estate-percentiles, monte-carlo-depletion-probability-by-year
-
-
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
Engine source: engine/src/montecarlo/run.ts
(MonteCarloSummary.successRate)
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.