Eval WS-2 — Mail-intelligence quality batteries — implementation plan

Status: In progress · Author: Christophe (with Claude) · Date: 2026-07-06 · Repo: north-os · Related: eval-coverage-completion roadmap (2026-07-03, WS-2) · matterbench design (2026-07-02, verifiable-first) · Linear NOS-40

Four live model calls decide what mail surfaces, to which matter, and what stays private — today guarded only against mocked output. WS-2 adds golden-labeled batteries that drive the real production functions with real model calls, scored deterministically (label equality), with hard gates on the two errors that matter most: a leaked private email (safety) and a wrong-matter link (the demo-day cardinal error).

Grounded surface map (verified in code, 2026-07-06)

SurfaceEntry pointModelPurityBattery
Relevance/privacy gatepackages/mail-intelligence/src/classify-gate.ts triageMailRelevanceHaiku 4.5Pure (ctx.anthropic + input)2a + 2c eval:mail-gate
Thin-chip classifierclassify-thin.ts classifyMailThinSonnet 4.6Pure2a eval:mail-classify
Matter resolver (3-layer + judge)resolve-matter.tsresolver/Sonnet 4.6 judge + Voyage embedDB-coupled (5 RLS read layers)2b eval:mail-resolve
stage1Triage (obvious-noise)stages/stage1-triage.tsHaiku 4.5PureOnly if a live caller exists (verify); else skip

Explicitly skipped, with reasons:

Corpus strategy (provenance-honest)

The DB stores classification outputs only (no bodies); the eval org (3faddc1b-8dd2-4b5f-9108-6dccd2c131c6) mailbox is ~87% simulator-fed threads (fictional personas, real deal names, full body_html in apps/simulator/scenarios/*.yaml), the rest genuinely-external vendor noise. Private mail is absent by design (the gate excludes before storage). So:

Batteries and gates

All env-gated EVAL_MAIL=1, with --task <id> / --runs / --gate / --model flags per eval-router.ts conventions, one cost line per run (actual token usage summed via a counting wrapper around the Anthropic client). Datasets in packages/mail-intelligence/eval/, scripts in packages/mail-intelligence/scripts/, wired with the dotenv prefix.

BatteryDatasetDeterministic scoreHard gate (exit 1)Report-only
2a/2c eval:mail-gate~50–70 cases across firm_actionable/firm_noise/exclude_privateper-class P/Rexclude_private recall = 1.0 (2c, safety); firm_actionable recall ≥ 0.9class precisions (privacy-biased over-exclusion is designed)
2a eval:mail-classify~35–45 cases across the 5 chipschip accuracy + per-chip recall; waiting → waiting_on non-null; suggested_owner_user_id null; firm_shareable false on internal-sensitiveneeds_action recall ≥ 0.9 (never-drop bias); chip accuracy ≥ 0.8confidence calibration
2b eval:mail-resolve~25–30 cases (ports + extends the 13 ground-truth cases in sample-validate-matter-resolution.ts, incl. the Brooklyn-Gardens↔Spring-Creek mislink guard, abstain traps, thread-continuity)resolved matter vs expected (display_number or abstain), method, link_statuszero false links — any wrong matter_id (auto or suggested) failslink recall (abstain-first is designed; missed links reported)
2b-guards (same script, --guards)judge output pinned via judgeOverridepin-gate, candidate-membership, ambiguity demote, confidence bands, noise guardall guard assertions

The resolver battery runs read-only: ctx.syncDb is omitted so auto-track never writes to the shared dev DB; scoring maps the returned clio id against clio_matter_index by SELECT.

LLM-judged residue (reasoning quality, privacy-leak wording): deferred — deterministic label scoring covers pass/fail; any multi-case Opus-judged loop needs an explicit go (cost rule).

Cost per full run (measured)

gate ~$0.05 (60 Haiku calls) · classify ~$0.15 (37 Sonnet calls) · resolve ~$0.57 (27 Sonnet judge calls + Voyage embeds) · guards sub-battery: $0. Full WS-2 sweep ≈ $0.78.

As built (2026-07-06) — results register