Files
retire-planner/MATH-FIX-REPORT.md
T

24 KiB
Raw Blame History

MATH-FIX REPORT — Engine math fixes + FIRE retirement age 40

Date: 2026-10-09 · Commit: 08398267040a832bdec0910626423f0dbfeb0a7f (pushed to origin/main, HEAD == origin/main) Plan: /home/ubuntu/projects/.hermes/plans/2026-10-09_engine-math-fixes-fire40.md — executed end-to-end.

Gate Result (real output)
tests/engine.test.js RESULT: 61 pass, 0 fail (baseline 36)
tests/i18n.test.js I18N-OK all i18n checks passed (50) (baseline 48)
tests/ui.smoke.js SMOKE-OK all ui smoke checks passed (146) (baseline 128)
tests/ps-pension.test.js RESULT: 70 pass, 0 fail (untouched, green)
tests/ps-auto-service.test.js RESULT: 21 pass, 0 fail (untouched, green)
scripts/deploy.sh DEPLOY-OK
Origin byte-check 4/4 BYTE-MATCH (engine/engine.js, app.js, i18n.js, index.html, sha256 vs live)
Off-LAN (Midhurst) OFFLAN-OK ("all_ok": true, checked_at 2026-10-09T19:52:48Z)
3-pass evidence rerun exit 0, 3× conservation: OK — full log in audit/math-fix/passes3-after.log

No pre-existing test encoded the old behavior: all 36 baseline engine tests stayed green unmodified. New tests extended the suite (36 → 61).


Fix 1 — GIS is payable only at age 65+

Rule: GIS is an OAS Supplement for Seniors — Service Canada pays it only once you receive OAS, i.e. from age 65. The engine credited GIS in pre-65 draw years.

Implementation (site/engine/engine.js):

  • All three single-person solve() sites: var g = a >= 65 ? gis(taxable1 - oasGross, 'single') : 0; (main ladder, non-registered branch, TFSA branch).
  • Couple merge: both-draw GIS requires ra.age >= 65 && rb.age >= 65; the singles-sum branch applies a per-partner >= 65 gate; then if (yr < 65) gisSum = 0;.
  • gis() header comment cites the stamp + Service Canada source-fact.

Before (live old engine, couple repro 52/52 ON 60k 100k/30k/20k ret 55 spend 93k CPP 60 — audit/math-fix/before-couple-rows.json):

Age OAS GIS (phantom)
55 $0 $21K
56 $0 $13K
59–64 $0 $25K–$27K

After (live, after-couple-rows.json): ages 55–64 → OAS $0, GIS $0 every row; first GIS row is age 65 ($12K). Screenshot pair: audit/math-fix/before-couple-table.png / after-couple-table.png.

Fix 2 — OAS clawback only when OAS is payable, capped at OAS received

Rule (plan spec, ETA s.3(1)): 15% × max(0, taxable − ~91k), plafonné au OAS payable. The recovery can never exceed the OAS actually received, and is 0 when no OAS is payable.

Implementation: every clawback call site is now oasGross > 0 ? Math.min(clawback(taxable…), oasGross) : 0 (single ladder, non-reg branch, couple split-solver newClaw). clawback() itself keeps the raw formula with a documented contract comment. Net OAS (oasGross − oasClawback) is now mathematically never negative.

  • Old single repro row 55: OASnet −$3,501 (documented in plan) → new: $0 (no OAS payable, no clawback).
  • Cap effect: 67y, 150k taxable → raw clawback 11,824 → capped at OAS gross 9,150 → net OAS $0 (was −2,674 uncapped).
  • Live after: negative OAS/GIS rows anywhere: 0 (all 40 draw rows, all 11 columns scanned).

Fix 3 — Couple mixed-phase rows: † footnote + bilingual legend

Rule: in the merged couple table, a row's phase is draw from the FIRST retirement; while one partner is still accumulating, the row is not in full retirement mode.

