audit: math-fix third-pass re-verification (all gates fresh, OFFLAN-OK 21:03Z) + report tracked
This commit is contained in:
1 parent
1074a7f6ac
commit
9dcf575b89
2 files changed
+161
-1
No files matched your search
@@ -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 `<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**.
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"has_i18n_script": true,
|
"has_i18n_script": true,
|
||||||
"i18n_js_fr_strings": true,
|
"i18n_js_fr_strings": true,
|
||||||
"i18n_js_size": 39689,
|
"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)",
|
"vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)",
|
||||||
"all_ok": true
|
"all_ok": true
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user