@skill-harness/core 0.22.3 → 0.23.0

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 CHANGED
@@ -36,13 +36,23 @@ export declare function parseVerdict(out: string): ParsedVerdict;
36
36
  */
37
37
  export declare function judgeResemblesSubject(judge: ModelRef, subject: ModelRef): boolean;
38
38
  export interface GradeResult extends ParsedVerdict {
39
+ /** The final judge reply, retained for callers that predate structured judging. */
39
40
  raw: string;
41
+ /** Every attempt in call order; two entries mean the structured retry also ran. */
42
+ rawReplies: string[];
40
43
  criteria: CriterionVote[];
41
- /** One identical-input retry after missing criterion votes. Absent on the first attempt. */
44
+ judgeFormat: "json";
45
+ /** One validation-guided retry after an invalid structured reply. Absent on the first attempt. */
42
46
  judgeRetries?: 1;
43
47
  /** Judge misfire: the overall verdict disagrees with AND(per-item grades). Recorded, never auto-passed; blocks SHIP until re-judged or overridden. */
44
48
  suspect: boolean;
45
49
  }
50
+ /** Parse and validate the JSON-only contract used for new judge calls. */
51
+ export declare function parseStructuredJudgeReply(raw: string, expectedCriteria: number): ParsedVerdict & {
52
+ criteria: CriterionVote[];
53
+ };
54
+ /** Preserve one raw reply byte-for-byte, or both retry attempts with explicit boundaries. */
55
+ export declare function formatJudgeRawReplies(replies: string[]): string;
46
56
  /**
47
57
  * Judge-misfire detector: parse the judge's per-checklist-item grades and assert
48
58
  * the overall verdict equals AND(items). A mismatch in EITHER direction — verdict
@@ -51,11 +61,11 @@ export interface GradeResult extends ParsedVerdict {
51
61
  * parse, or the verdict is ERROR, return false (never block a run on a parse miss).
52
62
  */
53
63
  export declare function detectMisfire(raw: string, verdict: Verdict): boolean;
54
- /** Drive the judge for one transcript and parse the result. */
55
- export declare function gradeTranscript(adapter: HarnessAdapter, judge: ModelRef, prompt: string, cwd: string, expectedCriteria?: number): Promise<GradeResult>;
64
+ /** Drive the judge for one transcript and parse the JSON-only result. */
65
+ export declare function gradeTranscript(adapter: HarnessAdapter, judge: ModelRef, prompt: string, cwd: string, expectedCriteria: number): Promise<GradeResult>;
56
66
  /**
57
67
  * Grade a transcript in a fresh, isolated, throwaway workspace — never the
58
68
  * subject's scenario dir — so the judge can't ingest repo context the subject
59
69
  * left behind (matters for provider CLIs that read cwd, including Pi).
60
70
  */
