feat(webllm): rewrite-quality eval harness (#65) - #149
Conversation
Phase 3 of the in-browser AI rewrite epic. Adds a deterministic eval
harness that scores section-rewrite outputs across (model × prompt
variant × fixture) so the default model and prompt are picked from
measurement, not vibes.
Six rubric criteria (numbers preserved / one line per bullet / action
verb lead / length sanity / no preamble leak / dedup effectiveness) all
computed without a judge model. LLM-judge slot is flag-gated and off
by default (slot exists, implementation is a follow-up).
Two execution legs:
- Scoring leg: pure logic + 31 unit tests under src/lib/webllm/eval/,
runs in default CI via npm run test.
- Inference leg: dev-only eval-rewrite.html page driven by
npm run eval:rewrite, one model per tab to avoid the WebGPU
eviction-then-reload crash path on consumer GPUs.
Verb list is DRYed: exports ACTION_VERBS from score.ts so the eval
extends the scorer set rather than duplicating it.
Ships with all three MODEL_REGISTRY models scored under
tests/fixtures/rewrite/reports/ (Qwen 67% / Gemma 58% / Llama 58%
best-variant) — confirms the current DEFAULT_MODEL_ID = Qwen is the
right call.
Closes #65.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| return `${JSON.stringify(report, null, 2)}\n`; | ||
| } | ||
|
|
||
| export function renderMarkdownReport(report: EvalReport): string { |
| const refs = getDomRefs(); | ||
| populateModelPicker(refs); | ||
|
|
||
| refs.runBtn.addEventListener("click", async () => { |
s-annam
left a comment
There was a problem hiding this comment.
PR Review: feat(webllm): rewrite-quality eval harness (#149)
Summary
Clean, exceptionally well-documented Phase 3 eval harness. Engine-agnostic runner + deterministic 6-criterion rubric + snapshot-tested report renderer, with the inference leg quarantined to a dev-only HTML entry so zero eval code reaches the production bundle. 31 new tests, all green. Closes #65.
Gate results (on checked-out PR branch)
| Gate | Result |
|---|---|
npm run typecheck |
✅ pass |
npm run lint |
✅ pass |
npm run test |
useModelSelection.integration.test.tsx (localStorage.clear is not a function) |
CI verify |
✅ pass (55s) |
CI fallow |
❌ red (report-only, non-blocking) |
The 2 local test failures are NOT this PR's defect. They reproduce identically on the #143 base branch this PR is stacked on (the test file comes from #143, untouched here), they're green in CI's verify, and the cause is a local jsdom/node localStorage env quirk. No action for #149.
Highlights
RewriteFninjection seam cleanly splits the Node-testable scoring leg from the WebGPU inference leg —runner.tsnever imports@mlc-ai/web-llm.- Sound non-vacuous guards throughout the rubric: empty output fails
oneLinePerBullet/actionVerbLead, and zero-bullet output can't trivially "win" dedup (outputBullets.length > 0 && < input.length). - Error rows score 0 and surface in the report rather than silently inflating an aggregate — genuinely defensive.
- Fixtures are synthetic, PII-clean (verified per repo policy).
- DRY done right:
ACTION_VERBSanchored inscore.ts, eval extends it.
Key Findings (no blockers)
- [Suggestion]
verbs.ts:43—export const ACTION_VERBSis never imported by any other module (onlystartsWithActionVerbis consumed). Dropping theexportkeeps it module-internal and clears two fallow alerts at once (#83 never-imported + #85 duplicate-name-across-modules). Matches the team's standing practice of clearing fallow dead-export flags. - [Nit]
rubric.ts:125—replace(b.toLowerCase(), "")strips only the first occurrence of each bullet. Edge-case-only and adjacent to thenoPreambleLeak-as-bullet blind spot already filed as a follow-up; fine to leave. - [Nit]
report.ts:26(renderMarkdownReportcognitive complexity 22) andrun-eval-browser.ts:199(CRAP 30) — fallow report-only flags on a snapshot-tested renderer and a dev-only entry. Acceptable as-is.
Verdict
Action: COMMENT — No blocking items. CI verify is green; the local test failures are inherited env noise, not a regression. Two small cleanups (esp. #1, a one-word fix that clears the red fallow check) before merge would be nice but aren't gating.
| // Lowercased substring match; the phrase list is conservative. | ||
| let rawMinusBullets = output.raw.toLowerCase(); | ||
| for (const b of outputBullets) { | ||
| rawMinusBullets = rawMinusBullets.replace(b.toLowerCase(), ""); |
There was a problem hiding this comment.
[Nit]: String.prototype.replace with a string arg strips only the first occurrence — if a bullet's text repeats in raw, later copies survive in rawMinusBullets. Edge-case-only, and adjacent to the noPreambleLeak-as-bullet blind spot you already filed as a follow-up. Fine to leave; flagging for the record.
Only `startsWithActionVerb` is consumed externally; `ACTION_VERBS` is used solely inside verbs.ts. De-exporting clears three fallow alerts at once — the never-imported export (#83), the verbs.ts name collision (#85), and the score.ts dual-export collision (#84) — with no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VoyggnCFiEueVMCKJ34wuF
Node 22+ ships a built-in global `localStorage` that shadows jsdom's `Storage` and lacks a `clear()` method, so the bare `localStorage.clear()` in this test's beforeEach threw on Node 25 (`localStorage.clear is not a function`) while staying green on CI's Node 20. Optional-chain the call to match the store's own defensive `globalThis.localStorage?.` access; the real per-key cleanup is already done by _resetPersistedModelSelectionForTesting. Full suite now 704/704 locally on Node 25. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VoyggnCFiEueVMCKJ34wuF
…lind spot (#150, #151, #152) Three small fixes surfaced by the rewrite-quality eval committed in PR #149. #150 — cleanRewriteLine drops chat-opener preambles like "Here are the rewritten bullets:" via a narrow regex (`/^here (?:are|is) (?:the )?(?:rewritten|new|updated)\b/i`). Anchored to start-of-line so a legitimate bullet that mentions "Here" mid-text is unaffected. Caught Llama 3.2 (3B) under the terse and examples-led prompt variants emitting a leading opener that was surviving cleanup and inflating the output bullet count by one. #152 — cleanRewriteLine strips leading `**Verb**` markdown bold when followed by body text (single-word capture by design — multi-word bolds are likely deliberate phrase emphasis). Runs before the whole-line emphasis strip, which only matches when the line both starts and ends with `**`. Caught Gemma 2 (2B) under the terse variant bolding just the leading verb on every bullet. #151 — scoreRubric.noPreambleLeak now also fails when any output bullet itself contains a preamble phrase. Previously the rubric only scanned `raw - bullets`, so a preamble that survived AS a bullet had its text erased from the scan and the criterion falsely reported a pass. With #150 landing in the same PR the upstream cleanup also catches this — but the rubric was measuring the wrong thing regardless, and stays robust to any future preamble shape that slips past cleanRewriteLine. 12 new unit tests across post-process.test.ts and rubric.test.ts pin all three fixes. Full suite: 716 passing (was 704). Closes #150, #151, #152. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lind spot (#150, #151, #152) (#154) Three small fixes surfaced by the rewrite-quality eval committed in PR #149. #150 — cleanRewriteLine drops chat-opener preambles like "Here are the rewritten bullets:" via a narrow regex (`/^here (?:are|is) (?:the )?(?:rewritten|new|updated)\b/i`). Anchored to start-of-line so a legitimate bullet that mentions "Here" mid-text is unaffected. Caught Llama 3.2 (3B) under the terse and examples-led prompt variants emitting a leading opener that was surviving cleanup and inflating the output bullet count by one. #152 — cleanRewriteLine strips leading `**Verb**` markdown bold when followed by body text (single-word capture by design — multi-word bolds are likely deliberate phrase emphasis). Runs before the whole-line emphasis strip, which only matches when the line both starts and ends with `**`. Caught Gemma 2 (2B) under the terse variant bolding just the leading verb on every bullet. #151 — scoreRubric.noPreambleLeak now also fails when any output bullet itself contains a preamble phrase. Previously the rubric only scanned `raw - bullets`, so a preamble that survived AS a bullet had its text erased from the scan and the criterion falsely reported a pass. With #150 landing in the same PR the upstream cleanup also catches this — but the rubric was measuring the wrong thing regardless, and stays robust to any future preamble shape that slips past cleanRewriteLine. 12 new unit tests across post-process.test.ts and rubric.test.ts pin all three fixes. Full suite: 716 passing (was 704). Closes #150, #151, #152. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 in-browser AI rewrite: deterministic eval harness scoring section-rewrite output across (model × prompt × fixture). Six model-free rubric criteria, engine-agnostic runner, dev-only inference entry (zero production-bundle impact), 31 new tests. Confirms DEFAULT_MODEL_ID = Qwen2.5-1.5B. Resolves #65.
…lind spot (#150, #151, #152) (#154) Three small fixes surfaced by the rewrite-quality eval committed in PR #149. #150 — cleanRewriteLine drops chat-opener preambles like "Here are the rewritten bullets:" via a narrow regex (`/^here (?:are|is) (?:the )?(?:rewritten|new|updated)\b/i`). Anchored to start-of-line so a legitimate bullet that mentions "Here" mid-text is unaffected. Caught Llama 3.2 (3B) under the terse and examples-led prompt variants emitting a leading opener that was surviving cleanup and inflating the output bullet count by one. #152 — cleanRewriteLine strips leading `**Verb**` markdown bold when followed by body text (single-word capture by design — multi-word bolds are likely deliberate phrase emphasis). Runs before the whole-line emphasis strip, which only matches when the line both starts and ends with `**`. Caught Gemma 2 (2B) under the terse variant bolding just the leading verb on every bullet. #151 — scoreRubric.noPreambleLeak now also fails when any output bullet itself contains a preamble phrase. Previously the rubric only scanned `raw - bullets`, so a preamble that survived AS a bullet had its text erased from the scan and the criterion falsely reported a pass. With #150 landing in the same PR the upstream cleanup also catches this — but the rubric was measuring the wrong thing regardless, and stays robust to any future preamble shape that slips past cleanRewriteLine. 12 new unit tests across post-process.test.ts and rubric.test.ts pin all three fixes. Full suite: 716 passing (was 704). Closes #150, #151, #152. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 in-browser AI rewrite: deterministic eval harness scoring section-rewrite output across (model × prompt × fixture). Six model-free rubric criteria, engine-agnostic runner, dev-only inference entry (zero production-bundle impact), 31 new tests. Confirms DEFAULT_MODEL_ID = Qwen2.5-1.5B. Resolves #65.
…lind spot (#150, #151, #152) (#154) Three small fixes surfaced by the rewrite-quality eval committed in PR #149. #150 — cleanRewriteLine drops chat-opener preambles like "Here are the rewritten bullets:" via a narrow regex (`/^here (?:are|is) (?:the )?(?:rewritten|new|updated)\b/i`). Anchored to start-of-line so a legitimate bullet that mentions "Here" mid-text is unaffected. Caught Llama 3.2 (3B) under the terse and examples-led prompt variants emitting a leading opener that was surviving cleanup and inflating the output bullet count by one. #152 — cleanRewriteLine strips leading `**Verb**` markdown bold when followed by body text (single-word capture by design — multi-word bolds are likely deliberate phrase emphasis). Runs before the whole-line emphasis strip, which only matches when the line both starts and ends with `**`. Caught Gemma 2 (2B) under the terse variant bolding just the leading verb on every bullet. #151 — scoreRubric.noPreambleLeak now also fails when any output bullet itself contains a preamble phrase. Previously the rubric only scanned `raw - bullets`, so a preamble that survived AS a bullet had its text erased from the scan and the criterion falsely reported a pass. With #150 landing in the same PR the upstream cleanup also catches this — but the rubric was measuring the wrong thing regardless, and stays robust to any future preamble shape that slips past cleanRewriteLine. 12 new unit tests across post-process.test.ts and rubric.test.ts pin all three fixes. Full suite: 716 passing (was 704). Closes #150, #151, #152. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Phase 3 of the in-browser AI rewrite epic. Adds a deterministic eval harness that scores section-rewrite outputs across (model × prompt variant × fixture) so the default model and prompt are picked from measurement rather than vibes.
checkNumbersPreservedfrom Phase 1), one line per bullet, action-verb lead, length sanity, no-preamble leakage, dedup effectiveness. All computed without a judge model.src/lib/webllm/eval/*.test.ts; runs in default CI vianpm run test.eval-rewrite.htmlpage driven bynpm run eval:rewrite, one model per tab to avoid the WebGPU eviction-then-reload crash path on consumer GPUs.ACTION_VERBSfromsrc/lib/score/score.tsso the eval extends the scorer's set instead of duplicating it.judgeEnabledplumbed through runner + report), implementation deliberately a follow-up.Findings from the committed reports
All three
MODEL_REGISTRYmodels ran against all four fixtures across three prompt variants. Aggregate best-variant scores:The shipped
DEFAULT_MODEL_ID = Qwen2.5-1.5B-Instruct-q4f16_1-MLCis confirmed. Bigger ≠ better here — Llama and Gemma both hallucinate more metrics and mangle number notation.Follow-up bugs the eval surfaced (filed as separate issues)
These are real but out of scope for this PR:
cleanRewriteLinedoesn't strip Llama's"Here are the rewritten bullets:"preamble line.noPreambleLeakrubric check has a blind spot when the preamble survives as a bullet (the bullet-stripping step erases it from the raw-text scan).cleanRewriteLinedoesn't strip inline**verb**markdown bold delimiters that Gemma emits on individual tokens (the existing regex only handles whole-bullet wraps).Architecture notes
RewriteFninjection lets tests use stub outputs while the browser entry uses real WebLLM engines.eval-rewrite.htmlis not inbuild.rollupOptions.input; the eval code is only reachable from that dev-only entry. Verified:dist/afternpm run buildcontains no eval artifacts.engine.chat.completions.createdirectly instead ofrewriteSectionWithLlmso a local benchmark doesn't pollutewebllm_section_rewrite_*counters.RewriteFnrecords an error on the cell and scoring continues; the row scores 0 so failures surface in the report instead of silently disappearing.Test plan
npm run typecheckpassesnpm run lintpassesnpm run test— 704/704 passing (was 673 on main; +31 new tests undersrc/lib/webllm/eval/)npm run buildsucceeds;dist/contains no eval codenpm run eval:rewriteopens the dev page, picks a model, runs all 12 cells, downloads JSON + Markdown reports — verified end-to-end against all three registry models on a real WebGPU devicetests/fixtures/rewrite/reports/are valid UTF-8 (mojibake in earlier paste-throughs was a clipboard artifact only —fileconfirmsUnicode text, UTF-8 text)Closes #65.
🤖 Generated with Claude Code