Skip to content

Commit 7562289

Browse files
Fix LLM judge overall pass reconciliation (#978)
* Reconcile LLM judge overall pass with criteria array. Derive overall_transcript_pass from criteria when present so eval-ci does not fail when the model marks every criterion pass but overall false. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden LLM judge boolean parsing per review feedback. Parse string "true"/"false" explicitly, apply the same to criteria pass fields, only log reconciliation when overall was explicitly set, and align test assertion messages with other eval unit tests. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 79d32ac commit 7562289

3 files changed

Lines changed: 114 additions & 4 deletions

File tree

docs/agent-evaluation/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212
"score": "node --import tsx src/score-eval.ts",
1313
"viz:trajectory": "node --import tsx src/generate-trajectory-html.ts",
1414
"typecheck": "tsc --noEmit",
15-
"test": "npm run test:trajectory && npm run test:redact-secrets",
15+
"test": "npm run test:trajectory && npm run test:redact-secrets && npm run test:llm-judge-parse",
1616
"test:trajectory": "node --import tsx src/trajectory-fixture-smoke.ts",
17-
"test:redact-secrets": "node --import tsx src/redact-secrets.test.ts"
17+
"test:redact-secrets": "node --import tsx src/redact-secrets.test.ts",
18+
"test:llm-judge-parse": "node --import tsx src/llm-judge-parse.test.ts"
1819
},
1920
"engines": {
2021
"node": ">=18"
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Unit tests for LLM judge JSON parsing / overall reconciliation.
3+
*/
4+
import {
5+
parseJudgeBooleanExplicit,
6+
reconcileOverallTranscriptPass,
7+
} from "./llm-judge.js";
8+
9+
function assert(condition: boolean, message: string): void {
10+
if (!condition) {
11+
throw new Error(`Assertion failed: ${message}`);
12+
}
13+
}
14+
15+
function testParseJudgeBooleanExplicit(): void {
16+
assert(parseJudgeBooleanExplicit(true).value === true, "boolean true");
17+
assert(parseJudgeBooleanExplicit(false).value === false, "boolean false");
18+
assert(parseJudgeBooleanExplicit("false").value === false, 'string "false"');
19+
assert(parseJudgeBooleanExplicit("FALSE").value === false, 'string "FALSE"');
20+
assert(parseJudgeBooleanExplicit("true").value === true, 'string "true"');
21+
assert(parseJudgeBooleanExplicit(undefined).value === false, "undefined => false");
22+
assert(!parseJudgeBooleanExplicit(undefined).explicit, "undefined not explicit");
23+
assert(parseJudgeBooleanExplicit("false").explicit, '"false" is explicit');
24+
assert(parseJudgeBooleanExplicit(true).explicit, "boolean is explicit");
25+
}
26+
27+
function testReconcileAllCriteriaPassOverridesOverallFalse(): void {
28+
const criteria = [
29+
{ criterion: "sdk", pass: true, evidence: "ok" },
30+
{ criterion: "execution", pass: true, evidence: "ok" },
31+
];
32+
assert(
33+
reconcileOverallTranscriptPass(false, criteria) === true,
34+
"all criteria pass => overall true even when model said false",
35+
);
36+
}
37+
38+
function testReconcileAnyCriterionFailForcesOverallFalse(): void {
39+
const criteria = [
40+
{ criterion: "sdk", pass: true, evidence: "ok" },
41+
{ criterion: "execution", pass: false, evidence: "failed" },
42+
];
43+
assert(
44+
reconcileOverallTranscriptPass(true, criteria) === false,
45+
"any criterion fail => overall false even when model said true",
46+
);
47+
}
48+
49+
function testReconcileEmptyCriteriaUsesModelOverall(): void {
50+
assert(
51+
reconcileOverallTranscriptPass(true, []) === true,
52+
"no criteria => keep model overall true",
53+
);
54+
assert(
55+
reconcileOverallTranscriptPass(false, []) === false,
56+
"no criteria => keep model overall false",
57+
);
58+
}
59+
60+
function main(): void {
61+
testParseJudgeBooleanExplicit();
62+
testReconcileAllCriteriaPassOverridesOverallFalse();
63+
testReconcileAnyCriterionFailForcesOverallFalse();
64+
testReconcileEmptyCriteriaUsesModelOverall();
65+
console.error("llm-judge-parse.test: OK");
66+
}
67+
68+
main();

docs/agent-evaluation/src/llm-judge.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,40 @@ function stripJsonFence(text: string): string {
9090
return t;
9191
}
9292

93+
/** When criteria[] is present, overall is the AND of criterion passes (models sometimes disagree). */
94+
export function reconcileOverallTranscriptPass(
95+
overall_from_model: boolean,
96+
criteria: readonly LlmCriterionJudgment[],
97+
): boolean {
98+
if (criteria.length === 0) {
99+
return overall_from_model;
100+
}
101+
return criteria.every((c) => c.pass);
102+
}
103+
104+
/** Parse booleans from judge JSON; treats string "true"/"false" (case-insensitive) as explicit. */
105+
export function parseJudgeBooleanExplicit(
106+
value: unknown,
107+
): { readonly explicit: boolean; readonly value: boolean } {
108+
if (typeof value === "boolean") {
109+
return { explicit: true, value };
110+
}
111+
if (typeof value === "string") {
112+
const normalized = value.trim().toLowerCase();
113+
if (normalized === "true") {
114+
return { explicit: true, value: true };
115+
}
116+
if (normalized === "false") {
117+
return { explicit: true, value: false };
118+
}
119+
}
120+
return { explicit: false, value: Boolean(value) };
121+
}
122+
123+
function parseJudgeBoolean(value: unknown): boolean {
124+
return parseJudgeBooleanExplicit(value).value;
125+
}
126+
93127
function parseJudgeJson(text: string): Omit<LlmJudgeReport, "model" | "runFile" | "scenarioFile" | "version"> & {
94128
version?: number;
95129
} {
@@ -101,7 +135,8 @@ function parseJudgeJson(text: string): Omit<LlmJudgeReport, "model" | "runFile"
101135
const detail = parse_err instanceof Error ? parse_err.message : String(parse_err);
102136
throw new Error(`JSON.parse failed: ${detail}`);
103137
}
104-
const overall = Boolean(parsed.overall_transcript_pass);
138+
const overall_parsed = parseJudgeBooleanExplicit(parsed.overall_transcript_pass);
139+
const overall_from_model = overall_parsed.value;
105140
const criteriaIn = parsed.criteria;
106141
const criteria: LlmCriterionJudgment[] = [];
107142
if (Array.isArray(criteriaIn)) {
@@ -110,11 +145,17 @@ function parseJudgeJson(text: string): Omit<LlmJudgeReport, "model" | "runFile"
110145
const o = c as Record<string, unknown>;
111146
criteria.push({
112147
criterion: String(o.criterion ?? o.id ?? "unnamed"),
113-
pass: Boolean(o.pass),
148+
pass: parseJudgeBoolean(o.pass),
114149
evidence: String(o.evidence ?? ""),
115150
});
116151
}
117152
}
153+
const overall = reconcileOverallTranscriptPass(overall_from_model, criteria);
154+
if (criteria.length > 0 && overall_parsed.explicit && overall !== overall_from_model) {
155+
console.error(
156+
`LLM judge: reconciled overall_transcript_pass ${overall_from_model} -> ${overall} (${criteria.length} criteria)`,
157+
);
158+
}
118159
const exec = parsed.execution_in_transcript;
119160
let execution_in_transcript: LlmJudgeReport["execution_in_transcript"] = {
120161
pass: null,

0 commit comments

Comments
 (0)