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

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


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

Queue gate: git status --porcelain empty at start, HEAD 920abe7 == origin/main (post-fetch), no .git/index.lock, 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 (:376–380) + if (yr < 65) gisSum = 0; (:382, Service Canada source comment :88–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 (incl. stale 52y/retire-40 clamp → first draw 53)
tests/i18n.test.js I18N-OK all i18n checks passed (50) (incl. q.retire.hint FIRE EN+FR)
tests/ui.smoke.js SMOKE-OK all ui smoke checks passed (146) (incl. † mixed-row fixture, 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-pass7.log. 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), depletion 56 (honest); PASS 2 depletion 82, GIS only 65+; PASS 3 FIRE: first draw 40 (Withdraw $62,837, CPP/OAS/GIS $0), max total $493,710 @39 = hand-math 350000×1.035¹⁰ ✓
scripts/deploy.sh DEPLOY-OK
Origin byte-check (full, all tracked files) FILELIST-MATCH + 21/21 MD5 match (local site/ == git cat-file origin/main: blob == deployed webroot /mnt/better/appdata/retireapp); the 13 site/vision/*.jpeg are intentionally gitignored generated assets (deployed, not in origin)
Live recapture (mathfix_capture.py after, retire.krisforbes.ca) couple 52/52 ret 55 spend 93k CPP 60 (40 draw rows 55–94): 0 negative-OAS rows, 0 GIS>0 rows <65, 0 OAS≠0 rows <65, first GIS row 65; rows 58–64: Total $0 / OAS $0 / GIS $0, Withdraw $49K@$58 then $0, Unfunded $47K–$93K — 0 growing-Total-with-Withdraw rows (post-depletion honest $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", row 40 Withdraw $63K / OAS $0 / GIS $0, 0 pre-65 GIS/OAS violations. Mobile 390: slider min=40 value=40, sliderRight 367 < vw 390 (no clip), value chip [25, 145], FIRE hint present, svAfter40: "40 years". Fresh PNGs 21:14Z: after-couple-table.png 1280×900, after-fire-table.png 1280×900, after-fire-step-390.png 1170×2532
Off-LAN (Midhurst trouble@100.96.20.61) OFFLAN-OK, "all_ok": true, checked_at 2026-10-09T21:14:30Z → audit/bilingual-geo-check.json

Harness notes: Playwright run with TMPDIR=/home/ubuntu/projects/retire-planner/.pwtmp (harness TMPDIR read-only; no site impact); ssh byte-check loop needs -n (plain ssh eats the loop's stdin — first run under-counted, re-run 21/21).

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


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

Queue gate: git status --porcelain empty at start (no untracked, no tracked diff), HEAD 57b05d9 == origin/main, no job in flight → proceed (not DSH_QUEUE_BUSY). Fix 1–4 re-audited in HEAD by direct file read: GIS a >= 65 ? gis(…) : 0 at the three solve() sites (engine.js:249/259/265, Service Canada comment :88–91) + merge per-partner ≥65 gates (:376–380) + if (yr < 65) gisSum = 0; (:382); 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 (incl. T1 clawback $0 @55 / GIS $0 @56, capped cw @67, couple GIS/claw zero <65 + net OAS ≥ 0 everywhere, depletion guard, 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. † mixed-row fixture ×3 EN+FR + legend shown, control-single clean, wi-ret min 40, legend element present)
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-pass8.log. PASS 1 (screenshot repro): rows 55–64 OASnet $0 / GIS $0 (plan evidence: −$3,501 @55, $12,916 @56 + phantom $13,667/yr to 94), first GIS row 65 ($12,567, OASnet $9,150), depletion 56 honest ($170,465 nest egg vs $93k spend); PASS 2 depletion 82, GIS phase-outs 65+ only; PASS 3 FIRE: first draw 40 (Withdraw $62,837, CPP/OAS/GIS $0), max total $493,710 @39 = hand-math 350000×1.035¹⁰ ✓
scripts/deploy.sh DEPLOY-OK
Origin byte-check sha256 (cache-free curl, live vs repo) 6/6 BYTE-OK: engine/engine.js 2101f46b…, engine/canada.js dbd30a0e…, app.js 092a35d7…, i18n.js cfc5ab0b…, index.html 28ab4f62…, style.css 2e9ba90a…; full sweep local == git cat-file origin/main: == webroot: 21/21 MD5 match (the 13 site/vision/*.jpeg are intentionally gitignored generated assets — deployed, not in origin)
Live recapture (mathfix_capture.py after, retire.krisforbes.ca) couple 52/52 ret 55 spend 93k CPP 60, rows 52–66 scraped from DOM: 0 negative-OAS cells (all ages), 0 GIS>0 rows <65, 0 OAS≠0 rows <65, first GIS row 65 (OAS $18K, GIS $12K); rows 58–64: Total $0 / OAS $0 / GIS $0, Withdraw $49K @58 then $0, Unfunded $47K–$93K — no growing-Total-with-Withdraw rows (post-fix Total drains honestly 248K→152K→48K→0, vs the screenshot's impossible 126→141K at 3.5%); wiRetMin: "40"; noteShown: false — correct (symmetric couple has no mixed rows, Deviation 2). FIRE 30/BC/ret 40: fire_first_draw_age: "40", 0 pre-65 GIS/OAS rows. Mobile 390: quiz retire slider min=40 value=40, sliderRight 367 ≤ vw 390 (no clip), value chip [25, 145], FIRE hint present EN, svAfter40: "40 years". Fresh PNGs 21:1xZ (validated 1280×900 tables, 1170×2532 mobile @3×)
Live mixed-window probe (new this pass) Attempted the only remaining live mixed path: couple me 52 / pt 50, retireAge: 51 (below my age → fillDefaults clamps self to 53; partner inherits the clamped 53). Engine + live DOM agree: rows 50–52 accum, both partners draw from 53, 0 mixed rows, noteShown: false (correct). Structurally confirms Deviation 2 via the last open path — the age-axis merge + shared (clamped) retireAge makes mixed draw windows unreachable in the live couple path; renderer † + bilingual legend therefore proven by the ui.smoke.js project() fixture (3 flagged rows EN+FR) and engine §16
Off-LAN (Midhurst trouble@100.96.20.61) OFFLAN-OK, "all_ok": true, "http_code": "200", checked_at 2026-10-09T21:17:23Z → audit/bilingual-geo-check.json

Harness notes: Playwright run with TMPDIR=/home/ubuntu/projects/retire-planner/.pwtmp (harness TMPDIR read-only; no site impact); this model has no image input, so PNGs validated by IHDR dimensions + file size + the DOM row scrapes (content evidence) rather than visual inspection; ssh byte-check uses -n (plain ssh eats loop stdin).

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


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

Queue gate: git status --porcelain empty at start, HEAD f2eca41 == origin/main, no job in flight → proceed (not DSH_QUEUE_BUSY). Fix 1–4 re-audited in HEAD by direct file read: GIS a >= 65 ? gis(…) : 0 at the three solve() sites (engine.js:249/260/265, Service Canada source comment :88–91) + merge per-partner ≥65 gates (:376–380) + if (yr < 65) gisSum = 0; (:382); 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 (T1 clawback $0 @55 / GIS $0 @56 / no GIS<65 anywhere, T2 100k draw @55 cw 0 + 150k draw @67 cw > 0 and capped at OAS, couple GIS/claw zero <65 + net OAS ≥ 0 + no growing-Total draw rows + GIS household rate @70, age-offset window rows 50–54, depletion guard intact, 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. † mixed-row fixture + bilingual legend, control-single clean, wi-ret min 40, legend element present)
tests/ps-pension.test.js / ps-auto-service.test.js 70 pass, 0 fail / 21 pass, 0 fail (unchanged files)
passes3.js rerun (live engine) exit 0, conservation: OK ×3 → audit/math-fix/passes3-pass9.log. PASS 1 (screenshot repro): rows 55–64 OASnet $0 / GIS $0 (plan evidence: −$3,501 @55, $12,916 @56 + phantom $13,667/yr to 94), first GIS row 65 ($12,567, OASnet $9,150), depletion 56 honest ($170,465 nest egg vs $93k spend); PASS 2 depletion 82, GIS 65+ only; PASS 3 FIRE: first draw 40 (Withdraw $62,837, CPP/OAS/GIS $0), max total $493,710 @39 = hand-math 350000×1.035¹⁰ ✓
scripts/deploy.sh DEPLOY-OK (fresh this pass)
Origin byte-check md5 curl-vs-local 4/4 BYTE-OK (engine/engine.js, app.js, i18n.js, index.html); full 3-way sweep local == git cat-file origin/main: == webroot: 21/21 match (13 site/vision/*.jpeg intentionally gitignored)
Live recapture (mathfix_capture.py after, retire.krisforbes.ca) couple 52/52 ret 55 spend 93k CPP 60, DOM rows scraped: 0 negative-OAS cells, 0 GIS>0 rows <65, first GIS row 65; rows 58–64 Total $0 / OAS $0 / GIS $0, Withdraw $49K @58 then $0, Unfunded $47K–$93K — 0 growing-Total-with-Withdraw rows; 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: first Withdraw>0 row age 40, 0 pre-65 GIS/neg-OAS violations. Mobile 390: slider min=40 value=40, sliderRight 367 ≤ vw 390 (no clip), value chip [25, 145], FIRE hint present, svAfter40: "40 years". Fresh PNGs 21:21Z: after-couple-table.png 1280×900, after-fire-table.png 1280×900, after-fire-step-390.png 1170×2532 (IHDR-validated)
Live mixed-window probe (re-run) couple me 52 / pt 50, retireAge: 51 → fillDefaults clamps to 53; live DOM: rows 50 (partner accum) then both-partner draw from 53, 0 † rows, noteShown: false, 0 GIS rows <65 — reconfirms Deviation 2 (age-axis merge + shared clamped retireAge ⇒ mixed draw windows unreachable live)
Off-LAN (Midhurst trouble@100.96.20.61) OFFLAN-OK, "all_ok": true, "http_code": "200", checked_at 2026-10-09T21:21:32Z → audit/bilingual-geo-check.json

Harness notes: Playwright run with TMPDIR=/home/ubuntu/projects/retire-planner/.pwtmp (harness scratch read-only; no site impact); PNGs validated by IHDR dimensions + row-scrape content evidence.

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

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

Queue gate: git status --porcelain empty at start, HEAD 7d88a20 == origin/main, no job in flight → proceed (not DSH_QUEUE_BUSY). Fix 1–4 re-audited in HEAD by direct file read: GIS 65+ floor — a >= 65 ? gis(…) : 0 at the three solve() sites (engine.js:249/260/265) + Service Canada source-fact comment (:88–91) + merge per-partner ≥65 gates (:379–380) + if (yr < 65) gisSum = 0; (:382); clawback only with OAS — oasGross > 0 ? Math.min(clawback(…), oasGross) : 0 at :248/:259 + s.3(1) ETA comment :81–83 + splitting parity :366–367; mixed rows — row.mixed :390 + depletion guard !row.mixed :395 + renderer †/legend app.js:810–819; FIRE — fillDefaults clamp retireAge < age+1 :188, quiz slider min: 40 app.js:211, #wi-ret min="40" index.html:86, FIRE hint EN/FR i18n.js:96/357, t.mixedNote EN/FR i18n.js:251/512. All acceptance checks re-executed fresh in this pass:

Check Fresh output (this pass)
tests/engine.test.js RESULT: 61 pass, 0 fail (§14 T1: clawback $0 @55 / GIS $0 @56 / OAS gross 0 @56 / net OAS ≥ 0 everywhere / no GIS<65 anywhere; §15: 100k RRSP draw @55 → cw 0, 67y 150k draw → cw $9,150 capped at OAS; §16: couple GIS/claw zero <65, net OAS ≥ 0, no growing-Total draw rows, GIS household rate @70, age-offset window, depletion guard intact; §17: 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 parity, q.retire.hint FIRE EN+FR)
tests/ui.smoke.js SMOKE-OK all ui smoke checks passed (146) (incl. † mixed-row fixture + bilingual legend under table, control-single no †, wi-ret min 40, legend element present)
tests/ps-pension.test.js / ps-auto-service.test.js 70 pass, 0 fail / 21 pass, 0 fail (unchanged files)
passes3.js rerun (live engine) exit 0, conservation: OK ×3 → audit/math-fix/passes3-thispass.log. PASS 1 (screenshot repro): rows 55–64 OASnet $0 / GIS $0 (plan evidence: −$3,501 @55 clawback, $12,916 @56 + phantom $13,667/yr GIS to 94 — all gone), first GIS row 65 (GIS $13,667, OASnet $9,150), depletion 56 honest ($170,465 nest egg vs $93k spend); PASS 2 depletion 82, GIS 65+ only; PASS 3 FIRE: first draw @40 (Withdraw $62,837, CPP/OAS/GIS $0), max total $493,710 @39 = hand-math 350000×1.035¹⁰ ✓, GIS appears at 65
scripts/deploy.sh DEPLOY-OK (fresh this pass)
Origin byte-check full 3-way sweep local == git show origin/main: == webroot md5: 21/21 match (7 code/data files swept individually first: 7/7; 13 site/vision/*.jpeg included in the full sweep)
Live recapture (mathfix_capture.py after10 + after, retire.krisforbes.ca) couple 52/52 ret 55 spend 93k CPP 60, DOM rows scraped: rows 58–64 Total $0 / OAS $0 / GIS $0, Withdraw $49K @58 then $0, Unfunded $47K–$93K — 0 negative-OAS cells, 0 GIS>0 rows <65, 0 growing-Total-with-Withdraw rows; wiRetMin: "40". FIRE 30/BC/ret 40: first Withdraw>0 row age 40, 0 pre-65 GIS/neg-OAS violations. Mobile 390: retire slider min=40 max=72 value=40, sliderRight 367 ≤ vw 390 (no clip), FIRE hint present, svAfter40: "40 years". Fresh PNGs (IHDR-validated): after10-couple-table.png 1280×900 (byte-identical 50,314 B to passes 1/8/9 — deterministic render), after10-mixed-table.png 1280×900, after-couple-table.png, after-fire-table.png 1280×900, after-fire-step-390.png 1170×2532
Live mixed-window probe (re-run, after10-mixed-*) couple me 52 / pt 50, retireAge: 55: merged rows 55+ draw for BOTH partners on the shared age axis (each retires at own age 55) → 0 † rows, noteShown: false, 0 GIS rows <65 — reconfirms Deviation 2 (age-axis merge + shared clamped retireAge ⇒ mixed draw windows unreachable live); † suffix + bilingual legend rendering proven by the smoke fixture + engine §16 invariants
Off-LAN (Midhurst trouble@100.96.20.61) OFFLAN-OK, "all_ok": true, "http_code": "200", checked_at 2026-10-09T21:24:43Z → audit/bilingual-geo-check.json

Harness notes: Playwright run with TMPDIR=/home/ubuntu/projects/retire-planner/.tmp (harness scratch read-only under the workspace-write sandbox; first attempt failed EROFS mkdtemp under .hermes/cache/scratch, relaunched with workspace TMPDIR; no site impact). audit/math-fix/ is gitignored by design — artifacts are local evidence, referenced by path.

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

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

Queue gate: git status --porcelain empty at start, HEAD 1d5fd12 == origin/main (post-fetch), no job in flight → proceed (not DSH_QUEUE_BUSY). Fix 1–4 re-audited in HEAD by direct file read: GIS 65+ floor — a >= 65 ? gis(…) : 0 at the three solve() sites (engine.js:249/260/265) + Service Canada source-fact comment (:88–91) + merge per-partner ≥65 gates (:376–380) + if (yr < 65) gisSum = 0; (:382); clawback only with OAS — oasGross > 0 ? Math.min(clawback(…), oasGross) : 0 at :248/:259 + s.3(1) ETA comment :81–83 + splitting-solver parity :366–367; mixed rows — row.mixed :390 + depletion guard !row.mixed :395 + renderer †/legend app.js:807–819; FIRE — fillDefaults clamp retireAge < age+1 :188, quiz slider min: 40 app.js:211, #wi-ret min="40" index.html:86, FIRE hint EN/FR i18n.js:96/357, t.mixedNote EN/FR i18n.js:251/512, legend element index.html:95. All acceptance checks re-executed fresh in this pass:

Check Fresh output (this pass)
tests/engine.test.js RESULT: 61 pass, 0 fail (§14 T1 repro: clawback 0 @55, GIS 0 @56, OAS gross 0 @56, net OAS ≥ 0 every row, no GIS<65 anywhere; §15: 100k RRSP draw @55 → cw 0, 67y 150k draw → cw > 0 capped at OAS gross; §16: couple GIS/clawback zero <65, net OAS ≥ 0, no growing-Total-with-Withdraw draw rows, household GIS @70, age-offset window, depletion guard intact; §17: fillDefaults 52→53 & 30→40, FIRE first draw 40, rows 40–64 GIS/clawback 0, stale 52/40 state first draw 53)
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. † mixed-row fixture ×3 + bilingual legend under table, control-single no †, wi-ret min 40, legend element present)
tests/ps-pension.test.js / ps-auto-service.test.js 70 pass, 0 fail / 21 pass, 0 fail (unchanged files)
passes3.js rerun (live engine) exit 0, conservation: OK ×3 → audit/math-fix/passes3-thispass.log. PASS 1 (screenshot repro): rows 52–64 OASnet $0 / GIS $0 (plan evidence −$3,501 @55 / $12,916 @56 / phantom $13,667/yr — all gone), first GIS row 65 (GIS $12,567, OASnet $9,150), depletion 56 honest (nest egg $170,465 vs $93k spend); PASS 2 depletion 82; PASS 3 FIRE: first draw @40, max total $493,710 @39 = hand-math 350000×1.035¹⁰ ✓
scripts/deploy.sh DEPLOY-OK (fresh this pass)
Origin byte-check full sweep local == git cat-file origin/main: == webroot md5: tracked 21/21 (3-way) + site/vision/*.jpeg 13/13 (2-way, gitignored generated assets) = 34/34 match, 0 mismatch (bash .tmp/sweep11.sh, ssh -n)
Live recapture (mathfix_capture.py after11 + after, retire.krisforbes.ca) couple 52/52 ret 55 spend 93k CPP 60, DOM rows scraped: rows 58–64 Total $0 / OAS $0 / GIS $0, Withdraw $49K @58 then $0, Unfunded $47K–$93K — 0 negative-OAS cells, 0 GIS>0 rows <65, 0 growing-Total-with-Withdraw rows; wiRetMin: "40". FIRE 30/BC/ret 40: first Withdraw>0 row age 40 ($63K, OAS $0 / GIS $0), 0 pre-65 GIS/neg-OAS violations. Mobile 390: retire slider min=40 max=72 value=40, sliderRight 367 ≤ vw 390 (no clip), FIRE hint present, svAfter40: "40 years"
Live mixed-window probe (re-run, after11-mixed-*) couple me 52 / pt 50, retireAge: 51 → fillDefaults clamps self to 53; partner inherits the shared clamped 53 → both draw from 53 (DOM row 53: Withdraw $96K) → 0 † rows, noteShown: false, 0 GIS rows <65 — reconfirms Deviation 2 (age-axis merge + shared clamped retireAge ⇒ mixed draw windows unreachable live); † suffix + bilingual legend proven by the smoke fixture + engine §16 invariants
Off-LAN (Midhurst trouble@100.96.20.61) OFFLAN-OK, "all_ok": true, "http_code": "200", checked_at 2026-10-09T21:30:16Z → audit/bilingual-geo-check.json

Determinism note: the first after11 couple screenshot came out as a 63,753 B render-race variant (y≥245 pixel diff; DOM row JSON byte-identical to pass 10, md5 129e2a45…, so table content unaffected). Immediate re-run converged: after11-couple-table.png 50,314 B md5 cbac51ed5089… — byte-identical to after-couple-table.png regenerated this pass and to the canonical renders of passes 1/8/9/10. Fresh PNGs IHDR-validated: couple/mixed/fire tables 1280×900, mobile after-fire-step-390.png 1170×2532.

Harness notes: Playwright run with TMPDIR=/home/ubuntu/projects/retire-planner/.pwtmp (harness TMPDIR read-only; no site impact); ssh byte-check loop uses -n; this model has no image input, so PNGs are validated by IHDR + byte-determinism + DOM row scrapes (content evidence). audit/math-fix/ is gitignored by design — artifacts are local evidence, referenced by path.

No code change required — plan executed end-to-end; fix commit 0839826 at origin/main; all gates fresh this pass. Final: MATH_FIX_DONE.

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

Queue gate: git status --porcelain empty at start, HEAD dc81260 == origin/main (post-fetch), no dsh-run process in flight → proceed (not DSH_QUEUE_BUSY). Fix 1–4 re-audited in HEAD by direct file read: GIS 65+ floor — a >= 65 ? gis(…) : 0 at the three solve() sites (engine.js:249/260/265) + Service Canada source-fact comment (:88–91) + merge per-partner ≥65 gates (:379–380) + if (yr < 65) gisSum = 0; (:382); clawback only with OAS — oasGross > 0 ? Math.min(clawback(…), oasGross) : 0 at :248/:259 + s.3(1) ETA comment :80–83 + splitting-solver parity :366–367; mixed rows — row.mixed :390 + depletion guard !row.mixed :395 + renderer †/legend app.js:807–819 + legend element index.html:95; FIRE — fillDefaults clamp retireAge < age+1 :188, quiz slider min: 40 app.js:211, #wi-ret min="40" index.html:86, FIRE hint EN/FR i18n.js:96/357, t.mixedNote EN/FR i18n.js:251/512. All acceptance checks re-executed fresh in this pass:

Check Fresh output (this pass)
tests/engine.test.js RESULT: 61 pass, 0 fail (§14 T1 repro: clawback $0 @55, GIS $0 @56, OAS gross 0 @56, net OAS ≥ 0 every row, no GIS<65 anywhere; §15: 100k RRSP draw @55 → cw 0, 67y 150k draw → cw > 0 capped at OAS gross; §16: couple GIS/clawback zero <65, net OAS ≥ 0, no growing-Total-with-Withdraw draw rows, household GIS @70, age-offset window, depletion guard intact; §17: fillDefaults 52→53 & 30→40, FIRE first draw 40, rows 40–64 GIS/clawback 0, stale 52/40 state first draw 53)
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. † mixed-row fixture ×3 + bilingual legend under table, control-single no †, wi-ret min 40, legend element present)
tests/ps-pension.test.js / ps-auto-service.test.js 70 pass, 0 fail / 21 pass, 0 fail (unchanged files)
passes3.js rerun (live engine) exit 0, conservation: OK ×3 → audit/math-fix/passes3-thispass.log (copy of passes3-pass12.log). PASS 1 (screenshot repro): rows 52–64 OASnet $0 / GIS $0 (plan evidence −$3,501 @55 / $12,916 @56 / phantom $13,667/yr to 94 — all gone), first GIS row 65 (GIS $13,667, OASnet $9,150), depletion 56 honest (nest egg $170,465 vs $93k spend, finalBal $0); PASS 2 depletion 82, GIS 65+ only; PASS 3 FIRE: first draw @40 (Withdraw $62,837, CPP/OAS/GIS $0), max total $493,710 @39 = hand-math 350000×1.035¹⁰ ✓, GIS appears at 65
scripts/deploy.sh DEPLOY-OK (fresh this pass)
Origin byte-check full sweep local == git cat-file origin/main: == webroot md5: tracked 21/21 (3-way) + site/vision/*.jpeg 13/13 (2-way, gitignored generated assets) = 34/34 match, 0 mismatch (bash .tmp/sweep11.sh, ssh -n)
Live recapture (mathfix_capture.py after12 + after, retire.krisforbes.ca) couple 52/52 ret 55 spend 93k CPP 60, DOM rows scraped: rows 58–64 Total $0 / OAS $0 / GIS $0, Withdraw $49K @58 then $0, Unfunded $47K–$93K — 0 negative-OAS cells, 0 GIS>0 rows <65, 0 growing-Total-with-Withdraw rows (programmatic sweep of all 40 DOM rows); wiRetMin: "40". FIRE 30/BC/ret 40: first Withdraw>0 row age 40 ($63K, OAS $0 / GIS $0), 0 pre-65 GIS/neg-OAS violations. Mobile 390: retire slider min=40 max=72 value=40, sliderRight 367 ≤ vw 390 (no clip), FIRE hint present, svAfter40: "40 years"
Live mixed-window probe (re-run, after12-mixed-*) couple me 52 / pt 50, retireAge: 51 → fillDefaults clamps self to 53; partner inherits the shared clamped 53 → both draw from 53 (DOM row 53: Withdraw $96K) → 0 † rows, noteShown: false, 0 GIS rows <65 — reconfirms Deviation 2 (age-axis merge + shared clamped retireAge ⇒ mixed draw windows unreachable live); † suffix + bilingual legend proven by the smoke fixture + engine §16 invariants
Off-LAN (Midhurst trouble@100.96.20.61) OFFLAN-OK, "all_ok": true, "http_code": "200", checked_at 2026-10-09T21:33:55Z → audit/bilingual-geo-check.json

Determinism: after12-couple-table.png 1280×900 50,314 B md5 cbac51ed5089… — byte-identical to the canonical renders of passes 1/8/9/10/11 on the FIRST attempt (no render-race variant this pass); after-couple-table.png regenerated fresh this pass matches the same md5. Fresh PNGs IHDR-validated: after12-mixed-table.png 1280×900 57,254 B, after-fire-table.png 1280×900 67,310 B, after-fire-step-390.png 1170×2532 167,045 B.

Harness notes: Playwright run with TMPDIR=/home/ubuntu/projects/retire-planner/.pwtmp (harness TMPDIR read-only; no site impact); ssh byte-check loop uses -n; PNGs validated by IHDR + byte-determinism + DOM row scrapes (content evidence). audit/math-fix/ is gitignored by design — artifacts are local evidence, referenced by path.

No code change required — plan executed end-to-end; fix commit 0839826 at origin/main; all gates fresh this pass. Final: MATH_FIX_DONE.


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

Queue gate: git status --porcelain empty at start (no untracked, no tracked diff), HEAD 126f90e == origin/main, no job in flight → proceed (not DSH_QUEUE_BUSY). Fix 1–4 re-audited by direct read of the working tree (== HEAD): GIS 65+ floor — a >= 65 ? gis(…) : 0 at the three solve() sites (engine.js:249/260/265) + Service Canada source-fact comment (:88–91) + merge per-partner ≥65 gates (:376–380) + if (yr < 65) gisSum = 0; (:382); clawback only with OAS — oasGross > 0 ? Math.min(clawback(…), oasGross) : 0 at :248/:259 + s.3(1) ETA comment :80–83 + splitting-solver parity :366–367; mixed rows — row.mixed :390 + depletion guard !row.mixed :395 + renderer †/legend app.js:807–819 + legend element index.html:95 + t.mixedNote EN/FR i18n.js:251/512; FIRE — fillDefaults clamp retireAge < age+1 :188, quiz slider min: 40 app.js:211, #wi-ret min="40" index.html:86, 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 (§14 T1 repro: clawback $0 @55, GIS $0 @56, OAS gross 0 @56, net OAS ≥ 0 every row, no GIS<65 anywhere; §15: 100k RRSP draw @55 → cw 0, 67y 150k draw → cw $9,150 capped at OAS gross; §16: couple GIS/clawback zero <65, net OAS ≥ 0, no growing-Total-with-Withdraw draw rows, household GIS @70, age-offset window rows 50–54, depletion guard intact, depletion never fires on a mixed row; §17: fillDefaults 52→53 & 30→40, FIRE first draw 40, rows 40–64 GIS/clawback 0, stale 52/40 state first draw 53)
tests/i18n.test.js I18N-OK all i18n checks passed (50) (incl. t.mixedNote EN+FR parity, q.retire.hint FIRE EN+FR)
tests/ui.smoke.js SMOKE-OK all ui smoke checks passed (146) (incl. † mixed-row fixture ×3 + bilingual legend under the table, control-single no †, wi-ret min 40, legend element present)
tests/ps-pension.test.js / ps-auto-service.test.js 70 pass, 0 fail / 21 pass, 0 fail (unchanged files)
passes3.js rerun (live engine) exit 0, conservation: OK ×3 → audit/math-fix/passes3-pass13.log (copied to passes3-thispass.log). PASS 1 (screenshot repro): rows 52–64 OASnet $0 / GIS $0 (plan evidence −$3,501 @55 / $12,916 @56 / phantom $13,667/yr to 94 — all gone), first GIS row 65 (GIS $12,567, OASnet $9,150), depletion 56 honest (nest egg $170,465 vs $93k spend, finalBal $0); PASS 2 depletion 82, GIS 65+ only; PASS 3 FIRE: first draw @40 (Withdraw $62,837, CPP/OAS/GIS $0), max total $493,710 @39 = hand-math 350000×1.035¹⁰ ✓, GIS $13,667 appears at 65
scripts/deploy.sh DEPLOY-OK (fresh this pass)
Origin byte-check cache-busted sha256 (live retire.krisforbes.ca vs repo) 6/6 BYTE-OK: engine/engine.js 2101f46b28e1…, engine/canada.js dbd30a0e414f…, app.js 092a35d711f0…, i18n.js cfc5ab0b41b2…, index.html 28ab4f620ee8…, style.css 2e9ba90ae919…; full sweep local == git cat-file origin/main: == webroot md5: tracked 21/21 (3-way) + site/vision/*.jpeg 13/13 (2-way, gitignored generated assets) = 34/34 match, 0 mismatch (bash .tmp/sweep13.sh, ssh -n)
Live recapture (mathfix_capture.py after13 + after, retire.krisforbes.ca) couple 52/52 ret 55 spend 93k CPP 60, all 40 DOM rows 55–94 parsed programmatically: 0 negative-OAS cells, 0 GIS>0 rows <65, 0 OAS≠0 rows <65, 0 growing-Total-with-Withdraw rows on consecutive ages; rows 58–64 Total $0 / OAS $0 / GIS $0, Withdraw $49K @58 then $0, Unfunded $47K–$93K (honest post-depletion drain 248K→152K→48K→0, vs the screenshot's impossible 126→141K at 3.5%); first GIS row 65; wiRetMin: "40". FIRE 30/BC/ret 40: first Withdraw>0 row age 40 ($63K, OAS $0 / GIS $0), 0 pre-65 GIS/neg-OAS violations. Mobile 390: retire slider min=40 max=72 value=40, sliderRight 367 ≤ vw 390 (no clip), value chip [25, 145], FIRE hint present EN, svAfter40: "40 years"
Live mixed-window probe (re-run, after13-mixed-*) couple me 52 / pt 50, retireAge: 51 → fillDefaults clamps self to 53; the partner inherits the shared clamped 53 → both draw from 53 (DOM row 53 Withdraw $96K), rows 50–52 accum → 0 † rows, noteShown: false, 0 GIS rows <65 — reconfirms Deviation 2 (age-axis merge + shared clamped retireAge ⇒ mixed draw windows are unreachable in the live couple path); the † suffix + bilingual legend rendering is proven by the ui.smoke.js project() fixture (3 flagged rows, EN+FR) and the engine §16 invariants
Off-LAN (Midhurst trouble@100.96.20.61) OFFLAN-OK, "all_ok": true, "http_code": "200", checked_at 2026-10-09T21:37:01Z → audit/bilingual-geo-check.json

Evidence hardening this pass: the DOM row JSONs are now swept by a parser (money cells → numbers) with a consecutive-age rule. The FIRE table collapses accumulation rows (30 → 35 → 40), so a naive "previous rendered row" comparison flags age 40 ($443K) against age 35 ($430K) as a growing-Total-with-Withdraw row; that pair is accum → first-draw, not a mixed window, and it is not flagged under the consecutive-age rule (0 violations in both scenarios).

Determinism: after13-couple-table.png 1280×900 50,314 B md5 cbac51ed5089… — byte-identical to the canonical renders of passes 1/8/9/10/11/12 on the FIRST attempt. The canonical after-couple-table.png first regenerated at the stock 900 ms settle as the known 63,753 B render-race variant (md5 eec99021c08c…, same size as pass 11's) with DOM row JSON byte-identical to after13-couple-rows.json; a re-shoot with a 3000 ms settle converged back to 50,314 B / cbac51ed5089… → the variant is chart-animation timing, not table content (pass-11 note closed with a concrete cause + remedy). after13-mixed-rows.json byte-identical to after12-mixed-rows.json. Fresh PNGs IHDR-validated: after13-couple-table.png / after13-mixed-table.png / after-couple-table.png / after-fire-table.png 1280×900, after-fire-step-390.png 1170×2532 @3×.

Harness notes: Playwright run with TMPDIR=/home/ubuntu/projects/retire-planner/.pwtmp (harness TMPDIR read-only; no site impact); ssh byte-check loop uses -n; PNGs validated by IHDR + byte-determinism + DOM row scrapes (content evidence). audit/math-fix/ is gitignored by design — artifacts are local evidence, referenced by path.

No code change required — plan executed end-to-end; fix commit 0839826 at origin/main; all gates fresh this pass. Final: MATH_FIX_DONE.

Addendum (post-push, same pass): audit commit bfe5bcbef049d7be… pushed 126f90e..bfe5bcb → origin/main (report + fresh off-LAN stamp; code untouched, so the plan's mandated fix commit 08398267040a832bdec0910626423f0dbfeb0a7f remains the code change under test). Post-push git fetch + full byte sweep re-run: tracked 21/21 (3-way) + site/vision/*.jpeg 13/13 = 34/34 match, 0 mismatch against the new origin/main; HEAD == origin/main == bfe5bcbef049d7be….