Implementation:

  • Merge: row.mixed = row.phase === 'draw' && (ra.phase === 'accum' || rb.phase === 'accum');
  • Renderer (site/app.js): Total cell gets ' †' when r.mixed; #yr-mixed-note (new <p class="cap" hidden> in index.html) shows t('t.mixedNote') when any row is mixed — EN: “† One partner is retired, the other is still working: their savings grow at the pre-retirement rate — this row is not in full retirement mode.” (FR in i18n.js).
  • Depletion guard: if (row.phase === 'draw' && !row.mixed && …) — mixed windows can't trigger depletion, symmetric for either partner.
  • Live (symmetric couple): noteShown: false — correct, no mixed rows exist in that scenario (see Deviation 2). † rendering + legend EN/FR proven in ui.smoke.js fixture: mixed rows footnoted † on Total cell (3 flagged) PASS, † sits inside the Total <b> cell PASS [<b>$148K †</b>], bilingual legend shown under the table PASS (EN + FR), control single PASS.

Fix 4 — FIRE: retirement age down to 40

Implementation: quiz retire slider min: 40, max: 72; #wi-ret min="40" max="72"; FIRE hint EN/FR (q.retire.hint); fillDefaults guard if (+o.retireAge < +o.age + 1) o.retireAge = +o.age + 1; (stale-payload clamp, FIRE-safe), and fillDefaults is now exported from the engine.

Evidence (real outputs):

  • Smoke: FIRE: quiz retire slider min=40 PASS [<input type="range" id="f1" min="40" max="72" step="1" value="65">], FIRE: hint mentions FIRE support PASS, what-if retirement slider min 40 (index.html) PASS [id="wi-ret" min="40" max="72" step="1"] (EN+FR).
  • Engine T17: fillDefaults({age:53, retireAge:40}) → 53 (guard), FIRE age 30 ret 40 → first draw row at 40, rows 40–64 GIS=0 & clawback=0 & net-OAS≥0.
  • Live FIRE (30/BC/90k/200k-100k-50k/ret 40/spend 60k): fire_first_draw_age: "40", GIS<65 rows: 0; PASS-3 log row 40: Withdraw $62,837, OAS $0, GIS $0.
  • Mobile 390: slider min=40 value=40, sliderRight 367 ≤ vw 390, value chip at [25, 145] — no clip; FIRE hint rendered; screenshot audit/math-fix/after-fire-step-390.png (1170×2532 @3×).

3-pass evidence rerun (passes3.js, post-deploy, exit 0)

All three passes: conservation: OK. Key rows from audit/math-fix/passes3-after.log:

PASS 1 — single 52/ON/100k+30k+20k, ret 55, spend 93k, CPP 60 (the screenshot repro; depletion 56 is honest — $170,465 nest egg vs $93k spend):

 age  RRSP     TFSA     NonReg   Total    Withdraw CPP    OASnet   GIS     Tax+clw  Unfunded  phase
 55   $63,924    $4,403        $0   $68,327  $102,139        $0        $0        $0    $9,139        $0  draw
 56        $0        $0        $0        $0   $70,035        $0        $0        $0   $10,454   $33,420  draw
 57        $0        $0        $0        $0        $0        $0        $0        $0        $0   $93,000  draw
 65        $0        $0        $0        $0        $0    $7,200    $9,150   $12,567      $170   $64,253  draw

(pre-fix this repro emitted OASnet −$3,501 at 55 and GIS $12,916 at 56; now $0/$0 across 55–64, GIS/OAS start at 65.)

PASS 2 — defaults 45/65/50k: draw starts at 65 with GIS phase-out $6,568 → $11,854 → $0 as RRSP income rises (normal GIS claw behaviour, only at 65+); depletion 82; conservation: OK.

PASS 3 — FIRE 30/BC/ret 40: first draw at 40 (Withdraw $62,837, tax $2,837, OAS/GIS $0); gap years 48–64 unfunded (self-funded, as the hint says); OAS+GIS enter at 65 (GIS $13,667, tax $0 → BC low-income years); conservation: OK; hand-check 350000×1.035¹⁰ = 493,710 ✓.

