Skip to content

Commit c4a2233

Browse files
feat(evals): independent judge provider/model + fix stale-grade skip on judge swap (#2710)
## What Two changes to the `hdx-eval` LLM-as-judge grader: 1. **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-model` accepts a `provider:model` spec (`anthropic` | `openai`; a bare model name defaults to `anthropic`), configurable per repo via `eval.config.json` `grading.judgeModel`. 2. **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: - The judge was hard-wired to Anthropic. - Even after making the provider configurable, `needsJudge` and the per-run reuse guard keyed on the **presence** of a cached judge, not its **identity**. So `grade <batch> --judge-model openai:gpt-5.6-sol` over 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 - **Provider resolution** (`src/grading/judgeModel.ts`, new): parses the `provider:model` spec and builds a Vercel AI SDK `LanguageModel`. Credentials come from `AI_API_KEY` / `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` (+ `AI_BASE_URL`, `AI_REQUEST_HEADERS`), mirroring `packages/api`. The **provider-specific key wins** over the generic `AI_API_KEY` so a runner key and a differing grader key don't collide. - **Judge robustness** (`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 (OpenAI `gpt-5.x` / o-series) so hidden reasoning tokens don't truncate the JSON. - **The fix** (`src/grading/grade.ts`): both `needsJudge` and the per-run reuse guard now compare the cached grade's `judgeModel` against the requested spec. A cached grade from a *different* judge is treated as stale and re-run. `--rerun-judge` still forces a same-judge refresh. Inspection evidence is still reused across judge swaps (re-inspecting after artifact cleanup would fail). - **Tests**: added regression coverage in `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**, `hyperdx` MCP, runner `claude-opus-4-6`. Graded the identical batch twice: `anthropic:claude-opus-4-7` (pass A) then `openai:gpt-5.6-sol` (pass B). 0 grading errors either pass. ### Aggregate | Metric | Opus judge | GPT-5.6 Sol | Δ | |---|---|---|---| | Mean judge score (0–1) | 0.721 | 0.605 | **−0.115** | | Spearman rank correlation | — | — | **0.906** | | GPT scored ≤ Opus | — | — | **19 / 21 (90%)** | **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) | Scenario | Opus | GPT | Δ | |---|---|---|---| | dashboard-build | 0.84 | 0.76 | −0.08 | | error-root-cause | 1.00 | 0.87 | −0.13 | | latency-spike | 0.39 | 0.37 | −0.02 | | metric-saturation | 0.92 | 0.74 | −0.18 | | noisy-signals | 0.56 | 0.50 | −0.07 | | segmented-regression | 0.78 | 0.56 | **−0.22** | | service-health-check | 0.55 | 0.45 | −0.10 | ## 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 full `rawResponse`, 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." - Opus *noticed* the flaw ("emphasizes 100% enterprise more than the intersection") but still gave correctness **4/5**. - GPT called it "materially mischaracterizes the scope … rather than only their intersection with cache miss" and gave **3/5**, and docked completeness for omitting the ~12% vs ~0.5% comparison (factually absent from the answer). - **GPT scored the rubric's actual intent; Opus forgave the exact error the scenario tests for.** **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). - Opus gave correctness **5/5**. - GPT gave **3/5** ("materially misattributes restarts") and additionally caught two distractors the agent ignored (coincidental deploy, stable neighbor) that Opus's rationale missed. **Control — they agree on clear failures.** On `latency-spike/1` (a truncated `max_turns` fragment), 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_answer` runs (n=19): Opus **+0.130** more lenient - `max_turns` runs (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 - n=21 — directionally strong and internally consistent, but not statistically heavy. The *mechanism* (Opus rewards plausibility on polished answers) is the robust takeaway; exact deltas will wobble. - Single runner (`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. - Reviewed the top divergences, not all 21 pairs. ## Test plan - `yarn ci:unit` (hdx-eval) — 32 judge/grade tests pass; `tsc --noEmit` clean. - New regression test mutation-checked: fails when the identity fix is reverted. - End-to-end: full 21-run suite graded twice with two providers, 0 errors; judge-swap re-ran correctly over cached Opus grades (validating the fix live).
1 parent f5f9cd1 commit c4a2233

14 files changed

Lines changed: 1567 additions & 178 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@hyperdx/hdx-eval": minor
3+
---
4+
5+
feat(evals): support an independent inference model/provider for LLM-as-judge
6+
grading. The grader can now differ from the run model (e.g. run with Anthropic,
7+
grade with OpenAI) to reduce same-model bias. `--judge-model` accepts a
8+
`provider:model` spec (`anthropic`, `openai`; a bare model name defaults to
9+
anthropic), configurable per repo via `eval.config.json` `grading.judgeModel`.
10+
The judge now uses the Vercel AI SDK, so grader credentials come from
11+
`AI_API_KEY`/`OPENAI_API_KEY`/`ANTHROPIC_API_KEY` (plus `AI_BASE_URL` /
12+
`AI_REQUEST_HEADERS`), mirroring the API package. The provider-specific key
13+
takes precedence over `AI_API_KEY` so a runner key and a differing grader key
14+
don't collide.
15+
16+
The judge prompt now defines explicit 0-5 scoring anchors so scores stay
17+
calibrated across judge models, and the judge is more robust to intermittent
18+
structured-output malformations (recovers nested/stringified `scores` payloads
19+
and retries once on schema failure). Reasoning judge models (OpenAI gpt-5.x /
20+
o-series) get a larger output-token budget so hidden reasoning tokens don't
21+
truncate the JSON.
22+
23+
fix(evals): re-grading a batch with a different judge model now actually
24+
re-runs that judge instead of silently returning the previously cached judge's
25+
scores. The `needsJudge` check and per-run reuse guard were keyed on the mere
26+
presence of a cached judge, not its identity, so `grade <batch> --judge-model
27+
openai:gpt-5.6-sol` over an Opus-graded batch would skip the LLM call and hand
28+
back the stale Opus scores. Both now key on the judge spec: a cached grade from
29+
a different judge model is treated as stale. `--rerun-judge` still forces a
30+
refresh with the same judge. This is required for grader-bias comparisons
31+
(grading one batch with two judges).

‎packages/hdx-eval/README.md‎

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -337,14 +337,44 @@ dev runs-instrument <batch> # enrich with ClickHouse query_log t
337337
### Grading & Reporting
338338

339339
```bash
340-
dev grade <batch> # programmatic + LLM-as-judge
341-
dev grade <batch> --judge-model claude-sonnet-4-6
342-
dev grade <batch> --no-judge # programmatic checks only
343-
dev grade <batch> --rerun-judge # re-run judge even if grades exist
344-
dev report <batch> # render _summary.md + _summary.json
345-
dev report <batch> --baseline hyperdx-main # override baseline for deltas
340+
dev grade <batch> # programmatic + LLM-as-judge
341+
dev grade <batch> --judge-model openai:gpt-4o # grade with a different provider
342+
dev grade <batch> --no-judge # programmatic checks only
343+
dev grade <batch> --rerun-judge # force re-grade with the current judge
344+
dev report <batch> # render _summary.md + _summary.json
345+
dev report <batch> --baseline hyperdx-main # override baseline for deltas
346346
```
347347

348+
#### Independent grader (different provider/model)
349+
350+
The LLM-as-judge can run on a **different provider/model than the run model**
351+
(Anthropic run graded by OpenAI, and vice-versa), which reduces same-model bias.
352+
Pass `--judge-model` as a `provider:model` spec — supported providers
353+
`anthropic` and `openai`; a bare model name defaults to `anthropic`:
354+
355+
```bash
356+
dev grade <batch> --judge-model openai:gpt-4o
357+
dev grade <batch> --judge-model claude-opus-4-7 # == anthropic:claude-opus-4-7
358+
```
359+
360+
Precedence: `--judge-model` flag → `eval.config.json` `grading.judgeModel` →
361+
built-in default (`anthropic:claude-opus-4-7`):
362+
363+
```json
364+
{ "grading": { "judgeModel": "openai:gpt-4o" } }
365+
```
366+
367+
Grades are cached per run; re-grading a batch with a **different** judge
368+
re-runs that judge automatically (a cached grade from another judge model is
369+
treated as stale). Use `--rerun-judge` to force a refresh with the same judge.
370+
371+
**Grader credentials** come from the environment, mirroring `packages/api`:
372+
`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` per provider, `AI_API_KEY` as a fallback,
373+
`AI_BASE_URL` for a custom endpoint (Azure AI, LiteLLM proxy), and
374+
`AI_REQUEST_HEADERS` (OpenAI) as a JSON object. The **provider-specific key
375+
wins** over `AI_API_KEY`, so the runner's Anthropic key can live in `AI_API_KEY`
376+
while an OpenAI grader uses `OPENAI_API_KEY` without the two colliding.
377+
348378
### Viewer
349379

350380
```bash
@@ -366,10 +396,16 @@ Combined score = `0.4 * programmatic + 0.6 * judge - toolErrorPenalty`
366396
answer, defined per-scenario in `ground-truth.json`. Includes both positive
367397
checks (must find the right thing) and negative checks (must not blame a
368398
distractor).
369-
- **LLM-as-judge (60%)** — multi-criteria rubric scored 0-5 by Claude Opus
370-
(configurable). Criteria are scenario-specific. Answers are **blinded** —
371-
MCP tool names and brand terms are redacted so the judge can't tell which
372-
MCP produced the answer.
399+
- **LLM-as-judge (60%)** — scenario-specific, multi-criteria rubric scored 0-5
400+
by an LLM judge (configurable — see [Independent grader](#independent-grader-different-providermodel)).
401+
The judge prompt defines **explicit 0-5 scoring anchors** (0 = wrong/absent …
402+
5 = fully correct) to keep scores calibrated across judge models, and answers
403+
are **blinded** — MCP tool names and brand terms are redacted so the judge
404+
can't tell which MCP produced the answer.
405+
406+
> Judge scores are only comparable **within the same judge model** (anchors
407+
> reduce but don't erase per-model scale differences). Hold the judge constant
408+
> across runs you compare directly.
373409
- **Tool error penalty** — up to 20% deducted for high tool-call error rates.
374410

375411
### Baseline + Challengers

‎packages/hdx-eval/package.json‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@
1313
"dist/*"
1414
],
1515
"dependencies": {
16-
"@anthropic-ai/sdk": "^0.92.0",
16+
"@ai-sdk/anthropic": "^3.0.58",
17+
"@ai-sdk/openai": "^3.0.47",
1718
"@clickhouse/client": "1.23.0-head.fae5998.1",
19+
"ai": "^6.0.116",
1820
"commander": "^12.1.0",
19-
"ms": "^2.1.3"
21+
"ms": "^2.1.3",
22+
"zod": "3.25"
2023
},
2124
"devDependencies": {
2225
"@dotenvx/dotenvx": "^1.51.1",
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
/**
2+
* Verifies that gradeBatch records the fully-qualified `provider:model` judge
3+
* spec on the GradeRecord and threads the resolved spec into judgeTrajectory.
4+
* The judge module is mocked so no provider credentials or network are needed.
5+
*/
6+
7+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
8+
import { join } from 'path';
9+
10+
jest.mock('@/grading/judge', () => ({
11+
judgeTrajectory: jest.fn(),
12+
}));
13+
14+
import { gradeBatch } from '@/grading/grade';
15+
import { judgeTrajectory } from '@/grading/judge';
16+
import type { JudgeResult } from '@/grading/types';
17+
import type { RunRecord } from '@/harness/types';
18+
19+
const mockJudge = judgeTrajectory as jest.MockedFunction<
20+
typeof judgeTrajectory
21+
>;
22+
23+
const ANSWER =
24+
'Root cause: payment-service ConnectionTimeoutError on db.payment.connect ' +
25+
'reaching db-payment.internal — DB connection timeout — cascading into ' +
26+
'checkout-api 5xx errors. Ruled out concurrent SMTP and CDN bursts ' +
27+
'(separate trace trees, no checkout parent).';
28+
29+
function buildRun(): RunRecord {
30+
return {
31+
schemaVersion: 1,
32+
runId: 'error-root-cause-test-0',
33+
scenario: 'error-root-cause',
34+
mcp: 'hyperdx',
35+
model: 'claude-sonnet-4-6',
36+
plugin: 'none',
37+
runIndex: 0,
38+
seed: 42,
39+
startedAt: '2026-05-10T00:00:00.000Z',
40+
endedAt: '2026-05-10T00:01:00.000Z',
41+
durationMs: 60_000,
42+
agentPrompt: 'p',
43+
systemPromptAppend: 's',
44+
termination: 'final_answer',
45+
exitCode: 0,
46+
tools: [],
47+
toolCalls: [
48+
{
49+
name: 'mcp__hyperdx__hyperdx_query',
50+
input: null,
51+
output: null,
52+
isError: false,
53+
startedAt: '',
54+
endedAt: null,
55+
durationMs: null,
56+
},
57+
],
58+
messages: [],
59+
finalAnswer: ANSWER,
60+
tokens: { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 },
61+
totalCostUsd: 0,
62+
stderr: '',
63+
};
64+
}
65+
66+
function writeBatch(batchDir: string): void {
67+
const dir = join(batchDir, 'error-root-cause', 'hyperdx');
68+
mkdirSync(dir, { recursive: true });
69+
writeFileSync(join(dir, '0.json'), JSON.stringify(buildRun(), null, 2));
70+
}
71+
72+
function fakeJudgeResult(model: string): JudgeResult {
73+
return {
74+
model,
75+
scores: {},
76+
weightedScore: 0.5,
77+
rawResponse: '{}',
78+
durationMs: 1,
79+
tokens: { input: 1, output: 1, cacheCreation: 0, cacheRead: 0 },
80+
};
81+
}
82+
83+
describe('gradeBatch judge model spec', () => {
84+
const tmpRoot = join('/tmp', `hdx-eval-judge-model-test-${Date.now()}`);
85+
const savedEnv: Record<string, string | undefined> = {};
86+
87+
beforeAll(() => {
88+
for (const k of ['AI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY']) {
89+
savedEnv[k] = process.env[k];
90+
}
91+
});
92+
93+
afterAll(() => {
94+
for (const [k, v] of Object.entries(savedEnv)) {
95+
if (v === undefined) delete process.env[k];
96+
else process.env[k] = v;
97+
}
98+
rmSync(tmpRoot, { recursive: true, force: true });
99+
});
100+
101+
beforeEach(() => {
102+
mockJudge.mockReset();
103+
});
104+
105+
it('threads the resolved spec to the judge and records it', async () => {
106+
process.env.OPENAI_API_KEY = 'sk-oai-test';
107+
delete process.env.AI_API_KEY;
108+
const batchDir = join(tmpRoot, 'openai');
109+
writeBatch(batchDir);
110+
111+
// The judge echoes back the spec it was called with.
112+
mockJudge.mockImplementation(async opts =>
113+
fakeJudgeResult(opts.judgeModel as string),
114+
);
115+
116+
const result = await gradeBatch(batchDir, { judgeModel: 'openai:gpt-4o' });
117+
118+
expect(mockJudge).toHaveBeenCalledTimes(1);
119+
expect(mockJudge.mock.calls[0][0].judgeModel).toBe('openai:gpt-4o');
120+
expect(result.graded[0].judgeModel).toBe('openai:gpt-4o');
121+
expect(result.graded[0].judge?.model).toBe('openai:gpt-4o');
122+
});
123+
124+
it('normalizes a bare model name to the anthropic provider', async () => {
125+
process.env.ANTHROPIC_API_KEY = 'sk-ant-test';
126+
delete process.env.AI_API_KEY;
127+
delete process.env.OPENAI_API_KEY;
128+
const batchDir = join(tmpRoot, 'anthropic-bare');
129+
writeBatch(batchDir);
130+
131+
mockJudge.mockImplementation(async opts =>
132+
fakeJudgeResult(opts.judgeModel as string),
133+
);
134+
135+
const result = await gradeBatch(batchDir, {
136+
judgeModel: 'claude-opus-4-7',
137+
});
138+
139+
expect(mockJudge.mock.calls[0][0].judgeModel).toBe(
140+
'anthropic:claude-opus-4-7',
141+
);
142+
expect(result.graded[0].judgeModel).toBe('anthropic:claude-opus-4-7');
143+
});
144+
145+
it('throws a provider-aware error when the grader key is missing', async () => {
146+
delete process.env.AI_API_KEY;
147+
delete process.env.ANTHROPIC_API_KEY;
148+
delete process.env.OPENAI_API_KEY;
149+
const batchDir = join(tmpRoot, 'nokey');
150+
writeBatch(batchDir);
151+
152+
await expect(
153+
gradeBatch(batchDir, { judgeModel: 'openai:gpt-4o' }),
154+
).rejects.toThrow(/No API key set for the "openai" judge/);
155+
expect(mockJudge).not.toHaveBeenCalled();
156+
});
157+
158+
it('records "skipped" and never calls the judge with --no-judge', async () => {
159+
delete process.env.AI_API_KEY;
160+
delete process.env.ANTHROPIC_API_KEY;
161+
delete process.env.OPENAI_API_KEY;
162+
const batchDir = join(tmpRoot, 'skipped');
163+
writeBatch(batchDir);
164+
165+
const result = await gradeBatch(batchDir, {
166+
judgeModel: 'openai:gpt-4o',
167+
skipJudge: true,
168+
});
169+
170+
expect(mockJudge).not.toHaveBeenCalled();
171+
expect(result.graded[0].judge).toBeNull();
172+
expect(result.graded[0].judgeModel).toBe('skipped');
173+
});
174+
175+
it('re-runs the judge when re-grading a batch with a DIFFERENT judge model', async () => {
176+
// Regression guard for the silent-skip bug: a cached grade from judge A
177+
// must NOT suppress judge B on a second pass. Without keying needsJudge /
178+
// the per-run guard on judge identity, pass B returns pass A's stale
179+
// scores relabeled — which would quietly corrupt an Opus-vs-GPT grader
180+
// comparison.
181+
process.env.ANTHROPIC_API_KEY = 'sk-ant-test';
182+
process.env.OPENAI_API_KEY = 'sk-oai-test';
183+
delete process.env.AI_API_KEY;
184+
const batchDir = join(tmpRoot, 'judge-swap');
185+
writeBatch(batchDir);
186+
187+
mockJudge.mockImplementation(async opts =>
188+
fakeJudgeResult(opts.judgeModel as string),
189+
);
190+
191+
// Pass A — Anthropic judge.
192+
await gradeBatch(batchDir, { judgeModel: 'anthropic:claude-opus-4-7' });
193+
expect(mockJudge).toHaveBeenCalledTimes(1);
194+
195+
// Pass B — different judge, NO --rerun-judge. Must re-run, not skip.
196+
mockJudge.mockClear();
197+
const passB = await gradeBatch(batchDir, {
198+
judgeModel: 'openai:gpt-5.6-sol',
199+
});
200+
201+
expect(mockJudge).toHaveBeenCalledTimes(1);
202+
expect(mockJudge.mock.calls[0][0].judgeModel).toBe('openai:gpt-5.6-sol');
203+
expect(passB.graded[0].judgeModel).toBe('openai:gpt-5.6-sol');
204+
expect(passB.graded[0].judge?.model).toBe('openai:gpt-5.6-sol');
205+
206+
// Sidecar now reflects judge B, not the stale judge A.
207+
const sidecar = JSON.parse(
208+
readFileSync(
209+
join(batchDir, 'error-root-cause', 'hyperdx', '0.grade.json'),
210+
'utf8',
211+
),
212+
) as { judgeModel: string };
213+
expect(sidecar.judgeModel).toBe('openai:gpt-5.6-sol');
214+
});
215+
216+
it('does NOT re-run the judge when re-grading with the SAME judge model', async () => {
217+
// The other half of the identity check: an unchanged judge should still be
218+
// cached (no needless re-spend). Only --rerun-judge forces a refresh.
219+
process.env.OPENAI_API_KEY = 'sk-oai-test';
220+
delete process.env.AI_API_KEY;
221+
const batchDir = join(tmpRoot, 'judge-same');
222+
writeBatch(batchDir);
223+
224+
mockJudge.mockImplementation(async opts =>
225+
fakeJudgeResult(opts.judgeModel as string),
226+
);
227+
228+
await gradeBatch(batchDir, { judgeModel: 'openai:gpt-5.6-sol' });
229+
expect(mockJudge).toHaveBeenCalledTimes(1);
230+
231+
mockJudge.mockClear();
232+
await gradeBatch(batchDir, { judgeModel: 'openai:gpt-5.6-sol' });
233+
expect(mockJudge).not.toHaveBeenCalled();
234+
});
235+
236+
it('reuses the sidecar-persisted judgeModel spec when re-reading', async () => {
237+
process.env.OPENAI_API_KEY = 'sk-oai-test';
238+
delete process.env.AI_API_KEY;
239+
const batchDir = join(tmpRoot, 'persist');
240+
writeBatch(batchDir);
241+
242+
mockJudge.mockImplementation(async opts =>
243+
fakeJudgeResult(opts.judgeModel as string),
244+
);
245+
246+
await gradeBatch(batchDir, { judgeModel: 'openai:gpt-4o' });
247+
const sidecar = JSON.parse(
248+
readFileSync(
249+
join(batchDir, 'error-root-cause', 'hyperdx', '0.grade.json'),
250+
'utf8',
251+
),
252+
) as { judgeModel: string };
253+
expect(sidecar.judgeModel).toBe('openai:gpt-4o');
254+
});
255+
});

0 commit comments

Comments
 (0)