diff --git a/MATH-FIX-REPORT.md b/MATH-FIX-REPORT.md new file mode 100644 index 0000000..a1816a9 --- /dev/null +++ b/MATH-FIX-REPORT.md @@ -0,0 +1,160 @@ +# 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 `
` 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 cell PASS [$148K †]`, `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 []`, `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**. diff --git a/audit/bilingual-geo-check.json b/audit/bilingual-geo-check.json index 4c2cb4c..c164f28 100644 --- a/audit/bilingual-geo-check.json +++ b/audit/bilingual-geo-check.json @@ -5,7 +5,7 @@ "has_i18n_script": true, "i18n_js_fr_strings": true, "i18n_js_size": 39689, - "checked_at": "2026-10-09T20:03:30Z", + "checked_at": "2026-10-09T21:03:32Z", "vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)", "all_ok": true } \ No newline at end of file