61
- export declare function judgeInWorkspace(adapter: HarnessAdapter, judge: ModelRef, prompt: string, specDir: string, expectedCriteria?: number): Promise<GradeResult>;
71
+ 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, completeCriterionVotes } from "./results.js";
2
+ import { 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
  /**
@@ -37,9 +37,8 @@ ${numbered}
37
37
  TRANSCRIPT (the assistant is the model under test):
38
38
  ${transcript}
39
39
  ${diffGuidance}
40
- Grade each checklist item PASS or FAIL with a <=12-word justification quoting the transcript. Be skeptical: if an item is not clearly satisfied, mark it FAIL. Then output exactly these two lines:
41
- VERDICT: PASS (only if EVERY item passed) — or — VERDICT: FAIL
42
- REASON: <15 words or fewer>`;
40
+ Grade each checklist item PASS or FAIL with a <=12-word justification quoting the transcript. Be skeptical: if an item is not clearly satisfied, mark it FAIL. Reply with exactly one JSON object and nothing else, with one vote per criterion in checklist order:
41
+ {"votes":[{"criterion":1,"vote":"PASS"|"FAIL","reason":"..."},...],"verdict":"PASS"|"FAIL","reason":"..."}`;
43
42
  }
44
43
  // Both anchor to the start of a line and REQUIRE the colon. Without those anchors the
45
44
  // reason pattern matched any word containing "reason" — git-ops GLM C1 stored
@@ -87,6 +86,85 @@ export function judgeResemblesSubject(judge, subject) {
87
86
  const b = subject.model;
88
87
  return a === b || a.includes(b) || b.includes(a);
89
88
  }
89
+ /** Extract the first complete top-level JSON object, tolerating prose or fences around it. */
90
+ function extractFirstJsonObject(raw) {
91
+ const start = raw.indexOf("{");
92
+ if (start < 0)
93
+ throw new Error("no JSON object in judge reply");
94
+ let depth = 0;
95
+ let inString = false;
96
+ let escaped = false;
97
+ for (let i = start; i < raw.length; i++) {
98
+ const ch = raw[i];
99
+ if (inString) {
100
+ if (escaped)
101
+ escaped = false;
102
+ else if (ch === "\\")
103
+ escaped = true;
104
+ else if (ch === '"')
105
+ inString = false;
106
+ continue;
107
+ }
108
+ if (ch === '"')
109
+ inString = true;
110
+ else if (ch === "{")
111
+ depth++;
112
+ else if (ch === "}" && --depth === 0)
113
+ return raw.slice(start, i + 1);
114
+ }
115
+ throw new Error("incomplete JSON object in judge reply");
116
+ }
117
+ function exactKeys(value, expected, context) {
118
+ const actual = Object.keys(value).sort();
119
+ const wanted = [...expected].sort();
120
+ if (JSON.stringify(actual) !== JSON.stringify(wanted)) {
121
+ throw new Error(`${context} must contain exactly ${wanted.join(", ")}`);
122
+ }
123
+ }
124
+ /** Parse and validate the JSON-only contract used for new judge calls. */
125
+ export function parseStructuredJudgeReply(raw, expectedCriteria) {
126
+ let value;
127
+ try {
128
+ value = JSON.parse(extractFirstJsonObject(raw));
129
+ }
130
+ catch (error) {
131
+ if (error instanceof Error && /JSON object in judge reply/.test(error.message))
132
+ throw error;
133
+ throw new Error(`invalid JSON: ${error.message}`);
134
+ }
135
+ if (!value || typeof value !== "object" || Array.isArray(value))
136
+ throw new Error("judge reply must be a JSON object");
137
+ const object = value;
138
+ exactKeys(object, ["votes", "verdict", "reason"], "judge reply");
139
+ if (!Array.isArray(object.votes))
140
+ throw new Error("votes must be an array");
141
+ if (object.votes.length !== expectedCriteria)
142
+ throw new Error(`expected ${expectedCriteria} votes, got ${object.votes.length}`);
143
+ const criteria = object.votes.map((rawVote, offset) => {
144
+ if (!rawVote || typeof rawVote !== "object" || Array.isArray(rawVote))
145
+ throw new Error(`vote ${offset + 1} must be an object`);
146
+ const vote = rawVote;
147
+ exactKeys(vote, ["criterion", "vote", "reason"], `vote ${offset + 1}`);
148
+ if (vote.criterion !== offset + 1)
149
+ throw new Error(`vote ${offset + 1} must have criterion ${offset + 1}`);
150
+ if (vote.vote !== "PASS" && vote.vote !== "FAIL")
151
+ throw new Error(`vote ${offset + 1} must be PASS or FAIL`);
152
+ if (typeof vote.reason !== "string")
153
+ throw new Error(`vote ${offset + 1} reason must be a string`);
154
+ return { index: offset + 1, verdict: vote.vote, reason: vote.reason };
155
+ });
156
+ if (object.verdict !== "PASS" && object.verdict !== "FAIL")
157
+ throw new Error("verdict must be PASS or FAIL");
158
+ if (typeof object.reason !== "string")
159
+ throw new Error("reason must be a string");
160
+ return { criteria, verdict: object.verdict, reason: object.reason };
161
+ }
162
+ /** Preserve one raw reply byte-for-byte, or both retry attempts with explicit boundaries. */
163
+ export function formatJudgeRawReplies(replies) {
164
+ if (replies.length === 1)
165
+ return replies[0];
166
+ return replies.map((reply, index) => `=== JUDGE REPLY ${index + 1} ===\n${reply}`).join("\n\n");
167
+ }
90
168
  const ITEM_RE = /^\s*\d+[.)]\s*\**\s*(PASS|FAIL)\b/gim;
91
169
  /**
92
170
  * Judge-misfire detector: parse the judge's per-checklist-item grades and assert
@@ -122,27 +200,48 @@ export function detectMisfire(raw, verdict) {
122
200
  }
123
201
  // Non-global twin of REASON_RE: matchAll needs /g, a single .match() must not have it.
124
202
  const REASON_LINE_RE = /^\s*\**\s*REASON\**\s*:\s*\**\s*(.*)$/im;
125
- /** Drive the judge for one transcript and parse the result. */
203
+ /** Drive the judge for one transcript and parse the JSON-only result. */
126
204
  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