Deploy & live verification

  • scripts/deploy.sh → DEPLOY-OK (rsync → docker retireapp:8103).
  • Byte-check against https://retire.krisforbes.ca/ (cache-busted sha256): BYTE-MATCH ×4.
  • Live couple scenario (exact plan inputs + what-if ret 55): ages 55–64 gis===0 && oasGross===0 on every row, no negative OAS/GIS anywhere, wiRetMin: "40", noteShown: false → acceptance PASS.
  • Off-LAN via Midhurst (trouble@100.96.20.61): OFFLAN-OK.

Test suite diffs (new tests added; zero pre-existing tests changed)

  • engine.test.js 36 → 61: §14 single pre-65 no GIS/no clawback (T1 repro); §15 clawback 0 pre-OAS + capped > 0 at 67; §16 couple: GIS/clawback zero < 65, net OAS ≥ 0 all rows, no growing-Total draw rows, GIS $11,753 at 70, age-offset window rows 50–54 present/accum/growing, depletion guard intact at 67; §17 fillDefaults 53/40 clamp, FIRE first draw 40, rows 40–64 clean, stale payload clamp.
  • i18n.test.js 48 → 50: t.mixedNote EN+FR present (starts with †); q.retire.hint mentions FIRE EN+FR. (Key parity itself already covered the new keys — the 2 checks assert the content contract.)
  • ui.smoke.js 128 → 146: FIRE quiz step EN+FR (min="40", FIRE hint, slider value), mixed-† fixture EN+FR (3× † in Total cells, legend shown, bilingual, control single clean), static #wi-ret min="40" + legend element in index.html.

Deviations from the plan (documented, all deliberate)

  1. Clawback cap added at call sites (Math.min(clawback, oasGross)): the plan's formula spec says “plafonné au OAS payable (s.3(1) ETA)”; the raw 15% formula alone over-recovers when taxable income exceeds the OAS. A new test caught this pre-fix (cw 11,824 > gross 9,150) — the cap is now in all 3 call sites and proven (capped at OAS gross test, net OAS 0 at 67/150k).
  2. Plan test-3 literal rows 50–54 mixed===true is structurally unreachable: the merge keys rows on the shared age axis and both partners share retireAge, so no real scenario yields mixed draw windows in the couple path. Replaced with honest assertions (window rows present, phase accum, totals growing, no depletion) + the renderer †/legend proof via a project() fixture in ui.smoke.js. The live 52/52 couple therefore shows no † (correct: it has no mixed rows).
  3. Depletion guard symmetry: the plan's literal ra.phase==='draw' && rb.phase!=='accum' would disable depletion detection for the sole-surviving partner (ra.phase==='none'). Implemented as a symmetric !row.mixed guard — mixed windows never deplete, the sole-surviving partner's draw rows still do (test: depletion at 67 in the age-offset case).
  4. Smoke count 146, not the plan's ~134 estimate — the FIRE/mixed sections carry 18 checks (4 FIRE + 4 mixed × EN/FR + 2 static).
  5. fillDefaults is now exported from the engine module (needed for T17; additive, no behavior change).

Artifacts

audit/math-fix/: before-couple-{rows.json,table.png}, after-couple-{rows.json,table.png}, after-fire-{rows.json,table.png}, after-fire-step-390.png, passes3-after.log; capture driver audit/mathfix_capture.py (committed; PNG/JSON dir gitignored).

Queue note: git tree at start had untracked files only (PS-AUTO-SERVICE-REPORT.md, scripts/math-fix-watcher.sh) — no tracked diff, so per plan semantics (clean = no tracked diff) the run proceeded rather than DSH_QUEUE_BUSY.


Re-verification (second DSH pass, 2026-10-09, post-completion)

