Skip to content

ci(email): derive the scorecard-refresh budget from measured times, guard the published basis#2107

Merged
itomek-amd merged 5 commits into
mainfrom
tmi/issue-2094-scorecard-refresh-budget
Jul 16, 2026
Merged

ci(email): derive the scorecard-refresh budget from measured times, guard the published basis#2107
itomek-amd merged 5 commits into
mainfrom
tmi/issue-2094-scorecard-refresh-budget

Conversation

@itomek

@itomek itomek commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Closes #2094

Why this matters

The email agent's published quality card — the accuracy figure integrators rely on — is refreshed by an automated workflow that was supposed to reject any change making quality worse. Two hours ago that workflow did the opposite: it auto-committed a card claiming 100% accuracy measured on 25 emails in a single run, silently replacing the real figure (83.4%, measured over 249 emails, repeated 3 times). Its regression check approved the swap because 1.0 beats 0.834 — it compares only the number, never the basis the number was measured on, so a small lucky sample reads as an improvement. That card is sitting on an open, merge-ready PR right now (flagged in #2060); main is still clean.

That incident reframes this issue. #2094 was filed because the workflow had never once completed — a hand-typed timeout-minutes: 90 against a ~2h workload. #2089's interim limit cut (60→25) made it fast enough to finish, which means the failure mode changed rather than went away: a workflow that reliably died became one that reliably publishes a meaningless number. The loud failure was the safer bug.

After this PR: a subset-sized run physically cannot commit a card, a run that would downgrade the published basis (fewer emails, fewer repeats, or a different context window) is rejected in seconds before spending any eval time, a sub-bar card can never reach the published surface, and an overrun fails at a named step instead of a silent job-level cancelled. The per-push trigger — which fired the incident above, unasked, from an unrelated refactor branch merging main — is gone; refresh is now a deliberate dispatch, and the budget is derived from measured step times with the arithmetic recorded in the workflow.

Evidence

