projection modeling enhancements based on youngest in a couple + documentation

This commit is contained in:
Emil Lerch 2026-06-28 14:29:30 -07:00
parent e22e987121
commit 4ed15c7cf4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
15 changed files with 1568 additions and 301 deletions

View file

@ -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

View file

@ -399,7 +399,7 @@ zig build test # run all tests (single binary, discovers all tests
zig build run -- <args> # 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`):

69
TODO.md
View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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);
}

View file

@ -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
}

View file

@ -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