A follow-up session re-entered the plan. Queue gate re-checked: HEAD 0839826 == origin/main, no tracked diff, no running node/dsh/hermes jobs; the only untracked file was this report (the plan's own deliverable) → proceed, not DSH_QUEUE_BUSY. Every acceptance item was independently re-executed; all outputs below are fresh:

Check Fresh output
tests/engine.test.js RESULT: 61 pass, 0 fail (incl. §14–17 math-fix tests: T1 GIS=0@56, clawback=0@55, capped cw@67, couple GIS/claw zero <65, depletion guard, fillDefaults 53/40, FIRE first draw 40)
tests/i18n.test.js I18N-OK all i18n checks passed (50)
tests/ui.smoke.js SMOKE-OK all ui smoke checks passed (146)
tests/ps-pension.test.js / ps-auto-service.test.js 70 pass, 0 fail / 21 pass, 0 fail
passes3.js rerun (live engine) exit 0, conservation: OK ×3 → audit/math-fix/passes3-rerun-verify.log. PASS 1 rows 55–64: OASnet $0, GIS $0 (was −$3,501 / $12,916); PASS 3 FIRE first draw 40, rows 40–64 GIS/OAS $0
scripts/deploy.sh DEPLOY-OK
Origin byte-check BYTE-MATCH 4/4: engine.js 2101f46b…, app.js 092a35d7…, i18n.js cfc5ab0b…, index.html 28ab4f62… (sha256, live :8103 vs repo)
Off-LAN (Midhurst) OFFLAN-OK, "all_ok": true, checked_at 2026-10-09T20:03:30Z
Artifact scan after-couple-rows.json: 0 negative-OAS cells, 0 GIS>0 rows <65, first GIS row 65 ($12K), wiRetMin: "40". after-fire-rows.json: row 40 Withdraw $63K / OAS $0 / GIS $0, 25 rows 40–64 all GIS/OAS $0. All 4 PNGs valid (1280×900 tables; 1170×2532 mobile @3×)

Commit 08398267040a832bdec0910626423f0dbfeb0a7f remains pushed at origin/main; no further code change was required.


Re-verification (third DSH pass, 2026-10-09 21:0x UTC, post-completion)

Queue gate re-checked per plan: HEAD 1074a7f == origin/main, git diff (tracked, staged, unstaged) empty, no .git/index.lock, no running node/python/deploy processes; sole untracked file = this report, which the plan itself mandates at workspace root (its presence is the success condition, not a queue signal) → proceed, not DSH_QUEUE_BUSY. Every acceptance check re-executed fresh; code unchanged (fix commit already in HEAD):

Check Fresh output (this pass)
Code audit of Fix 1–4 in HEAD solve() GIS gates ×3 (a >= 65 ? gis(…) : 0, engine.js:249/260/265); merge if (yr < 65) gisSum = 0; (:382) + per-partner ≥65 gates (:376–380); clawback gated oasGross > 0 ? Math.min(clawback(…), oasGross) : 0 at all 3 sites + split-solver parity (:248/259/366–367); row.mixed + depletion guard !row.mixed (:390/395); fillDefaults clamp retireAge < age+1 (:188); quiz slider min: 40 (app.js:211); #wi-ret min="40" (index.html:86); t.mixedNote EN+FR; #yr-mixed-note legend element
tests/engine.test.js RESULT: 61 pass, 0 fail
tests/i18n.test.js I18N-OK all i18n checks passed (50)
tests/ui.smoke.js SMOKE-OK all ui smoke checks passed (146) (incl. what-if retirement slider min 40 + legend element)
tests/ps-pension.test.js / ps-auto-service.test.js 70 pass, 0 fail / 21 pass, 0 fail
passes3.js rerun (live engine) exit 0, conservation: OK ×3 → audit/math-fix/passes3-thispass.log. PASS 1: age 55 OASnet $0 / GIS $0 (plan evidence was −$3,501), age 56 GIS $0 (was $12,916), GIS first appears row 65 ($12,567) with OASnet $9,150; PASS 3 FIRE: first draw 40 ($62,837, CPP/OAS/GIS $0), GIS $13,667 from 65; hand-math 493,710 ✓
scripts/deploy.sh DEPLOY-OK
Origin byte-check BYTE-MATCH 4/4 (sha256, repo vs live :8103): engine/engine.js 2101f46b28e1f960…, app.js 092a35d711f0509c…, i18n.js cfc5ab0b41b2f553…, index.html 28ab4f620ee80921…
Live scenario recapture (mathfix_capture.py after, retire.krisforbes.ca) couple 52/52 ret 55 spend 93k: 0 negative-OAS rows, 0 GIS>0 rows <65, first GIS row 65 ($12K); rows 58–64: OAS $0, GIS $0, Unfunded $47K–$93K, no growing-Total-withdraw rows; wiRetMin: "40"; FIRE: fire_first_draw_age: "40", 0 pre-65 GIS/OAS violations; mobile 390: slider min=40 value=40, sliderRight 367 < vw 390, value chip [25, 145], FIRE hint present. noteShown: false — correct, symmetric couple has no mixed rows (Deviation 2); †/legend proven by smoke fixture + engine §16
Off-LAN (Midhurst) OFFLAN-OK, "all_ok": true, checked_at 2026-10-09T21:03:32Z

Note: this harness mounts TMPDIR read-only; Playwright capture runs with TMPDIR=/tmp (no site impact).

No code change required — 0839826 (the plan's mandated commit) stands at origin/main; this pass adds only the re-verification evidence, the fresh off-LAN timestamp (audit/bilingual-geo-check.json), and this report (now tracked, per repo convention for root reports). Final: MATH_FIX_DONE.


Re-verification (fourth DSH pass, 2026-10-09 21:0x UTC, post-completion)

Queue gate: tree clean at start (git status --porcelain empty), HEAD 9dcf575 == origin/main, no prior job in flight → proceed (not DSH_QUEUE_BUSY). Full end-to-end acceptance re-executed, every output below fresh in this pass; code unchanged (fix commit 0839826 in HEAD, audit greps confirm Fix 1–4: GIS a >= 65 ? gis(…) : 0 ×3 :249/260/265, merge if (yr < 65) gisSum = 0; :382 + per-partner ≥65 :379–380, clawback oasGross > 0 ? Math.min(clawback(…), oasGross) : 0 ×3 + split-solver :248/259/366–367, row.mixed + depletion guard !row.mixed :390/395, fillDefaults clamp :188, slider min: 40 app.js:211 / #wi-ret min="40" index.html:86, t.mixedNote EN+FR i18n.js:251/512, #yr-mixed-note index.html:95):

Check Fresh output (this pass)
tests/engine.test.js RESULT: 61 pass, 0 fail
tests/i18n.test.js I18N-OK all i18n checks passed (50)
tests/ui.smoke.js SMOKE-OK all ui smoke checks passed (146)
tests/ps-pension.test.js / ps-auto-service.test.js 70 pass, 0 fail / 21 pass, 0 fail
passes3.js rerun (live engine) exit 0, conservation: OK ×3 → audit/math-fix/passes3-pass4.log. PASS 1: row 55 OASnet $0 (plan evidence −$3,501), row 56 GIS $0 (was $12,916), GIS first row 65 ($12,567, OASnet $9,150); PASS 3 FIRE: first draw 40 (Withdraw $62,837, CPP/OAS/GIS $0), total @39 $493,710 = 350000×1.035¹⁰ ✓
scripts/deploy.sh DEPLOY-OK
Origin byte-check BYTE-MATCH 4/4 (cache-busted sha256, repo vs live): engine/engine.js 2101f46b…, app.js 092a35d7…, i18n.js cfc5ab0b…, index.html 28ab4f62…
Live recapture (mathfix_capture.py after) couple 52/52 ret 55: rows 58–64 OAS $0, GIS $0 (0 negative-OAS cells, 0 GIS>0 rows <65), first GIS row 65 ($12K), no growing-Total-withdraw rows (post-depletion honest $0); wiRetMin: "40"; FIRE fire_first_draw_age: "40"; mobile 390: slider min=40 value=40, sliderRight 367 < vw 390, chip [25, 145], FIRE hint present; noteShown: false — correct, symmetric couple has no mixed rows (Deviation 2; †/legend proven by smoke fixture)
Off-LAN (Midhurst trouble@100.96.20.61) OFFLAN-OK, "all_ok": true, checked_at 2026-10-09T21:05:47Z

No code change required — plan executed end-to-end; commit 0839826 at origin/main. Final: MATH_FIX_DONE.


Re-verification (fifth DSH pass, 2026-10-09 21:0x UTC, post-completion)

Queue gate: git status --porcelain empty at start, HEAD 42da244 == origin/main (post-fetch), no job in flight → proceed (not DSH_QUEUE_BUSY). Fix 1–4 re-audited in HEAD (GIS a >= 65 ? gis(…) : 0 at the three solve() sites + merge if (yr < 65) gisSum = 0; and per-partner ≥65 gates; clawback oasGross > 0 ? Math.min(clawback(…), oasGross) : 0 ×3 + split-solver parity; row.mixed + !row.mixed depletion guard; fillDefaults clamp; sliders min: 40 quiz + #wi-ret min="40"; t.mixedNote EN/FR + #yr-mixed-note). All acceptance checks re-executed fresh in this pass:

Check Fresh output (this pass)
tests/engine.test.js RESULT: 61 pass, 0 fail
tests/i18n.test.js I18N-OK all i18n checks passed (50)
tests/ui.smoke.js SMOKE-OK all ui smoke checks passed (146)
tests/ps-pension.test.js / ps-auto-service.test.js 70 pass, 0 fail / 21 pass, 0 fail
passes3.js rerun (live engine) exit 0, conservation: OK ×3 → audit/math-fix/passes3-pass5.log. PASS 1: 55–64 OASnet $0 / GIS $0 (plan evidence: −$3,501 @55, $12,916 @56), first GIS row 65 ($12,567, OASnet $9,150); PASS 3 FIRE: first draw 40 (Withdraw $62,837, CPP/OAS/GIS $0), GIS $13,667 from 65, hand-math $493,710 ✓
scripts/deploy.sh DEPLOY-OK
Origin byte-check (cache-busted sha256, live vs repo) BYTE-MATCH 4/4: engine/engine.js 2101f46b28e1…, app.js 092a35d711f0…, i18n.js cfc5ab0b41b2…, index.html 28ab4f620ee8…
Live recapture (mathfix_capture.py after) couple 52/52 ret 55 spend 93k CPP 60 (40 draw rows): 0 negative-OAS cells, 0 GIS>0 rows <65, 0 OAS≠0 rows <65, first GIS row 65 ($12K); rows 58–64 Total $0 / OAS $0 / GIS $0, Unfunded $47K–$93K — no growing-Total-withdraw rows (honest post-depletion); wiRetMin: "40"; noteShown: false — correct, symmetric couple has no mixed rows (Deviation 2; †/legend proven by smoke fixture + engine §16). FIRE 30/BC/ret 40: fire_first_draw_age: "40", row 40 Withdraw $63K / OAS $0 / GIS $0, GIS first at 65. Mobile 390: slider min=40 value=40, sliderRight 367 < vw 390, value chip [25, 145], FIRE hint present. PNGs valid: couple/fire 1280×900, mobile 1170×2532
Off-LAN (Midhurst trouble@100.96.20.61) OFFLAN-OK, "all_ok": true, checked_at 2026-10-09T21:08:40Z

No code change required — plan executed end-to-end; commit 0839826 at origin/main. Final: MATH_FIX_DONE.


Re-verification (sixth DSH pass, 2026-10-09 21:11 UTC, post-completion)

Queue gate: git status --porcelain empty at start, HEAD d6a7cb6 == origin/main (post-fetch), no job in flight → proceed (not DSH_QUEUE_BUSY). Fix 1–4 re-audited in HEAD by grep: GIS a >= 65 ? gis(…) : 0 at the three solve() sites (engine.js:249/260/265) + merge per-partner ≥65 gates (:379–380) + if (yr < 65) gisSum = 0; (:382, Service Canada comment :89–91); clawback oasGross > 0 ? Math.min(clawback(…), oasGross) : 0 at :248/:259 + split-solver parity :366–367; row.mixed :390 + depletion guard !row.mixed :395; fillDefaults clamp retireAge < age+1 :188; quiz slider min: 40 app.js:211 + #wi-ret min="40" index.html:86; t.mixedNote EN/FR i18n.js:251/512 + #yr-mixed-note index.html:95; FIRE hint EN/FR i18n.js:96/357. All acceptance checks re-executed fresh in this pass:

Check Fresh output (this pass)
tests/engine.test.js RESULT: 61 pass, 0 fail (fresh run incl. §16/§17 lines: window rows 50–54 present/grow/accum, depletion never fires on a mixed row, fillDefaults 52→53 & 30→40, FIRE first draw 40, rows 40–64 GIS/clawback 0)
tests/i18n.test.js I18N-OK all i18n checks passed (50) (incl. t.mixedNote EN+FR, q.retire.hint FIRE EN+FR)
tests/ui.smoke.js SMOKE-OK all ui smoke checks passed (146) (incl. FIRE quiz step EN+FR, mixed rows footnoted † on Total cell (3 flagged), bilingual legend, control-single, wi-ret min 40)
tests/ps-pension.test.js / ps-auto-service.test.js 70 pass, 0 fail / 21 pass, 0 fail
passes3.js rerun (live engine) exit 0, conservation: OK ×3 → audit/math-fix/passes3-rerun.txt. PASS 1: rows 55–64 OASnet $0 / GIS $0 (plan evidence: −$3,501 @55, $12,916 @56), first GIS row 65 ($12,567, OASnet $9,150); PASS 2 depletion 82, GIS phase-outs only 65+; PASS 3 FIRE: first draw 40 (Withdraw $62,837, OAS/GIS $0), GIS $13,667 from 65, hand-math $493,710 = 350000×1.035¹⁰ ✓
scripts/deploy.sh DEPLOY-OK
Origin byte-check (full, all tracked files) FILELIST-MATCH + 21/21 md5 match (local site/ == git show origin/main: blob == deployed webroot /mnt/better/appdata/retireapp); the 13 site/vision/*.jpeg are intentionally gitignored generated assets (deployed + byte-present, not in origin)
Live recapture (mathfix_capture.py after, retire.krisforbes.ca) couple 52/52 ret 55 spend 93k CPP 60: 0 negative-OAS rows (all ages), 0 GIS>0 rows <65, first GIS row 65 (OAS $18K, GIS $12K); rows 58–64: Total $0 / OAS $0 / GIS $0, Unfunded $47K–$93K — no growing-Total-withdraw rows (post-fix Total drains honestly 248K→152K→48K→0); wiRetMin: "40"; noteShown: false — correct, symmetric couple has no mixed rows (Deviation 2; † + legend proven by smoke fixture + engine §16). FIRE 30/BC/ret 40: fire_first_draw_age: "40", 0 pre-65 GIS/OAS violations. Mobile 390: slider min=40 value=40, sliderRight 367 < vw 390, value chip [25, 145], FIRE hint present, svAfter40: "40 years". Fresh PNGs 17:11 local: after-couple-table.png, after-fire-table.png, after-fire-step-390.png
Off-LAN (Midhurst trouble@100.96.20.61) OFFLAN-OK, "all_ok": true, "http_code": "200", checked_at 2026-10-09T21:11:15Z → audit/bilingual-geo-check.json

Harness note: Playwright TMPDIR under .hermes/cache/scratch was read-only this session; capture ran with TMPDIR=/home/ubuntu/projects/retire-planner/.pwtmp (no site impact).

No code change required — plan executed end-to-end; commit 0839826 at origin/main. Final: MATH_FIX_DONE.