@skill-harness/core 0.22.0 → 0.22.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/grade.d.ts +4 -2
- package/dist/grade.js +14 -6
- package/dist/lift.js +1 -1
- package/dist/regate.js +7 -6
- package/dist/regrade.d.ts +7 -0
- package/dist/regrade.js +66 -26
- package/dist/report.d.ts +2 -0
- package/dist/report.js +8 -3
- package/dist/reps.d.ts +2 -0
- package/dist/reps.js +23 -4
- package/dist/rescore.js +12 -3
- package/dist/results.d.ts +5 -0
- package/dist/results.js +38 -10
- package/dist/run.js +3 -2
- package/dist/score.d.ts +2 -1
- package/dist/score.js +7 -1
- package/dist/stability.js +1 -1
- package/dist/trends.js +8 -4
- package/package.json +1 -1
- package/schemas/results-v2.schema.json +7 -1
- package/schemas/results-v3.schema.json +7 -1
package/dist/grade.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export declare function judgeResemblesSubject(judge: ModelRef, subject: ModelRef
|
|
|
38
38
|
export interface GradeResult extends ParsedVerdict {
|
|
39
39
|
raw: string;
|
|
40
40
|
criteria: CriterionVote[];
|
|
41
|
+
/** One identical-input retry after missing criterion votes. Absent on the first attempt. */
|
|
42
|
+
judgeRetries?: 1;
|
|
41
43
|
/** Judge misfire: the overall verdict disagrees with AND(per-item grades). Recorded, never auto-passed; blocks SHIP until re-judged or overridden. */
|
|
42
44
|
suspect: boolean;
|
|
43
45
|
}
|
|
@@ -50,10 +52,10 @@ export interface GradeResult extends ParsedVerdict {
|
|
|
50
52
|
*/
|
|
51
53
|
export declare function detectMisfire(raw: string, verdict: Verdict): boolean;
|
|
52
54
|
/** Drive the judge for one transcript and parse the result. */
|
|
53
|
-
export declare function gradeTranscript(adapter: HarnessAdapter, judge: ModelRef, prompt: string, cwd: string): Promise<GradeResult>;
|
|
55
|
+
export declare function gradeTranscript(adapter: HarnessAdapter, judge: ModelRef, prompt: string, cwd: string, expectedCriteria?: number): Promise<GradeResult>;
|
|
54
56
|
/**
|
|
55
57
|
* Grade a transcript in a fresh, isolated, throwaway workspace — never the
|
|
56
58
|
* subject's scenario dir — so the judge can't ingest repo context the subject
|
|
57
59
|
* left behind (matters for provider CLIs that read cwd, including Pi).
|
|
58
60
|
*/
|
|
59
|
-
export declare function judgeInWorkspace(adapter: HarnessAdapter, judge: ModelRef, prompt: string, specDir: string): Promise<GradeResult>;
|
|
61
|
+
export declare function judgeInWorkspace(adapter: HarnessAdapter, judge: ModelRef, prompt: string, specDir: string, expectedCriteria?: number): Promise<GradeResult>;
|
package/dist/grade.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createWorkspace } from "./workspace.js";
|
|
2
|
-
import { parseCriterionVotes } from "./results.js";
|
|
2
|
+
import { parseCriterionVotes, completeCriterionVotes } from "./results.js";
|
|
3
3
|
/** The heading runSeeded writes above the staged diff. Gating on this exact string is what keeps the guidance honest — see below. */
|
|
4
4
|
export const STAGED_DIFF_HEADING = "=== STAGED DIFF ===";
|
|
5
5
|
/**
|
|
@@ -123,8 +123,16 @@ export function detectMisfire(raw, verdict) {
|
|
|
123
123
|
// Non-global twin of REASON_RE: matchAll needs /g, a single .match() must not have it.
|
|
124
124
|
const REASON_LINE_RE = /^\s*\**\s*REASON\**\s*:\s*\**\s*(.*)$/im;
|
|
125
125
|
/** Drive the judge for one transcript and parse the result. */
|
|
126
|
-
export async function gradeTranscript(adapter, judge, prompt, cwd) {
|
|
127
|
-
const
|
|
126
|
+
export async function gradeTranscript(adapter, judge, prompt, cwd, expectedCriteria) {
|
|
127
|
+
const request = { model: judge, prompt, cwd };
|
|
128
|
+
let raw = await adapter.judge(request);
|
|
129
|
+
let criteria = parseCriterionVotes(raw);
|
|
130
|
+
let judgeRetries;
|
|
131
|
+
if (expectedCriteria !== undefined && completeCriterionVotes(criteria, expectedCriteria).some(vote => vote.verdict === "ERROR")) {
|
|
132
|
+
raw = await adapter.judge(request);
|
|
133
|
+
criteria = parseCriterionVotes(raw);
|
|
134
|
+
judgeRetries = 1;
|
|
135
|
+
}
|
|
128
136
|
const parsed = parseVerdict(raw);
|
|
129
137
|
// On a parse failure, surface what the judge actually emitted (e.g. a provider
|
|
130
138
|
// error) rather than a generic message — otherwise the cause is invisible.
|
|
@@ -134,17 +142,17 @@ export async function gradeTranscript(adapter, judge, prompt, cwd) {
|
|
|
134
142
|
parsed.reason = `judge unparseable: ${snippet}`;
|
|
135
143
|
}
|
|
136
144
|
const suspect = detectMisfire(raw, parsed.verdict);
|
|
137
|
-
return { ...parsed, raw, suspect, criteria:
|
|
145
|
+
return { ...parsed, raw, suspect, criteria, ...(judgeRetries ? { judgeRetries } : {}) };
|
|
138
146
|
}
|
|
139
147
|
/**
|
|
140
148
|
* Grade a transcript in a fresh, isolated, throwaway workspace — never the
|
|
141
149
|
* subject's scenario dir — so the judge can't ingest repo context the subject
|
|
142
150
|
* left behind (matters for provider CLIs that read cwd, including Pi).
|
|
143
151
|
*/
|
|
144
|
-
export async function judgeInWorkspace(adapter, judge, prompt, specDir) {
|
|
152
|
+
export async function judgeInWorkspace(adapter, judge, prompt, specDir, expectedCriteria) {
|
|
145
153
|
const ws = createWorkspace("none", { specDir });
|
|
146
154
|
try {
|
|
147
|
-
return await gradeTranscript(adapter, judge, prompt, ws.cwd);
|
|
155
|
+
return await gradeTranscript(adapter, judge, prompt, ws.cwd, expectedCriteria);
|
|
148
156
|
}
|
|
149
157
|
finally {
|
|
150
158
|
ws.cleanup();
|
package/dist/lift.js
CHANGED
|
@@ -29,7 +29,7 @@ function conclusive(verdict, suspect) {
|
|
|
29
29
|
// misfire are judge failures. Treating any of them as a FAIL would make
|
|
30
30
|
// "red FAIL -> green PASS" fire on infrastructure noise, and lift would
|
|
31
31
|
// measure flakiness while looking like skill value.
|
|
32
|
-
return !suspect && verdict !== "ERROR" && verdict !== "NOT-MEASURED" && verdict !== "JUDGE-AMBIGUOUS";
|
|
32
|
+
return !suspect && verdict !== "ERROR" && verdict !== "NOT-MEASURED" && verdict !== "JUDGE-AMBIGUOUS" && verdict !== "UNGRADED";
|
|
33
33
|
}
|
|
34
34
|
function classify(red, green) {
|
|
35
35
|
if (!conclusive(red.verdict, red.suspect) || !conclusive(green.verdict, green.suspect))
|
package/dist/regate.js
CHANGED
|
@@ -290,12 +290,13 @@ export async function regateRun(opts) {
|
|
|
290
290
|
}
|
|
291
291
|
// The gate blocked this rep before, so no judgement of it exists anywhere.
|
|
292
292
|
const transcript = readFileSync(tPath, "utf8");
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
293
|
+
const judged = await judgeOneRep({
|
|
294
|
+
runDir: opts.runDir, spec: opts.spec, scenario, transcript,
|
|
295
|
+
adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir,
|
|
296
|
+
mode, rep, now, rejudge: true,
|
|
297
|
+
});
|
|
298
|
+
outcomes.push({ ...judged, objective });
|
|
299
|
+
judgeCalls += judged.metrics?.judge_calls ?? 0;
|
|
299
300
|
judgedHere++;
|
|
300
301
|
}
|
|
301
302
|
const threshold = effectiveThreshold(rec, scenario);
|
package/dist/regrade.d.ts
CHANGED
|
@@ -34,6 +34,9 @@ export interface RegradeOptions {
|
|
|
34
34
|
mode?: string;
|
|
35
35
|
/** Recorded repetition count; when supplied, missing/extra rep artifacts fail before judge calls. */
|
|
36
36
|
expectedReps?: number;
|
|
37
|
+
/** Select only repetitions with retained ERROR criterion votes. */
|
|
38
|
+
onlyUnparsed?: boolean;
|
|
39
|
+
prior?: ScenarioResult;
|
|
37
40
|
now?: () => string;
|
|
38
41
|
}
|
|
39
42
|
/** Judge one saved transcript: writes the judge-raw artifact, emits a `judge-verdict` journal event (plus `misfire-flag` when the verdict is suspect), and returns the outcome. */
|
|
@@ -50,6 +53,8 @@ export declare function judgeOneRep(opts: {
|
|
|
50
53
|
now: () => string;
|
|
51
54
|
/** True when this call revisits a saved subject transcript. */
|
|
52
55
|
rejudge?: boolean;
|
|
56
|
+
/** Retained objective evidence must precede missing-vote normalization. */
|
|
57
|
+
objective?: RepOutcome["objective"];
|
|
53
58
|
}): Promise<RepOutcome>;
|
|
54
59
|
/**
|
|
55
60
|
* Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
|
|
@@ -72,6 +77,8 @@ export interface RegradeRunOptions {
|
|
|
72
77
|
* cleanly, so spending judge calls on them buys nothing.
|
|
73
78
|
*/
|
|
74
79
|
onlySuspect?: boolean;
|
|
80
|
+
/** Rejudge only retained repetitions containing ERROR criterion votes, with the recorded judge. */
|
|
81
|
+
onlyUnparsed?: boolean;
|
|
75
82
|
}
|
|
76
83
|
/**
|
|
77
84
|
* Re-judge every scenario in a run dir that has a transcript for the run's own
|
package/dist/regrade.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { buildJudgePrompt, judgeInWorkspace } from "./grade.js";
|
|
4
4
|
import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, carryRepObjectives, completeCriterionVotes, } from "./results.js";
|
|
5
|
-
import { outcomesToResult } from "./reps.js";
|
|
5
|
+
import { outcomesToResult, normalizeRepOutcome } from "./reps.js";
|
|
6
6
|
import { appendJournal } from "./journal.js";
|
|
7
7
|
import { rubricDigest, personaDigest, RUBRIC_PREFIX, PERSONA_KEY } from "./sources.js";
|
|
8
8
|
import { providerFailureFromTranscript } from "./provider-failure.js";
|
|
@@ -56,20 +56,21 @@ export async function judgeOneRep(opts) {
|
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
58
|
const prompt = buildJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript });
|
|
59
|
-
const g = await judgeInWorkspace(adapter, judge, prompt, specDir);
|
|
59
|
+
const g = await judgeInWorkspace(adapter, judge, prompt, specDir, scenario.checklist.length);
|
|
60
60
|
writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), g.raw, "utf8");
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
verdict: g.verdict, reason: g.reason, suspect: g.suspect,
|
|
66
|
-
judgment: { ordinal: 1, judge: { ...judge }, verdict: g.verdict, reason: g.reason, suspect: g.suspect, criteria: completeCriterionVotes(g.criteria, scenario.checklist.length) },
|
|
61
|
+
const outcome = normalizeRepOutcome({
|
|
62
|
+
verdict: g.verdict, reason: g.reason, suspect: g.suspect, objective: opts.objective,
|
|
63
|
+
judgment: { ordinal: 1, judge: { ...judge }, verdict: g.verdict, reason: g.reason, suspect: g.suspect, criteria: completeCriterionVotes(g.criteria, scenario.checklist.length), ...(g.judgeRetries ? { judgeRetries: g.judgeRetries } : {}) },
|
|
67
64
|
metrics: {
|
|
68
65
|
wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
|
|
69
|
-
judge_calls: 1,
|
|
70
|
-
judge_rejudge_calls: opts.rejudge ? 1 : 0,
|
|
66
|
+
judge_calls: 1 + (g.judgeRetries ?? 0),
|
|
67
|
+
judge_rejudge_calls: opts.rejudge ? 1 + (g.judgeRetries ?? 0) : 0,
|
|
71
68
|
},
|
|
72
|
-
};
|
|
69
|
+
});
|
|
70
|
+
appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: outcome.verdict, reason: outcome.reason, suspect: outcome.suspect, ...repField });
|
|
71
|
+
if (outcome.suspect)
|
|
72
|
+
appendJournal(runDir, { event: "misfire-flag", ts: now(), id: scenario.id, reason: outcome.reason, ...repField });
|
|
73
|
+
return outcome;
|
|
73
74
|
}
|
|
74
75
|
/**
|
|
75
76
|
* Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
|
|
@@ -81,26 +82,48 @@ export async function judgeOneRep(opts) {
|
|
|
81
82
|
export async function regradeScenario(opts) {
|
|
82
83
|
const now = opts.now ?? (() => new Date().toISOString());
|
|
83
84
|
const mode = opts.mode ?? "green";
|
|
84
|
-
|
|
85
|
+
let files = findTranscriptFiles(opts.runDir, opts.scenario.id, mode);
|
|
86
|
+
if (opts.onlyUnparsed)
|
|
87
|
+
files = files.filter(file => hasUnparsedVotes(opts.prior?.rep_judgments?.find(panel => panel.repetition === (repIndexOf(file) ?? 0))));
|
|
85
88
|
if (files.length === 0)
|
|
86
89
|
throw new Error(`no ${mode} transcripts for ${opts.scenario.id} in ${opts.runDir}`);
|
|
87
90
|
const expected = opts.expectedReps ?? files.length;
|
|
88
|
-
const expectedIndices =
|
|
91
|
+
const expectedIndices = opts.onlyUnparsed
|
|
92
|
+
? opts.prior.rep_judgments.filter(hasUnparsedVotes).map(panel => expected === 1 ? null : panel.repetition).sort((a, b) => (a ?? -1) - (b ?? -1))
|
|
93
|
+
: expected === 1 ? [null] : Array.from({ length: expected }, (_, index) => index);
|
|
89
94
|
const actualIndices = files.map((file) => repIndexOf(file)).sort((a, b) => (a ?? -1) - (b ?? -1));
|
|
90
|
-
if (files.length !==
|
|
95
|
+
if (files.length !== expectedIndices.length || JSON.stringify(actualIndices) !== JSON.stringify(expectedIndices)) {
|
|
91
96
|
throw new Error(`${opts.scenario.id}: transcript artifacts are incomplete for ${expected} recorded rep(s) — re-run instead of grading a smaller repetition set`);
|
|
92
97
|
}
|
|
93
98
|
const repCount = expected;
|
|
94
99
|
const outcomes = [];
|
|
95
|
-
for (
|
|
100
|
+
for (let repetition = 0; repetition < expected; repetition++) {
|
|
101
|
+
const panel = opts.prior?.rep_judgments?.find(panel => panel.repetition === repetition);
|
|
102
|
+
if (opts.onlyUnparsed && !hasUnparsedVotes(panel)) {
|
|
103
|
+
if (!panel)
|
|
104
|
+
throw new Error(`missing retained judgments for ${opts.scenario.id}#${repetition}`);
|
|
105
|
+
outcomes.push({ verdict: panel.recorded_verdict, reason: panel.judgments[0]?.reason ?? "", suspect: panel.judgments.length > 0 && !panel.judgments.some(judgment => !judgment.suspect && (judgment.verdict === "PASS" || judgment.verdict === "FAIL")), judgment: panel.judgments[0], objective: panel.objective });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const file = files.find(file => (repIndexOf(file) ?? 0) === repetition);
|
|
96
109
|
const rep = repIndexOf(file) ?? undefined;
|
|
97
110
|
const transcript = readFileSync(join(opts.runDir, file), "utf8");
|
|
98
111
|
outcomes.push(await judgeOneRep({
|
|
99
112
|
runDir: opts.runDir, spec: opts.spec, scenario: opts.scenario, transcript,
|
|
100
113
|
adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode, rep, now, rejudge: true,
|
|
114
|
+
objective: panel?.objective ?? (expected === 1 ? opts.prior?.objective : undefined),
|
|
101
115
|
}));
|
|
102
116
|
}
|
|
103
|
-
|
|
117
|
+
const result = outcomesToResult(opts.scenario.id, outcomes, repCount, opts.threshold);
|
|
118
|
+
if (opts.onlyUnparsed)
|
|
119
|
+
result.rep_judgments = result.rep_judgments?.map(panel => {
|
|
120
|
+
const prior = opts.prior.rep_judgments.find(prior => prior.repetition === panel.repetition);
|
|
121
|
+
return hasUnparsedVotes(prior) ? panel : prior;
|
|
122
|
+
});
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
function hasUnparsedVotes(panel) {
|
|
126
|
+
return panel?.judgments.some(judgment => judgment.criteria?.some(vote => vote.verdict === "ERROR")) ?? false;
|
|
104
127
|
}
|
|
105
128
|
/**
|
|
106
129
|
* Re-judge every scenario in a run dir that has a transcript for the run's own
|
|
@@ -114,17 +137,21 @@ export async function regradeScenario(opts) {
|
|
|
114
137
|
* ResultsFile. Shared by `cmdGrade` and the pi-extension's `judge` command.
|
|
115
138
|
*/
|
|
116
139
|
export async function regradeRun(opts) {
|
|
117
|
-
const { runDir, spec, adapter,
|
|
140
|
+
const { runDir, spec, adapter, specDir } = opts;
|
|
118
141
|
const now = opts.now ?? (() => new Date().toISOString());
|
|
119
142
|
const prev = existsSync(join(runDir, "results.yaml")) ? readResults(runDir) : null;
|
|
143
|
+
if (opts.onlyUnparsed && !prev)
|
|
144
|
+
throw new Error(`--unparsed-only needs a prior results.yaml in ${runDir}`);
|
|
145
|
+
if (opts.onlyUnparsed && opts.onlySuspect)
|
|
146
|
+
throw new Error("--unparsed-only cannot be combined with --suspect-only");
|
|
147
|
+
const judge = opts.onlyUnparsed ? prev.judge : opts.judge;
|
|
120
148
|
// Carried across a re-judge, per field, because the right answer differs:
|
|
121
149
|
// - `override`/`note` — the author's, never the judge's to discard.
|
|
122
150
|
// - `objective` — a re-judge does NOT re-evaluate trace gates (that is
|
|
123
151
|
// `regate`), so the recorded evidence still describes this run. Dropping it
|
|
124
152
|
// silently downgraded a gated scenario to "no assertions declared".
|
|
125
|
-
// `adjudication` is
|
|
126
|
-
//
|
|
127
|
-
// than none.
|
|
153
|
+
// `adjudication` is dropped when its repetition is rejudged. A selective
|
|
154
|
+
// repair of a different repetition carries that untouched evidence instead.
|
|
128
155
|
// The whole prior result per id — `rebuildScenarioResult` decides, field by
|
|
129
156
|
// field, what survives. Passing a hand-picked subset here is how fields got
|
|
130
157
|
// dropped before.
|
|
@@ -148,6 +175,8 @@ export async function regradeRun(opts) {
|
|
|
148
175
|
.filter((s) => s.suspect || s.judge_verdict === "JUDGE-AMBIGUOUS")
|
|
149
176
|
.map((s) => s.id);
|
|
150
177
|
}
|
|
178
|
+
if (opts.onlyUnparsed)
|
|
179
|
+
targets = recorded.filter(s => s.rep_judgments?.some(hasUnparsedVotes)).map(s => s.id);
|
|
151
180
|
// Schema-3 delivery is a prerequisite for behavioral judging. A later `grade`
|
|
152
181
|
// cannot turn an undelivered or unauthenticated transcript into product evidence.
|
|
153
182
|
if (prev?.schema === 3) {
|
|
@@ -161,6 +190,9 @@ export async function regradeRun(opts) {
|
|
|
161
190
|
const completeTranscripts = (record) => {
|
|
162
191
|
const expected = record.reps ?? 1;
|
|
163
192
|
const files = findTranscriptFiles(runDir, record.id, mode);
|
|
193
|
+
if (opts.onlyUnparsed) {
|
|
194
|
+
return record.rep_judgments?.length === expected && record.rep_judgments.every(panel => !hasUnparsedVotes(panel) || files.some(file => (repIndexOf(file) ?? 0) === panel.repetition)) || false;
|
|
195
|
+
}
|
|
164
196
|
const expectedIndices = expected === 1 ? [null] : Array.from({ length: expected }, (_, index) => index);
|
|
165
197
|
const actualIndices = files.map((file) => repIndexOf(file)).sort((a, b) => (a ?? -1) - (b ?? -1));
|
|
166
198
|
return files.length === expected && JSON.stringify(actualIndices) === JSON.stringify(expectedIndices);
|
|
@@ -173,6 +205,14 @@ export async function regradeRun(opts) {
|
|
|
173
205
|
if (missing.length > 0) {
|
|
174
206
|
throw new Error(`cannot re-grade ${missing.join(", ")} in ${runDir} (transcript missing or scenario no longer in the spec) — re-run instead of grading`);
|
|
175
207
|
}
|
|
208
|
+
if (opts.onlyUnparsed) {
|
|
209
|
+
const drifted = targets.filter(id => {
|
|
210
|
+
const count = recordedById.get(id).criterion_count;
|
|
211
|
+
return count !== undefined && count !== specById.get(id).checklist.length;
|
|
212
|
+
});
|
|
213
|
+
if (drifted.length)
|
|
214
|
+
throw new Error(`criterion count changed for ${drifted.join(", ")} — use full grade instead of --unparsed-only`);
|
|
215
|
+
}
|
|
176
216
|
const targetSet = new Set(targets);
|
|
177
217
|
const scenarioResults = [];
|
|
178
218
|
for (const rec of recorded) {
|
|
@@ -186,17 +226,17 @@ export async function regradeRun(opts) {
|
|
|
186
226
|
const threshold = effectiveThreshold(prevScenario, scenario);
|
|
187
227
|
const rr = await regradeScenario({
|
|
188
228
|
runDir, spec, scenario, adapter, judge, specDir, threshold, mode, now,
|
|
189
|
-
expectedReps: prevScenario?.reps ?? 1,
|
|
229
|
+
expectedReps: prevScenario?.reps ?? 1, onlyUnparsed: opts.onlyUnparsed, prior: prevScenario,
|
|
190
230
|
});
|
|
191
231
|
const carry = overrides.get(id);
|
|
192
232
|
if (prev?.schema === 3)
|
|
193
233
|
rr.criterion_count = scenario.checklist.length;
|
|
194
234
|
rr.metrics = mergeScenarioMetrics(carry?.metrics, rr.metrics);
|
|
195
235
|
rr.rep_judgments = carryRepObjectives(rr.rep_judgments, carry?.rep_judgments);
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
scenarioResults.push(rebuildScenarioResult(rr, carry, { objective: "carry", adjudication: "drop" }));
|
|
236
|
+
// Gates still describe the original subject execution. An adjudication of
|
|
237
|
+
// an untouched repetition also survives, bounded by rebuild's conflict guards.
|
|
238
|
+
const carryAdjudication = opts.onlyUnparsed && carry?.adjudication && !hasUnparsedVotes(carry.rep_judgments?.find(panel => panel.repetition === (carry.adjudication.repetition ?? 0)));
|
|
239
|
+
scenarioResults.push(rebuildScenarioResult(rr, carry, { objective: "carry", adjudication: carryAdjudication ? "carry" : "drop" }));
|
|
200
240
|
}
|
|
201
241
|
const ctx = scoreContextFor({ mode, partial: prev?.partial }, spec);
|
|
202
242
|
const results = writeResults(runDir, {
|
|
@@ -229,7 +269,7 @@ export async function regradeRun(opts) {
|
|
|
229
269
|
// say so. Doctrine narrowed 0.4.0, from "recorded hashes stay" to "recorded
|
|
230
270
|
// *stimulus* hashes stay" — see refreshRubricHashes.
|
|
231
271
|
partial: prev?.partial,
|
|
232
|
-
source_hashes: refreshRubricHashes(prev?.source_hashes, spec, targets),
|
|
272
|
+
source_hashes: opts.onlyUnparsed ? prev?.source_hashes : refreshRubricHashes(prev?.source_hashes, spec, targets),
|
|
233
273
|
source_hash_roots: prev?.source_hash_roots,
|
|
234
274
|
scenarios: scenarioResults,
|
|
235
275
|
}, ctx);
|
package/dist/report.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface RunColumn {
|
|
|
26
26
|
passes?: number;
|
|
27
27
|
clean?: number;
|
|
28
28
|
flakiness?: number;
|
|
29
|
+
ungraded_reps?: number;
|
|
29
30
|
metrics?: import("./results.js").ScenarioMetrics;
|
|
30
31
|
override: string | null;
|
|
31
32
|
note: string;
|
|
@@ -109,6 +110,7 @@ export declare function publicView(data: ReportData): {
|
|
|
109
110
|
passes?: number;
|
|
110
111
|
clean?: number;
|
|
111
112
|
flakiness?: number;
|
|
113
|
+
ungraded_reps?: number;
|
|
112
114
|
metrics?: import("./results.js").ScenarioMetrics;
|
|
113
115
|
override: string | null;
|
|
114
116
|
note: string;
|
package/dist/report.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { loadSpec } from "./spec.js";
|
|
4
|
-
import { readResults } from "./results.js";
|
|
4
|
+
import { readResults, normalizeScenarioResult, finalizeResults, scoreContextFor } from "./results.js";
|
|
5
5
|
import { collectLift, liftHeadline } from "./lift.js";
|
|
6
6
|
import { boundaryCells, collectStability, stabilityNote } from "./stability.js";
|
|
7
7
|
import { aggregateMetrics } from "./metrics.js";
|
|
@@ -40,9 +40,13 @@ export function collectReport(skillDir) {
|
|
|
40
40
|
if (!runDir)
|
|
41
41
|
continue;
|
|
42
42
|
const r = readResults(runDir);
|
|
43
|
+
const normalized = r.scenarios.map(normalizeScenarioResult);
|
|
44
|
+
const grade = normalized.some((s, i) => s !== r.scenarios[i])
|
|
45
|
+
? finalizeResults({ ...r, scenarios: normalized }, scoreContextFor(r, spec)).effective_grade
|
|
46
|
+
: r.effective_grade;
|
|
43
47
|
const tagName = tagDir.split("/").pop();
|
|
44
48
|
const cells = {};
|
|
45
|
-
for (const s of
|
|
49
|
+
for (const s of normalized) {
|
|
46
50
|
const boundary = boundaryByCell.get(`${tagName}\u0000${r.mode}\u0000${s.id}`);
|
|
47
51
|
cells[s.id] = {
|
|
48
52
|
...(boundary
|
|
@@ -72,6 +76,7 @@ export function collectReport(skillDir) {
|
|
|
72
76
|
passes: s.passes,
|
|
73
77
|
clean: s.clean,
|
|
74
78
|
flakiness: s.flakiness,
|
|
79
|
+
...(s.ungraded_reps ? { ungraded_reps: s.ungraded_reps } : {}),
|
|
75
80
|
metrics: s.metrics,
|
|
76
81
|
override: s.override,
|
|
77
82
|
note: s.note,
|
|
@@ -96,7 +101,7 @@ export function collectReport(skillDir) {
|
|
|
96
101
|
timestamp: r.timestamp,
|
|
97
102
|
mode: r.mode,
|
|
98
103
|
partial: r.partial === true,
|
|
99
|
-
grade
|
|
104
|
+
grade,
|
|
100
105
|
judge: r.judge,
|
|
101
106
|
metrics: aggregateMetrics(r.scenarios),
|
|
102
107
|
cells,
|
package/dist/reps.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface RepOutcome {
|
|
|
16
16
|
subject?: TraceMetrics;
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
+
/** Missing criterion evidence is a non-pass, not a judge misfire or a judged FAIL. */
|
|
20
|
+
export declare function normalizeRepOutcome(outcome: RepOutcome): RepOutcome;
|
|
19
21
|
/**
|
|
20
22
|
* Collapse per-rep objective results.
|
|
21
23
|
*
|
package/dist/reps.js
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/** Missing criterion evidence is a non-pass, not a judge misfire or a judged FAIL. */
|
|
2
|
+
export function normalizeRepOutcome(outcome) {
|
|
3
|
+
if (outcome.verdict === "ERROR")
|
|
4
|
+
return outcome;
|
|
5
|
+
if (outcome.objective && outcome.objective.status !== "PASS") {
|
|
6
|
+
return { ...outcome, verdict: outcome.objective.status, suspect: false };
|
|
7
|
+
}
|
|
8
|
+
if (outcome.verdict === "NOT-MEASURED")
|
|
9
|
+
return outcome;
|
|
10
|
+
if (outcome.verdict === "UNGRADED" || outcome.judgment?.criteria?.some(vote => vote.verdict === "ERROR")) {
|
|
11
|
+
return { ...outcome, verdict: "UNGRADED", reason: "UNGRADED: incomplete criterion votes after judge retry", suspect: false };
|
|
12
|
+
}
|
|
13
|
+
return outcome;
|
|
14
|
+
}
|
|
1
15
|
/**
|
|
2
16
|
* Collapse per-rep objective results.
|
|
3
17
|
*
|
|
@@ -31,7 +45,9 @@ export function aggregateObjective(outcomes) {
|
|
|
31
45
|
* (default caller threshold 0.5, ties pass). Flakiness = 1 - |2·pass_rate - 1|.
|
|
32
46
|
*/
|
|
33
47
|
export function aggregateReps(outcomes, threshold) {
|
|
48
|
+
outcomes = outcomes.map(normalizeRepOutcome);
|
|
34
49
|
const reps = outcomes.length;
|
|
50
|
+
const ungraded = outcomes.filter(o => o.verdict === "UNGRADED").length;
|
|
35
51
|
const clean = outcomes.filter((o) => !o.suspect);
|
|
36
52
|
const passes = clean.filter((o) => o.verdict === "PASS").length;
|
|
37
53
|
const errored = outcomes.filter((o) => o.verdict === "ERROR").length;
|
|
@@ -50,9 +66,9 @@ export function aggregateReps(outcomes, threshold) {
|
|
|
50
66
|
return { verdict: "FAIL", reason: `${reps - clean.length}/${reps} reps misfired — re-judge`, passes, reps, clean: clean.length, flakiness: 0, suspect: true };
|
|
51
67
|
}
|
|
52
68
|
const passRate = passes / clean.length;
|
|
53
|
-
const verdict = passRate >= threshold ? "PASS" : "FAIL";
|
|
69
|
+
const verdict = passRate >= threshold && (ungraded === 0 || passes > 0) ? "PASS" : ungraded > 0 ? "UNGRADED" : "FAIL";
|
|
54
70
|
const flakiness = 1 - Math.abs(2 * passRate - 1);
|
|
55
|
-
const reason = reps === 1 ? outcomes[0].reason : `${passes}/${clean.length} reps passed (flaky ${flakiness.toFixed(2)})`;
|
|
71
|
+
const reason = reps === 1 ? outcomes[0].reason : `${passes}/${clean.length} reps passed (flaky ${flakiness.toFixed(2)})${ungraded ? `; ${ungraded} UNGRADED` : ""}`;
|
|
56
72
|
return { verdict, reason, passes, reps, clean: clean.length, flakiness, suspect: false };
|
|
57
73
|
}
|
|
58
74
|
/**
|
|
@@ -63,6 +79,9 @@ export function aggregateReps(outcomes, threshold) {
|
|
|
63
79
|
* caller to merge.
|
|
64
80
|
*/
|
|
65
81
|
export function outcomesToResult(id, outcomes, repCount, threshold) {
|
|
82
|
+
outcomes = outcomes.map(normalizeRepOutcome);
|
|
83
|
+
const ungraded = outcomes.filter(outcome => outcome.verdict === "UNGRADED").length;
|
|
84
|
+
const ungradedField = ungraded ? { ungraded_reps: ungraded } : {};
|
|
66
85
|
// Spread rather than always-set: a scenario with no trace assertions must
|
|
67
86
|
// produce a result byte-identical to one from before this field existed.
|
|
68
87
|
const objective = aggregateObjective(outcomes);
|
|
@@ -88,13 +107,13 @@ export function outcomesToResult(id, outcomes, repCount, threshold) {
|
|
|
88
107
|
const repJudgmentField = outcomes.some(outcome => outcome.judgment) ? { rep_judgments: repJudgments } : {};
|
|
89
108
|
if (repCount === 1) {
|
|
90
109
|
const o = outcomes[0];
|
|
91
|
-
return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, ...metricsField, ...usageField, override: null, note: "", ...objectiveField, ...repJudgmentField };
|
|
110
|
+
return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, ...ungradedField, ...metricsField, ...usageField, override: null, note: "", ...objectiveField, ...repJudgmentField };
|
|
92
111
|
}
|
|
93
112
|
const agg = aggregateReps(outcomes, threshold);
|
|
94
113
|
return {
|
|
95
114
|
id, judge_verdict: agg.verdict, judge_reason: agg.reason, suspect: agg.suspect,
|
|
96
115
|
reps: agg.reps, passes: agg.passes, clean: agg.clean, flakiness: agg.flakiness,
|
|
97
|
-
pass_threshold: threshold, ...metricsField, ...usageField, override: null, note: "", ...objectiveField, ...repJudgmentField,
|
|
116
|
+
pass_threshold: threshold, ...ungradedField, ...metricsField, ...usageField, override: null, note: "", ...objectiveField, ...repJudgmentField,
|
|
98
117
|
};
|
|
99
118
|
}
|
|
100
119
|
function aggregateMetrics(outcomes) {
|
package/dist/rescore.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { readResults, writeResults, scoreContextFor, rebuildScenarioResult, effectiveThreshold } from "./results.js";
|
|
3
|
+
import { readResults, writeResults, scoreContextFor, rebuildScenarioResult, normalizeScenarioResult, effectiveThreshold } from "./results.js";
|
|
4
4
|
import { appendJournal } from "./journal.js";
|
|
5
5
|
import { policyDigest, POLICY_PREFIX } from "./sources.js";
|
|
6
6
|
/**
|
|
@@ -47,7 +47,8 @@ export function rescoreRun(opts) {
|
|
|
47
47
|
throw new Error(`no results.yaml in ${opts.runDir}`);
|
|
48
48
|
const specById = new Map(opts.spec.scenarios.map((s) => [s.id, s]));
|
|
49
49
|
const changes = [];
|
|
50
|
-
const scenarios = prev.scenarios.map((
|
|
50
|
+
const scenarios = prev.scenarios.map((recorded) => {
|
|
51
|
+
const s = normalizeScenarioResult(recorded);
|
|
51
52
|
const scenario = specById.get(s.id);
|
|
52
53
|
// no rate to re-apply, or an untrustworthy verdict → carry verbatim
|
|
53
54
|
if (!scenario || s.reps === undefined || s.clean === undefined || s.passes === undefined)
|
|
@@ -61,7 +62,7 @@ export function rescoreRun(opts) {
|
|
|
61
62
|
if (toThreshold === fromThreshold)
|
|
62
63
|
return s;
|
|
63
64
|
const rate = s.passes / s.clean;
|
|
64
|
-
const verdict = rate >= toThreshold ? "PASS" : "FAIL";
|
|
65
|
+
const verdict = rate >= toThreshold && (!s.ungraded_reps || s.passes > 0) ? "PASS" : s.ungraded_reps ? "UNGRADED" : "FAIL";
|
|
65
66
|
if (verdict !== s.judge_verdict) {
|
|
66
67
|
changes.push({ id: s.id, from: s.judge_verdict, to: verdict, passes: s.passes, clean: s.clean, fromThreshold, toThreshold });
|
|
67
68
|
}
|
|
@@ -76,6 +77,14 @@ export function rescoreRun(opts) {
|
|
|
76
77
|
// already measured. It re-measures nothing, so it may discard nothing.
|
|
77
78
|
return rebuildScenarioResult({ ...s, judge_verdict: verdict, pass_threshold: toThreshold }, s, { objective: "carry", adjudication: "carry" });
|
|
78
79
|
});
|
|
80
|
+
for (const s of scenarios) {
|
|
81
|
+
const recorded = prev.scenarios.find(prior => prior.id === s.id);
|
|
82
|
+
if (s.judge_verdict !== recorded.judge_verdict && !changes.some(change => change.id === s.id)) {
|
|
83
|
+
changes.push({ id: s.id, from: recorded.judge_verdict, to: s.judge_verdict,
|
|
84
|
+
passes: s.passes ?? (s.judge_verdict === "PASS" ? 1 : 0), clean: s.clean ?? 1,
|
|
85
|
+
fromThreshold: recorded.pass_threshold ?? 0.5, toThreshold: s.pass_threshold ?? 0.5 });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
79
88
|
const ctx = scoreContextFor(prev, opts.spec);
|
|
80
89
|
const results = writeResults(opts.runDir, {
|
|
81
90
|
schema: prev.schema, subject_invocations: prev.subject_invocations,
|
package/dist/results.d.ts
CHANGED
|
@@ -61,6 +61,8 @@ export interface ScenarioResult {
|
|
|
61
61
|
passes?: number;
|
|
62
62
|
clean?: number;
|
|
63
63
|
flakiness?: number;
|
|
64
|
+
/** Reps with incomplete criterion votes; included as non-passes, never misfires. */
|
|
65
|
+
ungraded_reps?: number;
|
|
64
66
|
pass_threshold?: number;
|
|
65
67
|
/** Cost/latency counters. Separate from verdicts: cheaper failure is still failure. */
|
|
66
68
|
metrics?: ScenarioMetrics;
|
|
@@ -99,6 +101,8 @@ export interface Judgment {
|
|
|
99
101
|
suspect: boolean;
|
|
100
102
|
/** Every numbered rubric-item vote parsed from this exact judge response. Required in schema v3. */
|
|
101
103
|
criteria?: CriterionVote[];
|
|
104
|
+
/** One retry with identical judge input after unparseable criterion votes. */
|
|
105
|
+
judgeRetries?: 1;
|
|
102
106
|
}
|
|
103
107
|
export interface AdjudicationResult {
|
|
104
108
|
/** The saved transcript repetition judged by every panel member (first rep by policy). */
|
|
@@ -347,6 +351,7 @@ export declare function resultsPath(runDir: string): string;
|
|
|
347
351
|
* deterministic assertion is a deliberate, recorded human act — and the failure
|
|
348
352
|
* this guards against was never a human deciding, it was nobody deciding.
|
|
349
353
|
*/
|
|
354
|
+
export declare function normalizeScenarioResult(s: ScenarioResult): ScenarioResult;
|
|
350
355
|
export declare function effectiveVerdicts(scenarios: ScenarioResult[]): ScenarioVerdict[];
|
|
351
356
|
/**
|
|
352
357
|
* The ONLY place effective_grade is computed. Every writer goes through here,
|
package/dist/results.js
CHANGED
|
@@ -5,6 +5,7 @@ import { modelSlug } from "./adapters/types.js";
|
|
|
5
5
|
import { score } from "./score.js";
|
|
6
6
|
import { HARNESS_VERSION } from "./version.js";
|
|
7
7
|
import { collapseVotePanel } from "./vote-panel.js";
|
|
8
|
+
import { outcomesToResult } from "./reps.js";
|
|
8
9
|
/** Merge bookkeeping from a later judge/gate pass without duplicating subject usage. */
|
|
9
10
|
export function mergeScenarioMetrics(prior, fresh) {
|
|
10
11
|
if (!prior)
|
|
@@ -149,8 +150,28 @@ export function resultsPath(runDir) {
|
|
|
149
150
|
* deterministic assertion is a deliberate, recorded human act — and the failure
|
|
150
151
|
* this guards against was never a human deciding, it was nobody deciding.
|
|
151
152
|
*/
|
|
153
|
+
export function normalizeScenarioResult(s) {
|
|
154
|
+
const panels = s.rep_judgments;
|
|
155
|
+
if (!panels?.some(panel => panel.recorded_verdict === "UNGRADED" || panel.judgments.some(judgment => judgment.criteria?.some(vote => vote.verdict === "ERROR"))))
|
|
156
|
+
return s;
|
|
157
|
+
const reps = s.reps ?? 1;
|
|
158
|
+
if (panels.length !== reps)
|
|
159
|
+
return { ...s, judge_verdict: "UNGRADED", suspect: false, ungraded_reps: panels.filter(panel => panel.judgments.some(judgment => judgment.criteria?.some(vote => vote.verdict === "ERROR"))).length };
|
|
160
|
+
const outcomes = [...panels].sort((a, b) => a.repetition - b.repetition).map(panel => ({
|
|
161
|
+
verdict: panel.recorded_verdict,
|
|
162
|
+
reason: panel.judgments[0]?.reason ?? s.judge_reason,
|
|
163
|
+
suspect: panel.recorded_verdict === "UNGRADED" ? false : panel.judgments.length > 0 && !panel.judgments.some(judgment => !judgment.suspect && (judgment.verdict === "PASS" || judgment.verdict === "FAIL")),
|
|
164
|
+
objective: panel.objective,
|
|
165
|
+
judgment: panel.judgments.find(judgment => judgment.criteria?.some(vote => vote.verdict === "ERROR")) ?? panel.judgments[0],
|
|
166
|
+
}));
|
|
167
|
+
const fresh = outcomesToResult(s.id, outcomes, reps, s.pass_threshold ?? 0.5);
|
|
168
|
+
fresh.metrics = s.metrics;
|
|
169
|
+
fresh.usage = s.usage;
|
|
170
|
+
fresh.rep_judgments = panels.map(panel => ({ ...panel, recorded_verdict: fresh.rep_judgments?.find(candidate => candidate.repetition === panel.repetition)?.recorded_verdict ?? panel.recorded_verdict }));
|
|
171
|
+
return rebuildScenarioResult(fresh, s, { objective: "carry", adjudication: "carry" });
|
|
172
|
+
}
|
|
152
173
|
export function effectiveVerdicts(scenarios) {
|
|
153
|
-
return scenarios.map((s) => ({
|
|
174
|
+
return scenarios.map(normalizeScenarioResult).map((s) => ({
|
|
154
175
|
id: s.id,
|
|
155
176
|
verdict: s.override ?? objectiveVerdict(s) ?? s.judge_verdict,
|
|
156
177
|
suspect: s.suspect && s.override == null, // an override resolves the misfire
|
|
@@ -182,9 +203,10 @@ function objectiveVerdict(s) {
|
|
|
182
203
|
* deployment and is scored (see SCORED_MODES directly above).
|
|
183
204
|
*/
|
|
184
205
|
export function finalizeResults(draft, ctx) {
|
|
206
|
+
const scenarios = draft.scenarios.map(normalizeScenarioResult);
|
|
185
207
|
let effective_grade;
|
|
186
208
|
if (ctx) {
|
|
187
|
-
const s = score(effectiveVerdicts(
|
|
209
|
+
const s = score(effectiveVerdicts(scenarios), { shipBar: ctx.shipBar, critical: ctx.critical });
|
|
188
210
|
effective_grade = { passed: s.passed, total: s.total, pct: s.pct, letter: s.letter, ship: s.ship, note: s.note };
|
|
189
211
|
}
|
|
190
212
|
else {
|
|
@@ -214,7 +236,7 @@ export function finalizeResults(draft, ctx) {
|
|
|
214
236
|
...(draft.source_hashes ? { source_hashes: draft.source_hashes } : {}),
|
|
215
237
|
...(draft.source_hash_roots ? { source_hash_roots: draft.source_hash_roots } : {}),
|
|
216
238
|
effective_grade,
|
|
217
|
-
scenarios
|
|
239
|
+
scenarios,
|
|
218
240
|
...(draft.subject_invocations ? { subject_invocations: draft.subject_invocations } : {}),
|
|
219
241
|
};
|
|
220
242
|
}
|
|
@@ -304,7 +326,11 @@ export function recomputeRecordedPanels(results) {
|
|
|
304
326
|
for (const panel of scenario.rep_judgments ?? []) {
|
|
305
327
|
const clean = panel.judgments.filter(j => !j.suspect && (j.verdict === "PASS" || j.verdict === "FAIL"));
|
|
306
328
|
let verdict = panel.recorded_verdict;
|
|
307
|
-
if (
|
|
329
|
+
if (panel.objective && panel.objective.status !== "PASS")
|
|
330
|
+
verdict = panel.objective.status;
|
|
331
|
+
else if (panel.recorded_verdict === "UNGRADED" && panel.judgments.some(judgment => judgment.criteria?.some(vote => vote.verdict === "ERROR")))
|
|
332
|
+
verdict = "UNGRADED";
|
|
333
|
+
else if (clean.length === 1)
|
|
308
334
|
verdict = clean[0].verdict;
|
|
309
335
|
else if (clean.length >= 2)
|
|
310
336
|
verdict = collapseVotePanel(panel.judgments).verdict ?? "JUDGE-AMBIGUOUS";
|
|
@@ -417,7 +443,7 @@ export function validateResults(raw) {
|
|
|
417
443
|
if (!Number.isInteger(scenario.adjudication.repetition) || scenario.adjudication.repetition < 0 || scenario.adjudication.repetition >= reps)
|
|
418
444
|
throw new Error(`schema v3 adjudication repetition missing or out of range for ${scenario.id}`);
|
|
419
445
|
const collapsed = collapseVotePanel(scenario.adjudication.judgments);
|
|
420
|
-
const boundedCriticalAggregate = (scenario.reps ?? 1) > 1 && scenario.pass_threshold === 1 && scenario.judge_verdict !== "PASS" && collapsed.verdict === "PASS" && scenario.adjudication.state === "unresolved" && scenario.adjudication.verdict === undefined;
|
|
446
|
+
const boundedCriticalAggregate = ((scenario.reps ?? 1) > 1 && scenario.pass_threshold === 1 || scenario.judge_verdict === "UNGRADED") && scenario.judge_verdict !== "PASS" && collapsed.verdict === "PASS" && scenario.adjudication.state === "unresolved" && scenario.adjudication.verdict === undefined;
|
|
421
447
|
if (!boundedCriticalAggregate && (scenario.adjudication.state !== collapsed.state || scenario.adjudication.verdict !== collapsed.verdict))
|
|
422
448
|
throw new Error(`recorded adjudication state/verdict diverges from recomputed votes for ${scenario.id}`);
|
|
423
449
|
if (scenario.adjudication.state !== "unresolved")
|
|
@@ -426,13 +452,14 @@ export function validateResults(raw) {
|
|
|
426
452
|
const panels = scenario.rep_judgments;
|
|
427
453
|
const errorCount = panels.filter(panel => panel.recorded_verdict === "ERROR").length;
|
|
428
454
|
const notMeasuredCount = panels.filter(panel => panel.recorded_verdict === "NOT-MEASURED").length;
|
|
429
|
-
const cleanPanels = panels.filter(panel => !(panel.judgments[0]?.suspect ?? false));
|
|
455
|
+
const cleanPanels = panels.filter(panel => panel.objective && panel.objective.status !== "PASS" || panel.recorded_verdict === "UNGRADED" || !(panel.judgments[0]?.suspect ?? false));
|
|
456
|
+
const ungradedCount = panels.filter(panel => panel.recorded_verdict === "UNGRADED").length;
|
|
430
457
|
const passes = cleanPanels.filter(panel => panel.recorded_verdict === "PASS").length;
|
|
431
458
|
let aggregateVerdict;
|
|
432
459
|
let aggregateSuspect = false;
|
|
433
460
|
if (panels.length === 1) {
|
|
434
461
|
aggregateVerdict = panels[0].recorded_verdict;
|
|
435
|
-
aggregateSuspect = panels[0].judgments[0]?.suspect ?? false;
|
|
462
|
+
aggregateSuspect = panels[0].objective && panels[0].objective.status !== "PASS" || panels[0].recorded_verdict === "UNGRADED" ? false : panels[0].judgments[0]?.suspect ?? false;
|
|
436
463
|
}
|
|
437
464
|
else if (errorCount > 0)
|
|
438
465
|
aggregateVerdict = "ERROR";
|
|
@@ -443,7 +470,7 @@ export function validateResults(raw) {
|
|
|
443
470
|
aggregateSuspect = true;
|
|
444
471
|
}
|
|
445
472
|
else
|
|
446
|
-
aggregateVerdict = passes / cleanPanels.length >= (scenario.pass_threshold ?? 0.5) ? "PASS" : "FAIL";
|
|
473
|
+
aggregateVerdict = passes / cleanPanels.length >= (scenario.pass_threshold ?? 0.5) && (ungradedCount === 0 || passes > 0) ? "PASS" : ungradedCount > 0 ? "UNGRADED" : "FAIL";
|
|
447
474
|
const expectedVerdict = adjudicatedVerdict ?? aggregateVerdict;
|
|
448
475
|
const expectedSuspect = scenario.adjudication ? scenario.adjudication.state === "unresolved" : aggregateSuspect;
|
|
449
476
|
if (scenario.judge_verdict !== expectedVerdict || scenario.suspect !== expectedSuspect)
|
|
@@ -600,7 +627,7 @@ export function diffPath(runDir, scenarioId, mode, rep) {
|
|
|
600
627
|
export function rebuildScenarioResult(fresh, prior, policy) {
|
|
601
628
|
// Exhaustive destructure. Do not replace with a spread: the spread is what
|
|
602
629
|
// allowed a new field to pass through unconsidered in the first place.
|
|
603
|
-
const { id, criterion_count: freshCriterionCount, judge_verdict, judge_reason, suspect, override: _freshOverride, note: _freshNote, reps, passes, clean, flakiness, pass_threshold, metrics: freshMetrics, usage: freshUsage, objective: freshObjective, adjudication: freshAdjudication, rep_judgments: freshRepJudgments, ...rest } = fresh;
|
|
630
|
+
const { id, criterion_count: freshCriterionCount, judge_verdict, judge_reason, suspect, override: _freshOverride, note: _freshNote, reps, passes, clean, flakiness, pass_threshold, ungraded_reps, metrics: freshMetrics, usage: freshUsage, objective: freshObjective, adjudication: freshAdjudication, rep_judgments: freshRepJudgments, ...rest } = fresh;
|
|
604
631
|
const _exhaustive = rest;
|
|
605
632
|
void _exhaustive;
|
|
606
633
|
void _freshOverride;
|
|
@@ -613,7 +640,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
|
|
|
613
640
|
const objective = pick(policy.objective, freshObjective, prior?.objective);
|
|
614
641
|
const pickedAdjudication = pick(policy.adjudication, freshAdjudication, prior?.adjudication);
|
|
615
642
|
const conflictsWithFreshEvidence = Boolean(pickedAdjudication?.verdict && (objective?.status === "FAIL" || objective?.status === "ERROR" || objective?.status === "NOT-MEASURED" ||
|
|
616
|
-
(pass_threshold === 1 && (reps ?? 1) > 1 && judge_verdict !== "PASS")));
|
|
643
|
+
(pickedAdjudication.verdict === "PASS" && (judge_verdict === "UNGRADED" || pass_threshold === 1 && (reps ?? 1) > 1 && judge_verdict !== "PASS"))));
|
|
617
644
|
// A settled cell-level panel cannot overrule newly evaluated objective gates,
|
|
618
645
|
// nor can one transcript erase a failed repetition under an all-clean policy.
|
|
619
646
|
const adjudication = conflictsWithFreshEvidence && pickedAdjudication
|
|
@@ -652,6 +679,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
|
|
|
652
679
|
...(clean === undefined ? {} : { clean }),
|
|
653
680
|
...(flakiness === undefined ? {} : { flakiness }),
|
|
654
681
|
...(pass_threshold === undefined ? {} : { pass_threshold }),
|
|
682
|
+
...(ungraded_reps === undefined ? {} : { ungraded_reps }),
|
|
655
683
|
...((freshMetrics ?? prior?.metrics) ? { metrics: freshMetrics ?? prior.metrics } : {}),
|
|
656
684
|
...((freshUsage ?? prior?.usage) ? { usage: freshUsage ?? prior.usage } : {}),
|
|
657
685
|
// The author owns the verdict; a re-measurement never discards their call.
|
package/dist/run.js
CHANGED
|
@@ -521,7 +521,7 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
521
521
|
reason = o.reason;
|
|
522
522
|
suspect = o.suspect;
|
|
523
523
|
judgment = o.judgment; // judgeOneRep already journaled (verdict + misfire)
|
|
524
|
-
judgeCalls =
|
|
524
|
+
judgeCalls = o.metrics?.judge_calls ?? 0;
|
|
525
525
|
}
|
|
526
526
|
log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
|
|
527
527
|
const subject = mergeTraces(traces)?.metrics;
|
|
@@ -567,7 +567,8 @@ export function formatScorecard(summary, lift, stability) {
|
|
|
567
567
|
const susp = s.suspect ? " ⚠suspect" : "";
|
|
568
568
|
const misfired = s.clean !== undefined && s.reps !== undefined && s.clean < s.reps ? ` · ${s.reps - s.clean} misfired` : "";
|
|
569
569
|
const repInfo = s.reps ? ` [${s.passes}/${s.clean}${misfired}${s.flakiness ? ` flaky ${s.flakiness.toFixed(2)}` : ""}]` : "";
|
|
570
|
-
|
|
570
|
+
const ungradedInfo = s.ungraded_reps ? ` · ${s.ungraded_reps} ungraded rep${s.ungraded_reps === 1 ? "" : "s"} (UNGRADED)` : "";
|
|
571
|
+
lines.push(` ${mark} ${s.id}${ov}${susp} ${s.judge_reason}${repInfo}${ungradedInfo}`);
|
|
571
572
|
}
|
|
572
573
|
const ship = g.ship ? "SHIP" : "NOT READY";
|
|
573
574
|
const note = g.note ? ` (${g.note})` : "";
|
package/dist/score.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ShipBar } from "./spec.js";
|
|
|
3
3
|
* "JUDGE-AMBIGUOUS": the judge emitted conflicting verdicts for one transcript. It is
|
|
4
4
|
* never a pass and never silently resolved — it marks the run for a rejudge.
|
|
5
5
|
*/
|
|
6
|
-
export type Verdict = "PASS" | "FAIL" | "ERROR" | "NOT-MEASURED" | "JUDGE-AMBIGUOUS";
|
|
6
|
+
export type Verdict = "PASS" | "FAIL" | "ERROR" | "NOT-MEASURED" | "JUDGE-AMBIGUOUS" | "UNGRADED";
|
|
7
7
|
export interface ScenarioVerdict {
|
|
8
8
|
id: string;
|
|
9
9
|
verdict: Verdict;
|
|
@@ -24,6 +24,7 @@ export interface ScoreResult {
|
|
|
24
24
|
suspectCount: number;
|
|
25
25
|
errorCount: number;
|
|
26
26
|
notMeasuredCount: number;
|
|
27
|
+
ungradedCount: number;
|
|
27
28
|
note: string;
|
|
28
29
|
}
|
|
29
30
|
export declare function letterFor(pct: number): string;
|
package/dist/score.js
CHANGED
|
@@ -28,6 +28,7 @@ export function score(verdicts, input) {
|
|
|
28
28
|
let suspectCount = 0;
|
|
29
29
|
let errorCount = 0;
|
|
30
30
|
let notMeasuredCount = 0;
|
|
31
|
+
let ungradedCount = 0;
|
|
31
32
|
for (const v of verdicts) {
|
|
32
33
|
if (v.suspect) {
|
|
33
34
|
suspectCount++;
|
|
@@ -46,6 +47,8 @@ export function score(verdicts, input) {
|
|
|
46
47
|
passed++;
|
|
47
48
|
continue;
|
|
48
49
|
}
|
|
50
|
+
if (v.verdict === "UNGRADED")
|
|
51
|
+
ungradedCount++;
|
|
49
52
|
if (critical.includes(v.id))
|
|
50
53
|
criticalFails++;
|
|
51
54
|
if (/^B/i.test(v.id))
|
|
@@ -75,12 +78,15 @@ export function score(verdicts, input) {
|
|
|
75
78
|
else if (notMeasuredCount > 0) {
|
|
76
79
|
note = `${notMeasuredCount} not measured: skill delivery was not established`;
|
|
77
80
|
}
|
|
81
|
+
else if (ungradedCount > 0) {
|
|
82
|
+
note = `${ungradedCount} UNGRADED: incomplete criterion votes (counted as non-passes)`;
|
|
83
|
+
}
|
|
78
84
|
else if (criticalFails > 0) {
|
|
79
85
|
note = `gated: ${criticalFails} critical fail${criticalFails === 1 ? "" : "s"}`;
|
|
80
86
|
}
|
|
81
87
|
else if (bSeriesFails > 0) {
|
|
82
88
|
note = `gated: ${bSeriesFails} B-series fail${bSeriesFails === 1 ? "" : "s"}`;
|
|
83
89
|
}
|
|
84
|
-
return { passed, total, pct, letter, ship, criticalFails, bSeriesFails, suspectCount, errorCount, notMeasuredCount, note };
|
|
90
|
+
return { passed, total, pct, letter, ship, criticalFails, bSeriesFails, suspectCount, errorCount, notMeasuredCount, ungradedCount, note };
|
|
85
91
|
}
|
|
86
92
|
//# sourceMappingURL=score.js.map
|
package/dist/stability.js
CHANGED
|
@@ -9,7 +9,7 @@ function conclusive(v) {
|
|
|
9
9
|
// Same rule lift.ts applies, for the same reason: ERROR is a harness failure and an
|
|
10
10
|
// unresolved misfire is a judge failure. Counting either as a side of a flip would
|
|
11
11
|
// report infrastructure noise as behavioural instability.
|
|
12
|
-
return !v.suspect && v.verdict !== "ERROR" && v.verdict !== "NOT-MEASURED" && v.verdict !== "JUDGE-AMBIGUOUS";
|
|
12
|
+
return !v.suspect && v.verdict !== "ERROR" && v.verdict !== "NOT-MEASURED" && v.verdict !== "JUDGE-AMBIGUOUS" && v.verdict !== "UNGRADED";
|
|
13
13
|
}
|
|
14
14
|
function pointFor(r, s, verdict) {
|
|
15
15
|
const reps = s.reps ?? 1;
|
package/dist/trends.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { loadSpec } from "./spec.js";
|
|
4
|
-
import { readResults, effectiveVerdicts, isScoredMode } from "./results.js";
|
|
4
|
+
import { readResults, effectiveVerdicts, isScoredMode, normalizeScenarioResult, finalizeResults, scoreContextFor } from "./results.js";
|
|
5
5
|
/** A directory that exists right now; false (never throws) if it vanished concurrently (e.g. ENOENT). */
|
|
6
6
|
function isDir(p) {
|
|
7
7
|
try {
|
|
@@ -112,12 +112,16 @@ export function collectTrends(skillDir, limit = 20) {
|
|
|
112
112
|
// effectiveVerdicts is the single source of truth for the override-aware
|
|
113
113
|
// verdict/suspect rule (suspect = s.suspect && s.override == null — an override
|
|
114
114
|
// resolves the misfire); zip in flakiness from the matching ScenarioResult.
|
|
115
|
-
const
|
|
115
|
+
const normalized = r.scenarios.map(normalizeScenarioResult);
|
|
116
|
+
const grade = normalized.some((s, i) => s !== r.scenarios[i])
|
|
117
|
+
? finalizeResults({ ...r, scenarios: normalized }, scoreContextFor(r, spec)).effective_grade
|
|
118
|
+
: r.effective_grade;
|
|
119
|
+
const verdicts = effectiveVerdicts(normalized);
|
|
116
120
|
const cells = {};
|
|
117
|
-
|
|
121
|
+
normalized.forEach((s, i) => {
|
|
118
122
|
cells[s.id] = { verdict: verdicts[i].verdict, suspect: verdicts[i].suspect ?? false, flakiness: s.flakiness };
|
|
119
123
|
});
|
|
120
|
-
runs.push({ timestamp: r.timestamp, label: r.label, grade
|
|
124
|
+
runs.push({ timestamp: r.timestamp, label: r.label, grade, cells });
|
|
121
125
|
}
|
|
122
126
|
models.push({ model: group.model, tag: group.tag, mode: group.mode, runs, truncated, skipped: group.skipped });
|
|
123
127
|
}
|
package/package.json
CHANGED
|
@@ -102,7 +102,8 @@
|
|
|
102
102
|
"PASS",
|
|
103
103
|
"FAIL",
|
|
104
104
|
"ERROR",
|
|
105
|
-
"JUDGE-AMBIGUOUS"
|
|
105
|
+
"JUDGE-AMBIGUOUS",
|
|
106
|
+
"UNGRADED"
|
|
106
107
|
]
|
|
107
108
|
},
|
|
108
109
|
"grade": {
|
|
@@ -232,6 +233,7 @@
|
|
|
232
233
|
"suspect"
|
|
233
234
|
],
|
|
234
235
|
"properties": {
|
|
236
|
+
"judgeRetries": { "type": "integer", "const": 1 },
|
|
235
237
|
"ordinal": {
|
|
236
238
|
"type": "integer",
|
|
237
239
|
"minimum": 1,
|
|
@@ -320,6 +322,10 @@
|
|
|
320
322
|
"minimum": 0,
|
|
321
323
|
"maximum": 1
|
|
322
324
|
},
|
|
325
|
+
"ungraded_reps": {
|
|
326
|
+
"type": "integer",
|
|
327
|
+
"minimum": 1
|
|
328
|
+
},
|
|
323
329
|
"pass_threshold": {
|
|
324
330
|
"type": "number",
|
|
325
331
|
"minimum": 0,
|
|
@@ -113,7 +113,8 @@
|
|
|
113
113
|
"FAIL",
|
|
114
114
|
"ERROR",
|
|
115
115
|
"NOT-MEASURED",
|
|
116
|
-
"JUDGE-AMBIGUOUS"
|
|
116
|
+
"JUDGE-AMBIGUOUS",
|
|
117
|
+
"UNGRADED"
|
|
117
118
|
]
|
|
118
119
|
},
|
|
119
120
|
"grade": {
|
|
@@ -244,6 +245,7 @@
|
|
|
244
245
|
"criteria"
|
|
245
246
|
],
|
|
246
247
|
"properties": {
|
|
248
|
+
"judgeRetries": { "type": "integer", "const": 1 },
|
|
247
249
|
"ordinal": {
|
|
248
250
|
"type": "integer",
|
|
249
251
|
"minimum": 1,
|
|
@@ -346,6 +348,10 @@
|
|
|
346
348
|
"minimum": 0,
|
|
347
349
|
"maximum": 1
|
|
348
350
|
},
|
|
351
|
+
"ungraded_reps": {
|
|
352
|
+
"type": "integer",
|
|
353
|
+
"minimum": 1
|
|
354
|
+
},
|
|
349
355
|
"pass_threshold": {
|
|
350
356
|
"type": "number",
|
|
351
357
|
"minimum": 0,
|