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 `