1. The bug this prevents, firing in productionrun 29457164041 (old workflow, main's version) auto-committed 2e21edb9:

before after the auto-commit
corpus / repeats 250 / 3 25 / 1
within-one accuracy 0.834 (stdev 0.0116) 1.0 (stdev 0.0)

Under this PR that run is a SUBSET profile: it evaluates, prints SUBSET RUN -- card NOT committed, restores the card, and exits 0.

2. Basis guard rejects a downgrade before spending eval timerun 29458595361, dispatched limit=250 experiments=3 ctx_size=16384 rebaseline=false:

##[error]Basis mismatch -- committed card ctx_size=<unstamped> vs requested ctx_size=16384.
Re-dispatch at the committed ctx_size to compare apples-to-apples, or pass rebaseline=true
with a rebaseline_reason to move the basis deliberately.
Re-dispatch command: gh workflow run email_scorecard_refresh.yml --ref ... -f rebaseline=true ...

Failed at the named step Resolve version, committed basis, and fail fast on a basis mismatch in 6 seconds — the whole run cost ~2 minutes instead of ~2 hours. This is AC-3 (loud, step-named failure) and the AC-4 guard in one artifact.

3. Subset end-to-end + 4. full-corpus terminal step — dispatched; posted as a follow-up comment.

Budget derivation (AC-2) — measured, not guessed

Ceilings derive from run 29433392568 and are recorded in the workflow header beside the arithmetic. The judge legs are fixed-size (19/21/11 cases) and don't shrink with limit, so they dominate a subset run: tonight's 50-minute run spent 34:25 of it in judges.

step ceiling (full / subset)
benchmark 620 / 45
drafting · action-item · briefing judges 35 · 45 · 25 (both profiles)
setup 35
five short steps @10 50
job 840 (sum 810) / 240 (sum 235)

The old value, 90, was never derived from a measurement. The job timeout is now only a backstop — every long step carries its own ceiling so an overrun names itself.

Tests

  • Unit: python -m pytest tests/unit/eval/ -q471 passed, 1 deselected (pre-existing worktree .env failure, reproduced identically at origin/main)
  • Judge pin (AC-5): python -m pytest tests/unit/eval/test_claude_judge.py -v28 passed; 18 new tests cover all 6 messages.create sites across 4 public methods. Tests were authored against the acceptance criteria before the implementation existed, and pin the judge to temperature=0.0 — with agent generation already at temp 0, the judge's own sampling was the last uncontrolled variance source
  • Lint: python util/lint.py --all → Black / isort / Pylint / Flake8 OK
  • Static: actionlint → 31→30 findings, zero new (remainder are pre-existing PowerShell-parsed-as-bash false positives already on main). Static only — it cannot prove the ternaries evaluate correctly or that PS 5.1 survives the string encoding; the dispatched runs above are the real validation
  • Real-world: basis-guard fail-fast (evidence 2) ✅
  • Real-world: subset end-to-end, terminal = NOT-COMMITTED
  • Real-world: full-corpus, terminal = commit or loud rejection

Notes for the reviewer

  • The full-corpus run is expected to reject, not commit. Prior measurement puts 16K at ~0.796 against the enforced 0.80 floor. That is the guard working: the card stays at its current basis and the measured number goes back for a decision (adjacent to eval(npu): settle the gemma4-it-e2b-FLM default against the 0.80 bar #2090). The bar does not move to make the run pass.
  • Known residual gap: this workflow can only govern what it commits. A human hand-editing SCORECARD.md in an unrelated PR bypasses every guard here.
  • Follow-up owed, deliberately not absorbed: six sibling workflows (test_api, test_embeddings, test_lemonade_server, test_rag, build_cpp, test_examples) share the stx runner label outside the lemonade-eval concurrency group, and most run cleanup-lemonade.ps1, which force-kills port 13305 — so an unrelated PR can kill a long eval mid-run. Widening the group is the right fix and needs its own issue.
  • Unrelated anomaly spotted in the incident data: the briefing judge finished in 36s against a ~11-15 min estimate — possibly a silent no-op. Noted on ci(email): scorecard-refresh has never completed — 90-min budget vs a ~2h workload #2094, not fixed here.
  • scorecard_gate.py is touched additively only: _env_ctx_size → public env_ctx_size (+4 tests), because the guard must read the ctx stamp from recipe.environment, and re-deriving that path in workflow PowerShell is how this class of bug gets written twice. No gate semantics, thresholds, or flags changed.

itomek added 4 commits July 15, 2026 19:16
…add basis guard (#2094)

email_scorecard_refresh.yml has never completed a single run (0/100):
timeout-minutes: 90 was a hand-typed guess against a measured ~2h workload
(run 29433392568), and lowering the sample count never helped since the
three fixed-size judge evals alone cost more than the benchmark.

Rewrites the workflow around two workflow_dispatch profiles:
- full (limit>=249, experiments>=committed n_runs): reproduces the
  committed card's 250x3 methodology; the only profile allowed to commit.
- subset (limit<249): an end-to-end smoke/dry-run that never commits.

Every long step now carries its own timeout-minutes derived from the
measured table in the job comment, so an overrun fails at a named step
instead of a silent job-level cancelled. A pre step reads the committed
card's ctx_size/n_runs stamp and fails fast (before any eval spend) on a
basis mismatch unless rebaseline=true names a reason. The push: trigger
is removed (0/100 successes means it never provided real coverage).

Promotes scorecard_gate._env_ctx_size to a public env_ctx_size so the
pre step can read the committed ctx stamp without re-deriving the
recipe.environment.ctx_size path, with a unit test for the legacy
unstamped-card case (the real current state of the committed card).
The header claimed test_email_agent_eval.yml was "red as of #2093". That
attribution is wrong: #2093 was the FIX for the specification.html drift
guard (54837cd, already on main), not a cause of the weekly's failures.
The weekly is red -- its last scheduled run (2026-07-13, 29235382831)
failed -- but that predates #2093 and the cause is unestablished. State
the observation, not an unverified cause.
…s.create sites (#2094)

Adds failing tests for ClaudeClient's new temperature kwarg: constructor
storage, all six messages.create call sites (get_completion,
get_completion_with_usage, analyze_file HTML/binary,
analyze_file_with_usage text/binary) forwarding temperature only when set,
and the three make_claude_judge factories (action_item_quality,
briefing_quality, draft_quality) pinning temperature=0.0.
The eval judges graded at the Anthropic API's default temperature because
ClaudeClient never sent one. With generation already pinned at temp 0,
the judge was the last uncontrolled variance source in the scorecard --
a tripwire that can flip on the grader's own sampling is not a tripwire.

ClaudeClient now takes an optional temperature, forwarded to all six
messages.create sites only when set, and the three make_claude_judge
factories pass 0.0. Left as None the kwarg is omitted entirely, so
today's behavior is preserved exactly for callers that never asked for a
pin -- pdf_document_generator constructs ClaudeClient to *generate*
synthetic fixture content, where temp-0 would collapse output diversity.
That is why the pin is judge-scoped rather than a class default.

Docs: eval-scorecard.mdx and EVALUATION.md still described the refresh
workflow as push-triggered with a binary commit/fail outcome; both now
describe the dispatch-only full/subset profiles and the basis guard.
@github-actions github-actions Bot added documentation Documentation changes devops DevOps/infrastructure changes eval Evaluation framework changes tests Test changes performance Performance-critical changes agent::email Email agent changes labels Jul 16, 2026
@itomek

itomek commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Evidence 3 — subset end-to-end: the incident, re-run against this fix

Run 29461875126 — dispatched limit=25 experiments=1, i.e. the same configuration that auto-committed the fake 1.0 card onto #2060 two hours earlier. Terminal step:

SUBSET RUN -- card NOT committed (basis limit=25 experiments=1 is smaller than
the committed limit=250 n_runs=3 basis). Restoring the committed SCORECARD.md.

git log origin/main..HEAD shows the branch's own 4 commits and nothing else — no auto-commit landed. Same inputs, same 53 minutes of real evaluation, opposite outcome: the card was measured, judged, and then not published, because a 25×1 basis cannot stand in for a 250×3 one.

Measured step times vs the ceilings this PR derives
step actual ceiling
pre (basis resolve + guard) 0:04 10
benchmark (25 emails) 14:01 45
drafting judge (19 cases) 14:53 35
action-item judge (21 cases) 21:59 45
briefing judge (11 cases) 0:39 25
gen_scorecard 0:02 10
commit (subset terminal) 0:00 10
total ~53 min job 240

Every step landed inside its derived ceiling, with the judge legs (37:31) dominating as predicted — they are fixed-size and do not shrink with limit.

Anomaly worth its own issue (not introduced here, not fixed here): the briefing judge finished in 0:39 against a ~11-15 min estimate — and it did the same on the incident run (0:36, 29457164041). Two independent runs agreeing suggests the briefing leg isn't performing a real judged evaluation, which would mean one of the three judged metrics on the published card is not what it claims. Flagged on #2094; out of scope for this PR.


Evidence 4 (full-corpus, AC-1's terminal step) is running — 29464281335, dispatched limit=250 experiments=3 ctx_size=16384 rebaseline=true. Expect a rejection rather than a commit: prior measurement puts 16K near 0.796 against the enforced 0.80 floor. That is the guard working as designed; the measured number goes back for a decision (see #2090) and the bar stays where it is.

kovtcharov
kovtcharov previously approved these changes Jul 16, 2026

@kovtcharov kovtcharov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified locally on the branch: the two new test suites pass (64 tests — judge-client hardening + gate basis checks). The design earns the approve on its own: the basis guard runs pre-spend and fails naming both bases + the exact re-dispatch command; SUBSET runs are structurally unable to commit (gates skipped by if:, card restored, loud notice) rather than trusted not to; rebaseline requires a stated reason; and dispatch against main is refused. This closes exactly the gap flagged in the #1900 closure caveats — the gate compared only the number, never the measurement basis, and a lucky 25-email run proved it two hours ago. The honest HISTORY comment (0/100 completions, per-limit breakdown from a real run) is the kind of archaeology that keeps the next editor from re-typing a hand-guessed timeout.

Two non-blocking notes: (1) the FULL profile's ~10.5h wall-clock on the single serial lemonade-eval slot means a full refresh monopolizes eval CI for a workday — the slot-occupancy warning covers it, but consider a scheduled monthly full run so refreshes don't rely on someone remembering the etiquette; (2) the workflow itself is only provable post-merge via dispatch — recommend a SUBSET dry-run as the first action after merging, before any FULL run.

…us run

Move the published card onto the #1892 production ctx envelope (16384). The committed card predates ctx stamping so its basis is unrecorded; approved for #2094 to make the published figure traceable to the window the agent actually ships with.
@itomek

itomek commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Evidence 4 — full-corpus run: AC-1 satisfied via the commit path

Run 29464281335limit=250 experiments=3 ctx_size=16384 rebaseline=true — completed success in ~6h25m (well under the 840-min ceiling) and reached the terminal commit step, landing 27798b14 on this branch. This is the first time in the workflow's history it has run end-to-end to a commit. AC-1 is met by this run.

I predicted this would reject; it did not, and the correction is worth stating plainly. I expected ~0.796 against the 0.80 floor. That figure came from a single-run measurement. The proper 3-run full-corpus measurement at 16K is:

metric old card (main, unstamped basis) re-baselined card (ctx=16384, 250×3)
within-one accuracy (gated aggregate) 0.834 0.8453
urgent recall (floor 0.90) 0.9938 1.0
ctx basis (unrecorded) 16384, stamped

So moving the card onto the production envelope did not lower the headline — it slightly raised it and made it traceable. This also resolves the open worry from #2090 that 16K might be sub-bar for triage: at n=3 it is 0.8453, comfortably clear.

The re-baselined card now rides in this PR's diff (27798b14). That is intentional — merging the workflow fix also lands the corrected, traceable 16K card, with the re-baseline reason recorded in the commit body.

Briefing-judge anomaly reproduced a third time: 41s on this full run (vs 39s subset, 36s incident). Three independent runs agree, so the briefing metric on the card is almost certainly not a real judged evaluation — the re-baselined card carries that suspect report-only value forward. It does not affect the gated aggregate (which comes from the triage benchmark, not the judge), but it needs its own fix. Flagged on #2094.

@itomek itomek self-assigned this Jul 16, 2026
@itomek
itomek marked this pull request as ready for review July 16, 2026 13:16
@itomek
itomek requested a review from kovtcharov-amd as a code owner July 16, 2026 13:16
@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Approve with suggestions

This turns the email scorecard-refresh workflow from "reliably dies / reliably publishes a lucky number" into a manual-dispatch job with a fail-fast basis guard, per-step timeouts, and a full/subset split where only a full-corpus run can ever commit a card. The judge-temperature pin and the guard logic are well-designed and thoroughly tested — the core change is sound. Two non-blocking things worth a look before this ships:

  • The subset smoke-run budget doesn't account for repeated experiments. The subset benchmark timeout was sized for a single pass over ~25 emails (~19 min), but the number-of-repeats input defaults to 3 and is applied to subset runs too — so a default smoke run does roughly 3× that work and can trip its own 45-minute step timeout. Either default subset to one repeat or widen the ceiling to cover three.
  • The published scorecard now carries Windows-backslash paths. The refreshed card was generated on the Windows runner, so its "reproduce" commands and recorded dataset path use tests\fixtures\... — those snippets are shown as bash and break if an integrator copy-pastes them on Linux/macOS. The card's methodology text also still says "the whole corpus is covered" while only 250 of 299 examples were scored.
🔍 Technical details

🟡 Important

1. Subset benchmark timeout is under-derived — experiments defaults to 3 for subset too (.github/workflows/email_scorecard_refresh.yml:150,192,349,377)

EXPERIMENTS defaults to '3' and the benchmark step passes --experiments "$env:EXPERIMENTS" for both profiles (line 377). But the subset budget derivation (line 192) sizes the 45-min ceiling for a single pass:

#   benchmark subset:  25 emails x 45s ~= 19min + model-load variance -> 45

Using the PR's own full-profile rate (3 × 249 × 45s ≈ 560 min ⇒ ~187 min/experiment for 249 emails), 25 emails ≈ ~19 min per experiment × 3 ≈ ~56 min — over the 45-min step ceiling (and it feeds the 240-min job budget on line 201, also derived at 45). A default subset dispatch (limit=25, experiments left at 3) would then fail at the named benchmark step. It fails loudly, which is the intended failure shape — but the documented budget is wrong. Cheapest fix is to default subset to one repeat:

        timeout-minutes: ${{ fromJSON(inputs.limit || '250') >= 249 && 620 || 60 }}

or, better, force --experiments 1 for the subset profile (a smoke run doesn't need variance) and keep the tight ceiling. Either way, reconcile the benchmark subset and job subset derivation comments with whichever you pick.

2. Published SCORECARD.md ships Windows-backslash paths + a stale "whole corpus" claim (hub/agents/npm/agent-email/SCORECARD.md:606,874,884,842)

The card was generated on the stx Windows runner, so the recorded ground_truth and the two bash "Reproduce" blocks now read tests\fixtures\email\ground_truth.json (was forward-slash). This is an integrator-facing doc rendered on npm/hub; a copy-paste of that gaia eval benchmark ... / gen_scorecard.py ... command on Linux/macOS eats the backslashes and the path resolves wrong. Root cause is in the generator (gen_scorecard.py, not in this diff) recording the CLI arg verbatim — worth normalizing paths to / there so no future Windows-generated card reintroduces this.

Separately, Dataset size | 299 with Test cases run | 250, yet the methodology says "The full corpus is scored — see dataset_size ... so the whole corpus is covered." Only 250 of 299 were scored, so that sentence is now inaccurate (the old card had dataset == run == 249, so it read correctly). Same generated-template origin.

Neither blocks merge, but both degrade a card whose whole purpose is to be the honest, reproducible published figure.

Strengths

  • _sampling_kwargs() is the right shape (src/gaia/eval/claude.py:1021): omitting the kwarg entirely when unpinned preserves the API default for generation callers (e.g. pdf_document_generator.py) instead of silently forcing temp on everyone — and the tests explicitly assert the "no temperature kwarg at all" path, not just temperature=None.
  • Test coverage is genuinely thorough: all 6 messages.create sites plus a non-0.0 forwarding test that distinguishes "reads self.temperature" from "hardcodes 0.0 per call site" — the latter would pass a weaker spec but rot on a default change.
  • Reuse over re-derivation: promoting _env_ctx_sizeenv_ctx_size and importing the tested helper into the pre step (rather than re-walking recipe.environment.ctx_size in ad-hoc PowerShell) matches the CLAUDE.md reuse guidance, and the fail-fast guard rejects a basis mismatch in seconds before spending eval time.
  • Root-cause fix for the incident: removing the push: trigger and the explicit refuse-to-commit-to-main check close the exact path that auto-committed the 25/1 lucky card.

@itomek-amd
itomek-amd added this pull request to the merge queue Jul 16, 2026
Merged via the queue into main with commit 5265b96 Jul 16, 2026
33 checks passed
@itomek-amd
itomek-amd deleted the tmi/issue-2094-scorecard-refresh-budget branch July 16, 2026 13:22
@itomek

itomek commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-ups filed from this PR's validation, both deliberately out of scope here: #2121 (briefing judge ~40s / likely not judging — its number ships on the card) and #2122 (six workflows share the stx runner outside lemonade-eval and force-kill its port, so an unrelated PR can kill a ~6.5h eval). The workflow header's slot-occupancy warning now has a tracked fix behind it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent::email Email agent changes devops DevOps/infrastructure changes documentation Documentation changes eval Evaluation framework changes performance Performance-critical changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci(email): scorecard-refresh has never completed — 90-min budget vs a ~2h workload

3 participants