From 4ed15c7cf4f08b8039f01d84a8d605b758b830af Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 28 Jun 2026 14:29:30 -0700 Subject: [PATCH] projection modeling enhancements based on youngest in a couple + documentation --- .pre-commit-config.yaml | 2 +- AGENTS.md | 2 +- TODO.md | 69 +- docs/explanation/projections-model.md | 56 +- docs/reference/config/projections-srf.md | 136 +- examples/README.md | 9 +- .../post-retirement-smile/projections.srf | 3 +- examples/post-retirement/projections.srf | 3 +- examples/pre-retirement-age/projections.srf | 3 +- examples/pre-retirement-both/projections.srf | 3 +- .../pre-retirement-spending/projections.srf | 12 +- src/analytics/projections.zig | 1103 ++++++++++++++--- src/commands/projections.zig | 96 +- src/tui/projections_tab.zig | 112 +- src/views/projections.zig | 260 +++- 15 files changed, 1568 insertions(+), 301 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bc707ba..31f8e6e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,7 +46,7 @@ repos: - id: test name: Run zig build test entry: zig - args: ["build", "coverage", "-Dcoverage-threshold=79"] + args: ["build", "coverage", "-Dcoverage-threshold=80"] language: system types: [file] pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 8357d98..cb2bbd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -399,7 +399,7 @@ zig build test # run all tests (single binary, discovers all tests zig build run -- # build and run CLI zig build docs # generate library documentation zig build coverage # run tests with kcov coverage (Linux only). See "Coverage" section. -zig build coverage -Dcoverage-threshold=72 # fail build if coverage < N% (see .pre-commit-config.yaml for current floor) +zig build coverage -Dcoverage-threshold=80 # fail build if coverage < N% (see .pre-commit-config.yaml for current floor) ``` **Tooling** (managed via `.mise.toml`): diff --git a/TODO.md b/TODO.md index e551ed7..2e5bb53 100644 --- a/TODO.md +++ b/TODO.md @@ -5,67 +5,16 @@ ordered roughly by priority within each section. Priority labels (`HIGH` / `MEDIUM` / `LOW`) mark items that deserve explicit ranking; unlabeled items are "someday, if the mood strikes." -## Projections: future enhancements +## Projections: Contribution-attribution overlay -- **Goal-seek over distribution horizon for W1 - priority LOW.** - Today the W1 ("set spending, find date") workflow reports the - earliest retirement at each user-configured `(horizon, confidence)` - cell. The philosophically correct version asks "when have I - accumulated enough wealth that the projection shows a 95% - probability of success withdrawing X per year from retirement - until age-of-death?" - i.e. goal-seek across both `accumulation_years` - AND `distribution_years` simultaneously, anchored to a configured - age-of-death. NP-shaped search; not worth optimizing until - someone wants it. -- **Per-person retirement_age - priority LOW.** - V1 of the accumulation-phase spec chose Option A: a single - household retirement boundary derived from the oldest configured - birthdate. Households where one earner retires significantly - earlier than the other would benefit from per-person - `retirement_age` fields on each `type::birthdate` record, with - contributions stopped per-person. -- **Historical projection overlay follow-ups.** The base - `--overlay-actuals` overlay shipped (CLI tip + TUI primary surface). - Open enhancements: - - Historical `metadata.srf` / `projections.srf` for back-dated - runs. Today the overlay re-runs against current classifications - and assumptions; for historically faithful what-the-model-said-then - output we'd check out the git-tracked versions of those files - at the as-of commit and load those instead. Edge case until - classifications materially drift. - - Contribution-attribution overlay. Today's actuals line includes - contributions implicitly; the bands assume modeled contributions - that may or may not match reality. A "decompose actuals into - market return vs contributions" annotation would clarify how - much of the trajectory was the model being right vs new money - arriving on schedule. - - **Better composition basis for imported-only as-of.** Today - the imported-only path uses today's allocations scaled by - `imported_liquid / today_total_liquid`. That's the simplest - thing that could work, but it's "today's mix back-dated" - - it ignores everything we know about the historical context. - Specifically: `imported_values.srf` already carries an - `expected_return` field per row that the user captured at - that date in their source spreadsheet. We could: - - Use the imported `expected_return` as a sanity check - against the simulation's per-position weighted return - (warn or clamp if they diverge wildly - the spreadsheet's - number reflects what the user actually saw at the time). - - Use the imported `expected_return` to bias the - stock/bond split inference: a higher expected return - implies a higher historical equity weighting than today's - mix probably reflects. - - Reach further: derive a synthetic stock/bond split from - the imported `expected_return` directly, treating it as - a weighted average of SPY and AGG returns at that date - and solving for the weights. That gives a per-imported- - row composition that's locally faithful instead of - one-mix-fits-all. - None of these are urgent - the current "today's mix scaled" - approximation is documented as such and the bands still - render meaningfully - but each would tighten the historical - faithfulness one notch. Pick whichever has the highest - payoff vs. complexity when this gets revisited. +Contribution-attribution overlay. Today's actuals line includes +contributions implicitly; the bands assume modeled contributions +that may or may not match reality. A "decompose actuals into +market return vs contributions" annotation would clarify how +much of the trajectory was the model being right vs new money +arriving on schedule. The contributions pipeline +(`transaction_log.srf` + lot-diff) already has the flow data, so +this is the one overlay follow-up with real analytical payoff. ## Investigate: detailed 401(k) contributions data source diff --git a/docs/explanation/projections-model.md b/docs/explanation/projections-model.md index e0d6251..1d7379c 100644 --- a/docs/explanation/projections-model.md +++ b/docs/explanation/projections-model.md @@ -25,7 +25,10 @@ Every projection runs the same two phases in order: length comes from your retirement-date input. With no input, it's zero years (an already-retired view). 2. **Distribution** -- annual spending withdrawn (CPI-adjusted by - default), no contributions. Its length is the configured `horizon`. + default), no contributions. Its length is the configured `horizon`, + or - when you set a `horizon_age` - the years until the *last + surviving* household member reaches that age of death (see + [Mortality](#mortality-the-surviving-spouse)). Spending is flat in real terms unless you set [`spending_change`](../reference/config/projections-srf.md#declining-spending-the-smile) to taper or grow it year over year (the Blanchett "spending smile"). @@ -103,14 +106,61 @@ When you set a `target_spending` instead of a date, zfin inverts the question: for each (horizon x confidence) cell it searches for the **earliest** accumulation length (up to `max_accumulation_years`, 50 years by default) that sustains your spending, and renders the grid of -answers. One cell is promoted to the -headline (see +answers. One cell is promoted to the headline (see [promotion rules](../reference/config/projections-srf.md#the-two-retirement-planning-inputs)). If no length within the cap works, the cell is **infeasible** -- shown honestly rather than fudged. A young saver with a runway longer than 50 years can raise the cap via [`max_accumulation_years`](../reference/config/projections-srf.md#config-fields). +For a plain `horizon` the distribution length is fixed, so a later +retirement means the money has to last from a later start to a fixed +number of years out. For an age-anchored `horizon_age` column the +distribution instead *shrinks* as retirement slides later, because the +end (the last survivor's death) is pinned: the total span from today is +constant and the column header reads `to age 95` rather than a year +count. + +## Mortality: the surviving spouse + +A `horizon_age` turns on a mortality model that a plain numeric horizon +doesn't have. It rests on the financial-planning standard for couples: +**the money must last until the last surviving member dies**, because +the household needs income for as long as *either* spouse is alive +(Blanchett, "How to Estimate 'The End' of Retirement," *Journal of +Financial Planning*, 2021). So the horizon is anchored on the +**youngest** member (who reaches the age of death latest), not the +oldest. + +Two adjustments fire at the **first** death (the oldest member reaching +the age of death): + +- **Spending steps down.** A surviving spouse needs less than the + couple but far more than half - shared costs (housing, utilities, + insurance) don't fall when one person dies. zfin scales base spending + by [`survivor_spending_pct`](../reference/config/projections-srf.md#survivor-spending-survivor_spending_pct) + (default 75%, a 25% cut). That default is the conservative edge of the + standard equivalence-scale range (the OECD-modified scale implies + ~67%, the square-root scale ~71%); financial-planning software is + gentler at ~80%. The income side falls harder than the need side - + the Chicago Fed found household income drops ~37% at widowhood but the + standard-of-living-adjusted decline is only ~11% - which is why the + knob is about *need*, not income. +- **The deceased's income stops.** Each person's Social Security, + pension, and wages (and their own late-life expenses), entered as + `type::event`, terminate the year that person dies. Survivor benefits + that partly continue (a pension's survivor percentage, Social + Security's keep-the-higher rule) are modeled as a separate event on + the surviving spouse - see the + [config reference](../reference/config/projections-srf.md#modeling-survivor-benefits). + +These two effects pull in opposite directions on the headline number: +the last-survivor horizon lengthens the plan (more conservative), while +the survivor spending cut and the (correct) retention of only the +survivor's own benefits shorten the funding need. Modeling both is more +faithful than either the old "stop at the first death" truncation or a +naive "fund the longer life at full couple spending." + ## The caveat that matters most zfin states this loudly by design, and so does this page: diff --git a/docs/reference/config/projections-srf.md b/docs/reference/config/projections-srf.md index 8c2dac6..6dd797b 100644 --- a/docs/reference/config/projections-srf.md +++ b/docs/reference/config/projections-srf.md @@ -35,10 +35,10 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400 | Field | Type | Description | |--------------------------------------|------|--------------------------------------------------------------------------------------------------------------------------------| | `target_stock_pct` | num | Asset-allocation target (0-100). Sets the simulation's stock/bond blend. | -| `expense_ratio` | num | Annual fund expense ratio as a percent (e.g. `0.18` = 0.18%), subtracted from the blended return each year. Default `0.18` (FIRECalc's default; realistic for a fund portfolio). Override down (`0.04`) for low-cost index funds, up for active funds, or `0` for all individual stocks. | -| `return_cap` | num | Optional ceiling, as a percent (e.g. `30` = 30%), on each position's conservative trailing return before it is weighted into the displayed **Projected return**. Default: none. See [Capping outlier returns](#capping-outlier-returns). | -| `horizon` | num | Distribution-phase length in years. Repeat the line for multiple horizons. | -| `horizon_age` | num | Horizon expressed as an age; resolves to `target_age - oldest_current_age`. Repeatable. | +| `expense_ratio` | num | Annual fund expense ratio as a percent (e.g. `0.18` = 0.18%), subtracted from the blended return each year. Default `0.18` (FIRECalc's default; realistic for a fund portfolio). Override down (`0.04`) for low-cost index funds, up for active funds, or `0` for all individual stocks. | +| `return_cap` | num | Optional ceiling, as a percent (e.g. `30` = 30%), on each position's conservative trailing return before it is weighted into the displayed **Projected return**. Default: none. See [Capping outlier returns](#capping-outlier-returns). | +| `horizon` | num | Distribution-phase length in years (a fixed horizon, no mortality modeling). Repeat the line for multiple horizons. | +| `horizon_age` | num | Horizon expressed as an **age of death**. The distribution runs until the *last surviving* member reaches this age; the column also models the survivor spending step-down and per-person income/expense termination at death. See [Planning to an age of death](#planning-to-an-age-of-death). Repeatable. | | `retirement_age` | num | Age the **oldest** configured person must reach to retire. | | `retirement_at` | date | Absolute retirement date (`YYYY-MM-DD`). Wins over `retirement_age` if both set. | | `annual_contribution` | num | Yearly accumulation-phase contribution, in today's dollars. | @@ -46,6 +46,7 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400 | `target_spending` | num | Desired retirement spending, in today's dollars. | | `target_spending_inflation_adjusted` | bool | If `true` (default), target spending grows with CPI during distribution. | | `spending_change` | num | Signed annual *real* change in spending across the distribution phase, as a whole percent. Negative = declining (e.g. `-2` = -2%/yr, the "spending smile"); positive = rising. Default: absent = flat real spending. Magnitude clamped to 10%/yr. See [Declining spending](#declining-spending-the-smile). | +| `survivor_spending_pct` | num | Percent of the couple's joint spending the surviving spouse needs after the first death, for `horizon_age` columns. Default `75` (a 25% reduction). Only applies to a multi-person household with an age gap. See [Planning to an age of death](#planning-to-an-age-of-death). | | `max_accumulation_years` | num | Ceiling (in years) the earliest-retirement search scans when `target_spending` is set. Default `50`, capped at `100`. | | `retirement_target` | num | Annotation on a `horizon`/`horizon_age` line that overrides the earliest-retirement promotion rule. Allowed: `90`, `95`, `99`. | @@ -118,6 +119,122 @@ How it interacts with the rest of the model: See the `post-retirement-smile/` example for a worked configuration. +### Planning to an age of death + +A plain `horizon` is a fixed number of distribution years. A +`horizon_age` instead anchors the horizon to an **age of death**, and +turns on the mortality model: + +``` +type::config,horizon_age:num:95 +type::birthdate,date::1962-03-01 +type::birthdate,date::1967-08-15,person:num:2 +``` + +This says "plan until we reach age 95." Three things follow: + +1. **Last-survivor horizon.** The money must last until the *youngest* + member reaches the age of death (they reach it latest in calendar + time), not the oldest. For a couple this is the standard + financial-planning treatment: a household's assets must fund the + period for as long as *either* spouse is alive. (Anchoring on the + oldest would truncate the plan at the first death and silently + under-fund the survivor's remaining years.) A `horizon_age` requires + at least one `birthdate`. + +2. **The horizon is coupled to the retirement date.** Distribution + length = `age_of_death - retirement_age`. With `target_spending` set, + the earliest-retirement search shrinks the distribution as it pushes + the retirement date later (the death date is fixed), so the column + header reads `to age 95` rather than a fixed year count. + +3. **Per-person income and expenses end at death.** Each person's + Social Security, pension, wages, and their own late-life expenses + (entered as `type::event` with that `person`) stop the year that + person reaches the age of death - a deceased spouse no longer + collects their own benefit. + +#### Survivor spending (`survivor_spending_pct`) + +In a couple with an age gap, the first death (the *oldest* reaching the +age of death) steps household spending down to a surviving-spouse level +for the remaining years. `survivor_spending_pct` is the percent of the +couple's joint spending the survivor needs: + +``` +type::config,survivor_spending_pct:num:75 +``` + +The **default is 75** (a 25% reduction). A surviving spouse needs *less* +than the couple (one fewer person) but far *more* than half, because +most costs - housing, property tax, utilities, insurance - are shared +and don't fall when one person dies. The default sits at the +conservative edge of the standard range: + +- **OECD equivalence scales**, the standard economic adjustment for + household size: the OECD-modified scale (1.0 for the first adult, + 0.5 for the second) implies a survivor needs ~67% (a 33% cut); the + square-root scale (needs proportional to sqrt of household size) + implies ~71% (a 29% cut). See Eurostat, + "[Equivalised disposable income](https://ec.europa.eu/eurostat/statistics-explained/index.php?title=Glossary:Equivalised_disposable_income)". +- **Financial-planning convention** is gentler, ~80% (a 20% cut); + e.g. Kiplinger, ["Five Financial Changes That Happen When Your + Spouse Dies"](https://www.kiplinger.com/retirement/financial-changes-that-happen-when-your-spouse-dies) + (2024), which also warns that the right number is individual - a + survivor's spending can even rise. +- The income side falls much harder than the need side, which is why + this is about *need*, not income: the Chicago Fed (Fadlon, Ramnath & + Tong, *Chicago Fed Letter* No. 438, 2020) found household income + drops ~37% at widowhood but, after adjusting for the smaller + household, the standard-of-living decline was only ~11%. + +The value is configurable because it is highly individual; 75% is a +defensible, slightly-conservative starting point. Any value `>= 0` is +honored (including above 100, for a survivor whose spending rises). + +#### Modeling survivor benefits + +Capping a deceased person's events at their death sets them to zero, +which is correct for benefits that die with the holder but not for +those that partly continue. Model the continuing portion as an event +tied to the **surviving** person: + +- A pension with a 50% survivor benefit: enter the survivor's 50% as a + separate event on the surviving spouse. +- Social Security's "keep the higher" rule (the survivor keeps the + larger of the two benefits): if the higher earner dies first, add a + survivor-benefit event on the survivor starting at the first-death + age. Otherwise the default reads slightly conservative for that + household. + +### Staggered retirement (one spouse retires earlier) + +zfin models a single household retirement boundary: the accumulation +phase ends and the distribution phase begins at one date. To model one +spouse retiring before the other: + +- If the early retirement has effectively already happened (one spouse + is no longer contributing), just set `annual_contribution` to the + remaining (solo) saver's amount. It applies across the whole + accumulation phase, which is exactly what you want. +- For a future step-down (both still working, retiring at different + dates), add a negative-contribution event at the early retiree's + date so the yearly inflow drops then: + + ```srf + type::event,name::Stop saving (A),start_age:num:62,person:num:1,amount:num:-20000 + ``` + + During accumulation a life event adjusts the portfolio's yearly + inflow, so a negative amount models the reduced saving directly. + +Caveat: during accumulation, life-event *income* (a positive amount) +is **added to the portfolio** - modeled as saved, not consumed. So +model an early retiree whose Social Security or pension is *spent* by +lowering `annual_contribution`, not by adding a positive income event +(which would inflate savings). Reserve positive accumulation-phase +events for income that genuinely gets invested. + ### Capping outlier returns The **Projected return** shown by `zfin projections` (and the "Projected @@ -202,10 +319,13 @@ about the file: When `target_spending` is set, the **earliest-retirement grid** shows, for each (horizon x confidence) pair, the earliest year that sustains -the spending. The default promotion rule picks the headline cell by -walking horizons longest-to-shortest at 99% confidence, preferring the -longest horizon that keeps the oldest person under age 100. Override it -with a `retirement_target` annotation on one horizon line: +the spending. The default promotion rule picks the headline cell at 99% +confidence: it prefers an age-of-death-anchored (`horizon_age`) column +(the latest death, if several), since that's the "plan to the last +survivor" answer; with only plain numeric horizons it walks them +longest-to-shortest, preferring the longest that keeps the oldest +person under age 100. Override it with a `retirement_target` annotation +on one horizon line: ```srf # use the 35yr x 95% cell as the headline diff --git a/examples/README.md b/examples/README.md index eaea2c9..6aec783 100644 --- a/examples/README.md +++ b/examples/README.md @@ -73,10 +73,11 @@ set, `target_spending` is not. Output renders: - The **Accumulation phase** block is populated by **promoting one cell** from the grid into the headline retirement line, plus the median portfolio at retirement and p10-p90 range. The default - promotion rule walks horizons longest -> shortest and picks the - longest one whose end year keeps the oldest configured person - under age 100, at 99% confidence (most conservative). If even - the shortest horizon overshoots, it's used anyway. + promotion rule prefers an age-of-death (`horizon_age`) column - the + "plan to the last survivor" answer - at 99% confidence (most + conservative); with only numeric horizons it walks them longest -> + shortest and picks the longest whose end year keeps the oldest + configured person under age 100. - The grid stays rendered for transparency - the user can see how the headline cell compares to the rest of the matrix. diff --git a/examples/post-retirement-smile/projections.srf b/examples/post-retirement-smile/projections.srf index 0d39f77..8d78e15 100644 --- a/examples/post-retirement-smile/projections.srf +++ b/examples/post-retirement-smile/projections.srf @@ -26,7 +26,8 @@ type::config,target_stock_pct:num:60 # flat real spending, the default. type::config,spending_change:num:-2 -# Distribution horizons - through age 90 (older partner first) +# Distribution horizons - plus a plan-to-age-95 column anchored on the +# youngest partner (the last survivor) type::config,horizon:num:20 type::config,horizon:num:30 type::config,horizon_age:num:95 diff --git a/examples/post-retirement/projections.srf b/examples/post-retirement/projections.srf index 90ffe7b..94073ea 100644 --- a/examples/post-retirement/projections.srf +++ b/examples/post-retirement/projections.srf @@ -13,7 +13,8 @@ # Allocation target shifts more conservative in retirement type::config,target_stock_pct:num:60 -# Distribution horizons - through age 90 (older partner first) +# Distribution horizons - plus a plan-to-age-95 column anchored on the +# youngest partner (the last survivor) type::config,horizon:num:20 type::config,horizon:num:30 type::config,horizon_age:num:95 diff --git a/examples/pre-retirement-age/projections.srf b/examples/pre-retirement-age/projections.srf index 4f93d85..a61a1bb 100644 --- a/examples/pre-retirement-age/projections.srf +++ b/examples/pre-retirement-age/projections.srf @@ -16,7 +16,8 @@ type::config,target_stock_pct:num:80 # Distribution-phase horizons to simulate type::config,horizon:num:25 type::config,horizon:num:35 -# Plan through age 95 - the older partner's first-to-hit-95 sets the floor +# Plan through age 95 - anchored on the youngest partner (the last +# survivor), so the money lasts until they reach 95. type::config,horizon_age:num:95 # Target retirement date: oldest partner (Pat) reaches 65 in 2046 diff --git a/examples/pre-retirement-both/projections.srf b/examples/pre-retirement-both/projections.srf index af17b0e..63a403b 100644 --- a/examples/pre-retirement-both/projections.srf +++ b/examples/pre-retirement-both/projections.srf @@ -24,7 +24,8 @@ type::config,target_stock_pct:num:80 # Distribution-phase horizons to simulate type::config,horizon:num:25 type::config,horizon:num:35 -# Plan through age 95 - the older partner's first-to-hit-95 sets the floor +# Plan through age 95 - anchored on the youngest partner (the last +# survivor), so the money lasts until they reach 95. type::config,horizon_age:num:95 # Target retirement date: oldest partner (Pat) reaches 65 in 2046 diff --git a/examples/pre-retirement-spending/projections.srf b/examples/pre-retirement-spending/projections.srf index 17ca2ff..d5fad7c 100644 --- a/examples/pre-retirement-spending/projections.srf +++ b/examples/pre-retirement-spending/projections.srf @@ -11,10 +11,11 @@ # earliest accumulation length that sustains that spending at each # configured (horizon × confidence) pair, renders the resulting # grid, and promotes one cell into the Accumulation phase block as -# the headline. The default promotion rule is "longest configured -# horizon at 99% confidence, where the oldest person stays under -# age 100." See `pre-retirement-spending-target/` for the explicit- -# override variant. +# the headline. The default promotion rule prefers an age-of-death +# (`horizon_age`) column at 99% confidence - the "plan to the last +# survivor" answer - falling back to the longest numeric horizon that +# keeps the oldest person under age 100. See +# `pre-retirement-spending-target/` for the explicit-override variant. # Asset allocation target (80% stocks / 20% bonds - typical pre-retirement) type::config,target_stock_pct:num:80 @@ -22,7 +23,8 @@ type::config,target_stock_pct:num:80 # Distribution-phase horizons to simulate type::config,horizon:num:25 type::config,horizon:num:35 -# Plan through age 95 - the older partner's first-to-hit-95 sets the floor +# Plan through age 95 - anchored on the youngest partner (the last +# survivor), so the money lasts until they reach 95. type::config,horizon_age:num:95 # Annual household contribution to retirement accounts diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig index 4bfd493..8b8fd3f 100644 --- a/src/analytics/projections.zig +++ b/src/analytics/projections.zig @@ -99,13 +99,68 @@ pub const LifeEvent = struct { /// 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; - return .{ + 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, + }; } }; @@ -162,7 +217,7 @@ pub const ResolvedRetirement = struct { /// #!srfv1 /// type::config,target_stock_pct:num:77 /// type::config,horizon:num:30 -/// type::config,horizon_age:num:90 # resolves to (90 - oldest current age) +/// 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 @@ -211,11 +266,24 @@ pub const UserConfig = struct { /// promote into the Accumulation phase block. See /// `pickPromotedCell` for the resolution algorithm. horizon_targets: [max_horizons]u8 = @splat(0), - /// Age-based horizon targets. Resolved at context-load time to - /// `target_age - max(currentAges())` years - i.e. how long until the - /// oldest configured person hits `target_age`. Rationale: the first - /// person to hit the target age sets the meaningful planning horizon, - /// because spending typically drops substantially after the first death. + /// 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` @@ -254,6 +322,21 @@ pub const UserConfig = struct { /// 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, @@ -329,11 +412,23 @@ pub const UserConfig = struct { return ages; } - /// Resolve age-based horizons (`horizon_ages`) into year counts and - /// append them to `horizons`. For each target age, computes - /// `target_age - max(currentAges(as_of))` - the number of years - /// until the oldest configured person hits that age. Targets that are - /// already in the past (oldest age ≥ target) are silently skipped. + /// 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 @@ -342,14 +437,18 @@ pub const UserConfig = struct { if (self.horizon_age_count == 0) return; if (self.birthdate_count == 0) return error.HorizonAgeWithoutBirthdate; - const oldest = self.oldestAge(as_of); + const youngest = self.youngestAge(as_of); for (0..self.horizon_age_count) |i| { const target = self.horizon_ages[i]; - if (target <= oldest) continue; // already past target, skip silently - const years: u16 = target - oldest; + 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]; @@ -382,11 +481,29 @@ pub const UserConfig = struct { 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.resolve(ages) orelse .{ + resolved[i] = ev.resolveToAge(ages, age_of_death) orelse .{ .start_year = std.math.maxInt(u16), // effectively never active .duration = 0, .annual_amount = 0, @@ -477,6 +594,34 @@ pub const UserConfig = struct { 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) ─────────────────────────────────── @@ -504,6 +649,9 @@ const SrfConfig = struct { /// (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, @@ -683,6 +831,19 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { 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 @@ -835,6 +996,25 @@ pub const SimParams = struct { /// "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 { @@ -936,7 +1116,18 @@ fn simulateTwoPhase( params.annual_contribution; portfolio += contribution + event_net; } else { - const real_spending = params.annual_spending * spend_factor; + 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 @@ -999,32 +1190,11 @@ fn simulateCycle( }); } -/// Run the full historical simulation: for each possible starting year, -/// simulate a retirement of `horizon` years. Returns the number of cycles -/// where the portfolio survived (never went to zero). -/// -/// `all_paths` is a 2D buffer: [cycle_index][year] = portfolio value. -/// Must be pre-allocated with dimensions [num_cycles][horizon + 1]. -fn runAllCycles( - all_paths: [][]f64, - horizon: u16, - initial_value: f64, - annual_spending: f64, - stock_pct: f64, - events: []const ResolvedEvent, -) usize { - return runAllCyclesParams(all_paths, shiller.annual_returns, .{ - .initial_value = initial_value, - .stock_pct = stock_pct, - .annual_spending = annual_spending, - .distribution_years = horizon, - .events = events, - }); -} - -/// Run all cycles with full SimParams - accumulation-aware variant -/// used by both the distribution-only wrapper above and the -/// earliest-retirement search (`findEarliestRetirement`). +/// 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, @@ -1053,18 +1223,19 @@ fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 { // ── Safe withdrawal search ───────────────────────────────────── -/// Two-phase variant of `findSafeWithdrawal`. Searches for the -/// largest `annual_spending` (in today's dollars) such that the -/// distribution-phase failure rate stays ≤ `1 - confidence`, with -/// `accumulation_years` of contributions feeding the portfolio first. +/// 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). Used as the inner search -/// for the target-retirement-date input when the user has configured -/// a non-zero accumulation phase. -pub fn findSafeWithdrawalWithAccumulation( +/// matches direct findSafeWithdrawal` test). +fn findSafeWithdrawalWithAccumulation( horizon: u16, initial_value: f64, stock_pct: f64, @@ -1095,10 +1266,10 @@ pub fn findSafeWithdrawalWithAccumulation( /// against the projected post-accumulation portfolio value. /// /// `base` carries every `SimParams` field except `annual_spending`, -/// which the search overwrites per probe. Both -/// `findSafeWithdrawal` (zero accumulation) and -/// `findSafeWithdrawalWithAccumulation` (non-zero accumulation) -/// delegate here. +/// 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 == @@ -1177,8 +1348,56 @@ pub const EarliestRetirement = struct { 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 @@ -1242,43 +1461,14 @@ pub fn findEarliestRetirement( // Found the earliest N. Run the full path simulation once to // extract the portfolio statistics at year N (the retirement // boundary). - const total = params.totalYears(); - const num_cycles = maxCyclesFor(data, total); - if (num_cycles == 0) { - return .{ - .horizon = distribution_years, - .confidence = confidence, - .accumulation_years = n, - .median_at_retirement = 0, - .p10_at_retirement = 0, - .p90_at_retirement = 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)); - + const stats = try retirementBoundaryStats(allocator, data, params, n); return .{ .horizon = distribution_years, .confidence = confidence, .accumulation_years = n, - .median_at_retirement = percentile(sort_buf, 0.50), - .p10_at_retirement = percentile(sort_buf, 0.10), - .p90_at_retirement = percentile(sort_buf, 0.90), + .median_at_retirement = stats.median, + .p10_at_retirement = stats.p10, + .p90_at_retirement = stats.p90, }; } @@ -1292,6 +1482,101 @@ pub fn findEarliestRetirement( }; } +/// 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 @@ -1322,11 +1607,14 @@ pub const promotion_age_cap: u16 = 100; /// Algorithm: /// 1. If exactly one horizon is annotated with `retirement_target`, /// honor that annotation regardless of length or feasibility. -/// 2. Else, walk horizons longest -> shortest. Pick the longest -/// whose end year keeps the oldest configured person under -/// `promotion_age_cap`. -/// 3. If even the shortest horizon overshoots, use it anyway. -/// 4. Default confidence is 99% (most conservative). +/// 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%. @@ -1360,8 +1648,28 @@ pub fn pickPromotedCell( // highest. Find the index whose value is closest to 0.99. const default_ci = confidenceIndex(confidence_levels, 99); - // Step 2: longest horizon where oldest person stays under the - // age cap. With no birthdates, the cap doesn't apply - just + // 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); @@ -1512,45 +1820,149 @@ pub const ProjectionData = struct { ci_99: usize, }; -/// Run the full projection-display batch up front: a safe-withdrawal -/// grid across every (horizon × confidence) pair, plus a percentile -/// band per horizon at the highest configured confidence (used for -/// the chart and the terminal-portfolio-value table). +/// 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). /// -/// Two distinct computations bundled in one call: -/// -/// 1. **Safe-withdrawal grid.** For each `(horizon, confidence)` -/// pair in `confidence_levels × horizons`, binary-searches the -/// maximum annual spending the portfolio can sustain across -/// that horizon at that confidence. Stored in -/// `withdrawals[ci * horizons.len + hi]`. -/// -/// 2. **Percentile bands.** For each horizon, runs the full -/// historical simulation at the highest-confidence withdrawal -/// rate and extracts p10/p25/p50/p75/p90 of the portfolio -/// value at each year. The highest-confidence rate is the -/// most conservative spending level, so the chart shows the -/// steepest survivable drawdown. Stored in `bands[hi]`. -/// -/// All four call sites in the codebase (CLI projections command, -/// TUI projections tab, the `--vs` comparison renderer, and the -/// view-model integration test) want exactly this bundle, so it's -/// computed once per projection rather than re-derived per render. -/// -/// Accumulation parameters are always honored - pass `0` / -/// `0` / `true` for the distribution-only case (already-retired -/// users, no contributions configured). The simulation core -/// produces identical results when accumulation degenerates to -/// zero, so this single function covers every input combination -/// `projections.srf` allows. -/// -/// `confidence_levels` should be sorted ascending; `ci_99` in the -/// returned struct refers to the LAST entry, which by convention -/// is the highest (most-conservative) confidence. +/// 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. -pub fn runProjectionGrid( +fn runProjectionGrid( alloc: std.mem.Allocator, horizons: []const u16, confidence_levels: []const f64, @@ -1563,42 +1975,21 @@ pub fn runProjectionGrid( expense_ratio: f64, spending_real_change: f64, ) !ProjectionData { - const num_results = horizons.len * confidence_levels.len; - const withdrawals = try alloc.alloc(WithdrawalResult, num_results); - for (confidence_levels, 0..) |conf, ci| { - for (horizons, 0..) |h, hi| { - withdrawals[ci * horizons.len + hi] = findSafeWithdrawalWithAccumulation( - h, - total_value, - stock_pct, - conf, - events, - accumulation_years, - annual_contribution, - contribution_inflation_adjusted, - expense_ratio, - spending_real_change, - ); - } - } - const ci_99 = confidence_levels.len - 1; - const bands = try alloc.alloc(?[]YearPercentiles, horizons.len); - for (horizons, 0..) |h, hi| { - const wr = withdrawals[ci_99 * horizons.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 = h, - .accumulation_years = accumulation_years, - .annual_contribution = annual_contribution, - .contribution_inflation_adjusted = contribution_inflation_adjusted, - .expense_ratio = expense_ratio, - .events = events, - }) catch null; - } - return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 }; + 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) ──────── @@ -2082,19 +2473,26 @@ test "parseProjectionsConfig horizon_age parsed raw" { try std.testing.expectEqual(@as(u8, 0), config.horizon_count); } -test "resolveHorizonAges uses oldest birthdate (first-to-hit semantics)" { - // Person 1: born 1975, age 50 as of 2025. Person 2: born 1980, age 45. - // Target age 90 -> 90 - 50 = 40 years (first to hit 90 is the older). +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, 6, 15); + 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, 40), config.horizons[0]); + 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); } @@ -2109,7 +2507,8 @@ test "resolveHorizonAges errors without a birthdate" { } test "resolveHorizonAges skips targets already in the past" { - // Oldest age is 60 as of 2025; target 40 is already past - skipped. + // 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 @@ -2121,6 +2520,7 @@ test "resolveHorizonAges skips targets already in the past" { // 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" { @@ -2136,6 +2536,9 @@ test "resolveHorizonAges mixes with explicit horizon records" { 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" { @@ -2906,6 +3309,326 @@ test "findEarliestRetirement: result includes portfolio statistics" { } } +// ── 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" { @@ -2956,6 +3679,40 @@ fn retirementLineForTest(buf: []u8, resolved: ResolvedRetirement) []const u8 { // ── 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; @@ -3057,7 +3814,7 @@ test "parseProjectionsConfig: retirement_target on horizon_age survives resoluti ; var config = parseProjectionsConfig(data); try std.testing.expectEqual(@as(u8, 99), config.horizon_age_targets[0]); - // Resolve: oldest age in 2025 is 50 -> horizon 40. + // 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]); diff --git a/src/commands/projections.zig b/src/commands/projections.zig index c4c5930..3400e64 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -966,7 +966,7 @@ pub fn runBands( try out.print("\n", .{}); try cli.printBold(out, color, "Terminal Portfolio Value (nominal, at 99% withdrawal rate)\n", .{}); - try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.terminal_col_width)}); + try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, ctx.config.horizon_death_age[0..horizons.len], view.terminal_col_width)}); const p_labels = [_][]const u8{ "Pessimistic (p10)", "Median (p50)", "Optimistic (p90)" }; const p_styles = [_]view.StyleIntent{ .muted, .normal, .muted }; @@ -980,7 +980,7 @@ pub fn runBands( try cli.printBold(out, color, "Safe Withdrawal (FIRECalc historical simulation)\n", .{}); // Header row - try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.withdrawal_col_width)}); + try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, ctx.config.horizon_death_age[0..horizons.len], view.withdrawal_col_width)}); // Withdrawal rows. When an accumulation phase is active the // per-row % rate is suppressed (it would divide today's-dollars @@ -1818,9 +1818,9 @@ fn renderEarliestBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, { var hdr: std.ArrayListUnmanaged(u8) = .empty; try hdr.appendNTimes(va, ' ', label_width); - for (horizons) |h| { + for (horizons, 0..) |h, hi| { var hbuf: [16]u8 = undefined; - const hlabel = view.fmtHorizonLabel(&hbuf, h); + const hlabel = view.fmtHorizonLabelAge(&hbuf, h, ctx.config.horizon_death_age[hi]); try hdr.appendNTimes(va, ' ', cell_width -| hlabel.len); try hdr.appendSlice(va, hlabel); } @@ -2508,3 +2508,91 @@ test "renderCompareRowPct: no ANSI when color=false" { try renderCompareRowPct(&w, false, "X", 0.1, 0.2); try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null); } + +// ── Render-block tests (synthetic context, in-memory writer) ─── + +/// Build a ProjectionContext from a config for render tests, using a +/// synthetic benchmark comparison (no network / DataService). Mirrors +/// the setup the view-model integration tests use. +fn buildCtxForTest(arena: std.mem.Allocator, config: projections.UserConfig, as_of: Date) !view.ProjectionContext { + const benchmark = @import("../analytics/benchmark.zig"); + const comparison: benchmark.BenchmarkComparison = .{ + .stock_returns = .{}, + .bond_returns = .{}, + .benchmark_returns = .{}, + .portfolio_returns = .{}, + .conservative_return = 0.07, + .stock_pct = 0.8, + .bond_pct = 0.2, + }; + return view.buildProjectionContext(arena, config, comparison, 0.8, 0.2, 3_000_000, &.{}, as_of); +} + +test "renderEarliestBlock: age-anchored column renders 'to age N' header" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + var config = projections.parseProjectionsConfig( + \\#!srfv1 + \\type::config,horizon_age:num:95 + \\type::config,target_spending:num:120000 + \\type::config,survivor_spending_pct:num:75 + \\type::birthdate,date::1965-03-01 + \\type::birthdate,date::1968-08-15,person:num:2 + ); + const as_of = Date.fromYmd(2026, 6, 15); + try config.resolveHorizonAges(as_of); + const ctx = try buildCtxForTest(a, config, as_of); + + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try renderEarliestBlock(&w, false, a, ctx, as_of); + const out = w.buffered(); + + try testing.expect(std.mem.indexOf(u8, out, "Earliest retirement") != null); + try testing.expect(std.mem.indexOf(u8, out, "to age 95") != null); + try testing.expect(std.mem.indexOf(u8, out, "% confidence") != null); + // No ANSI when color is off. + try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} + +test "renderEarliestBlock: numeric horizon renders 'N Year' header" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const config = projections.parseProjectionsConfig( + \\#!srfv1 + \\type::config,horizon:num:30 + \\type::config,target_spending:num:40000 + ); + const as_of = Date.fromYmd(2026, 6, 15); + const ctx = try buildCtxForTest(a, config, as_of); + + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try renderEarliestBlock(&w, false, a, ctx, as_of); + const out = w.buffered(); + + try testing.expect(std.mem.indexOf(u8, out, "30 Year") != null); + try testing.expect(std.mem.indexOf(u8, out, "to age") == null); +} + +test "renderAccumulationBlock: target-date config prints accumulation stats" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + var config = projections.UserConfig{}; + config.retirement_at = Date.fromYmd(2040, 7, 1); + config.annual_contribution = 60_000; + const as_of = Date.fromYmd(2026, 7, 1); + const ctx = try buildCtxForTest(a, config, as_of); + + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try renderAccumulationBlock(&w, false, a, ctx); + const out = w.buffered(); + + try testing.expect(std.mem.indexOf(u8, out, "Accumulation phase:") != null); + try testing.expect(std.mem.indexOf(u8, out, "Years until possible retirement") != null); + try testing.expect(std.mem.indexOf(u8, out, "Median portfolio at retirement") != null); +} diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 5da2887..fdd02df 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -1214,7 +1214,7 @@ fn buildFooterSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayList try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ - .text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.terminal_col_width)}), + .text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, config.horizon_death_age[0..horizons.len], view.terminal_col_width)}), .style = th.headerStyle(), }); @@ -1262,7 +1262,7 @@ fn appendSwrTable( try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ - .text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.withdrawal_col_width)}), + .text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, pctx.config.horizon_death_age[0..horizons.len], view.withdrawal_col_width)}), .style = th.headerStyle(), }); @@ -1417,9 +1417,9 @@ fn appendAccumulationBlocks( { var hdr: std.ArrayListUnmanaged(u8) = .empty; try hdr.appendNTimes(arena, ' ', label_width); - for (horizons) |h| { + for (horizons, 0..) |h, hi| { var hbuf: [16]u8 = undefined; - const hlabel = view.fmtHorizonLabel(&hbuf, h); + const hlabel = view.fmtHorizonLabelAge(&hbuf, h, pctx.config.horizon_death_age[hi]); try hdr.appendNTimes(arena, ' ', cell_width -| hlabel.len); try hdr.appendSlice(arena, hlabel); } @@ -2095,7 +2095,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style // Column header try lines.append(arena, .{ - .text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.terminal_col_width)}), + .text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, config.horizon_death_age[0..horizons.len], view.terminal_col_width)}), .style = th.headerStyle(), }); @@ -2325,3 +2325,105 @@ test "appendSwrTable: accumulation suppresses rate rows and adds one footnote" { try testing.expectEqual(@as(usize, 0), counts.rates); try testing.expectEqual(@as(usize, 1), counts.footnotes); } + +/// Build an age-of-death-anchored, target-spending context for the +/// TUI render tests (couple + `horizon_age` + survivor cut). Synthetic +/// benchmark comparison, no DataService. +fn buildAgeAnchoredTestCtx(arena: std.mem.Allocator, as_of: zfin.Date) !view.ProjectionContext { + const benchmark = @import("../analytics/benchmark.zig"); + const projections = @import("../analytics/projections.zig"); + var config = projections.parseProjectionsConfig( + \\#!srfv1 + \\type::config,horizon_age:num:95 + \\type::config,target_spending:num:120000 + \\type::config,survivor_spending_pct:num:75 + \\type::birthdate,date::1965-03-01 + \\type::birthdate,date::1968-08-15,person:num:2 + \\type::event,name::Social Security (A),start_age:num:70,person:num:1,amount:num:38400 + \\type::event,name::College Tuition,start_age:num:62,person:num:1,duration:num:4,amount:num:-55000 + \\type::event,name::Pension,start_age:num:65,person:num:2,amount:num:24000,inflation_adjusted:bool:false + ); + try config.resolveHorizonAges(as_of); + const comparison: benchmark.BenchmarkComparison = .{ + .stock_returns = .{}, + .bond_returns = .{}, + .benchmark_returns = .{}, + .portfolio_returns = .{}, + .conservative_return = 0.07, + .stock_pct = 0.8, + .bond_pct = 0.2, + }; + return view.buildProjectionContext(arena, config, comparison, 0.8, 0.2, 3_000_000, &.{}, as_of); +} + +test "appendAccumulationBlocks: age-anchored target spending renders grid with 'to age' header" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const as_of = zfin.Date.fromYmd(2026, 6, 15); + const ctx = try buildAgeAnchoredTestCtx(a, as_of); + + var lines: std.ArrayListUnmanaged(StyledLine) = .empty; + try appendAccumulationBlocks(&lines, a, theme.default_theme, ctx, as_of); + + var found_accum = false; + var found_earliest = false; + var found_age = false; + for (lines.items) |line| { + if (std.mem.indexOf(u8, line.text, "Accumulation phase") != null) found_accum = true; + if (std.mem.indexOf(u8, line.text, "Earliest retirement") != null) found_earliest = true; + if (std.mem.indexOf(u8, line.text, "to age 95") != null) found_age = true; + } + try testing.expect(found_accum); + try testing.expect(found_earliest); + // The "to age N" header is a plain text line (the per-confidence + // date rows are grapheme-rendered, so they're not searched here). + try testing.expect(found_age); +} + +test "appendAccumulationBlocks: numeric target-date config renders accumulation stats, no grid" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const as_of = zfin.Date.fromYmd(2026, 7, 1); + const ctx = try buildSwrTestCtx(a, zfin.Date.fromYmd(2040, 7, 1), 60_000, as_of); + + var lines: std.ArrayListUnmanaged(StyledLine) = .empty; + try appendAccumulationBlocks(&lines, a, theme.default_theme, ctx, as_of); + + var found_accum = false; + var found_earliest = false; + for (lines.items) |line| { + if (std.mem.indexOf(u8, line.text, "Accumulation phase") != null) found_accum = true; + if (std.mem.indexOf(u8, line.text, "Earliest retirement") != null) found_earliest = true; + } + try testing.expect(found_accum); + // Target-date input has no earliest-retirement grid. + try testing.expect(!found_earliest); +} + +test "appendEventSummary: renders a Life Events line per configured event" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const as_of = zfin.Date.fromYmd(2026, 6, 15); + const ctx = try buildAgeAnchoredTestCtx(a, as_of); + + var lines: std.ArrayListUnmanaged(StyledLine) = .empty; + try appendEventSummary(&lines, as_of, a, theme.default_theme, ctx); + + var found_header = false; + var found_ss = false; + var found_tuition = false; + var found_nominal = false; + for (lines.items) |line| { + if (std.mem.indexOf(u8, line.text, "Life Events") != null) found_header = true; + if (std.mem.indexOf(u8, line.text, "Social Security") != null) found_ss = true; + if (std.mem.indexOf(u8, line.text, "College Tuition") != null) found_tuition = true; + if (std.mem.indexOf(u8, line.text, "nominal") != null) found_nominal = true; + } + try testing.expect(found_header); + try testing.expect(found_ss); + try testing.expect(found_tuition); // expense event (negative amount) + try testing.expect(found_nominal); // the non-inflation-adjusted pension +} diff --git a/src/views/projections.zig b/src/views/projections.zig index 8cd943c..b4ff30e 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -100,6 +100,18 @@ pub fn fmtHorizonLabel(buf: []u8, horizon: u16) []const u8 { return std.fmt.bufPrint(buf, "{d} Year", .{horizon}) catch "??"; } +/// Format a horizon column header, age-aware. When `death_age` is +/// non-zero the column is age-of-death-anchored and renders "to age +/// N" (the horizon shrinks as retirement slides, so the fixed-year +/// label would be misleading). Otherwise falls back to the numeric +/// "N Year" label. +pub fn fmtHorizonLabelAge(buf: []u8, horizon: u16, death_age: u16) []const u8 { + if (death_age != 0) { + return std.fmt.bufPrint(buf, "to age {d}", .{death_age}) catch "??"; + } + return fmtHorizonLabel(buf, horizon); +} + // ── Allocation summary ───────────────────────────────────────── /// Result of formatting the allocation note. @@ -329,8 +341,6 @@ pub const ProjectionInputs = enum { pub const ProjectionData = projections.ProjectionData; -pub const runProjectionGrid = projections.runProjectionGrid; - pub fn buildProjectionContext( alloc: std.mem.Allocator, config: projections.UserConfig, @@ -352,13 +362,43 @@ pub fn buildProjectionContext( var retirement = config.resolveRetirement(as_of); const accumulation_years: u16 = retirement.accumulation_years; - const data = try runProjectionGrid( + const horizons = config.getHorizons(); + // Parallel age-of-death provenance: 0 = plain numeric horizon + // (no mortality), non-zero = age-anchored column. + const death_ages = config.horizon_death_age[0..horizons.len]; + + // Build per-column grid specs. Numeric columns share the uncapped + // `events` and carry no mortality (today's behavior). Age-anchored + // columns derive their distribution from the retirement boundary, + // terminate each person's events at their own death, and carry the + // survivor spending step-down at the first death. + const columns = try alloc.alloc(projections.GridColumn, horizons.len); + defer alloc.free(columns); + // Backing storage for age columns' capped events; only age slots + // are populated and read (numeric columns point at `events`). + const col_events = try alloc.alloc([projections.UserConfig.max_events]projections.ResolvedEvent, horizons.len); + defer alloc.free(col_events); + for (horizons, 0..) |h, i| { + if (death_ages[i] == 0) { + columns[i] = .{ .distribution_years = h, .events = events }; + } else { + const m = projections.columnMortality(&config, as_of, death_ages[i], accumulation_years); + col_events[i] = config.resolveEventsToAge(as_of, death_ages[i]); + columns[i] = .{ + .distribution_years = m.distribution_years, + .events = col_events[i][0..config.event_count], + .survivor_factor = m.survivor_factor, + .first_death_year = m.first_death_year, + }; + } + } + + const data = try projections.runProjectionGridColumns( alloc, - config.getHorizons(), + columns, config.getConfidenceLevels(), total_value, sim_stock_pct, - events, accumulation_years, config.annual_contribution, config.contribution_inflation_adjusted, @@ -371,7 +411,6 @@ pub fn buildProjectionContext( // available; same boundary year for all horizons). var accumulation_stats: ?AccumulationStats = null; if (accumulation_years > 0) { - const horizons = config.getHorizons(); if (horizons.len > 0) { const last_band = data.bands[horizons.len - 1]; if (last_band) |b| { @@ -389,31 +428,57 @@ pub fn buildProjectionContext( } } - // Earliest retirement grid: when `target_spending` is set, - // search for the earliest retirement year per (horizon × - // confidence) pair. + // Earliest retirement grid: when `target_spending` is set, search + // for the earliest retirement year per (horizon x confidence) + // pair. Numeric columns use the fixed-horizon search; age-anchored + // columns use the to-age search (distribution derived from the + // retirement date, mortality threaded through). var earliest: ?[]projections.EarliestRetirement = null; if (config.target_spending) |target| { - const horizons = config.getHorizons(); const confs = config.getConfidenceLevels(); const cells = try alloc.alloc(projections.EarliestRetirement, horizons.len * confs.len); for (confs, 0..) |conf, ci| { for (horizons, 0..) |h, hi| { - cells[ci * horizons.len + hi] = try projections.findEarliestRetirement( - alloc, - total_value, - sim_stock_pct, - config.annual_contribution, - config.contribution_inflation_adjusted, - target, - config.target_spending_inflation_adjusted, - h, - conf, - events, - config.max_accumulation_years, - sim_expense_ratio, - config.spending_real_change orelse 0, - ); + if (death_ages[hi] == 0) { + cells[ci * horizons.len + hi] = try projections.findEarliestRetirement( + alloc, + total_value, + sim_stock_pct, + config.annual_contribution, + config.contribution_inflation_adjusted, + target, + config.target_spending_inflation_adjusted, + h, + conf, + events, + config.max_accumulation_years, + sim_expense_ratio, + config.spending_real_change orelse 0, + ); + } else { + // The earliest search varies the retirement date, + // so the distribution is derived per-N from + // `total_span` (pass accumulation 0 here). + const m = projections.columnMortality(&config, as_of, death_ages[hi], 0); + cells[ci * horizons.len + hi] = try projections.findEarliestRetirementToAge( + alloc, + total_value, + sim_stock_pct, + config.annual_contribution, + config.contribution_inflation_adjusted, + target, + config.target_spending_inflation_adjusted, + m.total_span, + m.first_death_year, + m.survivor_factor, + death_ages[hi], + conf, + col_events[hi][0..config.event_count], + config.max_accumulation_years, + sim_expense_ratio, + config.spending_real_change orelse 0, + ); + } } } earliest = cells; @@ -437,7 +502,6 @@ pub fn buildProjectionContext( // target-spending answer below it. if (inputs == .target_spending) { if (earliest) |grid| { - const horizons = config.getHorizons(); const confs = config.getConfidenceLevels(); if (projections.pickPromotedCell(&config, as_of, confs)) |pc| { const cell = grid[pc.confidence_index * horizons.len + pc.horizon_index]; @@ -1071,13 +1135,17 @@ pub const TableRow = struct { style: StyleIntent, }; -/// Build a column header row for a given set of horizons and column width. -pub fn buildHeaderRow(arena: std.mem.Allocator, horizons: []const u16, col_width: usize) ![]const u8 { +/// Build a column header row for a given set of horizons and column +/// width. `death_ages` is parallel to `horizons` (0 = numeric column, +/// non-zero = age-anchored, rendered "to age N"); pass an +/// all-zero/empty-equivalent slice for a purely numeric grid. +pub fn buildHeaderRow(arena: std.mem.Allocator, horizons: []const u16, death_ages: []const u16, col_width: usize) ![]const u8 { var row: std.ArrayListUnmanaged(u8) = .empty; try row.appendNTimes(arena, ' ', withdrawal_label_width); - for (horizons) |h| { + for (horizons, 0..) |h, hi| { var hbuf: [16]u8 = undefined; - const hlabel = fmtHorizonLabel(&hbuf, h); + const da: u16 = if (hi < death_ages.len) death_ages[hi] else 0; + const hlabel = fmtHorizonLabelAge(&hbuf, h, da); try row.appendNTimes(arena, ' ', col_width -| hlabel.len); try row.appendSlice(arena, hlabel); } @@ -1682,11 +1750,26 @@ test "buildHeaderRow formats horizons" { const a = arena.allocator(); const horizons = [_]u16{ 30, 45 }; - const result = try buildHeaderRow(a, &horizons, withdrawal_col_width); + const result = try buildHeaderRow(a, &horizons, &.{}, withdrawal_col_width); try std.testing.expect(std.mem.indexOf(u8, result, "30 Year") != null); try std.testing.expect(std.mem.indexOf(u8, result, "45 Year") != null); } +test "buildHeaderRow renders age-anchored columns as 'to age N'" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const horizons = [_]u16{ 30, 33 }; + const death_ages = [_]u16{ 0, 95 }; // col 0 numeric, col 1 age-anchored + const result = try buildHeaderRow(a, &horizons, &death_ages, withdrawal_col_width); + try std.testing.expect(std.mem.indexOf(u8, result, "30 Year") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "to age 95") != null); + // The age column does NOT show its raw year count. + try std.testing.expect(std.mem.indexOf(u8, result, "33 Year") == null); +} + test "buildHeaderRow uses terminal column width" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); @@ -1694,8 +1777,8 @@ test "buildHeaderRow uses terminal column width" { const a = arena.allocator(); const horizons = [_]u16{20}; - const narrow = try buildHeaderRow(a, &horizons, withdrawal_col_width); - const wide = try buildHeaderRow(a, &horizons, terminal_col_width); + const narrow = try buildHeaderRow(a, &horizons, &.{}, withdrawal_col_width); + const wide = try buildHeaderRow(a, &horizons, &.{}, terminal_col_width); try std.testing.expect(wide.len > narrow.len); } @@ -1722,6 +1805,57 @@ test "buildWithdrawalRows produces amount and rate" { try std.testing.expect(rows.rate.style == .muted); } +test "buildPercentileRow renders '--' for null and empty bands" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const filled = [_]projections.YearPercentiles{ + .{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }, + .{ .year = 1, .p10 = 100, .p25 = 200, .p50 = 300, .p75 = 400, .p90 = 500 }, + }; + const empty: []const projections.YearPercentiles = &.{}; + const all_bands = [_]?[]const projections.YearPercentiles{ &filled, null, empty }; + + const row = try buildPercentileRow(a, "Median (p50)", 1, &all_bands, .normal); + // First column has data -> the p50 of the last year ($300). + try std.testing.expect(std.mem.indexOf(u8, row.text, "$300") != null); + // The null and empty columns each render the "--" sentinel. + try std.testing.expect(std.mem.indexOf(u8, row.text, "--") != null); +} + +test "fmtEventLine: income/expense, timing, duration, and nominal branches" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + const ages = [_]u16{60}; + + // Future income (start in the future) - positive style, "(in Nyr)". + var ss = projections.LifeEvent{ .start_age = 70, .person = 0, .annual_amount = 38_400 }; + ss.name_len = @intCast((std.fmt.bufPrint(&ss.name, "Social Security", .{}) catch unreachable).len); + const ss_line = try fmtEventLine(a, &ss, &ages); + try std.testing.expect(ss_line.style == .positive); + try std.testing.expect(std.mem.indexOf(u8, ss_line.text, "Social Security") != null); + try std.testing.expect(std.mem.indexOf(u8, ss_line.text, "in 10yr") != null); + + // Current expense with duration and nominal flag - negative style, + // "(now)", ", Nyr", ", nominal". + var exp = projections.LifeEvent{ .start_age = 60, .person = 0, .duration = 4, .annual_amount = -55_000, .inflation_adjusted = false }; + exp.name_len = @intCast((std.fmt.bufPrint(&exp.name, "Tuition", .{}) catch unreachable).len); + const exp_line = try fmtEventLine(a, &exp, &ages); + try std.testing.expect(exp_line.style == .negative); + try std.testing.expect(std.mem.indexOf(u8, exp_line.text, "now") != null); + try std.testing.expect(std.mem.indexOf(u8, exp_line.text, "4yr") != null); + try std.testing.expect(std.mem.indexOf(u8, exp_line.text, "nominal") != null); + + // Out-of-range person -> startYear null -> "age N" fallback (no timing). + const orphan = projections.LifeEvent{ .start_age = 67, .person = 3, .annual_amount = 1000 }; + const orphan_line = try fmtEventLine(a, &orphan, &ages); + try std.testing.expect(std.mem.indexOf(u8, orphan_line.text, "age 67") != null); +} + test "swrRateNote: null without accumulation, present with accumulation" { // Distribution-only (already retired): the rate is a correct // withdrawal rate against the current portfolio, so render it. @@ -1786,6 +1920,14 @@ test "fmtHorizonLabel" { try std.testing.expectEqualStrings("30 Year", label); } +test "fmtHorizonLabelAge" { + var buf: [16]u8 = undefined; + // death_age 0 -> numeric "N Year" label. + try std.testing.expectEqualStrings("30 Year", fmtHorizonLabelAge(&buf, 30, 0)); + // death_age non-zero -> "to age N", ignoring the (variable) year count. + try std.testing.expectEqualStrings("to age 95", fmtHorizonLabelAge(&buf, 33, 95)); +} + // ── Accumulation phase / earliest retirement view tests ──────── test "fmtRetirementLine: none" { @@ -2125,6 +2267,58 @@ test "buildProjectionContext: both_targets inputs when both fields configured" { try std.testing.expect(ctx.earliest != null); } +test "buildProjectionContext: age-anchored horizon flows through to the earliest grid" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + // Couple: born 1962 (~63) and 1967 (~58) as of mid-2026; plan to + // age 95 (horizon_age), targeting $50k/yr with a survivor cut. + var config = projections.parseProjectionsConfig( + \\#!srfv1 + \\type::config,horizon_age:num:95 + \\type::config,target_spending:num:50000 + \\type::config,survivor_spending_pct:num:70 + \\type::birthdate,date::1962-03-01 + \\type::birthdate,date::1967-08-15,person:num:2 + ); + const as_of = Date.fromYmd(2026, 6, 15); + try config.resolveHorizonAges(as_of); + + const comparison: benchmark.BenchmarkComparison = .{ + .stock_returns = .{}, + .bond_returns = .{}, + .benchmark_returns = .{}, + .portfolio_returns = .{}, + .conservative_return = 0.07, + .stock_pct = 0.75, + .bond_pct = 0.25, + }; + + const ctx = try buildProjectionContext( + arena.allocator(), + config, + comparison, + 0.75, + 0.25, + 3_000_000, + &.{}, + as_of, + ); + + try std.testing.expectEqual(ProjectionInputs.target_spending, ctx.inputs); + try std.testing.expect(ctx.earliest != null); + // One age horizon x 3 confidences = 3 cells, all flagged age-anchored + // at 95 (the youngest reaches 95 last, setting the horizon). + try std.testing.expectEqual(@as(usize, 3), ctx.earliest.?.len); + for (ctx.earliest.?) |cell| { + try std.testing.expectEqual(@as(u16, 95), cell.death_age); + } + // The promoted headline comes from the age column. + try std.testing.expect(ctx.retirement.source == .promoted or + ctx.retirement.source == .promoted_infeasible); +} + // ── Overlay-actuals tests ───────────────────────────────────── /// Build a TimelinePoint with just the date and liquid value