Repository navigation
Skill eval: API throttling masquerades as a held-out regression (infra failure scored as regression, not error) #920
Description
Summary
The held-out skill eval cannot distinguish "the model never answered" (an infra/throttle failure) from "the model gave a wrong answer" (a genuine quality regression). Both collapse to a failing case and exit code 1, which the workflow maps to outcome=regression — so a transient Anthropic API throttle opens/updates a false eval-health regression tracker against a skill that is actually fine.
This is a live false alarm right now: #911 (triage holdout, 0/5) was opened by a throttled run, even though the triage prompt fix (#897) scored a clean 5/5 hours earlier.
Evidence
Scheduled run 28020121086 (triage), job eval-report:
- Whole run took 9 seconds for 5 model-backed cases — the models never actually answered.
- Scorer log shows every model in the fallback chain throttled:
##[warning][claude] model claude-haiku-4-5-20251001 throttled (rc=1) — trying next in chain
##[warning][claude] model claude-sonnet-4-6 throttled (rc=1) — trying next in chain
…repeated for all 5 cases…
##[error]Process completed with exit code 1.
- Result:
score 0, all 5 casesactual: null,outcome=regression→notify-eval-health.shopened/updated Skill eval health —triage(holdout) #911 as a held-out regression.
Root cause
In scripts/evals/run-eval.sh:
-
Line 242 swallows the engine's exit status, discarding the only signal that the call failed for infra reasons:
raw="$("$EVAL_ENGINE_CMD" "$work_prompt")" || raw=""
A throttle (engine exits non-zero after exhausting its fallback chain) and a model that genuinely returned wrong/garbage output (engine exits
0, output unparseable) both end up asraw=""/actual=null→ indistinguishable downstream. -
Lines 262-263 exit
1whenever any case failed, regardless of cause:failed="$(jq -s 'map(select(.pass | not)) | length' "$results")" [ "$failed" -eq 0 ]
The exit-code contract (lines 52-55) already reserves 2 for hard/infra errors, and .github/workflows/skill-eval-report.yml already maps exit 2 → outcome=error (vs exit 1 → regression). The throttle path simply never reaches exit 2.
Note there is prior art for the "null everywhere" failure mode: the extract_json fence-stripping (line 129, ref #762) and the raw_excerpt capture (lines 156-160) were both added to make blind null-everywhere failures diagnosable — this issue is the remaining half: classifying an all-infra-failure run as error rather than regression.
Proposed fix
Capture the engine exit status per case and use it to distinguish infra failure from quality miss:
raw=""; eng_rc=0
raw="$("$EVAL_ENGINE_CMD" "$work_prompt")" || eng_rc=$?Then, when assembling the outcome: if no case produced a successful model response (every failing case is attributable to a non-zero engine exit — i.e. throttle/infra, not a parseable-but-wrong answer), exit 2 (hard error → outcome=error) instead of 1 (regression). A genuine quality miss (engine exits 0, output wrong or unparseable) must still score as a case failure and exit 1.
Correspondingly, notify-eval-health.sh should treat outcome=error distinctly from regression: do not open/update a "held-out regression" tracker on a pure infra error (optionally surface a separate, clearly-labeled infra-error signal so an outage is still visible without implying the skill regressed).
Acceptance criteria
- A run where every case's engine call exits non-zero (e.g. all models throttled) produces exit
2/outcome=error, and does not open or update aneval-healthregression issue. - A genuine quality miss (engine exits
0, output wrong/unparseable) still scores as a case failure → exit1→regression, unchanged. - A mixed run (some cases answered, some throttled) is handled deterministically per the rule above and documented in the script header.
-
tests/gains bats coverage for: all-throttled →error; genuine regression →regression(unchanged); mixed → defined outcome. Use the existingEVAL_ENGINE_CMDstub injection (no network). - No changes to
evals/**held-out case data.
References
- Live false alarm: Skill eval health —
triage(holdout) #911 - Scorer:
scripts/evals/run-eval.sh(lines 52-55, 242, 262-263) - Health notifier:
scripts/evals/notify-eval-health.sh - Workflow:
.github/workflows/skill-eval-report.yml(exit-code → outcome mapping) - Prior art on null-everywhere diagnosis: Skill eval health —
triage(holdout) #762
Activity
don-petry commentedon Jun 25, 2026
Dev-Lead Implementation Plan
Issue: #920 — Skill eval: API throttling masquerades as a held-out regression (infra failure scored as
regression, noterror)Scope
Capture the engine's per-case exit status in
run-eval.shso an all-throttled (infra) run exits2→outcome=errorinstead of1→regression, and splitnotify-eval-health.shso anerroroutcome opens a separate, clearly-labeled infra-error tracker rather than touching the held-out regression tracker.Implementation Checklist
scripts/evals/run-eval.sh— captureeng_rcper case (raw=""; eng_rc=0; raw="$(...)" || eng_rc=$?); embedengine_rcin each case result (both deterministic + llm-judge scorers)scripts/evals/run-eval.sh— replace the final[ "$failed" -eq 0 ]exit logic: if every failing case is an infra failure (engine exited non-zero, no parseable-but-wrong answer) → exit2; a genuine quality miss (engine exited0) keeps exit1; document the rule (incl. mixed runs) in the script headerscripts/evals/notify-eval-health.sh— giveerrorits own stable title +eval-infralabel;errormust never open/update/close theeval-healthregression tracker;passcloses whichever tracker(s) are open (regression and/or infra)evals/**held-out case data (read-only)Tests to Write
tests/test_skill_evals.bats— all-throttled stub → exit2; mixed (one answered, one throttled) → exit2; genuine wrong answer (engine rc 0) → exit1unchanged; unparseable-but-rc-0 → exit1; assert per-caseengine_rctests/test_eval_health_notify.bats—erroropens a separateeval-infratracker, never theeval-healthregression tracker (even when one is open);errorde-dupes its own infra tracker;regressionpath unchanged;passcloses an open infra trackerTest Command
npx bats tests/test_skill_evals.bats tests/test_eval_health_notify.bats(full suite +bash .dev-lead/scripts/dev-lead-lint.sh)don-petry commentedon Jun 25, 2026
Dev-Lead: Implementation Complete
Plan Execution
scripts/evals/run-eval.sh— capture per-case engine exit status (raw=""; eng_rc=0; raw="$(...)" || eng_rc=$?) and embedengine_rcin every case result (deterministic + llm-judge scorers)scripts/evals/run-eval.sh— replaced the final[ "$failed" -eq 0 ]with the infra/quality classifier: every failing case attributable to a non-zero engine exit → exit2(outcome=error); any quality miss (engine exited0, wrong/unparseable) keeps exit1(regression). Rule (incl. mixed runs) documented in the header.scripts/evals/notify-eval-health.sh—errornow opens/updates a separateeval-infratracker and never touches theeval-healthregression tracker;passcloses whichever tracker(s) (regression and/or infra) are open.evals/**held-out case data.Acceptance Criteria
2/outcome=error, does not open/update a regression issue.0, wrong/unparseable) → exit1/regression, unchanged.2(no parseable-but-wrong answer to call a regression); documented in the script header.tests/bats coverage added for all-throttled →error, genuine regression →regression, unparseable-but-clean-exit →regression, and mixed →error, all viaEVAL_ENGINE_CMDstub injection (no network).evals/**changes.Test Results
Full suite: 825 passing. One unrelated failure (
tests/test_batch_fallback.bats: "Gemini trust error (55) falls back to Copilot") is pre-existing — it fails identically on the clean base (verified viagit stash) and references neither changed script.bash .dev-lead/scripts/dev-lead-lint.sh→ all checks passed.Files Changed
scripts/evals/run-eval.sh: per-caseengine_rccapture + embed; exit-code classifier (all-infra →2, else1); header docs the rule.scripts/evals/notify-eval-health.sh: separateeval-infratracker forerror;_recover/_open_or_updatehelpers;passcloses both trackers.tests/test_skill_evals.bats: throttle + mixed stubs; 5 new classification tests;engine_rcassertion in llm-judge.tests/test_eval_health_notify.bats: infra-tracker title/helper; reworkederrortests + new separation/recovery tests.Notes
scripts/evals/gate.shalready treats run-eval exit2as a hard error (rc <= 1is success), so a throttle during the strict-improvement gate nowdies instead of comparing bogus 0-scores — an improvement, with all gate tests still green.eval-infralabel is auto-created (colorfbca04) on first use, mirroring the existingeval-healthlabel bootstrap.skill-eval-report.yml) needed no change: it already maps exit2→outcome=error. Its "Annotate outcome" text still says "hard-errored" for an error; left as-is to avoid out-of-scope workflow edits (the tracking-issue body now describes the infra cause accurately).feat: implement issue #920 — Skill eval: API throttling masquerades a…