feat(evals): independent judge provider/model + fix stale-grade skip on judge swap - #2710
Conversation
…on judge swap Let the LLM-as-judge run on a different provider/model than the run model (e.g. run Anthropic, grade OpenAI) to reduce same-model grading bias, and fix a silent-skip bug that made grader-vs-grader comparison return stale scores. - --judge-model accepts a "provider:model" spec (anthropic|openai; bare model defaults to anthropic), configurable via eval.config.json grading.judgeModel - judge moved onto the Vercel AI SDK; credentials via AI_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY (+ AI_BASE_URL / AI_REQUEST_HEADERS), provider-specific key wins over AI_API_KEY so runner/grader keys don't collide - explicit 0-5 scoring anchors + structured-output salvage/retry; larger output budget for reasoning judges (gpt-5.x / o-series) - fix: needsJudge and the per-run reuse guard keyed on judge *presence*, not identity, so re-grading an Opus-graded batch with openai:gpt-5.6-sol silently returned the Opus scores. Both now treat a cached grade from a different judge model as stale; --rerun-judge still forces a same-judge refresh.
🦋 Changeset detectedLatest commit: 7e0094a The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🟡 Tier 3 — StandardIntroduces new logic, modifies core functionality, or touches areas with non-trivial risk. Why this tier:
Review process: Full human review — logic, architecture, edge cases. Stats
|
Greptile SummaryThis PR adds independent model and provider selection for LLM-based grading. The main changes are:
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (3): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile |
E2E Test Results✅ All tests passed • 243 passed • 1 skipped • 1034s
Tests ran across 4 shards in parallel. |
Deep Review✅ No critical issues found. The core change — keying 🟡 P2 — recommended
🔵 P3 nitpicks (4)
Reviewers (6): correctness, testing, maintainability, security, reliability, project-standards. Testing gaps:
Coverage note: The shell sandbox was non-functional in this environment ( |
Addresses Greptile P2: a criterion ID colliding with an Object.prototype key (e.g. __proto__) would mutate the prototype instead of adding an own property, silently dropping the criterion from the generated schema. Criterion IDs come from static in-repo rubrics so this is defensive, but Object.create(null) is the correct idiom for a dict keyed by dynamic strings and costs nothing.
…-grading-model # Conflicts: # packages/hdx-eval/src/grading/grade.ts
What
Two changes to the
hdx-evalLLM-as-judge grader:Independent judge provider/model. The grader can now run on a different provider/model than the run model — e.g. run with Anthropic, grade with OpenAI — to reduce same-model grading bias.
--judge-modelaccepts aprovider:modelspec (anthropic|openai; a bare model name defaults toanthropic), configurable per repo viaeval.config.jsongrading.judgeModel.Fix a silent stale-grade bug on judge swap. Re-grading a batch with a different judge now actually re-runs that judge instead of returning the previously cached judge's scores.
Note: this does not change the default judge. The default remains
anthropic:claude-opus-4-7. This PR only makes an alternate grader possible and correct.Why
We wanted to measure grader bias: does the choice of judge model materially change eval grades, and is one judge more accurate than another? Answering that requires grading the same runs with two different judges and comparing.
Before this PR that was impossible in practice:
needsJudgeand the per-run reuse guard keyed on the presence of a cached judge, not its identity. Sograde <batch> --judge-model openai:gpt-5.6-solover an already-Opus-graded batch would see a cached judge, skip the LLM call, and silently hand back the Opus scores relabeled. You'd "compare" two judges and measure nothing — the worst kind of bug, because it fails silently and looks like agreement.How
src/grading/judgeModel.ts, new): parses theprovider:modelspec and builds a Vercel AI SDKLanguageModel. Credentials come fromAI_API_KEY/OPENAI_API_KEY/ANTHROPIC_API_KEY(+AI_BASE_URL,AI_REQUEST_HEADERS), mirroringpackages/api. The provider-specific key wins over the genericAI_API_KEYso a runner key and a differing grader key don't collide.src/grading/judge.ts): explicit 0–5 scoring anchors in the prompt (so scores stay calibrated across judge models), structured-output salvage + one retry on schema failure, and a larger output-token budget for reasoning judges (OpenAIgpt-5.x/ o-series) so hidden reasoning tokens don't truncate the JSON.src/grading/grade.ts): bothneedsJudgeand the per-run reuse guard now compare the cached grade'sjudgeModelagainst the requested spec. A cached grade from a different judge is treated as stale and re-run.--rerun-judgestill forces a same-judge refresh. Inspection evidence is still reused across judge swaps (re-inspecting after artifact cleanup would fail).grade.judgeModel.test.ts— grading one batch with judge A then judge B re-runs B (not skip), and re-grading with the same judge still no-ops. Verified the new test fails when the fix is reverted (mutation-checked), so it genuinely guards the regression.Eval results — Claude Opus vs GPT-5.6 Sol as judge
Ran the full suite on this branch to exercise the feature and answer the bias question.
Setup: 7 scenarios × n=3 = 21 runs,
hyperdxMCP, runnerclaude-opus-4-6. Graded the identical batch twice:anthropic:claude-opus-4-7(pass A) thenopenai:gpt-5.6-sol(pass B). 0 grading errors either pass.Aggregate
GPT-5.6 Sol is systematically ~11 pts stricter, but the two judges rank runs almost identically (0.91). Practical implication: a relative MCP comparison would largely survive a judge swap; absolute scores would not (consistent with the README caveat that anchors reduce but don't erase per-model scale differences).
Per-scenario (mean judge score)
How we verified GPT-5.6 Sol is more accurate (not just stricter)
"Stricter" is not "better" — a judge that scores everything 0 is strict and useless. Because every criterion stores a
{score, rationale}and the fullrawResponse, we could adjudicate each judge's reasoning against the scenario's documented ground truth. We reviewed the highest-divergence runs:1.
segmented-regression/1(Opus 0.82 → GPT 0.50). Ground truth: the regression is at the enterprise × cache-miss intersection; single-axis "all enterprise" answers are the designed trap. The agent answered "100% enterprise tenants."2.
metric-saturation/1(Opus 0.93 → GPT 0.64). The agent attributed pod restarts to "liveness-probe kills" — plausible, but not the seeded mechanism (the heap-leak crash cycle itself).Control — they agree on clear failures. On
latency-spike/1(a truncatedmax_turnsfragment), both judges scored ~0.2 and both gave 0/5 on correctness/completeness. GPT is not uniformly harsh; the judges converge on genuine failures and diverge only on borderline-good answers with plausible-but-flawed reasoning.Mechanism (generalizable). Splitting the Opus-minus-GPT lenience gap by termination:
final_answerruns (n=19): Opus +0.130 more lenientmax_turnsruns (n=2): −0.025 (even)Opus's over-scoring is concentrated on complete-looking answers — when an answer reads authoritative (headers, tables, confident prose), Opus tends to trust the presentation while GPT keeps verifying claims against ground truth.
Conclusion: in this sample, GPT-5.6 Sol is the more accurate judge — it catches plausible-but-wrong reasoning that Opus rewards, especially on polished answers. We are not changing the default judge yet (n=21; single runner/branch; this measures grader bias, not runner self-preference). This PR makes the alternate grader available and correct so the comparison can be run rigorously at larger n before any default change.
Caveats
claude-opus-4-6) and single branch — measures grader bias, not runner self-preference. Opus-runner + Opus-judge is a same-family pairing that could itself inflate Opus leniency; n is too small to isolate that from GPT simply being stricter.Test plan
yarn ci:unit(hdx-eval) — 32 judge/grade tests pass;tsc --noEmitclean.