/// Historical simulation engine for retirement projections. /// /// Implements the FIRECalc algorithm: for each starting year in the Shiller /// historical dataset (1871-present), simulate a retirement of `horizon` years /// using actual market returns, bond returns, and inflation. The portfolio is /// rebalanced annually to the target stock/bond allocation. /// /// Key outputs: /// - Safe withdrawal amount at a given confidence level (binary search to $1) /// - Success rate for a given spending level /// - Percentile bands of portfolio value at each year (for charting) const std = @import("std"); const builtin = @import("builtin"); const log = std.log.scoped(.projections); const shiller = @import("../data/shiller.zig"); const srf = @import("srf"); const srf_opts = @import("../srf_opts.zig"); const Date = @import("../Date.zig"); /// `log.warn` wrapper that no-ops under `zig build test`. Used for /// validation warnings emitted while parsing user-supplied /// `projections.srf` records: the test suite intentionally feeds /// invalid inputs to `validRetirementTarget` and the config-loader /// to verify they're rejected, but the resulting stderr noise /// pollutes test output. Keep using `log.warn` directly when the /// warning is interesting in tests too. fn warnUser(comptime fmt: []const u8, args: anytype) void { if (builtin.is_test) return; log.warn(fmt, args); } // ── Life events ───────────────────────────────────────────────── /// A resolved event ready for the simulation loop. All age-based timing /// has been converted to simulation years. The simulation functions only /// need this - no person indices, no ages array. pub const ResolvedEvent = struct { start_year: u16, duration: u16, // 0 = permanent annual_amount: f64, // positive = income, negative = expense inflation_adjusted: bool, pub fn isActive(self: *const ResolvedEvent, y: u16) bool { if (y < self.start_year) return false; if (self.duration == 0) return true; return y < self.start_year + self.duration; } pub fn cashFlow(self: *const ResolvedEvent, y: u16, cumulative_inflation: f64) f64 { if (!self.isActive(y)) return 0; if (self.inflation_adjusted) return self.annual_amount * cumulative_inflation; return self.annual_amount; } }; /// A discrete cash flow event that modifies the simulation's annual /// withdrawal. Positive amount = income (reduces withdrawal, e.g. /// Social Security). Negative = expense (increases withdrawal, e.g. /// college tuition). pub const LifeEvent = struct { name: [max_name_len]u8 = @splat(0), name_len: u8 = 0, start_age: u16, person: u8 = 0, // 0-indexed into birthdates array duration: u16 = 0, // 0 = permanent (until end of horizon) annual_amount: f64, // positive = income, negative = expense inflation_adjusted: bool = true, const max_name_len = 48; pub fn getName(self: *const LifeEvent) []const u8 { return self.name[0..self.name_len]; } /// Simulation year when this event starts, given the persons' current ages. /// Returns null if the person index is out of range. pub fn startYear(self: *const LifeEvent, current_ages: []const u16) ?u16 { if (self.person >= current_ages.len) return null; const age = current_ages[self.person]; if (self.start_age <= age) return 0; return self.start_age - age; } /// Is this event active in simulation year `y`? pub fn isActive(self: *const LifeEvent, y: u16, current_ages: []const u16) bool { const start = self.startYear(current_ages) orelse return false; if (y < start) return false; if (self.duration == 0) return true; // permanent return y < start + self.duration; } /// Cash flow contribution for simulation year `y`. /// Positive = income (reduces net withdrawal), negative = expense. pub fn cashFlow(self: *const LifeEvent, y: u16, cumulative_inflation: f64, current_ages: []const u16) f64 { if (!self.isActive(y, current_ages)) return 0; if (self.inflation_adjusted) return self.annual_amount * cumulative_inflation; return self.annual_amount; } /// Resolve this event into a ResolvedEvent using the given current ages. /// Returns null if the person index is out of range. pub fn resolve(self: *const LifeEvent, current_ages: []const u16) ?ResolvedEvent { return self.resolveToAge(current_ages, null); } /// Resolve into a ResolvedEvent, optionally terminating the event /// at the holder's death. When `age_of_death` is non-null, the /// event is capped so it stops the year `person` reaches that age: /// a dead person neither collects income (Social Security, /// pension, wages) nor incurs their own expenses. `age_of_death == /// null` reproduces `resolve` exactly (the event runs its /// configured duration / to the horizon end). /// /// Capping shortens the effective `duration` to `death_year - /// start` (or the configured finite duration, whichever is /// shorter). When the holder is already dead at or before the /// event would start, the event is returned as never-active (the /// `start_year = maxInt` sentinel). /// /// Survivor *benefits* that outlive the holder (a pension's /// survivor percentage, Social Security's keep-the-higher rule) /// are intentionally not modeled here - they're configured as a /// separate event tied to the surviving person. See the /// projections-srf docs. pub fn resolveToAge(self: *const LifeEvent, current_ages: []const u16, age_of_death: ?u16) ?ResolvedEvent { const start = self.startYear(current_ages) orelse return null; const aod = age_of_death orelse return .{ .start_year = start, .duration = self.duration, .annual_amount = self.annual_amount, .inflation_adjusted = self.inflation_adjusted, }; // Holder's death simulation-year (years from now). The // person index is already validated by startYear above. const age = current_ages[self.person]; const death_year: u16 = if (aod > age) aod - age else 0; const never_active: ResolvedEvent = .{ .start_year = std.math.maxInt(u16), .duration = 0, .annual_amount = self.annual_amount, .inflation_adjusted = self.inflation_adjusted, }; // Dead before (or exactly when) the event would begin -> no // cash flow at all. if (death_year <= start) return never_active; // Active window is [start, capped_end); capped_end is the // first dead year. For a permanent event (duration 0) the // only bound is death; for a finite event, the earlier of its // natural end and death. const max_span = death_year - start; // > 0 by the check above const capped: u16 = if (self.duration == 0) max_span else @min(self.duration, max_span); return .{ .start_year = start, .duration = capped, .annual_amount = self.annual_amount, .inflation_adjusted = self.inflation_adjusted, }; } }; // ── User configuration (from projections.srf) ────────────────── /// Resolved retirement boundary, derived from `UserConfig` against a /// reference date. The simulation consumes `accumulation_years` (an /// integer), but the display layer renders the exact `date`. pub const ResolvedRetirement = struct { /// Whole years of accumulation between today and the retirement /// date. The simulation runs in 1-year steps, so this is a /// floor - the displayed `date` is exact. accumulation_years: u16, /// Exact retirement date for display. `null` when `source == /// .none` (no accumulation phase configured / already retired) /// or `.promoted_infeasible` (the earliest-retirement cell was /// selected but no accumulation length sustains the target /// spending at the promoted confidence). date: ?Date, source: enum { /// No retirement date configured. The line renders "none". none, /// User configured `retirement_at::DATE` directly. at_date, /// User configured `retirement_age:num:N`, resolved against /// the oldest birthdate. at_age, /// User configured `target_spending` only. The retirement /// line shows the promoted cell's date (the headline pick /// from the earliest-retirement grid). promoted, /// Same as `.promoted` but the selected cell returned no /// feasible accumulation_years from `findEarliestRetirement`. /// The line renders "not feasible" instead of a date. promoted_infeasible, }, /// The accumulation/distribution boundary as a year-offset for the /// projection chart's x-axis. `bands[i].year == i` in production, /// so this offset doubles as the band index. Returns `null` when /// there's no accumulation phase to mark (`accumulation_years == 0`: /// already retired or distribution-only), in which case the chart /// draws no divider. Otherwise the chart draws a vertical line at /// this offset separating the saving phase (left) from the /// withdrawal phase (right). pub fn boundaryYear(self: ResolvedRetirement) ?u16 { return if (self.accumulation_years == 0) null else self.accumulation_years; } }; /// User-configurable projection parameters, loaded from projections.srf. /// /// Example projections.srf (union-tagged SRF records): /// #!srfv1 /// type::config,target_stock_pct:num:77 /// type::config,horizon:num:30 /// type::config,horizon_age:num:90 # plan to age 90 (last survivor) /// type::birthdate,date::1975-03-15 /// type::birthdate,date::1978-06-22,person:num:2 /// type::event,name::Social Security,start_age:num:67,amount:num:38400 pub const UserConfig = struct { /// Target stock allocation percentage (0-100). Used for simulation blending. target_stock_pct: ?f64 = null, /// Annual fund expense ratio as a percentage (e.g. 0.18 = 0.18%), /// applied as a drag on the blended return each simulated year. /// **Defaults to 0.18%** -- FIRECalc's default and a /// realistic, mildly conservative figure for a fund-holding /// portfolio (modeling no fee at all is less accurate and makes the /// projection too optimistic). Override via /// `type::config,expense_ratio:num:0.04` for a pure low-cost index /// portfolio, a higher value for active funds, or `0` for an /// all-individual-stock portfolio. Stored as a percentage here /// (like `target_stock_pct`); converted to the decimal the /// simulation wants (`/100`) at the view boundary. expense_ratio: f64 = 0.18, /// Optional per-position return cap, as a percentage (e.g. `30` = /// 30%). When set, each position's conservative MIN(3Y,5Y,10Y) /// trailing return is clamped to this ceiling before being /// market-value weighted into the "Projected return" estimate. This /// keeps a single outlier (e.g. NVDA's recent run) from inflating /// the forward-looking projected return. **Defaults to `null` (no /// cap).** Stored as a percentage here (like `target_stock_pct` / /// `expense_ratio`); converted to the decimal the analytics want /// (`/100`) at the view boundary. Override via /// `type::config,return_cap:num:30` in `projections.srf`. /// /// Note this caps the *displayed* conservative "Projected return", /// not the Monte Carlo bands - those blend Shiller S&P/bond history /// by the portfolio's aggregate stock_pct and never see individual /// positions. return_cap: ?f64 = null, /// Retirement horizons to simulate (years). Defaults to 20,30,45. horizons: [max_horizons]u16 = .{ 20, 30, 45 } ++ @as([max_horizons - 3]u16, @splat(0)), horizon_count: u8 = 3, /// Per-horizon `retirement_target` annotation (90/95/99 confidence /// percentage, or 0 = no annotation). Parallel to `horizons`. At /// most one horizon may carry a non-zero value; when more than /// one is configured, all annotations are dropped (validation /// failure -> fall back to the default promotion rule). /// /// Used by the target-spending input to pick which (horizon, /// confidence) cell from the Earliest retirement grid to /// promote into the Accumulation phase block. See /// `pickPromotedCell` for the resolution algorithm. horizon_targets: [max_horizons]u8 = @splat(0), /// Per-horizon age-of-death anchor, parallel to `horizons`. `0` /// marks a plain numeric horizon (a fixed distribution length, no /// mortality modeling - today's behavior). A non-zero value `A` /// marks an age-anchored column: the distribution runs until the /// last surviving member reaches age `A`, the column carries the /// survivor spending step-down at the first death, and each /// person's income/expense events terminate at their own death. /// Populated by `resolveHorizonAges` when it resolves a /// `horizon_age` record into a `horizons` slot. horizon_death_age: [max_horizons]u16 = @splat(0), /// Age-based horizon targets ("plan until age N"). Resolved at /// context-load time by `resolveHorizonAges` to /// `target_age - youngestAge(as_of)` - how long until the /// *youngest* (last-surviving) member reaches `target_age`, since /// the household's assets must last until the last death. Each /// resolved entry also sets the matching `horizon_death_age` slot, /// turning on the mortality model (survivor step-down at the first /// death, per-person event termination). horizon_ages: [max_horizons]u16 = @splat(0), /// Per-`horizon_age` `retirement_target` annotation, parallel to /// `horizon_ages`. Carried through to the resolved `horizon_targets` /// slot when `resolveHorizonAges` appends the resolved year count. horizon_age_targets: [max_horizons]u8 = @splat(0), horizon_age_count: u8 = 0, /// Confidence levels for safe withdrawal. Always 90/95/99. confidence_levels: [3]f64 = .{ 0.90, 0.95, 0.99 }, /// Birthdates for age-based event timing. birthdates: [max_persons]Date = @splat(Date.fromYmd(1970, 1, 1)), birthdate_count: u8 = 0, /// Life events (income/expenses) that modify annual cash flow. // SAFETY: paired with `event_count`; only `events[0..event_count]` is read. events: [max_events]LifeEvent = undefined, event_count: u8 = 0, // ── Accumulation phase ────────────────────────────────────── /// Target retirement age for the oldest configured person. The /// retirement date is the day they turn this age (clamping Feb 29 /// to Feb 28 in non-leap target years). Mutually exclusive with /// `retirement_at`; if both are set, `retirement_at` wins. retirement_age: ?u16 = null, /// Absolute retirement date. Wins over `retirement_age` when both /// are set. retirement_at: ?Date = null, /// Total household contributions per year, in today's dollars. /// Defaults to zero (distribution-only behavior). annual_contribution: f64 = 0, /// If true, contributions grow with CPI year-over-year (modeling /// a constant percentage of CPI-tracked income). If false, /// contributions are nominal. contribution_inflation_adjusted: bool = true, /// Target annual spending in today's dollars. When set, the /// projections command will search for the earliest retirement /// date at which this spending level is sustainable. target_spending: ?f64 = null, /// If true, the target spending grows with CPI during the /// distribution phase (matches the existing SWR model). target_spending_inflation_adjusted: bool = true, /// Percent of the couple's (joint) spending the surviving spouse /// needs after the first household death, for age-anchored /// columns. Stored as a percent (like `target_stock_pct` / /// `expense_ratio`); converted to the `SimParams.survivor_factor` /// decimal (`/100`) at the view boundary. **Defaults to 75** (a /// 25% reduction), the conservative edge of the standard /// equivalence-scale range - see the projections-model docs for /// the sourcing (OECD-modified scale 67%, square-root scale 71%, /// planning-software convention ~80%). Set via /// `type::config,survivor_spending_pct:num:N`. Only takes effect /// for a multi-person household with an age-of-death gap; a single /// person or a same-age couple has no survivor sub-phase. Negative /// values are rejected at parse time; otherwise any value is /// honored (a survivor whose spending *rises* is a real case). survivor_spending_pct: f64 = 75, /// Signed annual *real* change in spending across the /// distribution phase, as a fraction (e.g. -0.02 = declines /// 2%/yr, +0.01 = rises 1%/yr). `null` -> flat real spending, /// the historical default. Set via /// `type::config,spending_change:num:N` where N is a whole /// percent (negative = decline). Feeds `SimParams. /// spending_real_change` for every horizon/confidence cell. spending_real_change: ?f64 = null, /// Ceiling on the accumulation years the earliest-retirement /// search (`findEarliestRetirement`) will consider when /// `target_spending` is set. Defaults to /// `default_max_accumulation_years` (50). Override via /// `type::config,max_accumulation_years:num:N` in projections.srf /// for someone with a longer-than-50-year planning runway (a /// young saver). Clamped at parse time to /// `max_configurable_accumulation_years`. Only affects the /// target-spending search path; an explicit `retirement_age` / /// `retirement_at` derives its accumulation years directly and /// ignores this cap. max_accumulation_years: u16 = default_max_accumulation_years, /// Stock benchmark symbol for the projection's benchmark-comparison /// table and bands, as an inline buffer + length. Read it through /// `benchmarkStock()`, never directly: `benchmark_stock_len == 0` /// means "no override, use `default_benchmark_stock`". /// /// Override via `type::config,benchmark_stock::SYMBOL` in /// `projections.srf`. /// /// This USED to be a `[]const u8` that pointed into the sibling /// buffer when overridden. That is a self-referential struct, and /// `parseProjectionsConfig` returns `UserConfig` BY VALUE - so the /// returned copy's slice pointed into the dead local's frame. An /// override therefore arrived downstream as N bytes of whatever the /// stack had been reused for (observed: NULs), silently blanking the /// stock leg of the benchmark table. The defaults never broke /// because string literals live in .rodata, which is why a feature /// that had probably never worked went unnoticed. /// /// Do not reintroduce a stored slice here. The codebase convention /// is inline-buffer + length + accessor precisely so a value type /// stays copy-safe - see `LifeEvent.getName` below, plus /// `models/quote.zig:Quote.name`, `analytics/analysis.zig:Annotation`, /// `commands/cache.zig:FileInfo.lastDate`, and `tui.zig:ParsedArgs.symbol`. // SAFETY: paired with `benchmark_stock_len`; only // `benchmark_stock_buf[0..benchmark_stock_len]` is ever read, and // `benchmarkStock()` returns the default literal when the length is 0. benchmark_stock_buf: [16]u8 = undefined, /// Length of the `benchmark_stock` override; 0 = use the default. benchmark_stock_len: u8 = 0, /// Bond benchmark symbol. Same buffer + length + accessor mechanics /// as `benchmark_stock`; read via `benchmarkBond()`. // SAFETY: paired with `benchmark_bond_len`; same read-only-the-prefix // invariant as `benchmark_stock_buf`. benchmark_bond_buf: [16]u8 = undefined, /// Length of the `benchmark_bond` override; 0 = use the default. benchmark_bond_len: u8 = 0, const max_horizons: usize = 8; const max_persons: usize = 4; pub const max_events: usize = 16; /// Benchmark symbols used when `projections.srf` declares no /// override. Public so the fetch-policy layer and tests can name /// them instead of duplicating the literals. pub const default_benchmark_stock: []const u8 = "SPY"; pub const default_benchmark_bond: []const u8 = "AGG"; /// Errors that can arise when resolving age-based horizons. pub const ResolveError = error{ /// `type::config,horizon_age:num:N` was specified in projections.srf /// but no `type::birthdate` record exists to anchor the calculation. HorizonAgeWithoutBirthdate, }; /// The stock benchmark symbol: the user's override, else /// `default_benchmark_stock`. /// /// Derives the slice from `self` at call time, which is what makes /// `UserConfig` copy-safe. Returning a stored slice into /// `benchmark_stock_buf` instead is the bug documented on that /// field - don't. pub fn benchmarkStock(self: *const UserConfig) []const u8 { if (self.benchmark_stock_len == 0) return default_benchmark_stock; return self.benchmark_stock_buf[0..self.benchmark_stock_len]; } /// The bond benchmark symbol: the user's override, else /// `default_benchmark_bond`. Same copy-safety note as /// `benchmarkStock`. pub fn benchmarkBond(self: *const UserConfig) []const u8 { if (self.benchmark_bond_len == 0) return default_benchmark_bond; return self.benchmark_bond_buf[0..self.benchmark_bond_len]; } pub fn getHorizons(self: *const UserConfig) []const u16 { return self.horizons[0..self.horizon_count]; } pub fn getConfidenceLevels(self: *const UserConfig) []const f64 { return &self.confidence_levels; } pub fn getEvents(self: *const UserConfig) []const LifeEvent { return self.events[0..self.event_count]; } /// Compute ages (in whole years) as of `as_of`. Pass today's date /// for "current ages"; pass a historical date for backfill. pub fn currentAges(self: *const UserConfig, as_of: Date) [max_persons]u16 { var ages: [max_persons]u16 = @splat(0); for (0..self.birthdate_count) |i| { ages[i] = Date.wholeYearsBetween(self.birthdates[i], as_of); } return ages; } /// Resolve age-based horizons (`horizon_ages`) into `horizons` /// slots, anchored on the **youngest** configured person (the /// last survivor). For each target age `A`, the appended horizon /// value is `A - youngestAge(as_of)` - the number of years until /// the youngest person hits `A`, i.e. how long the money must /// last if retiring now (the total span from `as_of` to the last /// death). The matching `horizon_death_age` slot is set to `A` so /// the column is flagged age-anchored: downstream the distribution /// length is derived from the retirement date (`A - youngestAge - /// accumulation_years`), the survivor step-down anchors at the /// oldest person's death, and per-person events terminate at each /// holder's death. Targets already in the past (youngest age >= /// target - everyone is gone) are silently skipped. /// /// The youngest anchor (vs the oldest) is the financial-planning /// standard for couples: assets must fund the household until the /// last surviving member dies. See the projections-model docs. /// /// Errors if `horizon_ages` is non-empty but no birthdate is configured. /// Safe to call multiple times; subsequent calls are no-ops because /// `horizon_age_count` is cleared after resolution. pub fn resolveHorizonAges(self: *UserConfig, as_of: Date) ResolveError!void { if (self.horizon_age_count == 0) return; if (self.birthdate_count == 0) return error.HorizonAgeWithoutBirthdate; const youngest = self.youngestAge(as_of); for (0..self.horizon_age_count) |i| { const target = self.horizon_ages[i]; if (target <= youngest) continue; // last survivor already past target const years: u16 = target - youngest; if (self.horizon_count < max_horizons) { self.horizons[self.horizon_count] = years; // Flag this column age-anchored so the mortality // semantics (last-survivor horizon, survivor // step-down, per-person event termination) apply. self.horizon_death_age[self.horizon_count] = target; // Carry through any retirement_target annotation from // the source horizon_age record. self.horizon_targets[self.horizon_count] = self.horizon_age_targets[i]; self.horizon_count += 1; } } // Clear so a second call is a no-op. self.horizon_age_count = 0; } /// Resolve age-based horizons using today's date. Convenience wrapper /// around `resolveHorizonAges`. pub fn resolveHorizonAgesNow(self: *UserConfig) ResolveError!void { return self.resolveHorizonAges(Date.fromEpoch(std.time.timestamp())); } /// Sum all event cash flows for simulation year `y`. pub fn eventNetCashFlow(self: *const UserConfig, y: u16, cumulative_inflation: f64, current_ages: []const u16) f64 { var total: f64 = 0; for (self.events[0..self.event_count]) |*ev| { total += ev.cashFlow(y, cumulative_inflation, current_ages); } return total; } /// Resolve all events into ResolvedEvents for the simulation. /// Skips events with invalid person indices. pub fn resolveEvents(self: *const UserConfig, as_of: Date) [max_events]ResolvedEvent { const ages = self.currentAges(as_of); return resolveEventsWithAges(self, &ages); } /// Resolve all events, terminating each per-person event at its /// holder's death (the year that person reaches `age_of_death`). /// `age_of_death == null` is identical to `resolveEvents`. Used /// per age-anchored column so a deceased spouse's Social Security /// / pension / wages stop instead of paying out for the rest of /// the (last-survivor) horizon. pub fn resolveEventsToAge(self: *const UserConfig, as_of: Date, age_of_death: ?u16) [max_events]ResolvedEvent { const ages = self.currentAges(as_of); return resolveEventsWithAgesToAge(self, &ages, age_of_death); } /// Resolve all events using pre-computed ages (for testing). pub fn resolveEventsWithAges(self: *const UserConfig, ages: []const u16) [max_events]ResolvedEvent { return resolveEventsWithAgesToAge(self, ages, null); } /// Resolve all events using pre-computed ages, optionally /// terminating each at its holder's death. Events with invalid /// person indices become never-active sentinels. pub fn resolveEventsWithAgesToAge(self: *const UserConfig, ages: []const u16, age_of_death: ?u16) [max_events]ResolvedEvent { var resolved: [max_events]ResolvedEvent = undefined; for (self.events[0..self.event_count], 0..) |*ev, i| { resolved[i] = ev.resolveToAge(ages, age_of_death) orelse .{ .start_year = std.math.maxInt(u16), // effectively never active .duration = 0, .annual_amount = 0, .inflation_adjusted = true, }; } return resolved; } /// Resolve the configured retirement boundary against `as_of`. /// Returns the integer accumulation_years used by the simulation, /// the displayed exact date, and the resolution source. /// /// `as_of` is the reference date - pass today's date for live /// mode, or a historical snapshot date when re-running the /// projection against past data. The function works correctly /// for any reference date. /// /// Resolution rules: /// - `retirement_at` set and not in the past (relative to /// `as_of`) -> that date. /// - `retirement_age` set, with at least one birthdate, and /// the oldest person hasn't already passed that age as of /// `as_of` -> the date that person turns the target age /// (clamping Feb 29 to Feb 28 in non-leap target years). /// - Otherwise -> `.none`. accumulation_years = 0. /// /// `retirement_at` wins when both are set. pub fn resolveRetirement(self: *const UserConfig, as_of: Date) ResolvedRetirement { if (self.retirement_at) |d| { if (d.lessThan(as_of)) return .{ .accumulation_years = 0, .date = null, .source = .none, }; return .{ .accumulation_years = Date.wholeYearsBetween(as_of, d), .date = d, .source = .at_date, }; } if (self.retirement_age) |target_age| { const oldest_bd = self.oldestBirthdate() orelse return .{ .accumulation_years = 0, .date = null, .source = .none, }; const ret_date = oldest_bd.addYears(target_age); if (ret_date.lessThan(as_of)) return .{ .accumulation_years = 0, .date = null, .source = .none, }; return .{ .accumulation_years = Date.wholeYearsBetween(as_of, ret_date), .date = ret_date, .source = .at_age, }; } return .{ .accumulation_years = 0, .date = null, .source = .none }; } /// Find the birthdate of the oldest configured person - the /// earliest date in `birthdates[]`. Returns null if no /// birthdates are configured. /// /// Used by `resolveRetirement` (with `retirement_age`), /// `resolveHorizonAges`, and `pickPromotedCell` to anchor any /// "oldest person" computation against a single source of /// truth. Pair with `Date.wholeYearsBetween(oldest, as_of)` for /// "oldest person's age right now"; that's also packaged as /// `oldestAge(as_of)` for caller convenience. pub fn oldestBirthdate(self: *const UserConfig) ?Date { if (self.birthdate_count == 0) return null; var oldest = self.birthdates[0]; var i: u8 = 1; while (i < self.birthdate_count) : (i += 1) { if (self.birthdates[i].lessThan(oldest)) oldest = self.birthdates[i]; } return oldest; } /// Age (whole years) of the oldest configured person as of /// `as_of`. Returns 0 when no birthdates are configured (which /// callers should treat as "no person to age out" rather than /// "person aged zero"). pub fn oldestAge(self: *const UserConfig, as_of: Date) u16 { const oldest = self.oldestBirthdate() orelse return 0; return Date.wholeYearsBetween(oldest, as_of); } /// Find the birthdate of the youngest configured person - the /// latest date in `birthdates[]`. Returns null if no birthdates /// are configured. /// /// The youngest person sets the *last-survivor* horizon: under a /// single shared age-of-death, they reach it latest in calendar /// time, so they bound how long the money must last. This is the /// financial-planning standard for couples (fund until the last /// death; see the projections-model docs and Blanchett 2021). /// Pair with `Date.wholeYearsBetween(youngest, as_of)`, packaged /// as `youngestAge(as_of)`. pub fn youngestBirthdate(self: *const UserConfig) ?Date { if (self.birthdate_count == 0) return null; var youngest = self.birthdates[0]; var i: u8 = 1; while (i < self.birthdate_count) : (i += 1) { if (youngest.lessThan(self.birthdates[i])) youngest = self.birthdates[i]; } return youngest; } /// Age (whole years) of the youngest configured person as of /// `as_of`. Returns 0 when no birthdates are configured. pub fn youngestAge(self: *const UserConfig, as_of: Date) u16 { const youngest = self.youngestBirthdate() orelse return 0; return Date.wholeYearsBetween(youngest, as_of); } }; // ── SRF parse types (private) ─────────────────────────────────── const SrfConfig = struct { type: []const u8 = "", target_stock_pct: ?f64 = null, expense_ratio: ?f64 = null, return_cap: ?f64 = null, horizon: ?u16 = null, horizon_age: ?u16 = null, /// Earliest-retirement promotion override: when paired with /// `horizon` or `horizon_age`, marks that horizon as the one to /// use for the promoted retirement-line cell. Allowed values: /// 90, 95, 99. /// Anything else is rejected at parse time. retirement_target: ?u8 = null, retirement_age: ?u16 = null, retirement_at: ?Date = null, annual_contribution: ?f64 = null, contribution_inflation_adjusted: ?bool = null, target_spending: ?f64 = null, target_spending_inflation_adjusted: ?bool = null, /// Signed annual real spending change, in whole percent /// (negative = decline). Parsed/clamped into /// `UserConfig.spending_real_change` as a fraction. spending_change: ?f64 = null, /// Percent of joint spending the surviving spouse needs after the /// first death (default 75 in UserConfig). Negative rejected. survivor_spending_pct: ?f64 = null, max_accumulation_years: ?u16 = null, benchmark_stock: ?[]const u8 = null, benchmark_bond: ?[]const u8 = null, }; const SrfBirthdate = struct { type: []const u8 = "", date: Date, person: ?u8 = null, // 1-indexed in SRF; null = sequential }; const SrfEvent = struct { type: []const u8 = "", name: []const u8 = "", start_age: u16 = 0, person: u8 = 1, // 1-indexed in SRF duration: u16 = 0, amount: f64 = 0, inflation_adjusted: bool = true, }; const SrfProjection = union(enum) { pub const srf_tag_field = "type"; config: SrfConfig, birthdate: SrfBirthdate, event: SrfEvent, }; /// Clamp on the magnitude of `spending_change` (10%/yr real, in /// either direction). A larger drift is almost certainly a units /// typo - someone entering a fraction (0.02) where a whole percent /// (2) was expected reads as 0.02%/yr (negligible), but the reverse /// (entering 20 meaning 0.20) would otherwise crater spending to /// zero within a decade. The clamp keeps a fat-fingered value from /// silently producing nonsense. pub const max_abs_spending_real_change: f64 = 0.10; /// The benchmark stock/bond symbols configured by the `projections.srf` at /// `path`. /// /// Returns the SPY/AGG defaults when the file is absent or silent. /// /// Lives here, next to the config it reads. Takes a resolved PATH rather than a /// directory so this module does no path arithmetic: locating a file beside the /// portfolio anchor is one rule that belongs in one place /// (`commands/common.siblingPath`), and an earlier directory-taking version of /// this had three callers each hand-rolling that join. /// /// Command code should not call this directly - go through /// `commands/common.demandFetchedSymbols`, so that "which symbols are fetched on /// demand" stays a fetch-policy question and the cache sweep does not have to /// know that the answer happens to come from projections config. /// /// The strings are DUPED into `arena` so the result outlives the local /// `UserConfig` this reads them from. (An overridden symbol lives in an /// inline `[16]u8` inside that config, so borrowing would tie the /// caller's lifetime to a function-local; `UserConfig` itself is /// copy-safe, but a slice into one particular copy of it is not.) pub fn benchmarkSymbols(io: std.Io, arena: std.mem.Allocator, path: []const u8) []const []const u8 { const data = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024)) catch null; const cfg = parseProjectionsConfig(data); const pair = arena.alloc([]const u8, 2) catch return &.{}; pair[0] = arena.dupe(u8, cfg.benchmarkStock()) catch return &.{}; pair[1] = arena.dupe(u8, cfg.benchmarkBond()) catch return &.{}; return pair; } /// Parse a projections.srf file into a UserConfig. /// Returns default config if data is null or unparseable. /// /// Uses an internal stack-backed FixedBufferAllocator for the SRF /// iterator's scratch. The default `parse_allocator` keeps short /// string values borrowing from `data` (no copy) and transparently /// allocates from the iterator's fallback arena for any /// multi-line/binary values (e.g. an event `name` containing a /// comma, which `srf.fmt` encodes with a length prefix). The 8 KB /// buffer comfortably fits any realistic projections.srf - a /// handful of config + birthdate + event records. On overflow the /// parse aborts and we return the default config, matching the /// existing "unparseable -> defaults" contract. /// /// Format (union-tagged SRF records): /// type::config,target_stock_pct:num:80 /// type::config,horizon:num:30 /// type::config,spending_change:num:-2 /// type::birthdate,date::1975-03-15 /// type::event,name::Social Security,start_age:num:67,amount:num:38400 pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { var config = UserConfig{}; const raw = data orelse return config; if (raw.len == 0) return config; var scratch_buf: [8 * 1024]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&scratch_buf); const scratch = fba.allocator(); var reader = std.Io.Reader.fixed(raw); var it = srf.iterator(&reader, scratch, .{ .parse_allocator = .none }) catch return config; defer it.deinit(); var saw_horizon = false; var birthdate_seq: u8 = 0; // Count of valid `retirement_target` annotations seen during // parse (across both `horizon` and `horizon_age` records). More // than one is a configuration error - we'll drop them all // post-loop and let `pickPromotedCell` fall back to the default // rule. A single bad value (not in {90,95,99}) is treated as // "no annotation on this record" and doesn't poison the others. var annotation_count: u8 = 0; while (it.next() catch null) |field_it| { const rec = field_it.to(SrfProjection, srf_opts.user_edited) catch |err| { // Skip the record rather than losing the whole file, but // name the error: a dropped record reverts that setting to // its default without saying so. Quiet under // `zig build test`, where fixtures feed malformed records // on purpose. if (!builtin.is_test) { log.warn("projections.srf: skipping malformed record: {s}", .{@errorName(err)}); } continue; }; switch (rec) { .config => |c| { config.target_stock_pct = c.target_stock_pct orelse config.target_stock_pct; config.expense_ratio = c.expense_ratio orelse config.expense_ratio; if (c.return_cap) |cap| { // A return cap is a ceiling on a position's expected // forward return; a negative ceiling is nonsensical. // Stored as a percent (e.g. 30 = 30%). if (cap >= 0) { config.return_cap = cap; } else { warnUser("projections: return_cap must be >= 0 (got {d}); ignoring record", .{cap}); } } if (c.horizon) |h| { if (!saw_horizon) { config.horizon_count = 0; saw_horizon = true; } if (h == 0) { log.warn("projections: horizon must be > 0; ignoring record", .{}); } else if (config.horizon_count >= UserConfig.max_horizons) { log.warn("projections: horizon limit reached ({d}); ignoring extra horizon record (value {d})", .{ UserConfig.max_horizons, h }); } else { config.horizons[config.horizon_count] = h; if (validRetirementTarget(c.retirement_target)) |conf| { config.horizon_targets[config.horizon_count] = conf; annotation_count += 1; } config.horizon_count += 1; } } if (c.horizon_age) |age| { // Age-based horizons are stored raw and resolved later // via `UserConfig.resolveHorizonAges(as_of)` once the // view layer knows the projection date. They also count // as "saw_horizon" so a file containing only // `horizon_age` records replaces the default {20,30,45} // once resolved. if (!saw_horizon) { config.horizon_count = 0; saw_horizon = true; } if (age == 0) { log.warn("projections: horizon_age must be > 0; ignoring record", .{}); } else if (config.horizon_age_count >= UserConfig.max_horizons) { log.warn("projections: horizon_age limit reached ({d}); ignoring extra horizon_age record (value {d})", .{ UserConfig.max_horizons, age }); } else { config.horizon_ages[config.horizon_age_count] = age; if (validRetirementTarget(c.retirement_target)) |conf| { config.horizon_age_targets[config.horizon_age_count] = conf; annotation_count += 1; } config.horizon_age_count += 1; } } config.retirement_age = c.retirement_age orelse config.retirement_age; config.retirement_at = c.retirement_at orelse config.retirement_at; if (c.annual_contribution) |amt| { // Negative values are nonsensical (a contribution // is income into the portfolio); drop the record. if (amt >= 0) { config.annual_contribution = amt; } else { warnUser("projections: annual_contribution must be >= 0 (got {d}); ignoring record", .{amt}); } } if (c.contribution_inflation_adjusted) |b| { config.contribution_inflation_adjusted = b; } if (c.target_spending) |amt| { if (amt >= 0) { config.target_spending = amt; } else { warnUser("projections: target_spending must be >= 0 (got {d}); ignoring record", .{amt}); } } if (c.target_spending_inflation_adjusted) |b| { config.target_spending_inflation_adjusted = b; } if (c.spending_change) |pct| { // Entered as a whole percent (negative = decline, // positive = rising real spending); stored as a // fraction. Clamp the magnitude so a units typo // can't drive spending to zero or absurd growth. const frac = pct / 100.0; const cap = max_abs_spending_real_change; if (frac > cap) { warnUser("projections: spending_change capped at +{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct }); config.spending_real_change = cap; } else if (frac < -cap) { warnUser("projections: spending_change capped at -{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct }); config.spending_real_change = -cap; } else { config.spending_real_change = frac; } } if (c.survivor_spending_pct) |pct| { // Percent of joint spending the survivor needs // after the first death. Negative is nonsensical // (spending can't be negative); otherwise any // value is honored - a survivor whose spending // rises above the couple's is a real, if uncommon, // case (e.g. the deceased was the frugal one). if (pct >= 0) { config.survivor_spending_pct = pct; } else { warnUser("projections: survivor_spending_pct must be >= 0 (got {d}); ignoring record", .{pct}); } } if (c.max_accumulation_years) |n| { if (n == 0) { // A zero-year search ceiling is degenerate (it // would only ever ask "can I retire today?"). // Almost certainly a typo; keep the default. warnUser("projections: max_accumulation_years must be > 0; ignoring record", .{}); } else if (n > max_configurable_accumulation_years) { // Respect the intent (the user wants a large // ceiling) but clamp to keep the search bounded // and inside the historical data span. warnUser("projections: max_accumulation_years capped at {d} (got {d})", .{ max_configurable_accumulation_years, n }); config.max_accumulation_years = max_configurable_accumulation_years; } else { config.max_accumulation_years = n; } } if (c.benchmark_stock) |sym| { if (sym.len == 0 or sym.len > config.benchmark_stock_buf.len) { warnUser("projections: benchmark_stock must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_stock_buf.len, sym.len }); } else { // Copy into our own buffer + length so the value // outlives both the SRF iterator's backing data // AND this function's frame. Storing a slice into // the buffer instead would dangle the moment // `config` is returned by value. Same shape as // the `.event` arm's name handling below. @memcpy(config.benchmark_stock_buf[0..sym.len], sym); config.benchmark_stock_len = @intCast(sym.len); } } if (c.benchmark_bond) |sym| { if (sym.len == 0 or sym.len > config.benchmark_bond_buf.len) { warnUser("projections: benchmark_bond must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_bond_buf.len, sym.len }); } else { @memcpy(config.benchmark_bond_buf[0..sym.len], sym); config.benchmark_bond_len = @intCast(sym.len); } } }, .birthdate => |b| { // person is 1-indexed in SRF; convert to 0-indexed. // If not specified, assign sequentially. const idx: u8 = if (b.person) |p| p -| 1 else birthdate_seq; if (idx < UserConfig.max_persons) { config.birthdates[idx] = b.date; if (idx >= config.birthdate_count) config.birthdate_count = idx + 1; } else { log.warn("projections: birthdate person index {d} exceeds limit ({d}); ignoring record", .{ idx + 1, UserConfig.max_persons }); } birthdate_seq += 1; }, .event => |e| { if (e.start_age == 0) { log.warn("projections: event '{s}' has start_age 0; ignoring record", .{e.name}); } else if (config.event_count >= UserConfig.max_events) { log.warn("projections: event limit reached ({d}); ignoring extra event '{s}'", .{ UserConfig.max_events, e.name }); } else { var ev = LifeEvent{ .start_age = e.start_age, .person = e.person -| 1, // 1-indexed -> 0-indexed .duration = e.duration, .annual_amount = e.amount, .inflation_adjusted = e.inflation_adjusted, }; const len = @min(e.name.len, LifeEvent.max_name_len); @memcpy(ev.name[0..len], e.name[0..len]); ev.name_len = @intCast(len); config.events[config.event_count] = ev; config.event_count += 1; } }, } } // Validation: at most one `retirement_target` annotation may be // present across all horizon and horizon_age records. If more // than one was seen, drop them all and let `pickPromotedCell` // fall back to the default rule. Logged as a warning so the // user knows their override was ignored. if (annotation_count > 1) { warnUser("projections: retirement_target set on multiple horizons; ignoring all annotations and using default promotion rule", .{}); config.horizon_targets = @splat(0); config.horizon_age_targets = @splat(0); } return config; } /// Validate a `retirement_target` SRF value. Returns the value /// unchanged if it's exactly 90, 95, or 99; returns null otherwise /// (logged as a warning so the user notices the typo). Used at parse /// time; the view-layer `pickPromotedCell` trusts whatever lands in /// `horizon_targets`. fn validRetirementTarget(raw: ?u8) ?u8 { const v = raw orelse return null; if (v == 90 or v == 95 or v == 99) return v; warnUser("projections: retirement_target must be 90, 95, or 99 (got {d}); annotation ignored", .{v}); return null; } // ── Results ──────────────────────────────────────────────────── pub const WithdrawalResult = struct { /// Confidence level (e.g. 0.99 = 99%). confidence: f64, /// Maximum annual withdrawal that achieves this confidence. annual_amount: f64, /// As a fraction of starting portfolio value. withdrawal_rate: f64, }; pub const YearPercentiles = struct { /// Year offset from retirement start (0 = start, 1 = after year 1, etc.) year: u16, p10: f64, p25: f64, p50: f64, p75: f64, p90: f64, }; // ── Core simulation ──────────────────────────────────────────── /// Parameters bundling the full two-phase simulation inputs. Used /// internally by all simulation entry points so the same code path /// handles both distribution-only (today's behavior, with /// `accumulation_years == 0`) and accumulation-then-distribution. pub const SimParams = struct { initial_value: f64, stock_pct: f64, annual_spending: f64, spending_inflation_adjusted: bool = true, /// Signed annual *real* change in spending, applied across the /// distribution phase (the "spending smile" / Blanchett model). /// A fraction: -0.02 = spending declines 2%/yr in real terms /// ("slow-go" years), +0.01 = rises 1%/yr. `0` (the default) is /// flat real spending - the historical behavior, byte-identical. /// /// `annual_spending` is the *first* distribution year's spend; /// year `d` of distribution spends `annual_spending * (1 + /// spending_real_change)^d` in real terms, then the usual CPI /// factor converts to nominal. Localized late-life cost humps /// (healthcare) are modeled separately as `events`, so this is a /// monotonic drift, not the full U-curve. spending_real_change: f64 = 0, /// Distribution-phase length (the "horizon" in the existing API). distribution_years: u16, accumulation_years: u16 = 0, annual_contribution: f64 = 0, contribution_inflation_adjusted: bool = true, /// Annual fund expense ratio (decimal, e.g. 0.0018 = 0.18%), /// subtracted from the blended market return each year in both /// phases. Defaults to 0 (no fee modeled). Mirrors FIRECalc's /// "investment expenses" drag; its default is 0.18%. expense_ratio: f64 = 0, events: []const ResolvedEvent = &.{}, /// Multiplier applied to base `annual_spending` once the first /// household death occurs, modeling the surviving spouse's /// reduced consumption need (couple -> single). A fraction: /// 0.75 = the survivor spends 75% of the couple's spending (a /// 25% reduction). `1.0` (the default) is no change - the /// behavior for a single person, a same-age couple, or any /// projection without an age-of-death anchor, and byte-identical /// to the pre-survivor model. Only base spending is scaled; /// per-person income/expense events terminate at their holder's /// death separately (via capped event durations). survivor_factor: f64 = 1.0, /// Absolute simulation year (years from `as_of`/now) of the first /// household death - the oldest configured person reaching the /// age-of-death. In any distribution-phase year `y >= /// first_death_year`, base spending is scaled by `survivor_factor`. /// `null` (the default) disables the step entirely. Equal to the /// horizon end (i.e. no gap) for a single person or same-age /// couple, in which case the step never triggers inside the loop. first_death_year: ?u16 = null, /// Total simulated path length (including year 0). pub fn totalYears(self: SimParams) u16 { return self.accumulation_years + self.distribution_years; } }; /// Optional alternate market dataset, used by tests to inject a /// synthetic constant-return / constant-CPI fixture. When `null`, the /// global `shiller.annual_returns` is used. const ShillerYearSlice = []const shiller.ShillerYear; /// Maximum cycles available given a total horizon. Returns 0 if no /// data covers the full horizon. /// /// Counts every cohort whose full span fits in the data: a cohort /// starting at index `i` reads `data[i .. i + total_years - 1]`, so /// the valid starts are `0 .. data.len - total_years` inclusive, /// i.e. `data.len - total_years + 1` cohorts. This matches FIRECalc's /// convention ("1871, 1872, ... until the most recent year for which /// there are results available") and `shiller.maxCycles`. Earlier /// this returned `data.len - total_years`, which silently dropped the /// single most-recent cohort (e.g. the 1996-2025 start for a 30-year /// horizon) -- a complete, often-stressful sequence. See the FIRECalc /// parity suite below. fn maxCyclesFor(data: ShillerYearSlice, total_years: u16) usize { if (data.len < total_years) return 0; return data.len - total_years + 1; } /// Simulate a single cycle of the two-phase model: /// 1. Accumulation: contributions in, life events, market return, /// CPI advance. No spending. Failure not counted. /// 2. Distribution: spending out, life events, market return, CPI /// advance. Failure (portfolio ≤ 0) records and stops further /// simulation, with subsequent years zeroed. /// /// `buf` is optional. Pass a non-null buffer of length /// `params.totalYears() + 1` when you need the full path: `buf[0]` /// is the initial value; `buf[i]` for i ≥ 1 is the portfolio value /// at the END of simulation year i; the retirement boundary is at /// index `accumulation_years` (i.e. `buf[accumulation_years]` is /// the portfolio at retirement, before the first withdrawal). /// /// Pass `null` when you only need the survival verdict - the /// function will return `false` as soon as it detects failure, /// skipping the rest of the simulation and avoiding any buffer /// writes. Saves work in the SWR binary-search inner loop where /// `successRateParams` calls this thousands of times per search. /// /// Returns true if the cycle survived the distribution phase. fn simulateTwoPhase( buf: ?[]f64, data: ShillerYearSlice, start_index: usize, params: SimParams, ) bool { const total = params.totalYears(); var portfolio = params.initial_value; if (buf) |b| b[0] = portfolio; var cumulative_inflation: f64 = 1.0; // Real-spending multiplier for the current distribution year. // Pinned at 1.0 through accumulation and the first distribution // year (d=0), then compounded by `(1 + spending_real_change)` // each subsequent distribution year. `spending_real_change == 0` // leaves it at 1.0 forever -> flat real spending, byte-identical // to the pre-smile behavior. var spend_factor: f64 = 1.0; var failed = false; var y: usize = 0; while (y < total) : (y += 1) { const di = start_index + y; if (di >= data.len) { // Out of data - survived (or failed earlier and were // walking to end for the buffer fill). Path callers // get the tail filled with the last known value; // null-buf callers just return. if (buf) |b| { for (y + 1..@as(usize, total) + 1) |k| b[k] = portfolio; } return !failed; } const yr = data[di]; const in_accumulation = y < params.accumulation_years; // Life events apply in both phases. var event_net: f64 = 0; for (params.events) |*ev| { event_net += ev.cashFlow(@intCast(y), cumulative_inflation); } if (in_accumulation) { const contribution = if (params.contribution_inflation_adjusted) params.annual_contribution * cumulative_inflation else params.annual_contribution; portfolio += contribution + event_net; } else { const real_spending = blk: { var rs = params.annual_spending * spend_factor; // Survivor step-down: once the first household death // has occurred (sim-year y at or past first_death_year), // scale base spending to the surviving spouse's reduced // need. No-op when first_death_year is null or // survivor_factor is 1.0. if (params.first_death_year) |fd| { if (y >= fd) rs *= params.survivor_factor; } break :blk rs; }; const spending = if (params.spending_inflation_adjusted) real_spending * cumulative_inflation else real_spending; portfolio -= spending - event_net; if (portfolio <= 0 and !failed) { // Survival-only callers exit immediately - there's // no path to fill, and the verdict is locked in. if (buf == null) return false; failed = true; } // Compound the real-spending drift for next year. No-op // when `spending_real_change == 0` (factor stays 1.0). spend_factor *= (1.0 + params.spending_real_change); } // Market return on the post-cashflow balance, net of the // fund expense ratio (FIRECalc applies "investment expenses" // the same way). Skipped after failure (path callers have // already locked the verdict; remaining buf entries get // zeroed below). if (!failed) { const blended_return = params.stock_pct * yr.sp500_total_return + (1.0 - params.stock_pct) * yr.bond_total_return; portfolio *= (1.0 + blended_return - params.expense_ratio); } // Advance CPI for next year. (No-op for the verdict after // failure, but cheap and keeps the loop body uniform.) cumulative_inflation *= (1.0 + yr.cpi_inflation); if (buf) |b| b[y + 1] = if (failed) 0.0 else portfolio; } return !failed; } /// Simulate a single retirement cycle starting at `start_index` in the /// Shiller dataset, lasting `horizon` years, with the given annual spending /// (inflation-adjusted) and stock/bond allocation. /// /// Distribution-only convenience wrapper around `simulateTwoPhase`. /// Preserves the existing API; new accumulation-aware code paths use /// `simulateTwoPhase` directly. fn simulateCycle( buf: []f64, start_index: usize, horizon: u16, initial_value: f64, annual_spending: f64, stock_pct: f64, events: []const ResolvedEvent, ) void { _ = simulateTwoPhase(buf, shiller.annual_returns, start_index, .{ .initial_value = initial_value, .stock_pct = stock_pct, .annual_spending = annual_spending, .distribution_years = horizon, .events = events, }); } /// Run all cycles with full SimParams: for each historical cohort, /// simulate the two-phase path into `all_paths[cycle]` and return the /// count that survived the distribution phase. `all_paths` must be /// pre-allocated with dimensions [num_cycles][totalYears + 1]. Used by /// the percentile-band builder and the earliest-retirement search. fn runAllCyclesParams( all_paths: [][]f64, data: ShillerYearSlice, params: SimParams, ) usize { const num_cycles = maxCyclesFor(data, params.totalYears()); var survived: usize = 0; for (0..num_cycles) |cycle| { if (simulateTwoPhase(all_paths[cycle], data, cycle, params)) survived += 1; } return survived; } fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 { const num_cycles = maxCyclesFor(data, params.totalYears()); if (num_cycles == 0) return 0.0; var survived: usize = 0; for (0..num_cycles) |cycle| { // `null` buffer -> simulateTwoPhase exits as soon as a // failure is detected. Cheaper than collecting the full // path when we only need the survival verdict. if (simulateTwoPhase(null, data, cycle, params)) survived += 1; } return @as(f64, @floatFromInt(survived)) / @as(f64, @floatFromInt(num_cycles)); } // ── Safe withdrawal search ───────────────────────────────────── /// Test-only convenience wrapper around `searchSafeWithdrawal`: builds /// a no-mortality `SimParams` from positional args so the /// accumulation / fee / spending-drift test cases (including the /// FIRECalc parity suite) stay readable. Production does not use it - /// `runProjectionGridColumns` builds the `SimParams` (with the /// per-column mortality fields) and calls `searchSafeWithdrawal` /// directly. /// /// When `accumulation_years == 0`, contributions are zero, and /// `expense_ratio == 0`, this reduces exactly to `findSafeWithdrawal` /// (the equivalence is pinned by the `regression: zero accumulation /// matches direct findSafeWithdrawal` test). fn findSafeWithdrawalWithAccumulation( horizon: u16, initial_value: f64, stock_pct: f64, confidence: f64, events: []const ResolvedEvent, accumulation_years: u16, annual_contribution: f64, contribution_inflation_adjusted: bool, expense_ratio: f64, spending_real_change: f64, ) WithdrawalResult { return searchSafeWithdrawal(.{ .initial_value = initial_value, .stock_pct = stock_pct, .annual_spending = 0, // overwritten by the search loop .spending_real_change = spending_real_change, .distribution_years = horizon, .accumulation_years = accumulation_years, .annual_contribution = annual_contribution, .contribution_inflation_adjusted = contribution_inflation_adjusted, .expense_ratio = expense_ratio, .events = events, }, confidence); } /// Unified safe-withdrawal search. Binary-searches `annual_spending` /// over `[lo, hi]` to $1 precision, seeded with a 4%-rule estimate /// against the projected post-accumulation portfolio value. /// /// `base` carries every `SimParams` field except `annual_spending`, /// which the search overwrites per probe. Production reaches it via /// `runProjectionGridColumns` (which builds `base` with the per-column /// mortality fields); the `findSafeWithdrawal` / /// `findSafeWithdrawalWithAccumulation` test wrappers delegate here too. /// /// Bracket seeding: /// - When `accumulation_years == 0`, `projected_value == /// initial_value` so the seed and bracket reduce to the /// classic 4%-rule starting point. /// - When non-zero, `projected_value` is a rough estimate of the /// post-accumulation portfolio (initial × 1.06^N + N × /// contribution). The bracket-widening below corrects for any /// inaccuracy in the estimate. fn searchSafeWithdrawal(base: SimParams, confidence: f64) WithdrawalResult { // Project the post-accumulation portfolio. For zero-accumulation // configs `pow(1.06, 0) == 1.0` so this collapses to // `initial_value` - same seed the original `findSafeWithdrawal` // used. const accum_growth_factor: f64 = std.math.pow(f64, 1.06, @as(f64, @floatFromInt(base.accumulation_years))); const projected_value = base.initial_value * accum_growth_factor + base.annual_contribution * @as(f64, @floatFromInt(base.accumulation_years)); // Seed from the 4% rule, adjusted for horizon and confidence. // Base ~4% for 30yr/95%. Shorter horizons allow more; longer less. // Higher confidence requires less. const base_rate = 0.04; const horizon_adj = 30.0 / @as(f64, @floatFromInt(base.distribution_years)); const conf_adj = (1.0 - confidence) / 0.05; const estimate = projected_value * base_rate * @sqrt(horizon_adj) * @sqrt(conf_adj); // Search band: ±50% of estimate. The lower clamp is 0; the // upper clamp ensures we don't start below the projected value // (relevant when the 4%-rule estimate undershoots a high // accumulation case). var lo: f64 = @max(estimate * 0.5, 0); var hi: f64 = @max(estimate * 1.5, projected_value); // Mutable probe - same struct, different `annual_spending` per // iteration. Avoids reconstructing SimParams on every probe. var probe = base; // Verify bounds bracket the answer; widen if not. probe.annual_spending = lo; if (successRateParams(shiller.annual_returns, probe) < confidence) { log.debug("searchSafeWithdrawal: estimate too high, widening lo to 0 (horizon={d}, conf={d:.2})", .{ base.distribution_years, confidence }); lo = 0; } probe.annual_spending = hi; if (successRateParams(shiller.annual_returns, probe) >= confidence) { log.debug("searchSafeWithdrawal: estimate too low, widening hi (horizon={d}, conf={d:.2})", .{ base.distribution_years, confidence }); hi = @max(projected_value, base.initial_value) * 4.0; } // Binary search to $1 precision. while (hi - lo > 1.0) { const mid = @floor((lo + hi) / 2.0); probe.annual_spending = mid; const rate = successRateParams(shiller.annual_returns, probe); if (rate >= confidence) lo = mid else hi = mid; } return .{ .confidence = confidence, .annual_amount = lo, .withdrawal_rate = if (base.initial_value > 0) lo / base.initial_value else 0.0, }; } // ── Earliest-retirement search (target-spending input) ───────── /// Result of a `findEarliestRetirement` search. `accumulation_years /// == null` means no value of N in [0, max_years] sustains the /// target spending at the requested confidence over the distribution /// horizon. The portfolio statistics are computed from the same /// historical cycles at year `accumulation_years`. pub const EarliestRetirement = struct { horizon: u16, confidence: f64, accumulation_years: ?u16, median_at_retirement: f64, p10_at_retirement: f64, p90_at_retirement: f64, /// Age-of-death anchor for this column, or 0 for a plain numeric /// horizon. When non-zero, the column header renders "to age N" /// and `horizon` is the distribution length at the chosen /// `accumulation_years` (it shrinks as retirement slides later, /// since the death date is fixed). death_age: u16 = 0, }; /// Portfolio percentiles at the retirement boundary (sim-year `n`), /// across the historical cohorts. Shared by both earliest-retirement /// search variants. const BoundaryStats = struct { median: f64, p10: f64, p90: f64 }; /// Run the full-path simulation for `params` and extract the /// portfolio value distribution at sim-year `n` (the retirement /// boundary). Returns zeros when no cohort covers the span. fn retirementBoundaryStats( allocator: std.mem.Allocator, data: ShillerYearSlice, params: SimParams, n: u16, ) !BoundaryStats { const total = params.totalYears(); const num_cycles = maxCyclesFor(data, total); if (num_cycles == 0) return .{ .median = 0, .p10 = 0, .p90 = 0 }; const years_len: usize = @as(usize, total) + 1; const path_data = try allocator.alloc(f64, num_cycles * years_len); defer allocator.free(path_data); const paths = try allocator.alloc([]f64, num_cycles); defer allocator.free(paths); for (0..num_cycles) |i| { paths[i] = path_data[i * years_len .. (i + 1) * years_len]; } _ = runAllCyclesParams(paths, data, params); const sort_buf = try allocator.alloc(f64, num_cycles); defer allocator.free(sort_buf); for (0..num_cycles) |c| { sort_buf[c] = paths[c][@as(usize, n)]; } std.mem.sort(f64, sort_buf, {}, std.sort.asc(f64)); return .{ .median = percentile(sort_buf, 0.50), .p10 = percentile(sort_buf, 0.10), .p90 = percentile(sort_buf, 0.90), }; } /// Default ceiling on the accumulation years the earliest-retirement /// search considers. 50 covers a 25-year-old planning to age 75. /// Overridable per-portfolio via /// `type::config,max_accumulation_years:num:N` in projections.srf - /// see `UserConfig.max_accumulation_years`. pub const default_max_accumulation_years: u16 = 50; /// Hard ceiling on a user-configured `max_accumulation_years`. A /// newborn planning to age 100 is the outer edge of anything sane; /// larger values are clamped (with a warning) to keep the search /// bounded and comfortably inside the Shiller data span. Mirrors /// `promotion_age_cap`'s "nobody is still accumulating past 100" /// reasoning. pub const max_configurable_accumulation_years: u16 = 100; /// Earliest-retirement search: given a target annual spending /// level, find the smallest `accumulation_years` N in [0, `max_years`] /// such that the success /// rate over the distribution phase ≥ `confidence`. /// /// Returns the matching `EarliestRetirement`, with portfolio /// statistics taken from the cycles that survived. If no N up to /// `max_years` succeeds, `accumulation_years == null` and the /// portfolio statistics are zero. pub fn findEarliestRetirement( allocator: std.mem.Allocator, initial_value: f64, stock_pct: f64, annual_contribution: f64, contribution_inflation_adjusted: bool, target_spending: f64, target_spending_inflation_adjusted: bool, distribution_years: u16, confidence: f64, events: []const ResolvedEvent, max_years: u16, expense_ratio: f64, spending_real_change: f64, ) !EarliestRetirement { const data = shiller.annual_returns; var n: u16 = 0; while (n <= max_years) : (n += 1) { const params: SimParams = .{ .initial_value = initial_value, .stock_pct = stock_pct, .annual_spending = target_spending, .spending_inflation_adjusted = target_spending_inflation_adjusted, .spending_real_change = spending_real_change, .distribution_years = distribution_years, .accumulation_years = n, .annual_contribution = annual_contribution, .contribution_inflation_adjusted = contribution_inflation_adjusted, .expense_ratio = expense_ratio, .events = events, }; const rate = successRateParams(data, params); if (rate < confidence) continue; // Found the earliest N. Run the full path simulation once to // extract the portfolio statistics at year N (the retirement // boundary). const stats = try retirementBoundaryStats(allocator, data, params, n); return .{ .horizon = distribution_years, .confidence = confidence, .accumulation_years = n, .median_at_retirement = stats.median, .p10_at_retirement = stats.p10, .p90_at_retirement = stats.p90, }; } return .{ .horizon = distribution_years, .confidence = confidence, .accumulation_years = null, .median_at_retirement = 0, .p10_at_retirement = 0, .p90_at_retirement = 0, }; } /// Age-of-death-anchored earliest-retirement search. Unlike /// `findEarliestRetirement` (fixed distribution length), the /// distribution horizon here is *derived* from the retirement date: /// the money must last until the last surviving member dies /// (`total_span` years from now, the youngest reaching the /// age-of-death). Retiring later (larger N) therefore shortens the /// distribution, so the total simulated span is constant at /// `total_span` and success is monotonic in N - the first feasible N /// is the earliest retirement. /// /// Mortality is threaded through `SimParams`: `survivor_factor` scales /// base spending once the first death occurs at `first_death_year`, /// and `events` are expected to be already capped at each holder's /// death (so a deceased spouse's Social Security stops). The returned /// cell's `horizon` is the distribution length at the chosen N, and /// `death_age` is set so the column renders "to age N". /// /// `max_years` is clamped internally to `total_span - 1` (you cannot /// retire at or after the last death - there must be at least one /// distribution year). Returns `accumulation_years == null` only when /// no N within the cap sustains the spending, or when `total_span == /// 0` (the last survivor is already past the age-of-death). pub fn findEarliestRetirementToAge( allocator: std.mem.Allocator, initial_value: f64, stock_pct: f64, annual_contribution: f64, contribution_inflation_adjusted: bool, target_spending: f64, target_spending_inflation_adjusted: bool, total_span: u16, first_death_year: ?u16, survivor_factor: f64, death_age: u16, confidence: f64, events: []const ResolvedEvent, max_years: u16, expense_ratio: f64, spending_real_change: f64, ) !EarliestRetirement { const infeasible: EarliestRetirement = .{ .horizon = 0, .confidence = confidence, .accumulation_years = null, .median_at_retirement = 0, .p10_at_retirement = 0, .p90_at_retirement = 0, .death_age = death_age, }; // The last survivor is already at/past the age-of-death: there is // no retirement horizon to fund. if (total_span == 0) return infeasible; const data = shiller.annual_returns; // Cannot retire at or after the last death; need >= 1 distribution // year. Respect the configured accumulation ceiling too. const n_cap = @min(max_years, total_span - 1); var n: u16 = 0; while (n <= n_cap) : (n += 1) { const distribution_years = total_span - n; // >= 1 by n_cap const params: SimParams = .{ .initial_value = initial_value, .stock_pct = stock_pct, .annual_spending = target_spending, .spending_inflation_adjusted = target_spending_inflation_adjusted, .spending_real_change = spending_real_change, .distribution_years = distribution_years, .accumulation_years = n, .annual_contribution = annual_contribution, .contribution_inflation_adjusted = contribution_inflation_adjusted, .expense_ratio = expense_ratio, .events = events, .survivor_factor = survivor_factor, .first_death_year = first_death_year, }; const rate = successRateParams(data, params); if (rate < confidence) continue; const stats = try retirementBoundaryStats(allocator, data, params, n); return .{ .horizon = distribution_years, .confidence = confidence, .accumulation_years = n, .median_at_retirement = stats.median, .p10_at_retirement = stats.p10, .p90_at_retirement = stats.p90, .death_age = death_age, }; } return infeasible; } // ── Earliest-retirement promotion (the "headline" cell) ──────── /// Selected (horizon, confidence) pair for the promoted retirement /// line. The selection is independent of feasibility - the caller /// indexes the earliest-retirement grid with this pair and renders /// "not feasible" if the cell's `accumulation_years` is null. pub const PromotedCell = struct { horizon_index: usize, confidence_index: usize, /// True when the user explicitly tagged a horizon with a /// `retirement_target` annotation. Diagnostic only - display /// behavior is identical either way. explicit: bool, }; /// Maximum age the "longest-horizon-that-makes-sense" rule allows /// the oldest configured person to reach by the end of the promoted /// distribution. A 100-year-old shouldn't still be drawing down /// their working-age portfolio; if all horizons push past this, we /// fall through to the shortest configured horizon anyway ("fuck it" /// branch). pub const promotion_age_cap: u16 = 100; /// Pick the (horizon, confidence) cell to promote into the /// retirement line and accumulation block when the user configured /// `target_spending` without an explicit retirement date. /// /// Algorithm: /// 1. If exactly one horizon is annotated with `retirement_target`, /// honor that annotation regardless of length or feasibility. /// 2. Else, if any age-of-death-anchored column exists, promote the /// one with the latest death (largest age-of-death) - it's the /// "plan to the last survivor" answer this input is about. /// 3. Else, walk numeric horizons longest -> shortest. Pick the /// longest whose end year keeps the oldest configured person /// under `promotion_age_cap`. /// 4. If even the shortest horizon overshoots, use it anyway. /// 5. Default confidence is 99% (most conservative). /// /// `confidence_levels` must match the order used by the earliest /// grid - typically {.90, .95, .99} with index 2 being 99%. /// /// `as_of` is the reference date used to compute the oldest /// person's current age. The function works correctly for any /// reference date - pass today for the live mode or a historical /// snapshot date for back-dated runs. /// /// Returns null only if no horizons are configured at all (caller /// should treat this as "no promotion possible"). pub fn pickPromotedCell( config: *const UserConfig, as_of: Date, confidence_levels: []const f64, ) ?PromotedCell { if (config.horizon_count == 0 or confidence_levels.len == 0) return null; // Step 1: explicit override wins. var i: usize = 0; while (i < config.horizon_count) : (i += 1) { const tag = config.horizon_targets[i]; if (tag != 0) { const ci = confidenceIndex(confidence_levels, tag); return .{ .horizon_index = i, .confidence_index = ci, .explicit = true }; } } // Default confidence: highest configured (most conservative). // Convention: arrays sorted ascending, so the last entry is the // highest. Find the index whose value is closest to 0.99. const default_ci = confidenceIndex(confidence_levels, 99); // Step 2: prefer an age-of-death-anchored column - the meaningful // "plan to the last survivor's death" answer. Among age columns, // pick the latest death (largest age-of-death = longest, most // conservative horizon). { var age_idx: ?usize = null; var age_best: u16 = 0; var k: usize = 0; while (k < config.horizon_count) : (k += 1) { const a = config.horizon_death_age[k]; if (a != 0 and a > age_best) { age_best = a; age_idx = k; } } if (age_idx) |ai| { return .{ .horizon_index = ai, .confidence_index = default_ci, .explicit = false }; } } // Step 3: longest numeric horizon where oldest person stays under // the age cap. With no birthdates, the cap doesn't apply - just // pick the longest horizon. const oldest_age_as_of = config.oldestAge(as_of); var longest_idx: usize = 0; var longest_h: u16 = config.horizons[0]; for (1..config.horizon_count) |hi| { if (config.horizons[hi] > longest_h) { longest_h = config.horizons[hi]; longest_idx = hi; } } if (config.birthdate_count == 0) { return .{ .horizon_index = longest_idx, .confidence_index = default_ci, .explicit = false }; } // Sort indices by horizon length descending. var order: [UserConfig.max_horizons]u8 = @splat(0); for (0..config.horizon_count) |hi| order[hi] = @intCast(hi); const slice = order[0..config.horizon_count]; const SortCtx = struct { horizons: []const u16, pub fn lessThan(ctx: @This(), a: u8, b: u8) bool { return ctx.horizons[a] > ctx.horizons[b]; // descending } }; std.mem.sort(u8, slice, SortCtx{ .horizons = &config.horizons }, SortCtx.lessThan); for (slice) |hi| { const end_age = oldest_age_as_of + config.horizons[hi]; if (end_age < promotion_age_cap) { return .{ .horizon_index = hi, .confidence_index = default_ci, .explicit = false }; } } // Step 3: "fuck it" - even the shortest horizon overshoots. // Pick the shortest (last in our descending sort). const shortest_idx = slice[slice.len - 1]; return .{ .horizon_index = shortest_idx, .confidence_index = default_ci, .explicit = false }; } /// Find the index in `confidence_levels` (a slice of fractions like /// 0.90/0.95/0.99) that corresponds to the percentage `pct`. Falls /// back to the closest match if no exact one exists. Used to /// translate a `retirement_target` annotation (90/95/99) or the /// default 99 into an index into the earliest-retirement grid. fn confidenceIndex(confidence_levels: []const f64, pct: u8) usize { const target: f64 = @as(f64, @floatFromInt(pct)) / 100.0; var best_idx: usize = 0; var best_diff: f64 = std.math.inf(f64); for (confidence_levels, 0..) |c, idx| { const diff = @abs(c - target); if (diff < best_diff) { best_diff = diff; best_idx = idx; } } return best_idx; } // ── Percentile bands ─────────────────────────────────────────── /// Two-phase variant of `computePercentileBands`. Returns bands of /// length `params.totalYears() + 1`, where index 0 is the starting /// portfolio and index `accumulation_years` is the post-accumulation /// (retirement) portfolio. pub fn computePercentileBandsParams( allocator: std.mem.Allocator, params: SimParams, ) ![]YearPercentiles { const data = shiller.annual_returns; const total = params.totalYears(); const num_cycles = maxCyclesFor(data, total); if (num_cycles == 0) return &.{}; const years: usize = @as(usize, total) + 1; const path_data = try allocator.alloc(f64, num_cycles * years); defer allocator.free(path_data); const paths = try allocator.alloc([]f64, num_cycles); defer allocator.free(paths); for (0..num_cycles) |i| { paths[i] = path_data[i * years .. (i + 1) * years]; } _ = runAllCyclesParams(paths, data, params); // For each year, sort the values across all cycles and extract percentiles const bands = try allocator.alloc(YearPercentiles, years); // Temporary buffer for sorting one year's values const sort_buf = try allocator.alloc(f64, num_cycles); defer allocator.free(sort_buf); for (0..years) |y| { // Collect values for year y across all cycles for (0..num_cycles) |c| { sort_buf[c] = paths[c][y]; } std.mem.sort(f64, sort_buf, {}, std.sort.asc(f64)); bands[y] = .{ .year = @intCast(y), .p10 = percentile(sort_buf, 0.10), .p25 = percentile(sort_buf, 0.25), .p50 = percentile(sort_buf, 0.50), .p75 = percentile(sort_buf, 0.75), .p90 = percentile(sort_buf, 0.90), }; } return bands; } /// Linear interpolation percentile on a sorted slice. fn percentile(sorted: []const f64, p: f64) f64 { if (sorted.len == 0) return 0; if (sorted.len == 1) return sorted[0]; const n: f64 = @floatFromInt(sorted.len - 1); const idx = p * n; const lo_idx: usize = @intFromFloat(@floor(idx)); const hi_idx: usize = @min(lo_idx + 1, sorted.len - 1); const frac = idx - @floor(idx); return sorted[lo_idx] * (1.0 - frac) + sorted[hi_idx] * frac; } // ── High-level API ───────────────────────────────────────────── /// Pre-computed grid of safe-withdrawal results and percentile /// bands across a set of horizons × confidence levels. Produced by /// `runProjectionGrid` and consumed by both the CLI and TUI /// projections renderers. pub const ProjectionData = struct { /// Safe withdrawal results, indexed `[ci * horizons.len + hi]`. /// Owned by the caller - free with the same allocator. withdrawals: []WithdrawalResult, /// Per-horizon percentile bands. `null` entries indicate the /// band computation failed for that horizon (allocator failure, /// out-of-data, etc.). Each non-null slice is owned by the /// caller. bands: []?[]YearPercentiles, /// Index into `confidence_levels` corresponding to the 99% /// (highest configured) level. The chart and percentile-band /// blocks anchor on this confidence. ci_99: usize, }; /// One column of the projection grid. A plain numeric horizon is /// `{ .distribution_years = N }` (the defaults give today's behavior: /// shared events, no survivor step). An age-anchored column also /// carries per-column mortality: events already capped at each /// holder's death, the survivor spending multiplier, and the /// first-death simulation year where the step-down begins. pub const GridColumn = struct { distribution_years: u16, events: []const ResolvedEvent = &.{}, survivor_factor: f64 = 1.0, first_death_year: ?u16 = null, }; /// Per-column mortality, derived from an age-of-death anchor and the /// configured birthdates. Drives both the SWR/bands grid (with a fixed /// retirement boundary) and the earliest-retirement search (which /// re-derives the distribution per candidate N from `total_span`). pub const ColumnMortality = struct { /// Years from `as_of` until the last survivor (youngest person) /// reaches the age-of-death - the horizon end. total_span: u16, /// Distribution length for a fixed `accumulation_years` retirement /// boundary: `total_span - accumulation_years`, clamped to >= 1. /// (The earliest-retirement search ignores this and derives its /// own per-N distribution from `total_span`.) distribution_years: u16, /// Simulation year of the first household death (oldest person /// reaching the age-of-death), where the survivor step-down /// begins. `null` when there's no gap (single person or same-age /// couple), so the step is inert. first_death_year: ?u16, /// Survivor spending multiplier (`survivor_spending_pct / 100`) /// when a gap exists; `1.0` otherwise. survivor_factor: f64, }; /// Compute the mortality parameters for an age-anchored column. /// `death_age` is the configured age-of-death; `accumulation_years` /// is the retirement boundary for the SWR/bands path (pass 0 for the /// earliest-retirement search, which derives the distribution itself). /// /// The horizon ends when the *youngest* person (last survivor) /// reaches `death_age`; the survivor step-down begins when the /// *oldest* (first death) does. A single person or a same-age couple /// has no gap, so no step is applied. pub fn columnMortality( config: *const UserConfig, as_of: Date, death_age: u16, accumulation_years: u16, ) ColumnMortality { const youngest = config.youngestAge(as_of); const oldest = config.oldestAge(as_of); const total_span: u16 = if (death_age > youngest) death_age - youngest else 0; const first_from_now: u16 = if (death_age > oldest) death_age - oldest else 0; // A real survivor phase exists only when the oldest dies strictly // before the youngest (an age gap in a multi-person household). const has_gap = first_from_now < total_span; const dist: u16 = if (total_span > accumulation_years) total_span - accumulation_years else 1; // degenerate: retirement at/after the last death; clamp to 1 return .{ .total_span = total_span, .distribution_years = dist, .first_death_year = if (has_gap) first_from_now else null, .survivor_factor = if (has_gap) config.survivor_spending_pct / 100.0 else 1.0, }; } /// Columns-aware variant of `runProjectionGrid`. Each column carries /// its own distribution length, resolved events, and mortality /// (survivor step-down + first-death year), so a single grid can mix /// plain numeric horizons with age-of-death-anchored ones. Indexing /// matches `runProjectionGrid`: `withdrawals[ci * columns.len + hi]`, /// `bands[hi]`. Caller owns `withdrawals`, `bands`, and every non-null /// `bands` entry. pub fn runProjectionGridColumns( alloc: std.mem.Allocator, columns: []const GridColumn, confidence_levels: []const f64, total_value: f64, stock_pct: f64, accumulation_years: u16, annual_contribution: f64, contribution_inflation_adjusted: bool, expense_ratio: f64, spending_real_change: f64, ) !ProjectionData { const num_results = columns.len * confidence_levels.len; const withdrawals = try alloc.alloc(WithdrawalResult, num_results); errdefer alloc.free(withdrawals); for (confidence_levels, 0..) |conf, ci| { for (columns, 0..) |col, hi| { withdrawals[ci * columns.len + hi] = searchSafeWithdrawal(.{ .initial_value = total_value, .stock_pct = stock_pct, .annual_spending = 0, // overwritten by the search loop .spending_real_change = spending_real_change, .distribution_years = col.distribution_years, .accumulation_years = accumulation_years, .annual_contribution = annual_contribution, .contribution_inflation_adjusted = contribution_inflation_adjusted, .expense_ratio = expense_ratio, .events = col.events, .survivor_factor = col.survivor_factor, .first_death_year = col.first_death_year, }, conf); } } const ci_99 = confidence_levels.len - 1; const bands = try alloc.alloc(?[]YearPercentiles, columns.len); for (columns, 0..) |col, hi| { const wr = withdrawals[ci_99 * columns.len + hi]; bands[hi] = computePercentileBandsParams(alloc, .{ .initial_value = total_value, .stock_pct = stock_pct, .annual_spending = wr.annual_amount, .spending_real_change = spending_real_change, .distribution_years = col.distribution_years, .accumulation_years = accumulation_years, .annual_contribution = annual_contribution, .contribution_inflation_adjusted = contribution_inflation_adjusted, .expense_ratio = expense_ratio, .events = col.events, .survivor_factor = col.survivor_factor, .first_death_year = col.first_death_year, }) catch null; } return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 }; } /// Test-only convenience wrapper over `runProjectionGridColumns`: /// builds plain numeric (no-mortality) columns from a bare horizon /// list, sharing one `events` set, so the grid tests read cleanly. /// Byte-identical to passing those columns directly, since the /// `survivor_factor`/`first_death_year` defaults (1.0 / null) leave /// the simulation untouched. Production builds columns with /// per-column mortality and calls `runProjectionGridColumns` directly. /// /// Caller owns `withdrawals`, `bands`, and every non-null entry /// inside `bands`. Free with the same allocator. fn runProjectionGrid( alloc: std.mem.Allocator, horizons: []const u16, confidence_levels: []const f64, total_value: f64, stock_pct: f64, events: []const ResolvedEvent, accumulation_years: u16, annual_contribution: f64, contribution_inflation_adjusted: bool, expense_ratio: f64, spending_real_change: f64, ) !ProjectionData { const columns = try alloc.alloc(GridColumn, horizons.len); defer alloc.free(columns); for (horizons, 0..) |h, i| columns[i] = .{ .distribution_years = h, .events = events }; return runProjectionGridColumns( alloc, columns, confidence_levels, total_value, stock_pct, accumulation_years, annual_contribution, contribution_inflation_adjusted, expense_ratio, spending_real_change, ); } // ── Spending trough (the "how low does it get" callout) ──────── /// The lowest-spending year of a projection's distribution phase, /// in today's dollars. Surfaced next to the first-year safe /// withdrawal so a user running a declining ("slow-go") spending /// model can see how little they spend at the bottom. pub const SpendingTrough = struct { /// Minimum total real spending (today's dollars) reached. amount: f64, /// Distribution-year offset (0-based) where the minimum occurs. year_offset: u16, /// Years from `as_of` to that year, 1-based (1 = first /// retirement year). Equals `accumulation_years + year_offset + 1`. years_from_now: u16, /// Calendar date of the trough year (`as_of` advanced by /// `accumulation_years + year_offset`). date: Date, }; /// Find the lowest-spending distribution year, in today's dollars. /// /// Base spending follows the real-change drift: distribution year /// `d` spends `first_year_spend * (1 + spending_real_change)^d` in /// real terms. EXPENSE life events (negative `annual_amount`, e.g. /// late-life healthcare) add to spending - that is what produces a /// mid-retirement trough rather than a monotonic slide to the final /// year. Income events (Social Security, positive amounts) are /// funding rather than spending and are excluded. /// /// Today's-dollar (real) terms throughout: each active expense event /// contributes its configured magnitude. This is a deterministic /// display approximation - it does not erode non-inflation-adjusted /// events across time the way the per-cycle simulation does - but it /// gives a single, stable number for the callout. `as_of` anchors /// the calendar year. /// /// Returns `null` only for a zero-length distribution phase. pub fn spendingTrough( first_year_spend: f64, spending_real_change: f64, events: []const ResolvedEvent, accumulation_years: u16, distribution_years: u16, as_of: Date, ) ?SpendingTrough { if (distribution_years == 0) return null; var min_amount: f64 = std.math.floatMax(f64); var min_d: u16 = 0; var factor: f64 = 1.0; var d: u16 = 0; while (d < distribution_years) : (d += 1) { const sim_year = accumulation_years + d; var spend = first_year_spend * factor; for (events) |*ev| { // Only expense events count as spending; income (SS etc.) // funds withdrawals but is not consumption. if (ev.annual_amount < 0 and ev.isActive(sim_year)) { spend += -ev.annual_amount; } } if (spend < min_amount) { min_amount = spend; min_d = d; } factor *= (1.0 + spending_real_change); } return .{ .amount = min_amount, .year_offset = min_d, .years_from_now = accumulation_years + min_d + 1, .date = as_of.addYears(accumulation_years + min_d), }; } // // Thin, distribution-only, zero-fee wrappers over the production // `*Params` entry points (`searchSafeWithdrawal`, `successRateParams`, // `computePercentileBandsParams`). Nothing in the CLI/TUI calls these // -- production goes through `runProjectionGrid` / // `findSafeWithdrawalWithAccumulation`. They exist only to give the // test suite (including the FIRECalc parity tests) an ergonomic // primitive, so they live next to the tests and are `fn`-private. /// Maximum annual withdrawal (today's dollars) that survives `horizon` /// years in at least `confidence` of historical cycles. Binary search /// to $1 precision via `searchSafeWithdrawal`. fn findSafeWithdrawal( horizon: u16, initial_value: f64, stock_pct: f64, confidence: f64, events: []const ResolvedEvent, ) WithdrawalResult { return searchSafeWithdrawal(.{ .initial_value = initial_value, .stock_pct = stock_pct, .annual_spending = 0, // overwritten by the search loop .distribution_years = horizon, .events = events, }, confidence); } /// Success rate (fraction of cycles that survived) for a given /// spending level. fn successRate( horizon: u16, initial_value: f64, annual_spending: f64, stock_pct: f64, events: []const ResolvedEvent, ) f64 { return successRateParams(shiller.annual_returns, .{ .initial_value = initial_value, .stock_pct = stock_pct, .annual_spending = annual_spending, .distribution_years = horizon, .events = events, }); } /// Percentile bands across all simulated paths for a horizon and /// spending level. Allocates the result. fn computePercentileBands( allocator: std.mem.Allocator, horizon: u16, initial_value: f64, annual_spending: f64, stock_pct: f64, events: []const ResolvedEvent, ) ![]YearPercentiles { return computePercentileBandsParams(allocator, .{ .initial_value = initial_value, .stock_pct = stock_pct, .annual_spending = annual_spending, .distribution_years = horizon, .events = events, }); } // ── Tests ────────────────────────────────────────────────────── test "successRate with zero spending is 100%" { const rate = successRate(30, 1_000_000, 0, 0.75, &.{}); try std.testing.expectApproxEqAbs(@as(f64, 1.0), rate, 0.001); } test "successRate with excessive spending is 0%" { // Spending the entire portfolio in year 1 should fail every cycle const rate = successRate(30, 1_000_000, 1_000_000, 0.75, &.{}); try std.testing.expectApproxEqAbs(@as(f64, 0.0), rate, 0.001); } test "successRate decreases with higher spending" { const rate_low = successRate(30, 1_000_000, 20_000, 0.75, &.{}); const rate_mid = successRate(30, 1_000_000, 40_000, 0.75, &.{}); const rate_high = successRate(30, 1_000_000, 60_000, 0.75, &.{}); try std.testing.expect(rate_low >= rate_mid); try std.testing.expect(rate_mid >= rate_high); } test "findSafeWithdrawal produces reasonable results" { const result = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); try std.testing.expect(result.annual_amount >= 10_000); try std.testing.expect(result.annual_amount <= 60_000); try std.testing.expect(result.withdrawal_rate >= 0.01); try std.testing.expect(result.withdrawal_rate <= 0.06); } test "higher confidence means lower withdrawal" { const r90 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.90, &.{}); const r95 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); const r99 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.99, &.{}); try std.testing.expect(r90.annual_amount >= r95.annual_amount); try std.testing.expect(r95.annual_amount >= r99.annual_amount); } test "longer horizon means lower withdrawal" { const r20 = findSafeWithdrawal(20, 1_000_000, 0.75, 0.95, &.{}); const r30 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); const r45 = findSafeWithdrawal(45, 1_000_000, 0.75, 0.95, &.{}); try std.testing.expect(r20.annual_amount >= r30.annual_amount); try std.testing.expect(r30.annual_amount >= r45.annual_amount); } test "computePercentileBands basic properties" { const allocator = std.testing.allocator; const bands = try computePercentileBands(allocator, 30, 1_000_000, 30_000, 0.75, &.{}); defer allocator.free(bands); // Should have horizon + 1 entries try std.testing.expectEqual(@as(usize, 31), bands.len); // Year 0 should be the starting value for all percentiles try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), bands[0].p50, 1.0); // Percentiles should be ordered at each year for (bands) |b| { try std.testing.expect(b.p10 <= b.p25); try std.testing.expect(b.p25 <= b.p50); try std.testing.expect(b.p50 <= b.p75); try std.testing.expect(b.p75 <= b.p90); } } test "percentile interpolation" { const data = [_]f64{ 10, 20, 30, 40, 50 }; try std.testing.expectApproxEqAbs(@as(f64, 10.0), percentile(&data, 0.0), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 30.0), percentile(&data, 0.5), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 50.0), percentile(&data, 1.0), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 20.0), percentile(&data, 0.25), 0.01); } test "realistic portfolio safe withdrawal" { // Approximate real portfolio: ~$8.34M, ~82.5% stocks const portfolio = 8_340_000; const stock_pct = 0.825; const r99_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.99, &.{}); const r95_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.95, &.{}); const r99_30 = findSafeWithdrawal(30, portfolio, stock_pct, 0.99, &.{}); // 95% should be higher than 99% try std.testing.expect(r95_45.annual_amount > r99_45.annual_amount); // 30yr should be higher than 45yr at same confidence try std.testing.expect(r99_30.annual_amount > r99_45.annual_amount); // FIRECalc reference: on $7.7M at 82% / 45yr / 99% (fee=0), FIRECalc // returns ~$262.8K (audit June 2026). zfin runs ~+9% optimistic (see // the FIRECalc parity suite below for the why), so on $8.34M it lands // ~$310K. Bounds bracket that with margin. try std.testing.expect(r99_45.annual_amount >= 290_000); try std.testing.expect(r99_45.annual_amount <= 350_000); try std.testing.expect(r99_45.withdrawal_rate >= 0.03); try std.testing.expect(r99_45.withdrawal_rate <= 0.05); } // ── FIRECalc.com parity suite ────────────────────────────────── // // Cross-checks zfin's engine against FIRECalc.com ("FIRECalc 3.0", // data through 1/1/2026 -- the same 1871-2025 Shiller span zfin embeds). // Reference values were captured June 2026 by driving the FIRECalc web // form directly. Full method, captured numbers, and root-cause analysis // live in docs/explanation/projections-model.md -> "Parity with FIRECalc". // // The safe-withdrawal and success-rate references below use FIRECalc // with its expense ratio set to 0% (InvExp=0) and the default "Long // Interest" (10yr-Treasury) fixed-income model. fee=0 is the // apples-to-apples comparison for the no-fee convenience wrappers // (`findSafeWithdrawal`, `successRate`). zfin CAN now model a fee // (`SimParams.expense_ratio`, configurable via projections.srf); the // separate "expense ratio matches FIRECalc's default fee" test below // pins zfin against FIRECalc's *default* 0.18%-fee runs. // // Cohort counts now match FIRECalc exactly (e.g. 126 for a 30yr // horizon over 1871-2025) after the `maxCyclesFor` off-by-one fix. // // KNOWN, ACCEPTED DIVERGENCE: zfin runs systematically *more optimistic* // than FIRECalc -- ~+6-9% on safe-withdrawal dollars and ~+2-3pp on // success rate -- because zfin reconstructs nominal equity total returns // from Shiller's monthly-reinvested Real Total Return Price × CPI, which // compounds ~0.2-0.3%/yr higher than FIRECalc's equity series. This was // isolated with a $0-spending, 100%-stock run (no withdrawal/timing/fee // effects): for the 1966 cohort, zfin's year-30 nominal balance is // $20.24M vs FIRECalc's $18.60M -- a pure return-series gap. It is a // defensible modeling choice, not a bug; the tolerances below encode the // gap so this suite is a regression guard, not an exact-match assertion. const FcSwrCase = struct { name: []const u8, horizon: u16, value: f64, stock_pct: f64, confidence: f64, /// FIRECalc max-spending dollars for this scenario (InvExp=0). fc_ref: f64, }; test "FIRECalc parity: safe-withdrawal dollars" { const cases = [_]FcSwrCase{ .{ .name = "100% 30y 95% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.95, .fc_ref = 39_697 }, .{ .name = "75/25 30y 95% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 0.75, .confidence = 0.95, .fc_ref = 41_221 }, .{ .name = "100% 45y 95% $1M", .horizon = 45, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.95, .fc_ref = 35_835 }, .{ .name = "100% 20y 95% $1M", .horizon = 20, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.95, .fc_ref = 45_879 }, .{ .name = "100% 30y 90% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.90, .fc_ref = 43_804 }, .{ .name = "100% 30y 99% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.99, .fc_ref = 35_864 }, .{ .name = "100% 45y 99% $7.7M", .horizon = 45, .value = 7_700_000, .stock_pct = 1.00, .confidence = 0.99, .fc_ref = 254_461 }, .{ .name = "82% 45y 99% $7.7M", .horizon = 45, .value = 7_700_000, .stock_pct = 0.82, .confidence = 0.99, .fc_ref = 262_770 }, }; for (cases) |c| { const r = findSafeWithdrawal(c.horizon, c.value, c.stock_pct, c.confidence, &.{}); // zfin tracks FIRECalc within roughly -3% / +15%, currently // landing ~+6-9% high (equity return-series optimism). The lower // bound catches an engine that suddenly turns conservative; the // upper bound catches runaway optimism. try std.testing.expect(r.annual_amount >= c.fc_ref * 0.97); try std.testing.expect(r.annual_amount <= c.fc_ref * 1.15); } } test "FIRECalc parity: success rate" { // $1M, $40k/yr, 30yr, InvExp=0. FIRECalc: 100% stock -> 94.4% // (7/126 failed); 75/25 -> 96.8% (4/126). zfin runs ~+2-3pp higher // (fewer failures) for the same return-series reason. const sr_100 = successRate(30, 1_000_000, 40_000, 1.00, &.{}); const sr_75 = successRate(30, 1_000_000, 40_000, 0.75, &.{}); // Within 6pp of FIRECalc, and never *below* it by more than 1pp // (zfin is the more optimistic engine -- a large undershoot would be // a regression). try std.testing.expectApproxEqAbs(@as(f64, 0.944), sr_100, 0.06); try std.testing.expectApproxEqAbs(@as(f64, 0.968), sr_75, 0.06); try std.testing.expect(sr_100 >= 0.944 - 0.01); try std.testing.expect(sr_75 >= 0.968 - 0.01); } test "FIRECalc parity: terminal-value percentiles" { // S1: $1M, $40k, 30yr, 100% stock, InvExp=0. FIRECalc terminal // values, captured from its per-cohort spreadsheet export in NOMINAL // dollars (126 cohorts): p50 ~$5.12M, p90 ~$12.76M. // // Unit caveat: FIRECalc's on-screen "ending portfolio" figures are // REAL (start-of-retirement dollars); zfin's bands are NOMINAL. The // spreadsheet export is nominal, which is the basis used here. zfin's // percentiles run higher (median ~+11%, p90 ~+16%) for the same // return-series reason; the p10 gap is larger still because small // per-year differences explode near the failure boundary, so p10 is // intentionally not asserted. const a = std.testing.allocator; const bands = try computePercentileBands(a, 30, 1_000_000, 40_000, 1.00, &.{}); defer a.free(bands); const term = bands[30]; try std.testing.expect(term.p50 >= 5_124_810 * 0.97); try std.testing.expect(term.p50 <= 5_124_810 * 1.20); try std.testing.expect(term.p90 >= 12_763_289 * 0.97); try std.testing.expect(term.p90 <= 12_763_289 * 1.25); } test "FIRECalc parity: expense ratio matches FIRECalc's default fee" { // Validates the `expense_ratio` model against FIRECalc runs with // its *default* 0.18% fee enabled (InvExp=0.18). zfin's // expense_ratio is a decimal here (0.0018 = 0.18%). // // Two things this pins: // 1. The fee has the right *direction and magnitude*: enabling // 0.18% drops zfin's SWR ~1.8%, matching FIRECalc's own // ~2.0% fee effect (W2->W3: $41,221->$40,381). // 2. With fees matched on BOTH sides, the residual gap is still // ~+7-9% - i.e. the fee is NOT the source of the divergence; // the equity return series (documented above) is. So the // same -3%/+15% tolerance band applies. const sr_100 = successRateParams(shiller.annual_returns, .{ .initial_value = 1_000_000, .stock_pct = 1.00, .annual_spending = 40_000, .distribution_years = 30, .expense_ratio = 0.0018, }); const sr_75 = successRateParams(shiller.annual_returns, .{ .initial_value = 1_000_000, .stock_pct = 0.75, .annual_spending = 40_000, .distribution_years = 30, .expense_ratio = 0.0018, }); // FIRECalc fee=0.18: 100% stock 93.7%, 75/25 95.2%. try std.testing.expectApproxEqAbs(@as(f64, 0.937), sr_100, 0.06); try std.testing.expectApproxEqAbs(@as(f64, 0.952), sr_75, 0.06); // Safe withdrawal with the fee on. FIRECalc fee=0.18 refs: // 75/25 30y 95% -> $40,381; 75/25 45y 99% $7.7M -> $258,747. const w_30 = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0.0018, 0); const w_45 = findSafeWithdrawalWithAccumulation(45, 7_700_000, 0.75, 0.99, &.{}, 0, 0, true, 0.0018, 0); try std.testing.expect(w_30.annual_amount >= 40_381 * 0.97); try std.testing.expect(w_30.annual_amount <= 40_381 * 1.15); try std.testing.expect(w_45.annual_amount >= 258_747 * 0.97); try std.testing.expect(w_45.annual_amount <= 258_747 * 1.15); // Sanity: enabling the fee strictly lowers the safe withdrawal // relative to the no-fee result (same scenario). const w_30_nofee = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); try std.testing.expect(w_30.annual_amount < w_30_nofee.annual_amount); } test "simulateCycle produces correct year-0 value" { var buf: [31]f64 = undefined; simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75, &.{}); try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), buf[0], 0.01); } test "simulateCycle with zero spending grows portfolio" { var buf: [31]f64 = undefined; simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75, &.{}); // Over any 30-year period in history, zero spending should grow the portfolio try std.testing.expect(buf[30] > 1_000_000); } test "parseProjectionsConfig defaults" { const config = parseProjectionsConfig(null); try std.testing.expect(config.target_stock_pct == null); try std.testing.expectEqual(@as(u8, 3), config.horizon_count); try std.testing.expectEqual(@as(u16, 20), config.getHorizons()[0]); try std.testing.expectEqual(@as(u16, 30), config.getHorizons()[1]); try std.testing.expectEqual(@as(u16, 45), config.getHorizons()[2]); } test "parseProjectionsConfig from SRF" { const data = \\#!srfv1 \\type::config,target_stock_pct:num:77 \\type::config,horizon:num:25 \\type::config,horizon:num:35 \\type::config,horizon:num:50 ; const config = parseProjectionsConfig(data); try std.testing.expectApproxEqAbs(@as(f64, 77.0), config.target_stock_pct.?, 0.01); try std.testing.expectEqual(@as(u8, 3), config.horizon_count); try std.testing.expectEqual(@as(u16, 25), config.getHorizons()[0]); try std.testing.expectEqual(@as(u16, 35), config.getHorizons()[1]); try std.testing.expectEqual(@as(u16, 50), config.getHorizons()[2]); } test "parseProjectionsConfig partial" { const data = "#!srfv1\ntype::config,target_stock_pct:num:82.5\n"; const config = parseProjectionsConfig(data); try std.testing.expectApproxEqAbs(@as(f64, 82.5), config.target_stock_pct.?, 0.01); // Horizons should remain default try std.testing.expectEqual(@as(u8, 3), config.horizon_count); try std.testing.expectEqual(@as(u16, 20), config.getHorizons()[0]); } test "parseProjectionsConfig empty string" { const config = parseProjectionsConfig(""); try std.testing.expect(config.target_stock_pct == null); try std.testing.expectEqual(@as(u8, 3), config.horizon_count); } test "parseProjectionsConfig expense_ratio defaults to 0.18 and parses overrides" { const default_config = parseProjectionsConfig("#!srfv1\n"); // Default is FIRECalc's 0.18% (a realistic fund-fee assumption), // not 0 -- modeling no fee is less accurate and over-optimistic. try std.testing.expectApproxEqAbs(@as(f64, 0.18), default_config.expense_ratio, 0.0001); // A low-cost index investor overrides downward; verify parsing. const data = "#!srfv1\ntype::config,expense_ratio:num:0.04\n"; const config = parseProjectionsConfig(data); // Stored as a percentage (like target_stock_pct); the view layer // divides by 100 before handing it to the simulation. try std.testing.expectApproxEqAbs(@as(f64, 0.04), config.expense_ratio, 0.0001); // Explicit 0 is honored (all-individual-stock portfolio). const zeroed = parseProjectionsConfig("#!srfv1\ntype::config,expense_ratio:num:0\n"); try std.testing.expectEqual(@as(f64, 0), zeroed.expense_ratio); } test "parseProjectionsConfig invalid data" { const config = parseProjectionsConfig("not valid srf"); try std.testing.expect(config.target_stock_pct == null); } test "parseProjectionsConfig horizon_age parsed raw" { const data = \\#!srfv1 \\type::config,horizon_age:num:90 \\type::config,horizon_age:num:95 \\type::birthdate,date::1975-03-15 ; const config = parseProjectionsConfig(data); // horizon_ages are stored raw; not yet resolved into horizons. try std.testing.expectEqual(@as(u8, 2), config.horizon_age_count); try std.testing.expectEqual(@as(u16, 90), config.horizon_ages[0]); try std.testing.expectEqual(@as(u16, 95), config.horizon_ages[1]); // A horizon_age record counts as "saw_horizon", so the default // {20,30,45} is cleared. horizon_count is 0 until resolution. try std.testing.expectEqual(@as(u8, 0), config.horizon_count); } test "resolveHorizonAges uses youngest birthdate (last-survivor semantics)" { // Person 1: born 1975, ~50 as of mid-2025. Person 2: born 1980, ~45. // Target age 90 -> anchored on the YOUNGEST (last survivor): the money // must last until person 2 reaches 90, i.e. 90 - 45 = 45 years. The // oldest-anchored answer would have been 90 - 50 = 40 - this asserts we // switched to the youngest. `as_of` is a couple weeks past the June // birthdays so both ages are unambiguous (clear of the 365.25-day // exact-anniversary floor). var config = parseProjectionsConfig( \\#!srfv1 \\type::config,horizon_age:num:90 \\type::birthdate,date::1975-06-15 \\type::birthdate,date::1980-06-15,person:num:2 ); const as_of = Date.fromYmd(2025, 7, 1); try config.resolveHorizonAges(as_of); try std.testing.expectEqual(@as(u8, 1), config.horizon_count); try std.testing.expectEqual(@as(u16, 45), config.horizons[0]); // Column flagged age-anchored at the death age. try std.testing.expectEqual(@as(u16, 90), config.horizon_death_age[0]); // Resolved; horizon_age_count cleared to make resolve idempotent. try std.testing.expectEqual(@as(u8, 0), config.horizon_age_count); } test "resolveHorizonAges errors without a birthdate" { var config = parseProjectionsConfig( \\#!srfv1 \\type::config,horizon_age:num:90 ); const as_of = Date.fromYmd(2025, 1, 1); try std.testing.expectError(error.HorizonAgeWithoutBirthdate, config.resolveHorizonAges(as_of)); } test "resolveHorizonAges skips targets already in the past" { // Single person, age 60 as of 2025 (youngest == oldest); target 40 is // already past - skipped. Age 90 resolves (90 - 60 = 30). var config = parseProjectionsConfig( \\#!srfv1 \\type::config,horizon_age:num:40 \\type::config,horizon_age:num:90 \\type::birthdate,date::1965-01-01 ); const as_of = Date.fromYmd(2025, 6, 15); try config.resolveHorizonAges(as_of); // Only age 90 resolves (90 - 60 = 30). try std.testing.expectEqual(@as(u8, 1), config.horizon_count); try std.testing.expectEqual(@as(u16, 30), config.horizons[0]); try std.testing.expectEqual(@as(u16, 90), config.horizon_death_age[0]); } test "resolveHorizonAges mixes with explicit horizon records" { var config = parseProjectionsConfig( \\#!srfv1 \\type::config,horizon:num:30 \\type::config,horizon_age:num:95 \\type::birthdate,date::1975-06-15 ); const as_of = Date.fromYmd(2025, 6, 15); try config.resolveHorizonAges(as_of); // Explicit 30 from `horizon`, then appended 95 - 50 = 45 from `horizon_age`. try std.testing.expectEqual(@as(u8, 2), config.horizon_count); try std.testing.expectEqual(@as(u16, 30), config.horizons[0]); try std.testing.expectEqual(@as(u16, 45), config.horizons[1]); // Numeric column carries no death age; age column carries 95. try std.testing.expectEqual(@as(u16, 0), config.horizon_death_age[0]); try std.testing.expectEqual(@as(u16, 95), config.horizon_death_age[1]); } test "resolveHorizonAges is a no-op when nothing to resolve" { var config = parseProjectionsConfig( \\#!srfv1 \\type::config,horizon:num:30 ); // No birthdate, no horizon_age -> should succeed, not error. const as_of = Date.fromYmd(2025, 1, 1); try config.resolveHorizonAges(as_of); try std.testing.expectEqual(@as(u8, 1), config.horizon_count); try std.testing.expectEqual(@as(u16, 30), config.horizons[0]); } test "UserConfig getHorizons default" { const config = UserConfig{}; const horizons = config.getHorizons(); try std.testing.expectEqual(@as(usize, 3), horizons.len); try std.testing.expectEqual(@as(u16, 20), horizons[0]); try std.testing.expectEqual(@as(u16, 30), horizons[1]); try std.testing.expectEqual(@as(u16, 45), horizons[2]); } test "UserConfig getConfidenceLevels" { const config = UserConfig{}; const levels = config.getConfidenceLevels(); try std.testing.expectEqual(@as(usize, 3), levels.len); try std.testing.expectApproxEqAbs(@as(f64, 0.90), levels[0], 0.001); try std.testing.expectApproxEqAbs(@as(f64, 0.95), levels[1], 0.001); try std.testing.expectApproxEqAbs(@as(f64, 0.99), levels[2], 0.001); } test "LifeEvent.startYear basic" { const ev = LifeEvent{ .start_age = 67, .person = 0, .annual_amount = 38400 }; const ages = [_]u16{50}; try std.testing.expectEqual(@as(?u16, 17), ev.startYear(&ages)); } test "LifeEvent.startYear already active" { const ev = LifeEvent{ .start_age = 40, .person = 0, .annual_amount = 38400 }; const ages = [_]u16{50}; try std.testing.expectEqual(@as(?u16, 0), ev.startYear(&ages)); } test "LifeEvent.startYear person out of range" { const ev = LifeEvent{ .start_age = 67, .person = 5, .annual_amount = 38400 }; const ages = [_]u16{50}; try std.testing.expectEqual(@as(?u16, null), ev.startYear(&ages)); } test "LifeEvent.isActive permanent" { const ev = LifeEvent{ .start_age = 60, .person = 0, .duration = 0, .annual_amount = 38400 }; const ages = [_]u16{50}; try std.testing.expect(!ev.isActive(9, &ages)); // before start (year 10) try std.testing.expect(ev.isActive(10, &ages)); // start year try std.testing.expect(ev.isActive(30, &ages)); // well after } test "LifeEvent.isActive with duration" { const ev = LifeEvent{ .start_age = 53, .person = 0, .duration = 4, .annual_amount = -60000 }; const ages = [_]u16{50}; try std.testing.expect(!ev.isActive(2, &ages)); // before start try std.testing.expect(ev.isActive(3, &ages)); // year 3 (age 53) try std.testing.expect(ev.isActive(6, &ages)); // year 6 (age 56, last year) try std.testing.expect(!ev.isActive(7, &ages)); // year 7 (age 57, past duration) } test "LifeEvent.cashFlow inflation adjusted" { const ev = LifeEvent{ .start_age = 50, .person = 0, .annual_amount = 10000, .inflation_adjusted = true }; const ages = [_]u16{50}; try std.testing.expectApproxEqAbs(@as(f64, 12000), ev.cashFlow(0, 1.2, &ages), 0.01); } test "LifeEvent.cashFlow nominal" { const ev = LifeEvent{ .start_age = 50, .person = 0, .annual_amount = 10000, .inflation_adjusted = false }; const ages = [_]u16{50}; try std.testing.expectApproxEqAbs(@as(f64, 10000), ev.cashFlow(0, 1.2, &ages), 0.01); } test "LifeEvent.cashFlow inactive returns zero" { const ev = LifeEvent{ .start_age = 67, .person = 0, .annual_amount = 38400 }; const ages = [_]u16{50}; try std.testing.expectApproxEqAbs(@as(f64, 0), ev.cashFlow(5, 1.0, &ages), 0.01); } test "parseProjectionsConfig birthdates and events" { const data = \\#!srfv1 \\type::config,target_stock_pct:num:80 \\type::config,horizon:num:30 \\type::birthdate,date::1975-03-15 \\type::birthdate,date::1978-06-22,person:num:2 \\type::event,name::Social Security,start_age:num:67,person:num:1,amount:num:38400 \\type::event,name::College,start_age:num:53,duration:num:4,amount:num:-60000,inflation_adjusted:bool:false ; const config = parseProjectionsConfig(data); try std.testing.expectApproxEqAbs(@as(f64, 80.0), config.target_stock_pct.?, 0.01); try std.testing.expectEqual(@as(u8, 1), config.horizon_count); try std.testing.expectEqual(@as(u8, 2), config.birthdate_count); try std.testing.expectEqual(@as(i16, 1975), config.birthdates[0].year()); try std.testing.expectEqual(@as(i16, 1978), config.birthdates[1].year()); try std.testing.expectEqual(@as(u8, 2), config.event_count); // First event: Social Security const ev0 = config.events[0]; try std.testing.expectEqualStrings("Social Security", ev0.getName()); try std.testing.expectEqual(@as(u16, 67), ev0.start_age); try std.testing.expectEqual(@as(u8, 0), ev0.person); try std.testing.expectEqual(@as(u16, 0), ev0.duration); try std.testing.expectApproxEqAbs(@as(f64, 38400), ev0.annual_amount, 0.01); try std.testing.expect(ev0.inflation_adjusted); // Second event: College const ev1 = config.events[1]; try std.testing.expectEqualStrings("College", ev1.getName()); try std.testing.expectEqual(@as(u16, 53), ev1.start_age); try std.testing.expectEqual(@as(u16, 4), ev1.duration); try std.testing.expectApproxEqAbs(@as(f64, -60000), ev1.annual_amount, 0.01); try std.testing.expect(!ev1.inflation_adjusted); } test "income event increases safe withdrawal" { // With a permanent $20K/yr income event starting immediately, // the safe withdrawal should be higher than without. const no_events = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); const income_event = [_]ResolvedEvent{.{ .start_year = 0, .duration = 0, .annual_amount = 20_000, .inflation_adjusted = true, }}; const with_income = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &income_event); try std.testing.expect(with_income.annual_amount > no_events.annual_amount); // The increase should be roughly $20K (the income offsets withdrawal) const diff = with_income.annual_amount - no_events.annual_amount; try std.testing.expect(diff >= 15_000 and diff <= 25_000); } test "expense event decreases safe withdrawal" { const no_events = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); const expense_event = [_]ResolvedEvent{.{ .start_year = 0, .duration = 5, .annual_amount = -20_000, .inflation_adjusted = true, }}; const with_expense = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &expense_event); try std.testing.expect(with_expense.annual_amount < no_events.annual_amount); } test "UserConfig.eventNetCashFlow sums active events" { var config = UserConfig{}; config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1975, 1, 1); config.events[0] = .{ .start_age = 50, .person = 0, .annual_amount = 30000 }; config.events[1] = .{ .start_age = 55, .person = 0, .annual_amount = 10000 }; config.event_count = 2; const ages = [_]u16{50}; // At year 0: only first event active (age 50) try std.testing.expectApproxEqAbs(@as(f64, 30000), config.eventNetCashFlow(0, 1.0, &ages), 0.01); // At year 5: both active (ages 55, 55) try std.testing.expectApproxEqAbs(@as(f64, 40000), config.eventNetCashFlow(5, 1.0, &ages), 0.01); } // ── Accumulation phase tests ─────────────────────────────────── test "parseProjectionsConfig parses retirement_age" { const data = \\#!srfv1 \\type::config,retirement_age:num:65 ; const config = parseProjectionsConfig(data); try std.testing.expectEqual(@as(?u16, 65), config.retirement_age); try std.testing.expectEqual(@as(?Date, null), config.retirement_at); } test "parseProjectionsConfig parses retirement_at" { const data = \\#!srfv1 \\type::config,retirement_at::2036-07-01 ; const config = parseProjectionsConfig(data); try std.testing.expect(config.retirement_at != null); try std.testing.expectEqual(@as(i16, 2036), config.retirement_at.?.year()); try std.testing.expectEqual(@as(u8, 7), config.retirement_at.?.month()); try std.testing.expectEqual(@as(u8, 1), config.retirement_at.?.day()); } test "parseProjectionsConfig parses annual_contribution" { const data = \\#!srfv1 \\type::config,annual_contribution:num:100000 \\type::config,contribution_inflation_adjusted:bool:false ; const config = parseProjectionsConfig(data); try std.testing.expectApproxEqAbs(@as(f64, 100_000), config.annual_contribution, 0.01); try std.testing.expect(!config.contribution_inflation_adjusted); } test "parseProjectionsConfig rejects negative annual_contribution" { const data = \\#!srfv1 \\type::config,annual_contribution:num:-50000 ; const config = parseProjectionsConfig(data); // Negative dropped; default zero retained. try std.testing.expectApproxEqAbs(@as(f64, 0), config.annual_contribution, 0.01); } test "parseProjectionsConfig parses target_spending" { const data = \\#!srfv1 \\type::config,target_spending:num:80000 \\type::config,target_spending_inflation_adjusted:bool:false ; const config = parseProjectionsConfig(data); try std.testing.expectApproxEqAbs(@as(f64, 80_000), config.target_spending.?, 0.01); try std.testing.expect(!config.target_spending_inflation_adjusted); } test "parseProjectionsConfig rejects negative target_spending" { const data = \\#!srfv1 \\type::config,target_spending:num:-1000 ; const config = parseProjectionsConfig(data); try std.testing.expectEqual(@as(?f64, null), config.target_spending); } test "parseProjectionsConfig max_accumulation_years defaults to 50" { const config = parseProjectionsConfig("#!srfv1\n"); try std.testing.expectEqual(default_max_accumulation_years, config.max_accumulation_years); } test "parseProjectionsConfig parses max_accumulation_years override" { const data = \\#!srfv1 \\type::config,max_accumulation_years:num:65 ; const config = parseProjectionsConfig(data); try std.testing.expectEqual(@as(u16, 65), config.max_accumulation_years); } test "parseProjectionsConfig rejects zero max_accumulation_years" { const data = \\#!srfv1 \\type::config,max_accumulation_years:num:0 ; const config = parseProjectionsConfig(data); // Zero is degenerate; dropped, default retained. try std.testing.expectEqual(default_max_accumulation_years, config.max_accumulation_years); } test "parseProjectionsConfig clamps oversized max_accumulation_years to ceiling" { const data = \\#!srfv1 \\type::config,max_accumulation_years:num:500 ; const config = parseProjectionsConfig(data); try std.testing.expectEqual(max_configurable_accumulation_years, config.max_accumulation_years); } test "parseProjectionsConfig return_cap defaults to null" { const config = parseProjectionsConfig("#!srfv1\n"); try std.testing.expectEqual(@as(?f64, null), config.return_cap); } test "parseProjectionsConfig parses return_cap as a percent" { const data = \\#!srfv1 \\type::config,return_cap:num:30 ; const config = parseProjectionsConfig(data); // Stored as a percentage (like target_stock_pct / expense_ratio); // the view layer divides by 100 before handing it to the analytics. try std.testing.expectApproxEqAbs(@as(f64, 30), config.return_cap.?, 0.0001); } test "parseProjectionsConfig rejects negative return_cap" { const data = \\#!srfv1 \\type::config,return_cap:num:-5 ; const config = parseProjectionsConfig(data); // Negative ceiling is nonsensical; dropped, default null retained. try std.testing.expectEqual(@as(?f64, null), config.return_cap); } test "parseProjectionsConfig benchmark defaults are SPY and AGG" { const config = parseProjectionsConfig(null); try std.testing.expectEqualStrings("SPY", config.benchmarkStock()); try std.testing.expectEqualStrings("AGG", config.benchmarkBond()); // len == 0 is what "no override" means; the buffers stay unread. try std.testing.expectEqual(@as(u8, 0), config.benchmark_stock_len); try std.testing.expectEqual(@as(u8, 0), config.benchmark_bond_len); } test "parseProjectionsConfig parses benchmark_stock and benchmark_bond" { const data = \\#!srfv1 \\type::config,benchmark_stock::VTI \\type::config,benchmark_bond::BND ; const config = parseProjectionsConfig(data); try std.testing.expectEqualStrings("VTI", config.benchmarkStock()); try std.testing.expectEqualStrings("BND", config.benchmarkBond()); } test "parseProjectionsConfig: an override survives copying the config by value" { // THE REGRESSION TEST. `benchmark_stock` used to be a `[]const u8` // pointing into `benchmark_stock_buf` - a self-reference - while // `parseProjectionsConfig` returns `UserConfig` BY VALUE. Every copy // after the first therefore carried a slice into a dead frame. // // The pre-existing tests all read the symbol one statement after the // parse call, in the frame that received the return value, so the // dead bytes were still intact and all of them passed. This one // copies the struct, scribbles over the stack, and only then reads - // which is what production does via `ProjectionContext`. const data = \\#!srfv1 \\type::config,benchmark_stock::VTI \\type::config,benchmark_bond::BND ; var copies: [4]UserConfig = undefined; copies[0] = parseProjectionsConfig(data); // Copy through a chain, the way buildContextFromParts -> // buildProjectionContext -> ProjectionContext does. copies[1] = copies[0]; copies[2] = copies[1]; copies[3] = copies[2]; // Churn the stack that `parseProjectionsConfig` used, so a dangling // pointer reads garbage rather than stale-but-correct bytes. stackChurn(); for (copies) |c| { try std.testing.expectEqualStrings("VTI", c.benchmarkStock()); try std.testing.expectEqualStrings("BND", c.benchmarkBond()); } } /// Overwrite a chunk of stack so a dangling slice into a returned-by-value /// struct reads scribble instead of stale-but-intact bytes. `noinline` and /// the volatile-ish sum keep the optimizer from eliding it. noinline fn stackChurn() void { var scratch: [16 * 1024]u8 = undefined; @memset(&scratch, 0xAA); var sum: usize = 0; for (scratch) |b| sum +%= b; std.mem.doNotOptimizeAway(sum); } test "parseProjectionsConfig partial benchmark override falls back to default" { // Only benchmark_stock configured - benchmark_bond stays at default. const data = \\#!srfv1 \\type::config,benchmark_stock::QQQ ; const config = parseProjectionsConfig(data); try std.testing.expectEqualStrings("QQQ", config.benchmarkStock()); try std.testing.expectEqualStrings("AGG", config.benchmarkBond()); try std.testing.expectEqual(@as(u8, 0), config.benchmark_bond_len); } test "parseProjectionsConfig rejects oversized benchmark symbol" { // 17-char symbol exceeds the 16-byte buffer; should be ignored. const data = \\#!srfv1 \\type::config,benchmark_stock::ABCDEFGHIJKLMNOPQ ; const config = parseProjectionsConfig(data); try std.testing.expectEqualStrings("SPY", config.benchmarkStock()); try std.testing.expectEqual(@as(u8, 0), config.benchmark_stock_len); } test "UserConfig: a 16-char symbol fits exactly (boundary)" { const data = \\#!srfv1 \\type::config,benchmark_stock::ABCDEFGHIJKLMNOP ; const config = parseProjectionsConfig(data); try std.testing.expectEqualStrings("ABCDEFGHIJKLMNOP", config.benchmarkStock()); try std.testing.expectEqual(@as(u8, 16), config.benchmark_stock_len); } test "parseProjectionsConfig parses both retirement_age and retirement_at" { // Both fields can be set in the file; resolver picks retirement_at. // Parsing just stores both raw. const data = \\#!srfv1 \\type::config,retirement_age:num:65 \\type::config,retirement_at::2036-07-01 ; const config = parseProjectionsConfig(data); try std.testing.expectEqual(@as(?u16, 65), config.retirement_age); try std.testing.expect(config.retirement_at != null); } test "resolveRetirement: retirement_at in future" { var config = UserConfig{}; config.retirement_at = Date.fromYmd(2036, 7, 1); const today = Date.fromYmd(2026, 7, 1); const r = config.resolveRetirement(today); try std.testing.expectEqual(@as(u16, 10), r.accumulation_years); try std.testing.expect(r.date != null); try std.testing.expect(r.date.?.eql(Date.fromYmd(2036, 7, 1))); try std.testing.expectEqual(.at_date, r.source); } test "resolveRetirement: retirement_at in past degrades to none" { var config = UserConfig{}; config.retirement_at = Date.fromYmd(2020, 1, 1); const today = Date.fromYmd(2026, 7, 1); const r = config.resolveRetirement(today); try std.testing.expectEqual(@as(u16, 0), r.accumulation_years); try std.testing.expect(r.date == null); try std.testing.expectEqual(.none, r.source); } test "resolveRetirement: retirement_age with birthday already passed this year" { // Born 1975-03-15; today 2025-06-01 (past 03-15 this year). // Target 65 -> date 2040-03-15; accumulation_years = floor(years between today and 2040-03-15). var config = UserConfig{}; config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1975, 3, 15); config.retirement_age = 65; const today = Date.fromYmd(2025, 6, 1); const r = config.resolveRetirement(today); try std.testing.expect(r.date != null); try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15))); try std.testing.expectEqual(.at_age, r.source); // ~14.78 years -> floor = 14 try std.testing.expectEqual(@as(u16, 14), r.accumulation_years); } test "resolveRetirement: retirement_age with birthday still ahead this year" { // Born 1975-08-15; today 2025-06-01 (before 08-15 this year). // Target 65 -> date 2040-08-15; ~15.21 years -> floor = 15. var config = UserConfig{}; config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1975, 8, 15); config.retirement_age = 65; const today = Date.fromYmd(2025, 6, 1); const r = config.resolveRetirement(today); try std.testing.expectEqual(@as(u16, 15), r.accumulation_years); try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 8, 15))); } test "resolveRetirement: retirement_age already past degrades to none" { var config = UserConfig{}; config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1965, 1, 1); // age ~60 in 2025 config.retirement_age = 40; // already past const today = Date.fromYmd(2025, 6, 1); const r = config.resolveRetirement(today); try std.testing.expectEqual(.none, r.source); } test "resolveRetirement: retirement_age with no birthdate degrades to none" { var config = UserConfig{}; config.retirement_age = 65; const today = Date.fromYmd(2025, 6, 1); const r = config.resolveRetirement(today); try std.testing.expectEqual(.none, r.source); } test "resolveRetirement: multi-person uses oldest birthdate" { // Person 1: born 1975-03-15 (oldest). Person 2: born 1980-06-15. // Target age 65 -> date is for person 1: 2040-03-15. var config = UserConfig{}; config.birthdate_count = 2; config.birthdates[0] = Date.fromYmd(1975, 3, 15); config.birthdates[1] = Date.fromYmd(1980, 6, 15); config.retirement_age = 65; const today = Date.fromYmd(2025, 6, 1); const r = config.resolveRetirement(today); try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15))); } test "resolveRetirement: multi-person uses oldest regardless of order" { // Person 1 (slot 0) is the YOUNGER one. Resolver should still // pick slot 1 (the older) for the retirement date. var config = UserConfig{}; config.birthdate_count = 2; config.birthdates[0] = Date.fromYmd(1980, 6, 15); config.birthdates[1] = Date.fromYmd(1975, 3, 15); config.retirement_age = 65; const today = Date.fromYmd(2025, 6, 1); const r = config.resolveRetirement(today); try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15))); } test "resolveRetirement: retirement_at wins when both set" { var config = UserConfig{}; config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1975, 3, 15); config.retirement_age = 65; config.retirement_at = Date.fromYmd(2030, 1, 1); const today = Date.fromYmd(2025, 6, 1); const r = config.resolveRetirement(today); try std.testing.expectEqual(.at_date, r.source); try std.testing.expect(r.date.?.eql(Date.fromYmd(2030, 1, 1))); } test "resolveRetirement: none when neither field is set" { const config = UserConfig{}; const today = Date.fromYmd(2025, 6, 1); const r = config.resolveRetirement(today); try std.testing.expectEqual(.none, r.source); try std.testing.expectEqual(@as(u16, 0), r.accumulation_years); try std.testing.expect(r.date == null); } test "resolveRetirement: retirement_age and retirement_at agree on same boundary" { // Configure retirement_at and retirement_age such that both // resolve to the same accumulation_years. retirement_at wins per // the rule, but the integer years should match. var c1 = UserConfig{}; c1.retirement_at = Date.fromYmd(2036, 7, 1); var c2 = UserConfig{}; c2.birthdate_count = 1; c2.birthdates[0] = Date.fromYmd(1971, 7, 1); // turns 65 on 2036-07-01 c2.retirement_age = 65; const today = Date.fromYmd(2026, 7, 1); const r1 = c1.resolveRetirement(today); const r2 = c2.resolveRetirement(today); try std.testing.expectEqual(r1.accumulation_years, r2.accumulation_years); try std.testing.expect(r1.date.?.eql(r2.date.?)); } test "ResolvedRetirement.boundaryYear: zero accumulation -> null, positive -> offset" { // No accumulation phase (already retired / distribution-only): // no divider to draw. const none_r: ResolvedRetirement = .{ .accumulation_years = 0, .date = null, .source = .none }; try std.testing.expectEqual(@as(?u16, null), none_r.boundaryYear()); // An accumulation phase: the boundary offset equals // accumulation_years (which doubles as the band index). const acc_r: ResolvedRetirement = .{ .accumulation_years = 12, .date = Date.fromYmd(2038, 1, 1), .source = .at_age }; try std.testing.expectEqual(@as(?u16, 12), acc_r.boundaryYear()); } // ── Two-phase simulation regression tests ────────────────────── test "regression: findSafeWithdrawal(30, 1M, 0.75, 0.95) unchanged" { // Pin the post-refactor value of the canonical SWR call. If this // test ever fails, the two-phase refactor changed // distribution-only behavior - investigate before bumping the // golden value. Captured 2026-05-12. const r = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); // Use a tight band - the binary search has $1 precision, so // anything farther than a few dollars off is a real change. try std.testing.expect(r.annual_amount >= 38_000); try std.testing.expect(r.annual_amount <= 50_000); // Snapshot the exact value as well so we notice silent drift. // Actual value at refactor time was determined empirically. const expected = 44_036.0; try std.testing.expectApproxEqAbs(expected, r.annual_amount, 5.0); } test "regression: zero accumulation matches direct findSafeWithdrawal" { // Both wrappers go through `searchSafeWithdrawal`; with // accumulation_years=0 and zero contributions, the bracket // seeding and search loop are identical. Tolerance is 0 // because the two paths execute the same code with the same // inputs - any drift here means the unification broke. const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); const via_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0); try std.testing.expectEqual(direct.annual_amount, via_accum.annual_amount); try std.testing.expectEqual(direct.confidence, via_accum.confidence); try std.testing.expectEqual(direct.withdrawal_rate, via_accum.withdrawal_rate); } test "two-phase: 10y accumulation with $100k/yr contributions raises post-accum portfolio" { // Compare the median portfolio at year 10 with vs without // contributions. Contributions should produce a meaningfully // higher median. const allocator = std.testing.allocator; const params_no_contrib: SimParams = .{ .initial_value = 1_000_000, .stock_pct = 0.75, .annual_spending = 0, .distribution_years = 30, .accumulation_years = 10, .annual_contribution = 0, }; const params_with_contrib: SimParams = .{ .initial_value = 1_000_000, .stock_pct = 0.75, .annual_spending = 0, .distribution_years = 30, .accumulation_years = 10, .annual_contribution = 100_000, }; const bands_no = try computePercentileBandsParams(allocator, params_no_contrib); defer allocator.free(bands_no); const bands_with = try computePercentileBandsParams(allocator, params_with_contrib); defer allocator.free(bands_with); // Both bands span 40 years (10 accum + 30 dist) -> 41 entries. try std.testing.expectEqual(@as(usize, 41), bands_no.len); try std.testing.expectEqual(@as(usize, 41), bands_with.len); // Year-0 starts the same in both. try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), bands_no[0].p50, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), bands_with[0].p50, 1.0); // At the retirement boundary (year 10), with-contributions // median should exceed without by significantly more than // 10 × $100k (compounding helps). try std.testing.expect(bands_with[10].p50 > bands_no[10].p50 + 1_000_000); } test "two-phase: nominal contributions produce lower year-10 median than CPI-adjusted" { // CPI-adjusted contributions grow over time; nominal stay flat. // Over 10 years, CPI-adjusted should accumulate more. const allocator = std.testing.allocator; const cpi_adj: SimParams = .{ .initial_value = 1_000_000, .stock_pct = 0.75, .annual_spending = 0, .distribution_years = 30, .accumulation_years = 10, .annual_contribution = 100_000, .contribution_inflation_adjusted = true, }; const nominal: SimParams = .{ .initial_value = 1_000_000, .stock_pct = 0.75, .annual_spending = 0, .distribution_years = 30, .accumulation_years = 10, .annual_contribution = 100_000, .contribution_inflation_adjusted = false, }; const b_cpi = try computePercentileBandsParams(allocator, cpi_adj); defer allocator.free(b_cpi); const b_nom = try computePercentileBandsParams(allocator, nominal); defer allocator.free(b_nom); // Median at year 10 should be higher with CPI-adjusted (over // any sufficiently inflationary historical window the diff is // positive; CPI is non-negative on the long term). try std.testing.expect(b_cpi[10].p50 >= b_nom[10].p50); } test "two-phase: SWR with accumulation exceeds same-portfolio direct SWR" { // 10 years of $100k contributions on top of $1M should produce // a higher safe withdrawal than $1M alone over a 30-year // distribution at the same confidence. const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); const with_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 10, 100_000, true, 0, 0); try std.testing.expect(with_accum.annual_amount > direct.annual_amount); } test "simulateTwoPhase: null-buf and non-null-buf agree on verdict" { // Locks in the invariant that calling simulateTwoPhase with // null produces the same survival bit as calling it with a // path buffer. This is the load-bearing equivalence that lets // `successRateParams` use the cheaper null-buf path while // `runAllCyclesParams` uses the path-storing version, with // both producing the same answer about whether a given cycle // failed. // // Cover three regimes: clear survivor, clear failure, and a // marginal case driven by an extreme spending level. const cases = [_]struct { params: SimParams, starts: []const usize, }{ .{ // Clear survivor: zero spending. .params = .{ .initial_value = 1_000_000, .stock_pct = 0.75, .annual_spending = 0, .distribution_years = 30, }, .starts = &.{ 0, 25, 50, 75 }, }, .{ // Clear failure: spend $200k/yr from $500k, 30 years. .params = .{ .initial_value = 500_000, .stock_pct = 0.75, .annual_spending = 200_000, .distribution_years = 30, }, .starts = &.{ 0, 25, 50, 75 }, }, .{ // Marginal: 10y accumulation then 30y of moderate spend. .params = .{ .initial_value = 1_000_000, .stock_pct = 0.75, .annual_spending = 60_000, .distribution_years = 30, .accumulation_years = 10, .annual_contribution = 50_000, }, .starts = &.{ 0, 30, 60 }, }, }; var buf: [101]f64 = undefined; // max total ≈ 50 + 50, slack for (cases) |case| { for (case.starts) |start| { const total = case.params.totalYears(); std.debug.assert(total + 1 <= buf.len); const verdict_null = simulateTwoPhase(null, shiller.annual_returns, start, case.params); const verdict_buf = simulateTwoPhase(buf[0 .. total + 1], shiller.annual_returns, start, case.params); try std.testing.expectEqual(verdict_null, verdict_buf); } } } // ── findEarliestRetirement tests ─────────────────────────────── test "findEarliestRetirement: feasible at N=0 returns 0" { // $10M portfolio, $40k/yr spending, 30y distribution, 95% // confidence - feasible immediately (1.6× the 4% rule). const allocator = std.testing.allocator; const r = try findEarliestRetirement( allocator, 10_000_000, // initial_value 0.75, // stock_pct 0, // annual_contribution true, 40_000, // target_spending true, 30, // distribution_years 0.95, // confidence &.{}, 50, // max_years 0, // expense_ratio 0, // spending_real_change ); try std.testing.expectEqual(@as(?u16, 0), r.accumulation_years); } test "findEarliestRetirement: unreachable returns null" { // $1M portfolio, $1M/yr spending, no contributions: never // feasible. Returns null. const allocator = std.testing.allocator; const r = try findEarliestRetirement( allocator, 1_000_000, 0.75, 0, // no contributions true, 1_000_000, // target spending = entire portfolio every year true, 30, 0.95, &.{}, 50, 0, // expense_ratio 0, // spending_real_change ); try std.testing.expectEqual(@as(?u16, null), r.accumulation_years); } test "findEarliestRetirement: longer distribution shifts retirement later or unchanged" { // Same setup, just two horizons. const allocator = std.testing.allocator; const short = try findEarliestRetirement( allocator, 1_000_000, 0.75, 50_000, true, 80_000, true, 20, // 20-year distribution 0.95, &.{}, 50, 0, // expense_ratio 0, // spending_real_change ); const long = try findEarliestRetirement( allocator, 1_000_000, 0.75, 50_000, true, 80_000, true, 45, // 45-year distribution 0.95, &.{}, 50, 0, // expense_ratio 0, // spending_real_change ); if (short.accumulation_years != null and long.accumulation_years != null) { try std.testing.expect(long.accumulation_years.? >= short.accumulation_years.?); } } test "findEarliestRetirement: result includes portfolio statistics" { const allocator = std.testing.allocator; const r = try findEarliestRetirement( allocator, 2_000_000, 0.75, 100_000, true, 80_000, true, 30, 0.95, &.{}, 50, 0, // expense_ratio 0, // spending_real_change ); if (r.accumulation_years) |n| { if (n > 0) { // Median portfolio at retirement should be >= initial // value (we accumulate before drawing down). try std.testing.expect(r.median_at_retirement >= 1_500_000); // p10 ≤ p50 ≤ p90. try std.testing.expect(r.p10_at_retirement <= r.median_at_retirement); try std.testing.expect(r.median_at_retirement <= r.p90_at_retirement); } } } // ── Mortality: youngest anchor, survivor step-down, event termination ── test "youngestBirthdate / youngestAge pick the latest-born person" { var config = UserConfig{}; config.birthdates[0] = Date.fromYmd(1958, 3, 1); config.birthdates[1] = Date.fromYmd(1965, 9, 20); config.birthdate_count = 2; const as_of = Date.fromYmd(2025, 1, 1); // Youngest = latest birthdate (1965). try std.testing.expectEqual(Date.fromYmd(1965, 9, 20), config.youngestBirthdate().?); // Oldest = earliest birthdate (1958) - the existing helper, sanity. try std.testing.expectEqual(Date.fromYmd(1958, 3, 1), config.oldestBirthdate().?); // Youngest is younger than oldest as of the same date. try std.testing.expect(config.youngestAge(as_of) < config.oldestAge(as_of)); } test "youngestBirthdate is null with no birthdates" { const config = UserConfig{}; try std.testing.expectEqual(@as(?Date, null), config.youngestBirthdate()); try std.testing.expectEqual(@as(u16, 0), config.youngestAge(Date.fromYmd(2025, 1, 1))); } test "parse survivor_spending_pct: default, explicit, above-100, and negative-rejected" { // Default when unset. const dflt = parseProjectionsConfig( \\#!srfv1 \\type::config,horizon:num:30 ); try std.testing.expectEqual(@as(f64, 75), dflt.survivor_spending_pct); // Explicit value honored. const set = parseProjectionsConfig( \\#!srfv1 \\type::config,survivor_spending_pct:num:60 ); try std.testing.expectEqual(@as(f64, 60), set.survivor_spending_pct); // Above 100 is allowed (a survivor whose spending rises). const high = parseProjectionsConfig( \\#!srfv1 \\type::config,survivor_spending_pct:num:110 ); try std.testing.expectEqual(@as(f64, 110), high.survivor_spending_pct); // Negative is rejected -> default retained. const neg = parseProjectionsConfig( \\#!srfv1 \\type::config,survivor_spending_pct:num:-20 ); try std.testing.expectEqual(@as(f64, 75), neg.survivor_spending_pct); } test "LifeEvent.resolveToAge: permanent income terminates at the holder's death" { // SS at age 70 for a person currently 50 -> starts sim-year 20. // Age-of-death 90 -> dies sim-year 40. The permanent event is // capped to [20, 40): duration 20. const ev = LifeEvent{ .start_age = 70, .annual_amount = 38_400 }; const ages = [_]u16{50}; const uncapped = ev.resolveToAge(&ages, null).?; try std.testing.expectEqual(@as(u16, 20), uncapped.start_year); try std.testing.expectEqual(@as(u16, 0), uncapped.duration); // permanent const capped = ev.resolveToAge(&ages, 90).?; try std.testing.expectEqual(@as(u16, 20), capped.start_year); try std.testing.expectEqual(@as(u16, 20), capped.duration); try std.testing.expect(capped.isActive(39)); try std.testing.expect(!capped.isActive(40)); // dead } test "LifeEvent.resolveToAge: dead before the event would start -> never active" { // Event at age 70, person 50 -> starts year 20, but age-of-death // 60 -> dies year 10, before the event begins. Never active. const ev = LifeEvent{ .start_age = 70, .annual_amount = 10_000 }; const ages = [_]u16{50}; const capped = ev.resolveToAge(&ages, 60).?; try std.testing.expect(!capped.isActive(20)); try std.testing.expect(!capped.isActive(0)); } test "LifeEvent.resolveToAge: finite duration shorter than death is preserved" { // Tuition at age 60 (start year 10) for 4 years; death at 90 (year // 40) is well beyond, so the 4-year duration is unchanged. const ev = LifeEvent{ .start_age = 60, .duration = 4, .annual_amount = -55_000 }; const ages = [_]u16{50}; const capped = ev.resolveToAge(&ages, 90).?; try std.testing.expectEqual(@as(u16, 10), capped.start_year); try std.testing.expectEqual(@as(u16, 4), capped.duration); } test "resolveEventsToAge caps each holder's events at their own death" { var config = UserConfig{}; config.birthdates[0] = Date.fromYmd(1965, 1, 1); // ~60 as of 2025 config.birthdates[1] = Date.fromYmd(1970, 1, 1); // ~55 as of 2025 config.birthdate_count = 2; // Person 0 SS at 70; person 1 SS at 70. config.events[0] = LifeEvent{ .start_age = 70, .person = 0, .annual_amount = 30_000 }; config.events[1] = LifeEvent{ .start_age = 70, .person = 1, .annual_amount = 28_000 }; config.event_count = 2; const as_of = Date.fromYmd(2025, 1, 1); const resolved = config.resolveEventsToAge(as_of, 90); // Person 0 (~60): SS at year 10, dies ~year 30 -> active just before // 30, gone at 30. try std.testing.expect(resolved[0].isActive(29)); try std.testing.expect(!resolved[0].isActive(30)); // Person 1 (~55): dies ~year 35, later than person 0. try std.testing.expect(resolved[1].isActive(34)); try std.testing.expect(!resolved[1].isActive(35)); } test "simulateTwoPhase: survivor step-down lets a portfolio survive that flat spending exhausts" { // Constant zero return / zero inflation: a pure cash-flow ledger. const flat = shiller.ShillerYear{ .year = 2000, .sp500_total_return = 0, .bond_total_return = 0, .cpi_inflation = 0, }; const data = [_]shiller.ShillerYear{flat} ** 11; const base = SimParams{ .initial_value = 100, .stock_pct = 1.0, .annual_spending = 10, .distribution_years = 10, }; // No survivor step: spends 10/yr * 10yr = 100 -> exhausts (fails). try std.testing.expect(!simulateTwoPhase(null, &data, 0, base)); // Survivor step at year 5 to 50%: 5*10 + 5*5 = 75 < 100 -> survives. var stepped = base; stepped.first_death_year = 5; stepped.survivor_factor = 0.5; try std.testing.expect(simulateTwoPhase(null, &data, 0, stepped)); } test "findEarliestRetirementToAge: infeasible when the last survivor is already past the age" { const allocator = std.testing.allocator; const r = try findEarliestRetirementToAge( allocator, 1_000_000, 0.75, 0, true, 40_000, true, 0, // total_span == 0: everyone already at/past age-of-death null, 1.0, 95, // death_age (propagated for rendering) 0.95, &.{}, 50, 0, 0, ); try std.testing.expectEqual(@as(?u16, null), r.accumulation_years); try std.testing.expectEqual(@as(u16, 95), r.death_age); } test "findEarliestRetirementToAge: feasible-now case carries the death age" { const allocator = std.testing.allocator; const r = try findEarliestRetirementToAge( allocator, 10_000_000, // ample 0.75, 0, true, 40_000, // modest spend true, 30, // total_span (last survivor 30y out) null, 1.0, 95, 0.95, &.{}, 50, 0, 0, ); try std.testing.expectEqual(@as(?u16, 0), r.accumulation_years); try std.testing.expectEqual(@as(u16, 95), r.death_age); // Distribution at N=0 is the full span. try std.testing.expectEqual(@as(u16, 30), r.horizon); } test "findEarliestRetirementToAge: a survivor spending cut never delays retirement" { const allocator = std.testing.allocator; // Flat: survivor_factor 1.0, no first death. const flat = try findEarliestRetirementToAge( allocator, 1_000_000, 0.80, 40_000, // annual_contribution true, 70_000, // target_spending (tight enough to need accumulation) true, 35, // total_span null, 1.0, 95, 0.95, &.{}, 50, 0, 0, ); // Survivor cut to 60% at year 15 (first death). const cut = try findEarliestRetirementToAge( allocator, 1_000_000, 0.80, 40_000, true, 70_000, true, 35, 15, 0.60, 95, 0.95, &.{}, 50, 0, 0, ); if (flat.accumulation_years) |f| { // The reduced post-first-death spending can only help: the // earliest feasible retirement is no later than the flat case. try std.testing.expect(cut.accumulation_years != null); try std.testing.expect(cut.accumulation_years.? <= f); } } test "columnMortality: single person, couple with gap, and degenerate cases" { var single = UserConfig{}; single.birthdates[0] = Date.fromYmd(1960, 1, 1); // ~66 as of 2026 single.birthdate_count = 1; single.survivor_spending_pct = 70; const as_of = Date.fromYmd(2026, 1, 1); // Single person: no gap -> factor 1.0, no first-death step. const m_single = columnMortality(&single, as_of, 95, 0); try std.testing.expectEqual(@as(u16, 29), m_single.total_span); // 95 - 66 try std.testing.expectEqual(@as(?u16, null), m_single.first_death_year); try std.testing.expectEqual(@as(f64, 1.0), m_single.survivor_factor); // Couple with an age gap: oldest dies first (step), youngest sets span. var couple = UserConfig{}; couple.birthdates[0] = Date.fromYmd(1960, 1, 1); // ~66 -> dies at 95 in 29y couple.birthdates[1] = Date.fromYmd(1966, 1, 1); // ~60 -> dies at 95 in 35y couple.birthdate_count = 2; couple.survivor_spending_pct = 70; const m_couple = columnMortality(&couple, as_of, 95, 0); try std.testing.expectEqual(@as(u16, 35), m_couple.total_span); // youngest try std.testing.expectEqual(@as(?u16, 29), m_couple.first_death_year); // oldest try std.testing.expectEqual(@as(f64, 0.70), m_couple.survivor_factor); // Degenerate: age-of-death already reached by the youngest -> span 0. const m_past = columnMortality(&couple, as_of, 50, 0); try std.testing.expectEqual(@as(u16, 0), m_past.total_span); // Distribution clamps to >= 1 when accumulation meets/exceeds span. const m_clamp = columnMortality(&couple, as_of, 95, 40); // acc 40 > span 35 try std.testing.expectEqual(@as(u16, 1), m_clamp.distribution_years); } test "findEarliestRetirementToAge: infeasible within cap when spending is absurd" { const allocator = std.testing.allocator; // total_span 30, but spending far exceeds what any accumulation // length within the cap can sustain -> exhausts the loop and // returns the infeasible sentinel. const r = try findEarliestRetirementToAge( allocator, 500_000, 0.75, 0, // no contributions true, 2_000_000, // $2M/yr on a $500k base: never sustainable true, 30, null, 1.0, 95, 0.95, &.{}, 25, // cap below total_span - 1 0, 0, ); try std.testing.expectEqual(@as(?u16, null), r.accumulation_years); try std.testing.expectEqual(@as(u16, 95), r.death_age); } test "resolveToAge: out-of-range person index yields a never-active sentinel" { var config = UserConfig{}; config.birthdates[0] = Date.fromYmd(1970, 1, 1); config.birthdate_count = 1; // Event references person index 4, past the 4-slot persons array // -> startYear returns null -> never-active sentinel. config.events[0] = LifeEvent{ .start_age = 70, .person = 4, .annual_amount = 1000 }; config.event_count = 1; const resolved = config.resolveEventsToAge(Date.fromYmd(2026, 1, 1), 95); try std.testing.expectEqual(@as(u16, std.math.maxInt(u16)), resolved[0].start_year); try std.testing.expect(!resolved[0].isActive(10)); } test "ResolvedEvent.cashFlow: non-inflation-adjusted returns the flat amount" { const ev: ResolvedEvent = .{ .start_year = 0, .duration = 0, .annual_amount = 24_000, .inflation_adjusted = false, }; // cumulative_inflation is ignored when inflation_adjusted is false. try std.testing.expectEqual(@as(f64, 24_000), ev.cashFlow(3, 1.5)); } // ── ResolvedRetirement formatter tests ───────────────────────── test "fmtRetirementLine: none case" { var buf: [128]u8 = undefined; const line = retirementLineForTest(&buf, .{ .accumulation_years = 0, .date = null, .source = .none, }); try std.testing.expectEqualStrings("Years until possible retirement: none", line); } test "fmtRetirementLine: at_date case" { var buf: [128]u8 = undefined; const line = retirementLineForTest(&buf, .{ .accumulation_years = 10, .date = Date.fromYmd(2036, 7, 1), .source = .at_date, }); try std.testing.expectEqualStrings("Years until possible retirement: 10 (2036-07-01)", line); } test "fmtRetirementLine: at_age case" { var buf: [128]u8 = undefined; const line = retirementLineForTest(&buf, .{ .accumulation_years = 14, .date = Date.fromYmd(2040, 3, 15), .source = .at_age, }); try std.testing.expectEqualStrings("Years until possible retirement: 14 (2040-03-15)", line); } /// Test-only adapter to avoid dragging the views/projections.zig /// module into this file's import surface. Mirrors /// `views.fmtRetirementLine` exactly; if the formatter ever moves, /// update both. fn retirementLineForTest(buf: []u8, resolved: ResolvedRetirement) []const u8 { if (resolved.source == .none) { return std.fmt.bufPrint(buf, "Years until possible retirement: none", .{}) catch "Years until possible retirement: none"; } var date_buf: [10]u8 = undefined; const date_str = if (resolved.date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??"; return std.fmt.bufPrint(buf, "Years until possible retirement: {d} ({s})", .{ resolved.accumulation_years, date_str, }) catch "Years until possible retirement: ?"; } // ── pickPromotedCell tests ───────────────────────────────────── test "pickPromotedCell: age-anchored column is preferred over numeric horizons" { var config = UserConfig{}; config.horizon_count = 3; config.horizons = .{ 30, 18, 33 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0)); // Columns 1 and 2 are age-anchored (90 and 95); column 0 is numeric. config.horizon_death_age = .{ 0, 90, 95 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0)); config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1965, 4, 12); const today = Date.fromYmd(2026, 5, 12); const confs = [_]f64{ 0.90, 0.95, 0.99 }; const pc = pickPromotedCell(&config, today, &confs).?; // The latest death (95, index 2) wins, at the 99% default. try std.testing.expectEqual(@as(usize, 2), pc.horizon_index); try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); try std.testing.expect(!pc.explicit); } test "pickPromotedCell: explicit retirement_target still wins over age columns" { var config = UserConfig{}; config.horizon_count = 2; config.horizons = .{ 30, 33 } ++ @as([UserConfig.max_horizons - 2]u16, @splat(0)); config.horizon_death_age = .{ 0, 95 } ++ @as([UserConfig.max_horizons - 2]u16, @splat(0)); config.horizon_targets = .{ 90, 0 } ++ @as([UserConfig.max_horizons - 2]u8, @splat(0)); config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1965, 4, 12); const today = Date.fromYmd(2026, 5, 12); const confs = [_]f64{ 0.90, 0.95, 0.99 }; const pc = pickPromotedCell(&config, today, &confs).?; // The explicitly-tagged numeric column 0 (target 90%) wins. try std.testing.expectEqual(@as(usize, 0), pc.horizon_index); try std.testing.expectEqual(@as(usize, 0), pc.confidence_index); // 90% try std.testing.expect(pc.explicit); } test "pickPromotedCell: longest horizon selected when oldest stays under cap" { var config = UserConfig{}; config.horizon_count = 3; config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0)); config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1981, 4, 12); // ~age 45 in 2026 const today = Date.fromYmd(2026, 5, 12); const confs = [_]f64{ 0.90, 0.95, 0.99 }; const pc = pickPromotedCell(&config, today, &confs).?; // Longest is 50; 45 + 50 = 95 < 100 -> 50yr horizon picked. try std.testing.expectEqual(@as(usize, 2), pc.horizon_index); try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); // 99% default try std.testing.expect(!pc.explicit); } test "pickPromotedCell: longest horizon overshoots, second-longest selected" { var config = UserConfig{}; config.horizon_count = 3; config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0)); config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1968, 4, 12); // ~age 58 in 2026 const today = Date.fromYmd(2026, 5, 12); const confs = [_]f64{ 0.90, 0.95, 0.99 }; const pc = pickPromotedCell(&config, today, &confs).?; // Longest is 50; 58 + 50 = 108 >= 100 -> skip. // Next is 35; 58 + 35 = 93 < 100 -> pick. try std.testing.expectEqual(@as(u16, 35), config.horizons[pc.horizon_index]); try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); } test "pickPromotedCell: all horizons overshoot, fall through to shortest" { var config = UserConfig{}; config.horizon_count = 3; config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0)); config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1948, 4, 12); // ~age 78 in 2026 const today = Date.fromYmd(2026, 5, 12); const confs = [_]f64{ 0.90, 0.95, 0.99 }; const pc = pickPromotedCell(&config, today, &confs).?; // All overshoot 100. Shortest is 25 -> pick it (fuck-it branch). try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]); } test "pickPromotedCell: explicit retirement_target wins regardless of length" { var config = UserConfig{}; config.horizon_count = 3; config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0)); // Annotate the SHORTEST horizon - overrides default rule which // would pick the longest. config.horizon_targets[0] = 95; config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1981, 4, 12); const today = Date.fromYmd(2026, 5, 12); const confs = [_]f64{ 0.90, 0.95, 0.99 }; const pc = pickPromotedCell(&config, today, &confs).?; try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]); try std.testing.expectEqual(@as(usize, 1), pc.confidence_index); // 95% -> index 1 try std.testing.expect(pc.explicit); } test "pickPromotedCell: no birthdates falls through to longest horizon" { var config = UserConfig{}; config.horizon_count = 3; config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0)); const today = Date.fromYmd(2026, 5, 12); const confs = [_]f64{ 0.90, 0.95, 0.99 }; const pc = pickPromotedCell(&config, today, &confs).?; try std.testing.expectEqual(@as(u16, 50), config.horizons[pc.horizon_index]); try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); // 99% } test "pickPromotedCell: zero horizons returns null" { var config = UserConfig{}; config.horizon_count = 0; const today = Date.fromYmd(2026, 5, 12); const confs = [_]f64{ 0.90, 0.95, 0.99 }; try std.testing.expect(pickPromotedCell(&config, today, &confs) == null); } test "parseProjectionsConfig: retirement_target on horizon record" { const data = \\#!srfv1 \\type::config,horizon:num:25 \\type::config,horizon:num:35,retirement_target:num:95 \\type::config,horizon:num:50 ; const config = parseProjectionsConfig(data); try std.testing.expectEqual(@as(u8, 3), config.horizon_count); try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]); try std.testing.expectEqual(@as(u8, 95), config.horizon_targets[1]); try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[2]); } test "parseProjectionsConfig: retirement_target on horizon_age survives resolution" { const data = \\#!srfv1 \\type::config,horizon_age:num:90,retirement_target:num:99 \\type::birthdate,date::1975-01-01 ; var config = parseProjectionsConfig(data); try std.testing.expectEqual(@as(u8, 99), config.horizon_age_targets[0]); // Resolve: youngest age (single person here) in 2025 is 50 -> horizon 40. try config.resolveHorizonAges(Date.fromYmd(2025, 6, 15)); try std.testing.expectEqual(@as(u8, 1), config.horizon_count); try std.testing.expectEqual(@as(u16, 40), config.horizons[0]); try std.testing.expectEqual(@as(u8, 99), config.horizon_targets[0]); } test "parseProjectionsConfig: invalid retirement_target value dropped silently per record" { const data = \\#!srfv1 \\type::config,horizon:num:25 \\type::config,horizon:num:35,retirement_target:num:80 \\type::config,horizon:num:50,retirement_target:num:99 ; const config = parseProjectionsConfig(data); // Record with retirement_target:80 keeps the horizon but drops // the invalid annotation. The 99 on the third horizon is the // ONLY valid annotation, so it stays. try std.testing.expectEqual(@as(u8, 3), config.horizon_count); try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]); try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[1]); try std.testing.expectEqual(@as(u8, 99), config.horizon_targets[2]); } test "parseProjectionsConfig: multiple retirement_target annotations all dropped" { const data = \\#!srfv1 \\type::config,horizon:num:25,retirement_target:num:95 \\type::config,horizon:num:35,retirement_target:num:99 \\type::config,horizon:num:50 ; const config = parseProjectionsConfig(data); // Validation post-pass: > 1 annotation -> drop them all. try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]); try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[1]); try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[2]); } test "validRetirementTarget: 90/95/99 pass, others fail" { try std.testing.expectEqual(@as(?u8, 90), validRetirementTarget(90)); try std.testing.expectEqual(@as(?u8, 95), validRetirementTarget(95)); try std.testing.expectEqual(@as(?u8, 99), validRetirementTarget(99)); try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(null)); try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(0)); try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(85)); try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(100)); } // ── oldestBirthdate / oldestAge tests ────────────────────────── test "oldestBirthdate: no birthdates returns null" { const config = UserConfig{}; try std.testing.expectEqual(@as(?Date, null), config.oldestBirthdate()); } test "oldestBirthdate: single birthdate returns it" { var config = UserConfig{}; config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1981, 4, 12); const result = config.oldestBirthdate(); try std.testing.expect(result.?.eql(Date.fromYmd(1981, 4, 12))); } test "oldestBirthdate: multi-person picks earliest date" { var config = UserConfig{}; config.birthdate_count = 2; config.birthdates[0] = Date.fromYmd(1983, 9, 8); config.birthdates[1] = Date.fromYmd(1981, 4, 12); // older const result = config.oldestBirthdate(); try std.testing.expect(result.?.eql(Date.fromYmd(1981, 4, 12))); } test "oldestBirthdate: multi-person regardless of slot order" { var config = UserConfig{}; config.birthdate_count = 2; config.birthdates[0] = Date.fromYmd(1981, 4, 12); // older config.birthdates[1] = Date.fromYmd(1983, 9, 8); const result = config.oldestBirthdate(); try std.testing.expect(result.?.eql(Date.fromYmd(1981, 4, 12))); } test "oldestAge: no birthdates returns 0" { const config = UserConfig{}; const as_of = Date.fromYmd(2026, 5, 12); try std.testing.expectEqual(@as(u16, 0), config.oldestAge(as_of)); } test "oldestAge: derives whole years from oldest birthdate" { var config = UserConfig{}; config.birthdate_count = 2; config.birthdates[0] = Date.fromYmd(1981, 4, 12); config.birthdates[1] = Date.fromYmd(1983, 9, 8); // 1981-04-12 -> 2026-05-12 spans 45 full years. const as_of = Date.fromYmd(2026, 5, 12); try std.testing.expectEqual(@as(u16, 45), config.oldestAge(as_of)); } // ── runProjectionGrid tests ──────────────────────────────────── /// Free a `ProjectionData` produced by `runProjectionGrid`. Used by /// the tests below to keep their cleanup blocks tidy. fn freeProjectionData(allocator: std.mem.Allocator, data: ProjectionData) void { allocator.free(data.withdrawals); for (data.bands) |b| { if (b) |slice| allocator.free(slice); } allocator.free(data.bands); } test "runProjectionGrid: structure and indexing" { const allocator = std.testing.allocator; const horizons = [_]u16{ 20, 30 }; const conf = [_]f64{ 0.95, 0.99 }; const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0); defer freeProjectionData(allocator, data); // 2 horizons × 2 confidence levels = 4 withdrawal results. try std.testing.expectEqual(@as(usize, 4), data.withdrawals.len); // 2 bands (one per horizon). try std.testing.expectEqual(@as(usize, 2), data.bands.len); // ci_99 is the last (highest) confidence index. try std.testing.expectEqual(@as(usize, 1), data.ci_99); } test "runProjectionGrid: withdrawal monotonicity along confidence axis" { // Same horizon, lower confidence -> higher allowed spending. // Indexing: withdrawals[ci * horizons.len + hi]. const allocator = std.testing.allocator; const horizons = [_]u16{30}; const conf = [_]f64{ 0.90, 0.95, 0.99 }; const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0); defer freeProjectionData(allocator, data); const w_90 = data.withdrawals[0 * horizons.len + 0].annual_amount; const w_95 = data.withdrawals[1 * horizons.len + 0].annual_amount; const w_99 = data.withdrawals[2 * horizons.len + 0].annual_amount; try std.testing.expect(w_90 >= w_95); try std.testing.expect(w_95 >= w_99); } test "runProjectionGrid: withdrawal monotonicity along horizon axis" { // Same confidence, longer horizon -> lower allowed spending. const allocator = std.testing.allocator; const horizons = [_]u16{ 20, 30, 45 }; const conf = [_]f64{0.95}; const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0); defer freeProjectionData(allocator, data); const w_20 = data.withdrawals[0 * horizons.len + 0].annual_amount; const w_30 = data.withdrawals[0 * horizons.len + 1].annual_amount; const w_45 = data.withdrawals[0 * horizons.len + 2].annual_amount; try std.testing.expect(w_20 >= w_30); try std.testing.expect(w_30 >= w_45); } test "runProjectionGrid: distribution-only band length is horizon + 1" { const allocator = std.testing.allocator; const horizons = [_]u16{ 20, 30 }; const conf = [_]f64{ 0.95, 0.99 }; const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0); defer freeProjectionData(allocator, data); // band[0] covers horizons[0] = 20 -> 21 entries; band[1] covers // horizons[1] = 30 -> 31 entries. try std.testing.expectEqual(@as(usize, 21), data.bands[0].?.len); try std.testing.expectEqual(@as(usize, 31), data.bands[1].?.len); } test "runProjectionGrid: with-accumulation band length includes accumulation_years" { const allocator = std.testing.allocator; const horizons = [_]u16{30}; const conf = [_]f64{0.95}; // 10 years of accumulation + 30 years distribution -> 41 entries. const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0, 0); defer freeProjectionData(allocator, data); try std.testing.expectEqual(@as(usize, 41), data.bands[0].?.len); } test "runProjectionGrid: bands are p10 ≤ p25 ≤ p50 ≤ p75 ≤ p90 at every year" { const allocator = std.testing.allocator; const horizons = [_]u16{30}; const conf = [_]f64{0.95}; const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0); defer freeProjectionData(allocator, data); for (data.bands[0].?) |b| { try std.testing.expect(b.p10 <= b.p25); try std.testing.expect(b.p25 <= b.p50); try std.testing.expect(b.p50 <= b.p75); try std.testing.expect(b.p75 <= b.p90); } } test "runProjectionGrid: year 0 in every band equals total_value" { const allocator = std.testing.allocator; const horizons = [_]u16{ 20, 30 }; const conf = [_]f64{0.95}; const total_value: f64 = 2_000_000; const data = try runProjectionGrid(allocator, &horizons, &conf, total_value, 0.75, &.{}, 0, 0, true, 0, 0); defer freeProjectionData(allocator, data); for (data.bands) |b_opt| { const b = b_opt.?; try std.testing.expectApproxEqAbs(total_value, b[0].p10, 1.0); try std.testing.expectApproxEqAbs(total_value, b[0].p50, 1.0); try std.testing.expectApproxEqAbs(total_value, b[0].p90, 1.0); } } test "runProjectionGrid: bands are computed at the highest-confidence withdrawal" { // The chart anchors on `ci_99` - the LAST entry in // `confidence_levels` - by feeding that withdrawal rate into // `computePercentileBandsParams`. With confidence_levels = // {.90, .95, .99}, the bands should reflect spending at 99% // (the smallest, most-conservative withdrawal). // // Verification: re-running the band computation with the // 99%-confidence withdrawal should produce identical bands. const allocator = std.testing.allocator; const horizons = [_]u16{30}; const conf = [_]f64{ 0.90, 0.95, 0.99 }; const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0); defer freeProjectionData(allocator, data); const wr_99 = data.withdrawals[data.ci_99 * horizons.len + 0]; const expected = try computePercentileBandsParams(allocator, .{ .initial_value = 1_000_000, .stock_pct = 0.75, .annual_spending = wr_99.annual_amount, .distribution_years = 30, }); defer allocator.free(expected); const actual = data.bands[0].?; try std.testing.expectEqual(expected.len, actual.len); for (expected, actual) |exp, act| { try std.testing.expectEqual(exp.year, act.year); try std.testing.expectEqual(exp.p10, act.p10); try std.testing.expectEqual(exp.p50, act.p50); try std.testing.expectEqual(exp.p90, act.p90); } } test "runProjectionGrid: accumulation passes through to both withdrawals and bands" { // Same horizon, same confidence, same starting portfolio: // 10 years of $50k contributions should produce a meaningfully // higher safe withdrawal than zero accumulation (the // post-accumulation portfolio is bigger), AND the bands should // be longer (accumulation_years + distribution_years + 1). const allocator = std.testing.allocator; const horizons = [_]u16{30}; const conf = [_]f64{0.95}; const dist_only = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0); defer freeProjectionData(allocator, dist_only); const with_accum = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0, 0); defer freeProjectionData(allocator, with_accum); // SWR with 10y of contributions on top should exceed SWR // without. try std.testing.expect(with_accum.withdrawals[0].annual_amount > dist_only.withdrawals[0].annual_amount); // Band length differs by exactly accumulation_years. try std.testing.expectEqual(dist_only.bands[0].?.len + 10, with_accum.bands[0].?.len); } test "runProjectionGrid: zero horizons produces empty results without crashing" { const allocator = std.testing.allocator; const horizons = [_]u16{}; const conf = [_]f64{ 0.95, 0.99 }; const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0); defer freeProjectionData(allocator, data); try std.testing.expectEqual(@as(usize, 0), data.withdrawals.len); try std.testing.expectEqual(@as(usize, 0), data.bands.len); } // ── Spending-drift (the "smile") tests ───────────────────────── test "spending_real_change: declining spending raises safe withdrawal, rising lowers it" { // Same portfolio, horizon, and confidence - only the spending // trajectory differs. Spending less in the slow-go years frees up // a higher first-year draw; spending more requires a lower one. const flat = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0); const declining = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, -0.02); const rising = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0.02); try std.testing.expect(declining.annual_amount > flat.annual_amount); try std.testing.expect(rising.annual_amount < flat.annual_amount); } test "spending_real_change: zero drift is identical to the flat model" { // The default (rate 0) must reproduce the pre-smile behavior // exactly - the regression pin for every existing projection. const flat = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); const zero_drift = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0); try std.testing.expectEqual(flat.annual_amount, zero_drift.annual_amount); } test "spendingTrough: monotonic decline bottoms out in the final year" { const as_of = Date.fromYmd(2026, 1, 1); const t = spendingTrough(60_000, -0.02, &.{}, 0, 30, as_of).?; // No events -> spending falls every year -> trough is the last // distribution year (d = 29). try std.testing.expectEqual(@as(u16, 29), t.year_offset); try std.testing.expectEqual(@as(u16, 30), t.years_from_now); const expected = 60_000.0 * std.math.pow(f64, 0.98, 29); try std.testing.expectApproxEqAbs(expected, t.amount, 1.0); try std.testing.expectEqual(@as(i16, 2055), t.date.year()); } test "spendingTrough: a late healthcare expense pulls the trough to mid-retirement" { const as_of = Date.fromYmd(2026, 1, 1); // Base spending declines 2%/yr; a permanent +$40k/yr healthcare // expense begins at distribution year 20. Spending slides until // then, then jumps - so the trough is the year just before the // hump (d = 19), not the final year. This is the whole reason the // trough is computed rather than read off the last year. const healthcare = [_]ResolvedEvent{.{ .start_year = 20, .duration = 0, .annual_amount = -40_000, .inflation_adjusted = true, }}; const t = spendingTrough(60_000, -0.02, &healthcare, 0, 30, as_of).?; try std.testing.expectEqual(@as(u16, 19), t.year_offset); } test "spendingTrough: rising spending bottoms out in the first year" { const as_of = Date.fromYmd(2026, 1, 1); const t = spendingTrough(50_000, 0.01, &.{}, 0, 30, as_of).?; try std.testing.expectEqual(@as(u16, 0), t.year_offset); try std.testing.expectEqual(@as(u16, 1), t.years_from_now); try std.testing.expectApproxEqAbs(@as(f64, 50_000), t.amount, 0.01); } test "spendingTrough: income events do not count as spending" { const as_of = Date.fromYmd(2026, 1, 1); // A Social Security income event funds withdrawals but is not // consumption, so it must not lower the reported spending trough. const ss = [_]ResolvedEvent{.{ .start_year = 5, .duration = 0, .annual_amount = 30_000, // positive = income .inflation_adjusted = true, }}; const with_income = spendingTrough(60_000, -0.02, &ss, 0, 30, as_of).?; const without = spendingTrough(60_000, -0.02, &.{}, 0, 30, as_of).?; try std.testing.expectEqual(without.amount, with_income.amount); try std.testing.expectEqual(without.year_offset, with_income.year_offset); } test "spendingTrough: accumulation phase offsets the trough year and date" { const as_of = Date.fromYmd(2026, 1, 1); // 10 accumulation years, then 20 distribution years declining // 1%/yr. Trough at the last distribution year (d = 19); // years_from_now = 10 + 19 + 1 = 30; calendar 2026 + 29 = 2055. const t = spendingTrough(50_000, -0.01, &.{}, 10, 20, as_of).?; try std.testing.expectEqual(@as(u16, 19), t.year_offset); try std.testing.expectEqual(@as(u16, 30), t.years_from_now); try std.testing.expectEqual(@as(i16, 2055), t.date.year()); } test "spendingTrough: zero distribution years returns null" { const as_of = Date.fromYmd(2026, 1, 1); try std.testing.expectEqual( @as(?SpendingTrough, null), spendingTrough(60_000, -0.02, &.{}, 0, 0, as_of), ); } test "parseProjectionsConfig spending_change negative is a decline" { const config = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:-2\n"); try std.testing.expectApproxEqAbs(@as(f64, -0.02), config.spending_real_change.?, 1e-9); } test "parseProjectionsConfig spending_change positive is a rise" { const config = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:1\n"); try std.testing.expectApproxEqAbs(@as(f64, 0.01), config.spending_real_change.?, 1e-9); } test "parseProjectionsConfig spending_change absent stays null (flat)" { const config = parseProjectionsConfig("#!srfv1\ntype::config,horizon:num:30\n"); try std.testing.expectEqual(@as(?f64, null), config.spending_real_change); } test "parseProjectionsConfig spending_change magnitude is clamped both directions" { const hi = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:50\n"); try std.testing.expectApproxEqAbs(max_abs_spending_real_change, hi.spending_real_change.?, 1e-9); const lo = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:-50\n"); try std.testing.expectApproxEqAbs(-max_abs_spending_real_change, lo.spending_real_change.?, 1e-9); } test "integration: declining model + late healthcare troughs mid-retirement" { // Mirrors the shipped `examples/post-retirement-smile` config // (keep the two in sync). Exercises the full path - parse the // signed-percent drift, resolve the life events, and compute the // trough. Composing the 2%/yr decline with the age-80 healthcare // expense must put the spending trough in mid-retirement (the // year just before the expense begins), not at the final // distribution year - the whole reason the trough is searched for // rather than read off the last year. (The shipped example file // itself is validated by running the binary against it; @embedFile // can't reach outside src/.) const cfg = \\#!srfv1 \\type::config,target_stock_pct:num:60 \\type::config,spending_change:num:-2 \\type::config,horizon:num:30 \\type::birthdate,date::1958-02-19 \\type::birthdate,date::1961-07-04,person:num:2 \\type::event,name::Social Security (Robin),start_age:num:67,person:num:1,amount:num:34800 \\type::event,name::Healthcare (late-life),start_age:num:80,person:num:1,amount:num:-55000 ; const config = parseProjectionsConfig(cfg); // -2 whole percent -> -0.02 fraction. try std.testing.expectApproxEqAbs(@as(f64, -0.02), config.spending_real_change.?, 1e-9); // Resolve the life events against a fixed reference date (not // "today" - tests must be deterministic). const as_of = Date.fromYmd(2026, 6, 26); const resolved = config.resolveEvents(as_of); const events = resolved[0..config.event_count]; // Find the resolved start year of the lone expense event (the // late-life healthcare bump). var hc_start: ?u16 = null; for (events) |ev| { if (ev.annual_amount < 0) hc_start = ev.start_year; } try std.testing.expect(hc_start != null); const dist_years: u16 = 30; const t = spendingTrough(150_000, config.spending_real_change.?, events, 0, dist_years, as_of).?; // Trough is the year just before healthcare starts... try std.testing.expectEqual(hc_start.? - 1, t.year_offset); // ...which is strictly before the final distribution year. try std.testing.expect(t.year_offset < dist_years - 1); } test "benchmarkSymbols: defaults to SPY/AGG when projections.srf is absent" { // These two are fetched for the benchmark comparison and held nowhere, which // is why AGG went stale unnoticed while SPY - which doubles as a `ticker::` // alias on a real holding - stayed current. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const pair = benchmarkSymbols(std.testing.io, arena_state.allocator(), "/nonexistent/projections.srf"); try std.testing.expectEqual(@as(usize, 2), pair.len); try std.testing.expectEqualStrings("SPY", pair[0]); try std.testing.expectEqualStrings("AGG", pair[1]); } test "benchmarkSymbols: an override is honoured and outlives the config" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const dir = try tmp.dir.realPathFileAlloc(io, ".", allocator); defer allocator.free(dir); { const f = try tmp.dir.createFile(io, "projections.srf", .{}); defer f.close(io); var buf: [256]u8 = undefined; var w = f.writer(io, &buf); try w.interface.writeAll("#!srfv1\ntype::config,benchmark_stock::VTI,benchmark_bond::BND\n"); try w.interface.flush(); } var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); const path = try std.fs.path.join(arena_state.allocator(), &.{ dir, "projections.srf" }); // Duped, not borrowed: an override lives in a [16]u8 inside the UserConfig, // which dies with this call. const pair = benchmarkSymbols(io, arena_state.allocator(), path); try std.testing.expectEqual(@as(usize, 2), pair.len); try std.testing.expectEqualStrings("VTI", pair[0]); try std.testing.expectEqualStrings("BND", pair[1]); }