- }
136
- const parsed = parseVerdict(raw);
137
- // On a parse failure, surface what the judge actually emitted (e.g. a provider
138
- // error) rather than a generic message — otherwise the cause is invisible.
139
- if (parsed.verdict === "ERROR") {
140
- const snippet = raw.trim().replace(/\s+/g, " ").slice(0, 160);
141
- if (snippet)
142
- parsed.reason = `judge unparseable: ${snippet}`;
205
+ const rawReplies = [];
206
+ let validationError = "";
207
+ for (let attempt = 0; attempt < 2; attempt++) {
208
+ const retry = attempt === 0 ? "" : `\n\nYour reply was not valid: ${JSON.stringify(validationError)}; reply with only the JSON object.`;
209
+ const raw = await adapter.judge({ model: judge, prompt: prompt + retry, cwd });
210
+ rawReplies.push(raw);
211
+ // Pi adapter failures are infrastructure errors, not malformed model votes.
212
+ if (/^\[judge error:/i.test(raw.trim())) {
213
+ const snippet = raw.trim().replace(/\s+/g, " ").slice(0, 160);
214
+ return {
215
+ verdict: "ERROR", reason: `judge unparseable: ${snippet}`, suspect: false,
216
+ raw, rawReplies, criteria: completeCriterionVotes([], expectedCriteria), judgeFormat: "json",
217
+ ...(attempt === 1 ? { judgeRetries: 1 } : {}),
218
+ };
219
+ }
220
+ try {
221
+ const parsed = parseStructuredJudgeReply(raw, expectedCriteria);
222
+ const suspect = parsed.verdict === "FAIL"
223
+ ? parsed.criteria.every(vote => vote.verdict === "PASS")
224
+ : parsed.criteria.some(vote => vote.verdict === "FAIL");
225
+ return {
226
+ ...parsed, raw, rawReplies, suspect, judgeFormat: "json",
227
+ ...(attempt === 1 ? { judgeRetries: 1 } : {}),
228
+ };
229
+ }
230
+ catch (error) {
231
+ validationError = error.message;
232
+ }
143
233
  }
144
- const suspect = detectMisfire(raw, parsed.verdict);
145
- return { ...parsed, raw, suspect, criteria, ...(judgeRetries ? { judgeRetries } : {}) };
234
+ const raw = rawReplies[rawReplies.length - 1];
235
+ return {
236
+ verdict: "UNGRADED",
237
+ reason: `judge structured reply invalid after retry: ${validationError}`,
238
+ suspect: false,
239
+ raw,
240
+ rawReplies,
241
+ criteria: completeCriterionVotes([], expectedCriteria),
242
+ judgeFormat: "json",
243
+ judgeRetries: 1,
244
+ };
146
245
  }
147
246
  /**
148
247
  * Grade a transcript in a fresh, isolated, throwaway workspace — never the
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
@@ -49,11 +49,19 @@ function rewriteTranscript(path, gateLines) {
49
49
  renameSync(path, path.replace(/\.txt$/, ".pre-regate.txt"));
50
50
  writeFileSync(path, `${head}${TRAILER}\n${gateLines.join("\n")}\n\n${tail}`, "utf8");
51
51
  }
52
- /** Recover a rep's judge verdict from its saved judge-raw artifact — free, and exact. */
53
- function verdictFromSavedJudgement(runDir, id, mode, rep) {
52
+ /** Recover a rep's judge verdict from retained structured evidence or its legacy prose artifact. */
53
+ function verdictFromSavedJudgement(runDir, id, mode, rep, retained) {
54
54
  const path = judgeRawPath(runDir, id, mode, rep);
55
55
  if (!existsSync(path))
56
56
  return null;
57
+ if (retained?.judgeFormat === "json") {
58
+ return {
59
+ verdict: retained.verdict,
60
+ reason: retained.reason,
61
+ suspect: retained.suspect,
62
+ judgment: retained,
63
+ };
64
+ }
57
65
  const raw = readFileSync(path, "utf8");
58
66
  const parsed = parseVerdict(raw);
59
67
  return { verdict: parsed.verdict, reason: parsed.reason, suspect: detectMisfire(raw, parsed.verdict) };
@@ -284,7 +292,9 @@ export async function regateRun(opts) {
284
292
  if (!oldGateFailed) {
285
293
  // The judge already saw this rep. Its verdict is on disk — re-read it rather
286
294
  // than paying to ask the same question again.
287
- const saved = verdictFromSavedJudgement(opts.runDir, scenario.id, mode, rep);
295
+ const panel = rec.rep_judgments?.find(candidate => candidate.repetition === (rep ?? 0));
296
+ const retained = panel?.judgments.find(judgment => judgment.ordinal === 1) ?? panel?.judgments[0];
297
+ const saved = verdictFromSavedJudgement(opts.runDir, scenario.id, mode, rep, retained);
288
298
  outcomes.push({ ...(saved ?? { verdict: rec.judge_verdict, reason: rec.judge_reason, suspect: rec.suspect }), objective });
289
299
  continue;
290
300
  }
package/dist/regrade.d.ts CHANGED
@@ -53,6 +53,8 @@ export declare function judgeOneRep(opts: {
53
53
  now: () => string;
54
54
  /** True when this call revisits a saved subject transcript. */
55
55
  rejudge?: boolean;
56
+ /** Retained objective evidence must precede missing-vote normalization. */
57
+ objective?: RepOutcome["objective"];
56
58
  }): Promise<RepOutcome>;
57
59
  /**
58
60
  * Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
package/dist/regrade.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { buildJudgePrompt, judgeInWorkspace } from "./grade.js";
3
+ import { buildJudgePrompt, formatJudgeRawReplies, 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";
@@ -57,19 +57,20 @@ export async function judgeOneRep(opts) {
57
57
  }
58
58
  const prompt = buildJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript });
59
59
  const g = await judgeInWorkspace(adapter, judge, prompt, specDir, scenario.checklist.length);
60
- writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), g.raw, "utf8");
61
- appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: g.verdict, reason: g.reason, suspect: g.suspect, ...repField });
62
- if (g.suspect)
63
- appendJournal(runDir, { event: "misfire-flag", ts: now(), id: scenario.id, reason: g.reason, ...repField });
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), ...(g.judgeRetries ? { judgeRetries: g.judgeRetries } : {}) },
60
+ writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), formatJudgeRawReplies(g.rawReplies), "utf8");
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), judgeFormat: g.judgeFormat, ...(g.judgeRetries ? { judgeRetries: g.judgeRetries } : {}) },
67
64
  metrics: {
68
65
  wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
69
66
  judge_calls: 1 + (g.judgeRetries ?? 0),
70
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
@@ -110,6 +111,7 @@ export async function regradeScenario(opts) {
110
111
  outcomes.push(await judgeOneRep({
111
112
  runDir: opts.runDir, spec: opts.spec, scenario: opts.scenario, transcript,
112
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),
113
115
  }));
114
116
  }
115
117
  const result = outcomesToResult(opts.scenario.id, outcomes, repCount, opts.threshold);
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 r.scenarios) {
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: r.effective_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((s) => {
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,7 +101,9 @@ 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[];
102
- /** One retry with identical judge input after unparseable criterion votes. */
104
+ /** Structured format for new judgments; prose is retained compatibility evidence. */
105
+ judgeFormat?: "json" | "prose";
106
+ /** One validation-guided retry after an invalid structured reply. */
103
107
  judgeRetries?: 1;
104
108
  }
105
109
  export interface AdjudicationResult {
@@ -349,6 +353,7 @@ export declare function resultsPath(runDir: string): string;
349
353
  * deterministic assertion is a deliberate, recorded human act — and the failure
350
354
  * this guards against was never a human deciding, it was nobody deciding.
351
355
  */
356
+ export declare function normalizeScenarioResult(s: ScenarioResult): ScenarioResult;
352
357
  export declare function effectiveVerdicts(scenarios: ScenarioResult[]): ScenarioVerdict[];
353
358
  /**
354
359
  * 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(draft.scenarios), { shipBar: ctx.shipBar, critical: ctx.critical });
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: draft.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 (clean.length === 1)
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
- (pickedAdjudication.verdict === "PASS" && 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
@@ -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
- lines.push(` ${mark} ${s.id}${ov}${susp} ${s.judge_reason}${repInfo}`);
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 verdicts = effectiveVerdicts(r.scenarios);
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
- r.scenarios.forEach((s, i) => {
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: r.effective_grade, cells });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.22.3",
3
+ "version": "0.23.0",
4
4
  "description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
+ "judgeFormat": { "enum": ["json", "prose"] },
235
237
  "judgeRetries": { "type": "integer", "const": 1 },
236
238
  "ordinal": {
237
239
  "type": "integer",
@@ -321,6 +323,10 @@
321
323
  "minimum": 0,
322
324
  "maximum": 1
323
325
  },
326
+ "ungraded_reps": {
327
+ "type": "integer",
328
+ "minimum": 1
329
+ },
324
330
  "pass_threshold": {
325
331
  "type": "number",
326
332
  "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
+ "judgeFormat": { "enum": ["json", "prose"] },
247
249
  "judgeRetries": { "type": "integer", "const": 1 },
248
250
  "ordinal": {
249
251
  "type": "integer",
@@ -347,6 +349,10 @@
347
349
  "minimum": 0,
348
350
  "maximum": 1
349
351
  },
352
+ "ungraded_reps": {
353
+ "type": "integer",
354
+ "minimum": 1
355
+ },
350
356
  "pass_threshold": {
351
357
  "type": "number",
352
358
  "minimum": 0,