@skill-harness/core 0.21.0 → 0.22.3
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/regate.d.ts +8 -3
- package/dist/regate.js +94 -29
- package/dist/regrade.d.ts +5 -0
- package/dist/regrade.js +57 -19
- package/dist/reps.js +2 -0
- package/dist/results.d.ts +5 -0
- package/dist/results.js +1 -1
- package/dist/run.js +16 -9
- package/dist/seeded.d.ts +22 -0
- package/dist/seeded.js +69 -0
- package/dist/sources.d.ts +2 -2
- package/dist/sources.js +7 -5
- package/dist/spec.d.ts +8 -4
- package/dist/spec.js +37 -5
- package/package.json +1 -1
- package/schemas/results-v2.schema.json +12 -0
- package/schemas/results-v3.schema.json +12 -0
- package/schemas/specification-v1.schema.json +16 -0
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/regate.d.ts
CHANGED
|
@@ -26,10 +26,12 @@ export interface RegateResult {
|
|
|
26
26
|
judgeCalls: number;
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
|
-
* Re-evaluate
|
|
30
|
-
* verdicts they determined — without re-running the model.
|
|
29
|
+
* Re-evaluate diff, output, and trace gates against a run's saved artifacts and
|
|
30
|
+
* re-decide the verdicts they determined — without re-running the model.
|
|
31
31
|
*
|
|
32
|
-
* `diff_contains` / `diff_excludes` are pure functions of the diff, and
|
|
32
|
+
* `diff_contains` / `diff_excludes` are pure functions of the diff, and
|
|
33
|
+
* `output_matches` / `output_excludes` are pure functions of the saved final
|
|
34
|
+
* assistant message. Since
|
|
33
35
|
* `f6a5f6c` every seeded rep persists its diff as a run artifact. So the defect class
|
|
34
36
|
* "the gate was wrong, the behavior wasn't" — hit three times in the reference corpus
|
|
35
37
|
* (a context needle, a baseline-satisfied needle, a filename needle) — no longer costs
|
|
@@ -48,6 +50,9 @@ export interface RegateResult {
|
|
|
48
50
|
* That third row is what keeps this cheap without guessing: a rep the judge already
|
|
49
51
|
* saw has its verdict on disk, so regate re-reads it rather than re-asking.
|
|
50
52
|
*
|
|
53
|
+
* Output gates likewise use the complete saved transcript set and never re-run the
|
|
54
|
+
* subject. A newly failing output gate is therefore judge-free.
|
|
55
|
+
*
|
|
51
56
|
* **Limits, deliberately hard failures rather than partial work:** `assert.vitest` and
|
|
52
57
|
* `assert.post_test` need the workspace and cannot be re-evaluated from any artifact,
|
|
53
58
|
* so a scenario carrying either is not regatable. Diffs and judge-raw files are
|
package/dist/regate.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
import { parseVerdict, detectMisfire } from "./grade.js";
|
|
4
|
-
import { evaluateNeedleGates, hasNeedleGates } from "./seeded.js";
|
|
4
|
+
import { evaluateNeedleGates, evaluateOutputGates, hasNeedleGates, hasOutputGates } from "./seeded.js";
|
|
5
5
|
import { evaluateTraceGates } from "./trace-gates.js";
|
|
6
6
|
import { mergeTraces, deserializeTrace } from "./execution-trace.js";
|
|
7
7
|
import { judgeOneRep } from "./regrade.js";
|
|
8
|
-
import { readResults, writeResults, diffPath, transcriptPath, judgeRawPath,
|
|
8
|
+
import { readResults, writeResults, diffPath, transcriptPath, judgeRawPath, findDiffFiles, findTranscriptFiles, findTraceFiles, tracePath, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, deliveryStatusForObservations, } from "./results.js";
|
|
9
9
|
import { outcomesToResult } from "./reps.js";
|
|
10
10
|
import { appendJournal } from "./journal.js";
|
|
11
11
|
import { gatesDigest, GATES_PREFIX } from "./sources.js";
|
|
@@ -13,6 +13,19 @@ import { gatesDigest, GATES_PREFIX } from "./sources.js";
|
|
|
13
13
|
const GATE_FAILED_RE = /: (MISSING|PRESENT)$/m;
|
|
14
14
|
const TRAILER = "=== SEEDED GATES ===";
|
|
15
15
|
const DIFF_HEADER = "=== STAGED DIFF ===";
|
|
16
|
+
const TRAILER_LINE_RE = /^=== SEEDED GATES ===[ \t]*$/gm;
|
|
17
|
+
const DIFF_HEADER_LINE_RE = /^=== STAGED DIFF ===[ \t]*$/gm;
|
|
18
|
+
/** Last exact full-line marker before `before`; model/diff substrings do not count. */
|
|
19
|
+
function lastLineIndexBefore(text, pattern, before = text.length) {
|
|
20
|
+
pattern.lastIndex = 0;
|
|
21
|
+
let found = -1;
|
|
22
|
+
for (const match of text.matchAll(pattern)) {
|
|
23
|
+
if (match.index >= before)
|
|
24
|
+
break;
|
|
25
|
+
found = match.index;
|
|
26
|
+
}
|
|
27
|
+
return found;
|
|
28
|
+
}
|
|
16
29
|
/**
|
|
17
30
|
* Rebuild a transcript with a fresh gates trailer, preserving the model's turns and
|
|
18
31
|
* the embedded diff exactly.
|
|
@@ -24,10 +37,13 @@ const DIFF_HEADER = "=== STAGED DIFF ===";
|
|
|
24
37
|
*/
|
|
25
38
|
function rewriteTranscript(path, gateLines) {
|
|
26
39
|
const original = readFileSync(path, "utf8");
|
|
27
|
-
|
|
40
|
+
// The harness-owned diff header is last; the harness-owned trailer is the last
|
|
41
|
+
// exact trailer line before it. Prefixes and marker text inside the diff do not
|
|
42
|
+
// become structural boundaries.
|
|
43
|
+
const diffAt = lastLineIndexBefore(original, DIFF_HEADER_LINE_RE);
|
|
44
|
+
const trailerAt = lastLineIndexBefore(original, TRAILER_LINE_RE, diffAt === -1 ? original.length : diffAt);
|
|
28
45
|
if (trailerAt === -1)
|
|
29
46
|
return; // no trailer to correct (non-seeded shape); leave it alone
|
|
30
|
-
const diffAt = original.indexOf(DIFF_HEADER);
|
|
31
47
|
const head = original.slice(0, trailerAt);
|
|
32
48
|
const tail = diffAt === -1 ? "" : original.slice(diffAt);
|
|
33
49
|
renameSync(path, path.replace(/\.txt$/, ".pre-regate.txt"));
|
|
@@ -43,10 +59,12 @@ function verdictFromSavedJudgement(runDir, id, mode, rep) {
|
|
|
43
59
|
return { verdict: parsed.verdict, reason: parsed.reason, suspect: detectMisfire(raw, parsed.verdict) };
|
|
44
60
|
}
|
|
45
61
|
/**
|
|
46
|
-
* Re-evaluate
|
|
47
|
-
* verdicts they determined — without re-running the model.
|
|
62
|
+
* Re-evaluate diff, output, and trace gates against a run's saved artifacts and
|
|
63
|
+
* re-decide the verdicts they determined — without re-running the model.
|
|
48
64
|
*
|
|
49
|
-
* `diff_contains` / `diff_excludes` are pure functions of the diff, and
|
|
65
|
+
* `diff_contains` / `diff_excludes` are pure functions of the diff, and
|
|
66
|
+
* `output_matches` / `output_excludes` are pure functions of the saved final
|
|
67
|
+
* assistant message. Since
|
|
50
68
|
* `f6a5f6c` every seeded rep persists its diff as a run artifact. So the defect class
|
|
51
69
|
* "the gate was wrong, the behavior wasn't" — hit three times in the reference corpus
|
|
52
70
|
* (a context needle, a baseline-satisfied needle, a filename needle) — no longer costs
|
|
@@ -65,6 +83,9 @@ function verdictFromSavedJudgement(runDir, id, mode, rep) {
|
|
|
65
83
|
* That third row is what keeps this cheap without guessing: a rep the judge already
|
|
66
84
|
* saw has its verdict on disk, so regate re-reads it rather than re-asking.
|
|
67
85
|
*
|
|
86
|
+
* Output gates likewise use the complete saved transcript set and never re-run the
|
|
87
|
+
* subject. A newly failing output gate is therefore judge-free.
|
|
88
|
+
*
|
|
68
89
|
* **Limits, deliberately hard failures rather than partial work:** `assert.vitest` and
|
|
69
90
|
* `assert.post_test` need the workspace and cannot be re-evaluated from any artifact,
|
|
70
91
|
* so a scenario carrying either is not regatable. Diffs and judge-raw files are
|
|
@@ -87,8 +108,9 @@ export async function regateRun(opts) {
|
|
|
87
108
|
for (const rec of prev.scenarios) {
|
|
88
109
|
const s = specById.get(rec.id);
|
|
89
110
|
const needles = hasNeedleGates(s ?? {});
|
|
111
|
+
const outputGated = hasOutputGates(s ?? {});
|
|
90
112
|
const traceGated = Boolean(s?.traceAssert);
|
|
91
|
-
if (!s || (!needles && !traceGated))
|
|
113
|
+
if (!s || (!needles && !outputGated && !traceGated))
|
|
92
114
|
continue; // nothing for regate to re-decide
|
|
93
115
|
if (s.assert?.vitest || s.assert?.post_test) {
|
|
94
116
|
blocked.push(`${s.id}: declares ${s.assert.vitest ? "assert.vitest" : "assert.post_test"}, which needs the workspace — ` +
|
|
@@ -102,6 +124,10 @@ export async function regateRun(opts) {
|
|
|
102
124
|
blocked.push(`${s.id}: staged-diff artifacts are incomplete for ${expectedReps} recorded rep(s) — regate refuses a partial repetition set`);
|
|
103
125
|
continue;
|
|
104
126
|
}
|
|
127
|
+
if (outputGated && !complete(findTranscriptFiles(opts.runDir, s.id, mode), expectedSuffixes.map((rep) => transcriptPath(opts.runDir, s.id, mode, rep)))) {
|
|
128
|
+
blocked.push(`${s.id}: transcript artifacts are incomplete for ${expectedReps} recorded rep(s) — regate refuses a partial repetition set`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
105
131
|
// A trace gate is only re-decidable from a saved trace. A run recorded before
|
|
106
132
|
// traces existed has none, and saying so is the whole point — pretending
|
|
107
133
|
// regate can answer would report a verdict derived from no evidence.
|
|
@@ -114,7 +140,7 @@ export async function regateRun(opts) {
|
|
|
114
140
|
}
|
|
115
141
|
if (targets.length === 0) {
|
|
116
142
|
throw new Error(`nothing to regate in ${opts.runDir}` +
|
|
117
|
-
(blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares
|
|
143
|
+
(blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff/output assertions or assert.trace"));
|
|
118
144
|
}
|
|
119
145
|
const changes = [];
|
|
120
146
|
let judgeCalls = 0;
|
|
@@ -125,11 +151,11 @@ export async function regateRun(opts) {
|
|
|
125
151
|
scenarios.push(rec); // untouched: not regatable, or no gates
|
|
126
152
|
continue;
|
|
127
153
|
}
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
154
|
+
const expectedReps = rec.reps ?? 1;
|
|
155
|
+
const repKeys = (expectedReps === 1 ? [undefined] : Array.from({ length: expectedReps }, (_, index) => index)).map((rep) => ({
|
|
156
|
+
rep,
|
|
157
|
+
diffFile: hasNeedleGates(scenario) ? basename(diffPath(opts.runDir, scenario.id, mode, rep)) : undefined,
|
|
158
|
+
}));
|
|
133
159
|
const outcomes = [];
|
|
134
160
|
// Per scenario, not run-wide: with several regated scenarios, a global counter
|
|
135
161
|
// would report every change as "re-judged" because some other scenario was.
|
|
@@ -139,9 +165,24 @@ export async function regateRun(opts) {
|
|
|
139
165
|
for (const { rep, diffFile } of repKeys) {
|
|
140
166
|
const diff = diffFile ? readFileSync(join(opts.runDir, diffFile), "utf8") : "";
|
|
141
167
|
const needleGate = diffFile ? evaluateNeedleGates(scenario, diff) : { lines: [], failure: null };
|
|
142
|
-
|
|
168
|
+
const tPath = transcriptPath(opts.runDir, scenario.id, mode, rep);
|
|
169
|
+
const before = existsSync(tPath) ? readFileSync(tPath, "utf8") : "";
|
|
170
|
+
const outputGate = evaluateOutputGates(scenario, before);
|
|
171
|
+
// Output and trace gates are re-decided from saved artifacts. Free: no model, no judge.
|
|
143
172
|
let traceFailure = null;
|
|
144
|
-
|
|
173
|
+
const expectedOutputHash = rec.objective?.rep_output_sha256?.[rep ?? 0]
|
|
174
|
+
?? (rec.reps === undefined ? rec.objective?.output_sha256 : undefined);
|
|
175
|
+
const outputDigestMismatch = expectedOutputHash !== undefined && expectedOutputHash !== outputGate.outputSha256;
|
|
176
|
+
const outputIntegrityAssertion = outputDigestMismatch
|
|
177
|
+
? [{ kind: "output_evidence", status: "ERROR", detail: "saved final assistant message no longer matches the hash recorded by the run" }]
|
|
178
|
+
: [];
|
|
179
|
+
let objective = hasOutputGates(scenario)
|
|
180
|
+
? {
|
|
181
|
+
status: outputDigestMismatch ? "ERROR" : outputGate.status,
|
|
182
|
+
...(outputGate.outputSha256 ? { output_sha256: outputGate.outputSha256 } : {}),
|
|
183
|
+
assertions: [...outputIntegrityAssertion, ...outputGate.assertions],
|
|
184
|
+
}
|
|
185
|
+
: undefined;
|
|
145
186
|
if (scenario.traceAssert) {
|
|
146
187
|
const tp = tracePath(opts.runDir, scenario.id, mode, rep);
|
|
147
188
|
// A PARTIAL read is refused, not graded. `deserializeTrace` returns null
|
|
@@ -163,7 +204,7 @@ export async function regateRun(opts) {
|
|
|
163
204
|
usable.length === lines.length
|
|
164
205
|
? "objective: saved trace is missing or unreadable — cannot re-evaluate assert.trace"
|
|
165
206
|
: `objective: saved trace is incomplete (${usable.length}/${lines.length} turns readable) — cannot re-evaluate assert.trace`;
|
|
166
|
-
objective = { status: "ERROR", assertions: [] };
|
|
207
|
+
objective = { ...(objective ?? {}), status: "ERROR", assertions: objective?.assertions ?? [] };
|
|
167
208
|
}
|
|
168
209
|
else {
|
|
169
210
|
const g = evaluateTraceGates(scenario.traceAssert, merged);
|
|
@@ -172,7 +213,17 @@ export async function regateRun(opts) {
|
|
|
172
213
|
const integrityAssertion = digestMismatch
|
|
173
214
|
? [{ kind: "trace_evidence", status: "ERROR", detail: "saved trace no longer matches the hash recorded by the run" }]
|
|
174
215
|
: [];
|
|
175
|
-
|
|
216
|
+
const priorStatus = objective?.status ?? "PASS";
|
|
217
|
+
const status = digestMismatch || g.status === "ERROR" || priorStatus === "ERROR"
|
|
218
|
+
? "ERROR"
|
|
219
|
+
: g.status === "FAIL" || priorStatus === "FAIL"
|
|
220
|
+
? "FAIL"
|
|
221
|
+
: "PASS";
|
|
222
|
+
objective = {
|
|
223
|
+
...(objective ?? { assertions: [] }), status,
|
|
224
|
+
trace_version: merged.trace_version, trace_sha256: merged.trace_sha256,
|
|
225
|
+
assertions: [...(objective?.assertions ?? []), ...integrityAssertion, ...g.assertions],
|
|
226
|
+
};
|
|
176
227
|
if (g.status === "FAIL" || g.status === "ERROR" || digestMismatch) {
|
|
177
228
|
const bad = [...integrityAssertion.map((x) => x.detail), ...g.assertions.filter((x) => x.status === g.status && g.status !== "PASS").map((x) => x.detail)];
|
|
178
229
|
traceFailure = `objective: ${bad.join("; ")}`;
|
|
@@ -194,16 +245,29 @@ export async function regateRun(opts) {
|
|
|
194
245
|
if (delivery.status !== "PASS")
|
|
195
246
|
traceFailure = `objective: ${delivery.detail}`;
|
|
196
247
|
}
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
248
|
+
const outputFailure = outputDigestMismatch
|
|
249
|
+
? "objective: saved final assistant message no longer matches the hash recorded by the run"
|
|
250
|
+
: outputGate.status === "ERROR" || outputGate.status === "FAIL" ? outputGate.failure : null;
|
|
251
|
+
const gate = { lines: [...needleGate.lines, ...outputGate.lines], failure: needleGate.failure ?? outputFailure ?? traceFailure };
|
|
200
252
|
// Two sources, because the two gate kinds record their prior state
|
|
201
253
|
// differently: a seeded needle gate leaves a trailer in the transcript, a
|
|
202
254
|
// trace gate leaves an `objective` block on the result. Reading only the
|
|
203
255
|
// trailer meant a trace gate flipping to PASS never triggered the re-judge
|
|
204
256
|
// it needs, leaving a stale FAIL verdict beside a PASS objective.
|
|
205
|
-
const
|
|
206
|
-
|
|
257
|
+
const perRepObjective = rec.rep_judgments?.find(panel => panel.repetition === (rep ?? 0))?.objective;
|
|
258
|
+
// Modern --reps records carry objective evidence per repetition. Falling
|
|
259
|
+
// back to the strict scenario aggregate there would label every rep failed
|
|
260
|
+
// when only one failed, and unnecessarily re-judge already-passing reps.
|
|
261
|
+
// Legacy records without panels have only the aggregate, so retain that
|
|
262
|
+
// conservative fallback for compatibility.
|
|
263
|
+
const oldObjective = perRepObjective ?? (rec.rep_judgments?.length ? undefined : rec.objective);
|
|
264
|
+
const oldObjectiveFailed = oldObjective?.status === "FAIL" || oldObjective?.status === "ERROR" || oldObjective?.status === "NOT-MEASURED";
|
|
265
|
+
const oldDiffAt = lastLineIndexBefore(before, DIFF_HEADER_LINE_RE);
|
|
266
|
+
const oldTrailerAt = lastLineIndexBefore(before, TRAILER_LINE_RE, oldDiffAt === -1 ? before.length : oldDiffAt);
|
|
267
|
+
const oldGateTrailer = oldTrailerAt === -1
|
|
268
|
+
? ""
|
|
269
|
+
: before.slice(oldTrailerAt, oldDiffAt === -1 ? before.length : oldDiffAt);
|
|
270
|
+
const oldGateFailed = GATE_FAILED_RE.test(oldGateTrailer) || oldObjectiveFailed;
|
|
207
271
|
// The trailer is regenerated whatever the outcome: leaving a stale
|
|
208
272
|
// `MISSING` note beside a corrected verdict would misinform the next reader
|
|
209
273
|
// (and the next judge, which reads this transcript).
|
|
@@ -226,12 +290,13 @@ export async function regateRun(opts) {
|
|
|
226
290
|
}
|
|
227
291
|
// The gate blocked this rep before, so no judgement of it exists anywhere.
|
|
228
292
|
const transcript = readFileSync(tPath, "utf8");
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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;
|
|
235
300
|
judgedHere++;
|
|
236
301
|
}
|
|
237
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. */
|
|
@@ -72,6 +75,8 @@ export interface RegradeRunOptions {
|
|
|
72
75
|
* cleanly, so spending judge calls on them buys nothing.
|
|
73
76
|
*/
|
|
74
77
|
onlySuspect?: boolean;
|
|
78
|
+
/** Rejudge only retained repetitions containing ERROR criterion votes, with the recorded judge. */
|
|
79
|
+
onlyUnparsed?: boolean;
|
|
75
80
|
}
|
|
76
81
|
/**
|
|
77
82
|
* Re-judge every scenario in a run dir that has a transcript for the run's own
|
package/dist/regrade.js
CHANGED
|
@@ -56,18 +56,18 @@ 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
61
|
appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: g.verdict, reason: g.reason, suspect: g.suspect, ...repField });
|
|
62
62
|
if (g.suspect)
|
|
63
63
|
appendJournal(runDir, { event: "misfire-flag", ts: now(), id: scenario.id, reason: g.reason, ...repField });
|
|
64
64
|
return {
|
|
65
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) },
|
|
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 } : {}) },
|
|
67
67
|
metrics: {
|
|
68
68
|
wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
|
|
69
|
-
judge_calls: 1,
|
|
70
|
-
judge_rejudge_calls: opts.rejudge ? 1 : 0,
|
|
69
|
+
judge_calls: 1 + (g.judgeRetries ?? 0),
|
|
70
|
+
judge_rejudge_calls: opts.rejudge ? 1 + (g.judgeRetries ?? 0) : 0,
|
|
71
71
|
},
|
|
72
72
|
};
|
|
73
73
|
}
|
|
@@ -81,18 +81,30 @@ export async function judgeOneRep(opts) {
|
|
|
81
81
|
export async function regradeScenario(opts) {
|
|
82
82
|
const now = opts.now ?? (() => new Date().toISOString());
|
|
83
83
|
const mode = opts.mode ?? "green";
|
|
84
|
-
|
|
84
|
+
let files = findTranscriptFiles(opts.runDir, opts.scenario.id, mode);
|
|
85
|
+
if (opts.onlyUnparsed)
|
|
86
|
+
files = files.filter(file => hasUnparsedVotes(opts.prior?.rep_judgments?.find(panel => panel.repetition === (repIndexOf(file) ?? 0))));
|
|
85
87
|
if (files.length === 0)
|
|
86
88
|
throw new Error(`no ${mode} transcripts for ${opts.scenario.id} in ${opts.runDir}`);
|
|
87
89
|
const expected = opts.expectedReps ?? files.length;
|
|
88
|
-
const expectedIndices =
|
|
90
|
+
const expectedIndices = opts.onlyUnparsed
|
|
91
|
+
? opts.prior.rep_judgments.filter(hasUnparsedVotes).map(panel => expected === 1 ? null : panel.repetition).sort((a, b) => (a ?? -1) - (b ?? -1))
|
|
92
|
+
: expected === 1 ? [null] : Array.from({ length: expected }, (_, index) => index);
|
|
89
93
|
const actualIndices = files.map((file) => repIndexOf(file)).sort((a, b) => (a ?? -1) - (b ?? -1));
|
|
90
|
-
if (files.length !==
|
|
94
|
+
if (files.length !== expectedIndices.length || JSON.stringify(actualIndices) !== JSON.stringify(expectedIndices)) {
|
|
91
95
|
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
96
|
}
|
|
93
97
|
const repCount = expected;
|
|
94
98
|
const outcomes = [];
|
|
95
|
-
for (
|
|
99
|
+
for (let repetition = 0; repetition < expected; repetition++) {
|
|
100
|
+
const panel = opts.prior?.rep_judgments?.find(panel => panel.repetition === repetition);
|
|
101
|
+
if (opts.onlyUnparsed && !hasUnparsedVotes(panel)) {
|
|
102
|
+
if (!panel)
|
|
103
|
+
throw new Error(`missing retained judgments for ${opts.scenario.id}#${repetition}`);
|
|
104
|
+
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 });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const file = files.find(file => (repIndexOf(file) ?? 0) === repetition);
|
|
96
108
|
const rep = repIndexOf(file) ?? undefined;
|
|
97
109
|
const transcript = readFileSync(join(opts.runDir, file), "utf8");
|
|
98
110
|
outcomes.push(await judgeOneRep({
|
|
@@ -100,7 +112,16 @@ export async function regradeScenario(opts) {
|
|
|
100
112
|
adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode, rep, now, rejudge: true,
|
|
101
113
|
}));
|
|
102
114
|
}
|
|
103
|
-
|
|
115
|
+
const result = outcomesToResult(opts.scenario.id, outcomes, repCount, opts.threshold);
|
|
116
|
+
if (opts.onlyUnparsed)
|
|
117
|
+
result.rep_judgments = result.rep_judgments?.map(panel => {
|
|
118
|
+
const prior = opts.prior.rep_judgments.find(prior => prior.repetition === panel.repetition);
|
|
119
|
+
return hasUnparsedVotes(prior) ? panel : prior;
|
|
120
|
+
});
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
function hasUnparsedVotes(panel) {
|
|
124
|
+
return panel?.judgments.some(judgment => judgment.criteria?.some(vote => vote.verdict === "ERROR")) ?? false;
|
|
104
125
|
}
|
|
105
126
|
/**
|
|
106
127
|
* Re-judge every scenario in a run dir that has a transcript for the run's own
|
|
@@ -114,17 +135,21 @@ export async function regradeScenario(opts) {
|
|
|
114
135
|
* ResultsFile. Shared by `cmdGrade` and the pi-extension's `judge` command.
|
|
115
136
|
*/
|
|
116
137
|
export async function regradeRun(opts) {
|
|
117
|
-
const { runDir, spec, adapter,
|
|
138
|
+
const { runDir, spec, adapter, specDir } = opts;
|
|
118
139
|
const now = opts.now ?? (() => new Date().toISOString());
|
|
119
140
|
const prev = existsSync(join(runDir, "results.yaml")) ? readResults(runDir) : null;
|
|
141
|
+
if (opts.onlyUnparsed && !prev)
|
|
142
|
+
throw new Error(`--unparsed-only needs a prior results.yaml in ${runDir}`);
|
|
143
|
+
if (opts.onlyUnparsed && opts.onlySuspect)
|
|
144
|
+
throw new Error("--unparsed-only cannot be combined with --suspect-only");
|
|
145
|
+
const judge = opts.onlyUnparsed ? prev.judge : opts.judge;
|
|
120
146
|
// Carried across a re-judge, per field, because the right answer differs:
|
|
121
147
|
// - `override`/`note` — the author's, never the judge's to discard.
|
|
122
148
|
// - `objective` — a re-judge does NOT re-evaluate trace gates (that is
|
|
123
149
|
// `regate`), so the recorded evidence still describes this run. Dropping it
|
|
124
150
|
// silently downgraded a gated scenario to "no assertions declared".
|
|
125
|
-
// `adjudication` is
|
|
126
|
-
//
|
|
127
|
-
// than none.
|
|
151
|
+
// `adjudication` is dropped when its repetition is rejudged. A selective
|
|
152
|
+
// repair of a different repetition carries that untouched evidence instead.
|
|
128
153
|
// The whole prior result per id — `rebuildScenarioResult` decides, field by
|
|
129
154
|
// field, what survives. Passing a hand-picked subset here is how fields got
|
|
130
155
|
// dropped before.
|
|
@@ -148,6 +173,8 @@ export async function regradeRun(opts) {
|
|
|
148
173
|
.filter((s) => s.suspect || s.judge_verdict === "JUDGE-AMBIGUOUS")
|
|
149
174
|
.map((s) => s.id);
|
|
150
175
|
}
|
|
176
|
+
if (opts.onlyUnparsed)
|
|
177
|
+
targets = recorded.filter(s => s.rep_judgments?.some(hasUnparsedVotes)).map(s => s.id);
|
|
151
178
|
// Schema-3 delivery is a prerequisite for behavioral judging. A later `grade`
|
|
152
179
|
// cannot turn an undelivered or unauthenticated transcript into product evidence.
|
|
153
180
|
if (prev?.schema === 3) {
|
|
@@ -161,6 +188,9 @@ export async function regradeRun(opts) {
|
|
|
161
188
|
const completeTranscripts = (record) => {
|
|
162
189
|
const expected = record.reps ?? 1;
|
|
163
190
|
const files = findTranscriptFiles(runDir, record.id, mode);
|
|
191
|
+
if (opts.onlyUnparsed) {
|
|
192
|
+
return record.rep_judgments?.length === expected && record.rep_judgments.every(panel => !hasUnparsedVotes(panel) || files.some(file => (repIndexOf(file) ?? 0) === panel.repetition)) || false;
|
|
193
|
+
}
|
|
164
194
|
const expectedIndices = expected === 1 ? [null] : Array.from({ length: expected }, (_, index) => index);
|
|
165
195
|
const actualIndices = files.map((file) => repIndexOf(file)).sort((a, b) => (a ?? -1) - (b ?? -1));
|
|
166
196
|
return files.length === expected && JSON.stringify(actualIndices) === JSON.stringify(expectedIndices);
|
|
@@ -173,6 +203,14 @@ export async function regradeRun(opts) {
|
|
|
173
203
|
if (missing.length > 0) {
|
|
174
204
|
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
205
|
}
|
|
206
|
+
if (opts.onlyUnparsed) {
|
|
207
|
+
const drifted = targets.filter(id => {
|
|
208
|
+
const count = recordedById.get(id).criterion_count;
|
|
209
|
+
return count !== undefined && count !== specById.get(id).checklist.length;
|
|
210
|
+
});
|
|
211
|
+
if (drifted.length)
|
|
212
|
+
throw new Error(`criterion count changed for ${drifted.join(", ")} — use full grade instead of --unparsed-only`);
|
|
213
|
+
}
|
|
176
214
|
const targetSet = new Set(targets);
|
|
177
215
|
const scenarioResults = [];
|
|
178
216
|
for (const rec of recorded) {
|
|
@@ -186,17 +224,17 @@ export async function regradeRun(opts) {
|
|
|
186
224
|
const threshold = effectiveThreshold(prevScenario, scenario);
|
|
187
225
|
const rr = await regradeScenario({
|
|
188
226
|
runDir, spec, scenario, adapter, judge, specDir, threshold, mode, now,
|
|
189
|
-
expectedReps: prevScenario?.reps ?? 1,
|
|
227
|
+
expectedReps: prevScenario?.reps ?? 1, onlyUnparsed: opts.onlyUnparsed, prior: prevScenario,
|
|
190
228
|
});
|
|
191
229
|
const carry = overrides.get(id);
|
|
192
230
|
if (prev?.schema === 3)
|
|
193
231
|
rr.criterion_count = scenario.checklist.length;
|
|
194
232
|
rr.metrics = mergeScenarioMetrics(carry?.metrics, rr.metrics);
|
|
195
233
|
rr.rep_judgments = carryRepObjectives(rr.rep_judgments, carry?.rep_judgments);
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
scenarioResults.push(rebuildScenarioResult(rr, carry, { objective: "carry", adjudication: "drop" }));
|
|
234
|
+
// Gates still describe the original subject execution. An adjudication of
|
|
235
|
+
// an untouched repetition also survives, bounded by rebuild's conflict guards.
|
|
236
|
+
const carryAdjudication = opts.onlyUnparsed && carry?.adjudication && !hasUnparsedVotes(carry.rep_judgments?.find(panel => panel.repetition === (carry.adjudication.repetition ?? 0)));
|
|
237
|
+
scenarioResults.push(rebuildScenarioResult(rr, carry, { objective: "carry", adjudication: carryAdjudication ? "carry" : "drop" }));
|
|
200
238
|
}
|
|
201
239
|
const ctx = scoreContextFor({ mode, partial: prev?.partial }, spec);
|
|
202
240
|
const results = writeResults(runDir, {
|
|
@@ -229,7 +267,7 @@ export async function regradeRun(opts) {
|
|
|
229
267
|
// say so. Doctrine narrowed 0.4.0, from "recorded hashes stay" to "recorded
|
|
230
268
|
// *stimulus* hashes stay" — see refreshRubricHashes.
|
|
231
269
|
partial: prev?.partial,
|
|
232
|
-
source_hashes: refreshRubricHashes(prev?.source_hashes, spec, targets),
|
|
270
|
+
source_hashes: opts.onlyUnparsed ? prev?.source_hashes : refreshRubricHashes(prev?.source_hashes, spec, targets),
|
|
233
271
|
source_hash_roots: prev?.source_hash_roots,
|
|
234
272
|
scenarios: scenarioResults,
|
|
235
273
|
}, ctx);
|
package/dist/reps.js
CHANGED
|
@@ -16,9 +16,11 @@ export function aggregateObjective(outcomes) {
|
|
|
16
16
|
if (present.length === 1)
|
|
17
17
|
return picked;
|
|
18
18
|
const traceHashes = present.map((objective) => objective.trace_sha256);
|
|
19
|
+
const outputHashes = present.map((objective) => objective.output_sha256);
|
|
19
20
|
return {
|
|
20
21
|
...picked,
|
|
21
22
|
...(traceHashes.every((hash) => typeof hash === "string") ? { rep_trace_sha256: traceHashes } : {}),
|
|
23
|
+
...(outputHashes.every((hash) => typeof hash === "string") ? { rep_output_sha256: outputHashes } : {}),
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
26
|
/**
|
package/dist/results.d.ts
CHANGED
|
@@ -99,6 +99,8 @@ export interface Judgment {
|
|
|
99
99
|
suspect: boolean;
|
|
100
100
|
/** Every numbered rubric-item vote parsed from this exact judge response. Required in schema v3. */
|
|
101
101
|
criteria?: CriterionVote[];
|
|
102
|
+
/** One retry with identical judge input after unparseable criterion votes. */
|
|
103
|
+
judgeRetries?: 1;
|
|
102
104
|
}
|
|
103
105
|
export interface AdjudicationResult {
|
|
104
106
|
/** The saved transcript repetition judged by every panel member (first rep by policy). */
|
|
@@ -117,8 +119,11 @@ export interface ObjectiveResult {
|
|
|
117
119
|
status: "PASS" | "FAIL" | "ERROR" | "NOT-MEASURED";
|
|
118
120
|
trace_version?: number;
|
|
119
121
|
trace_sha256?: string;
|
|
122
|
+
/** SHA-256 of the final assistant message evaluated by output regex gates. */
|
|
123
|
+
output_sha256?: string;
|
|
120
124
|
/** Per-repetition hashes retained when an aggregate has more than one rep. */
|
|
121
125
|
rep_trace_sha256?: string[];
|
|
126
|
+
rep_output_sha256?: string[];
|
|
122
127
|
assertions: {
|
|
123
128
|
kind: string;
|
|
124
129
|
status: "PASS" | "FAIL" | "ERROR" | "NOT-MEASURED";
|
package/dist/results.js
CHANGED
|
@@ -613,7 +613,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
|
|
|
613
613
|
const objective = pick(policy.objective, freshObjective, prior?.objective);
|
|
614
614
|
const pickedAdjudication = pick(policy.adjudication, freshAdjudication, prior?.adjudication);
|
|
615
615
|
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")));
|
|
616
|
+
(pickedAdjudication.verdict === "PASS" && pass_threshold === 1 && (reps ?? 1) > 1 && judge_verdict !== "PASS")));
|
|
617
617
|
// A settled cell-level panel cannot overrule newly evaluated objective gates,
|
|
618
618
|
// nor can one transcript erase a failed repetition under an all-clean policy.
|
|
619
619
|
const adjudication = conflictsWithFreshEvidence && pickedAdjudication
|
package/dist/run.js
CHANGED
|
@@ -6,7 +6,7 @@ import { NONE_ARM, seedArmDefinitions } from "./arms.js";
|
|
|
6
6
|
import { runDirFor, transcriptPath, diffPath, tracePath, writeResults, ensureResultsGitignore, scoreContextFor, effectiveThreshold, isScoredMode, } from "./results.js";
|
|
7
7
|
import { appendJournal } from "./journal.js";
|
|
8
8
|
import { liftHeadline } from "./lift.js";
|
|
9
|
-
import { runSeeded } from "./seeded.js";
|
|
9
|
+
import { evaluateOutputGates, hasOutputGates, runSeeded } from "./seeded.js";
|
|
10
10
|
import { serializeTrace, mergeTraces, traceSha256 } from "./execution-trace.js";
|
|
11
11
|
import { evaluateTraceGates } from "./trace-gates.js";
|
|
12
12
|
import { snapshotPaths, diffSnapshots, createWorkspace } from "./workspace.js";
|
|
@@ -194,11 +194,11 @@ export async function runSkillModel(opts) {
|
|
|
194
194
|
* ends the last assistant section.
|
|
195
195
|
*/
|
|
196
196
|
export function hasEmptyAssistantTurn(transcript) {
|
|
197
|
-
const sections = transcript.split(/^<<< ASSISTANT
|
|
197
|
+
const sections = transcript.split(/^<<< ASSISTANT:[ \t]*$/m).slice(1);
|
|
198
198
|
if (sections.length === 0)
|
|
199
199
|
return false;
|
|
200
200
|
return sections.some((sec) => {
|
|
201
|
-
const body = sec.split(/^(?:>>>
|
|
201
|
+
const body = sec.split(/^(?:>>> USER(?: \(turn \d+\/\d+\))?:[ \t]*|=== SEEDED GATES ===[ \t]*|\[pi exited [^\]]+\][ \t]*)$/m)[0];
|
|
202
202
|
return body.trim() === "";
|
|
203
203
|
});
|
|
204
204
|
}
|
|
@@ -396,6 +396,9 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
396
396
|
}
|
|
397
397
|
}
|
|
398
398
|
const repSuffix = repCount > 1 ? rep : undefined;
|
|
399
|
+
const outputGate = !adapterFailure && !noResponse && !infrastructureFailure
|
|
400
|
+
? evaluateOutputGates(scenario, transcript)
|
|
401
|
+
: { status: "PASS", failure: null, assertions: [], lines: [] };
|
|
399
402
|
writeFileSync(transcriptPath(runDir, scenario.id, mode, repSuffix), transcript, "utf8");
|
|
400
403
|
if (scenario.mode === "seeded") {
|
|
401
404
|
// The workspace is torn down in the `finally` below, so this is the only
|
|
@@ -440,10 +443,11 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
440
443
|
// timeout that caused it, and it is that text, not the real reason, that would
|
|
441
444
|
// reach the results record. The verdict is ERROR either way.
|
|
442
445
|
let objective;
|
|
443
|
-
if (scenario.traceAssert && !adapterFailure) {
|
|
444
|
-
const assertionResults = [];
|
|
445
|
-
let status =
|
|
446
|
+
if ((scenario.traceAssert || hasOutputGates(scenario)) && !adapterFailure && !noResponse && !infrastructureFailure) {
|
|
447
|
+
const assertionResults = [...outputGate.assertions];
|
|
448
|
+
let status = outputGate.status;
|
|
446
449
|
let traceMeta = {};
|
|
450
|
+
const outputMeta = outputGate.outputSha256 ? { output_sha256: outputGate.outputSha256 } : {};
|
|
447
451
|
if (scenario.traceAssert) {
|
|
448
452
|
if (traces.length > 0) {
|
|
449
453
|
writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
|
|
@@ -459,12 +463,15 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
459
463
|
}
|
|
460
464
|
else {
|
|
461
465
|
const gate = evaluateTraceGates(scenario.traceAssert, merged);
|
|
462
|
-
|
|
466
|
+
if (gate.status === "ERROR" || status === "ERROR")
|
|
467
|
+
status = "ERROR";
|
|
468
|
+
else if (gate.status === "FAIL" || status === "FAIL")
|
|
469
|
+
status = "FAIL";
|
|
463
470
|
assertionResults.push(...gate.assertions);
|
|
464
471
|
traceMeta = { trace_version: merged.trace_version, trace_sha256: merged.trace_sha256 };
|
|
465
472
|
}
|
|
466
473
|
}
|
|
467
|
-
objective = { status, ...traceMeta, assertions: assertionResults };
|
|
474
|
+
objective = { status, ...traceMeta, ...outputMeta, assertions: assertionResults };
|
|
468
475
|
if (status !== "PASS") {
|
|
469
476
|
const details = assertionResults.filter((result) => result.status === status).map((result) => result.detail);
|
|
470
477
|
gatePrefix = `objective: ${details.join("; ") || "structured evidence could not be evaluated"}`;
|
|
@@ -514,7 +521,7 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
514
521
|
reason = o.reason;
|
|
515
522
|
suspect = o.suspect;
|
|
516
523
|
judgment = o.judgment; // judgeOneRep already journaled (verdict + misfire)
|
|
517
|
-
judgeCalls =
|
|
524
|
+
judgeCalls = o.metrics?.judge_calls ?? 0;
|
|
518
525
|
}
|
|
519
526
|
log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
|
|
520
527
|
const subject = mergeTraces(traces)?.metrics;
|
package/dist/seeded.d.ts
CHANGED
|
@@ -82,6 +82,28 @@ export interface SeededOutcome {
|
|
|
82
82
|
export declare function changedLines(diff: string): string;
|
|
83
83
|
/** Whether a scenario declares any needle gate at all. */
|
|
84
84
|
export declare function hasNeedleGates(scenario: Scenario): boolean;
|
|
85
|
+
/** Whether a scenario declares any final-assistant-message gate. */
|
|
86
|
+
export declare function hasOutputGates(scenario: Scenario): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* The final assistant turn exactly as the transcript artifact records its text.
|
|
89
|
+
*
|
|
90
|
+
* Delimiter-shaped model output is ambiguous in this plain-text artifact. Refuse
|
|
91
|
+
* it instead of truncating the output and turning a forbidden suffix into PASS.
|
|
92
|
+
*/
|
|
93
|
+
export declare function finalAssistantMessage(transcript: string, scenario: Scenario): string | null;
|
|
94
|
+
export interface OutputGateResult {
|
|
95
|
+
status: "PASS" | "FAIL" | "ERROR";
|
|
96
|
+
failure: string | null;
|
|
97
|
+
outputSha256?: string;
|
|
98
|
+
assertions: {
|
|
99
|
+
kind: "output_matches" | "output_excludes" | "output_evidence";
|
|
100
|
+
status: "PASS" | "FAIL" | "ERROR";
|
|
101
|
+
detail: string;
|
|
102
|
+
}[];
|
|
103
|
+
lines: string[];
|
|
104
|
+
}
|
|
105
|
+
/** Evaluate final-message regex gates without exposing the matched text. */
|
|
106
|
+
export declare function evaluateOutputGates(scenario: Scenario, transcript: string): OutputGateResult;
|
|
85
107
|
/**
|
|
86
108
|
* Evaluate `diff_contains` / `diff_excludes` against a staged diff: the trailer lines
|
|
87
109
|
* to report, and the first failure (null when every needle is satisfied).
|
package/dist/seeded.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { copyFileSync, statSync } from "node:fs";
|
|
2
3
|
import { extname, isAbsolute, join, resolve } from "node:path";
|
|
3
4
|
import { exec } from "./util/exec.js";
|
|
@@ -60,6 +61,74 @@ export function changedLines(diff) {
|
|
|
60
61
|
export function hasNeedleGates(scenario) {
|
|
61
62
|
return (scenario.assert?.diff_contains?.length ?? 0) > 0 || (scenario.assert?.diff_excludes?.length ?? 0) > 0;
|
|
62
63
|
}
|
|
64
|
+
/** Whether a scenario declares any final-assistant-message gate. */
|
|
65
|
+
export function hasOutputGates(scenario) {
|
|
66
|
+
return (scenario.assert?.output_matches?.length ?? 0) > 0 || (scenario.assert?.output_excludes?.length ?? 0) > 0;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The final assistant turn exactly as the transcript artifact records its text.
|
|
70
|
+
*
|
|
71
|
+
* Delimiter-shaped model output is ambiguous in this plain-text artifact. Refuse
|
|
72
|
+
* it instead of truncating the output and turning a forbidden suffix into PASS.
|
|
73
|
+
*/
|
|
74
|
+
export function finalAssistantMessage(transcript, scenario) {
|
|
75
|
+
const assistantMarker = /^<<< ASSISTANT:[ \t]*$/gm;
|
|
76
|
+
const userMarker = /^>>> USER(?: \(turn \d+\/\d+\))?:[ \t]*$/gm;
|
|
77
|
+
const gateMarker = /^=== SEEDED GATES ===[ \t]*$/gm;
|
|
78
|
+
const exitMarker = /^\[pi exited [^\]]+\][ \t]*$/gm;
|
|
79
|
+
const assistantCount = [...transcript.matchAll(assistantMarker)].length;
|
|
80
|
+
const userCount = [...transcript.matchAll(userMarker)].length;
|
|
81
|
+
const gateCount = [...transcript.matchAll(gateMarker)].length;
|
|
82
|
+
const exitCount = [...transcript.matchAll(exitMarker)].length;
|
|
83
|
+
const expectedGates = scenario.mode === "seeded" ? 1 : 0;
|
|
84
|
+
if (assistantCount !== scenario.turns.length || userCount !== scenario.turns.length || gateCount !== expectedGates || exitCount !== 0) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const sections = transcript.split(/^<<< ASSISTANT:[ \t]*$/m);
|
|
88
|
+
return sections[sections.length - 1]
|
|
89
|
+
.split(/^(?:>>> USER(?: \(turn \d+\/\d+\))?:[ \t]*|=== SEEDED GATES ===[ \t]*|\[pi exited [^\]]+\][ \t]*)$/m)[0]
|
|
90
|
+
.trim();
|
|
91
|
+
}
|
|
92
|
+
/** Evaluate final-message regex gates without exposing the matched text. */
|
|
93
|
+
export function evaluateOutputGates(scenario, transcript) {
|
|
94
|
+
if (!hasOutputGates(scenario))
|
|
95
|
+
return { status: "PASS", failure: null, assertions: [], lines: [] };
|
|
96
|
+
const output = finalAssistantMessage(transcript, scenario);
|
|
97
|
+
if (output === null) {
|
|
98
|
+
const detail = "final assistant message is missing or transcript delimiters are ambiguous";
|
|
99
|
+
return {
|
|
100
|
+
status: "ERROR", failure: `output evidence: ${detail}`,
|
|
101
|
+
assertions: [{ kind: "output_evidence", status: "ERROR", detail }],
|
|
102
|
+
lines: [` output: ERROR (${detail})`],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const assertions = [];
|
|
106
|
+
const lines = [];
|
|
107
|
+
let failure = null;
|
|
108
|
+
for (const pattern of scenario.assert?.output_matches ?? []) {
|
|
109
|
+
const ok = new RegExp(pattern, "m").test(output);
|
|
110
|
+
const detail = ok ? `final assistant message matches ${JSON.stringify(pattern)}` : `final assistant message does not match ${JSON.stringify(pattern)}`;
|
|
111
|
+
assertions.push({ kind: "output_matches", status: ok ? "PASS" : "FAIL", detail });
|
|
112
|
+
lines.push(` output_matches ${JSON.stringify(pattern)}: ${ok ? "MATCH" : "MISSING"}`);
|
|
113
|
+
if (!ok && !failure)
|
|
114
|
+
failure = `output_matches ${JSON.stringify(pattern)} did not match the final assistant message`;
|
|
115
|
+
}
|
|
116
|
+
for (const pattern of scenario.assert?.output_excludes ?? []) {
|
|
117
|
+
const ok = !new RegExp(pattern, "m").test(output);
|
|
118
|
+
const detail = ok ? `final assistant message excludes ${JSON.stringify(pattern)}` : `final assistant message matches forbidden regex ${JSON.stringify(pattern)}`;
|
|
119
|
+
assertions.push({ kind: "output_excludes", status: ok ? "PASS" : "FAIL", detail });
|
|
120
|
+
lines.push(` output_excludes ${JSON.stringify(pattern)}: ${ok ? "ABSENT" : "PRESENT"}`);
|
|
121
|
+
if (!ok && !failure)
|
|
122
|
+
failure = `output_excludes ${JSON.stringify(pattern)} matched the final assistant message`;
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
status: failure ? "FAIL" : "PASS",
|
|
126
|
+
failure,
|
|
127
|
+
outputSha256: createHash("sha256").update(output).digest("hex"),
|
|
128
|
+
assertions,
|
|
129
|
+
lines,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
63
132
|
/**
|
|
64
133
|
* Evaluate `diff_contains` / `diff_excludes` against a staged diff: the trailer lines
|
|
65
134
|
* to report, and the first failure (null when every needle is satisfied).
|
package/dist/sources.d.ts
CHANGED
|
@@ -54,7 +54,7 @@ export declare const FIXTURE_PREFIX = "fixture:";
|
|
|
54
54
|
* | `stimulus:<id>` | mode, turns, workspace, remote, agent-file path, fixture path, `assert.vitest`, `post_test` path | the transcripts answer a different question | `run` (model + judge) |
|
|
55
55
|
* | `rubric:<id>` | title, checklist | transcripts fine, verdicts wrong | `grade` (judge only) |
|
|
56
56
|
* | `policy:<id>` | critical, reps, pass_threshold | only the scoring moved | `rescore` (free) |
|
|
57
|
-
* | `gates:<id>` |
|
|
57
|
+
* | `gates:<id>` | diff/output regex gates and `assert.trace` | gate wrong, saved evidence still valid | `regate` (no subject call; judges fail→pass reps) |
|
|
58
58
|
* | `rubric:__persona` | spec-level `judge_persona` | every verdict in the skill | `grade` per model |
|
|
59
59
|
*
|
|
60
60
|
* Why this matters more than it looks: with one key, lint had exactly one remedy for
|
|
@@ -105,7 +105,7 @@ export declare function dirSha256(dir: string): string | null;
|
|
|
105
105
|
export declare function stimulusDigest(s: Scenario): string;
|
|
106
106
|
export declare function rubricDigest(s: Scenario): string;
|
|
107
107
|
export declare function policyDigest(s: Scenario): string;
|
|
108
|
-
/** Null when the scenario declares no
|
|
108
|
+
/** Null when the scenario declares no offline-replayable gates — no key is recorded for it. */
|
|
109
109
|
export declare function gatesDigest(s: Scenario): string | null;
|
|
110
110
|
/** The spec-level judge persona, which is rubric for every scenario at once. */
|
|
111
111
|
export declare function personaDigest(persona: string): string;
|
package/dist/sources.js
CHANGED
|
@@ -58,7 +58,7 @@ export const FIXTURE_PREFIX = "fixture:";
|
|
|
58
58
|
* | `stimulus:<id>` | mode, turns, workspace, remote, agent-file path, fixture path, `assert.vitest`, `post_test` path | the transcripts answer a different question | `run` (model + judge) |
|
|
59
59
|
* | `rubric:<id>` | title, checklist | transcripts fine, verdicts wrong | `grade` (judge only) |
|
|
60
60
|
* | `policy:<id>` | critical, reps, pass_threshold | only the scoring moved | `rescore` (free) |
|
|
61
|
-
* | `gates:<id>` |
|
|
61
|
+
* | `gates:<id>` | diff/output regex gates and `assert.trace` | gate wrong, saved evidence still valid | `regate` (no subject call; judges fail→pass reps) |
|
|
62
62
|
* | `rubric:__persona` | spec-level `judge_persona` | every verdict in the skill | `grade` per model |
|
|
63
63
|
*
|
|
64
64
|
* Why this matters more than it looks: with one key, lint had exactly one remedy for
|
|
@@ -163,7 +163,7 @@ function walk(dir, prefix = "") {
|
|
|
163
163
|
* A scenario's fields, sorted into the four buckets, as canonical JSON.
|
|
164
164
|
*
|
|
165
165
|
* One function so the exhaustive-destructure trick below covers all four digests at
|
|
166
|
-
* once: adding a field to `Scenario` or `
|
|
166
|
+
* once: adding a field to `Scenario` or `ScenarioAssert` fails the build **here** until
|
|
167
167
|
* someone decides which bucket — and therefore which remedy — it belongs to. A field
|
|
168
168
|
* nobody assigned is a permanent staleness blind spot, and a field assigned to the
|
|
169
169
|
* wrong bucket is worse than that: it would tell a user `rescore` is enough when the
|
|
@@ -180,14 +180,14 @@ function facets(s) {
|
|
|
180
180
|
// anywhere would charge a re-run (or at best a re-score) for editing a label,
|
|
181
181
|
// which is the exact trap the facet split was built to remove.
|
|
182
182
|
void _coversIsMetadata;
|
|
183
|
-
const { vitest, diff_contains, diff_excludes, post_test, ...restAssert } = assert ?? {};
|
|
183
|
+
const { vitest, diff_contains, diff_excludes, output_matches, output_excludes, post_test, ...restAssert } = assert ?? {};
|
|
184
184
|
const _assertExhaustive = restAssert;
|
|
185
185
|
void _assertExhaustive;
|
|
186
186
|
// `traceAssert` is a GATE, not stimulus: it is evaluated against a trace the run
|
|
187
187
|
// already saved, so `regate` can re-answer it without re-running the subject model.
|
|
188
188
|
// Note the asymmetry with `env.extensions` in Phase 3, which IS stimulus — one
|
|
189
189
|
// changes what gets executed, the other only what we conclude from it.
|
|
190
|
-
const hasGates = diff_contains !== undefined || diff_excludes !== undefined || traceAssert !== undefined;
|
|
190
|
+
const hasGates = diff_contains !== undefined || diff_excludes !== undefined || output_matches !== undefined || output_excludes !== undefined || traceAssert !== undefined;
|
|
191
191
|
return {
|
|
192
192
|
// `vitest` and the `post_test` PATH are stimulus, not gates: both change what the
|
|
193
193
|
// run executes in the workspace, and neither can be re-evaluated from a saved
|
|
@@ -214,6 +214,8 @@ function facets(s) {
|
|
|
214
214
|
gates: hasGates
|
|
215
215
|
? JSON.stringify([
|
|
216
216
|
id, diff_contains ?? null, diff_excludes ?? null,
|
|
217
|
+
...(output_matches ? [["output_matches", output_matches]] : []),
|
|
218
|
+
...(output_excludes ? [["output_excludes", output_excludes]] : []),
|
|
217
219
|
...(traceAssert ? [traceAssert] : []),
|
|
218
220
|
])
|
|
219
221
|
: null,
|
|
@@ -231,7 +233,7 @@ export function rubricDigest(s) {
|
|
|
231
233
|
export function policyDigest(s) {
|
|
232
234
|
return sha(facets(s).policy);
|
|
233
235
|
}
|
|
234
|
-
/** Null when the scenario declares no
|
|
236
|
+
/** Null when the scenario declares no offline-replayable gates — no key is recorded for it. */
|
|
235
237
|
export function gatesDigest(s) {
|
|
236
238
|
const g = facets(s).gates;
|
|
237
239
|
return g === null ? null : sha(g);
|
package/dist/spec.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { WorkspaceKind } from "./workspace.js";
|
|
2
2
|
import { type TraceAssert } from "./trace-gates.js";
|
|
3
3
|
export type ScenarioMode = "inline" | "seeded";
|
|
4
|
-
export interface
|
|
4
|
+
export interface ScenarioAssert {
|
|
5
5
|
vitest?: boolean;
|
|
6
6
|
diff_contains?: string[];
|
|
7
7
|
/**
|
|
@@ -11,6 +11,10 @@ export interface SeededAssert {
|
|
|
11
11
|
* from the model's prose.
|
|
12
12
|
*/
|
|
13
13
|
diff_excludes?: string[];
|
|
14
|
+
/** Regular expressions that must all match the final assistant message. */
|
|
15
|
+
output_matches?: string[];
|
|
16
|
+
/** Regular expressions that must not match the final assistant message. */
|
|
17
|
+
output_excludes?: string[];
|
|
14
18
|
/**
|
|
15
19
|
* A test file copied into the workspace AFTER the agent finishes, then run.
|
|
16
20
|
* The model never sees it, so it cannot write code shaped to pass it — this
|
|
@@ -28,12 +32,12 @@ export interface Scenario {
|
|
|
28
32
|
turns: string[];
|
|
29
33
|
checklist: string[];
|
|
30
34
|
fixture?: string;
|
|
31
|
-
assert?:
|
|
35
|
+
assert?: ScenarioAssert;
|
|
32
36
|
/**
|
|
33
37
|
* Objective assertions over the execution trace.
|
|
34
38
|
*
|
|
35
|
-
* Deliberately NOT part of `
|
|
36
|
-
*
|
|
39
|
+
* Deliberately NOT part of `ScenarioAssert`: trace assertions have their own
|
|
40
|
+
* closed schema and opt execution into structured capture.
|
|
37
41
|
* Declaring it opts the scenario into structured (`--mode json`) execution.
|
|
38
42
|
*/
|
|
39
43
|
traceAssert?: TraceAssert;
|
package/dist/spec.js
CHANGED
|
@@ -11,6 +11,21 @@ export class SpecError extends Error {
|
|
|
11
11
|
function isStringArray(v) {
|
|
12
12
|
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
13
13
|
}
|
|
14
|
+
function parseRegexList(v, id, field, file) {
|
|
15
|
+
assertStringList(v, id, field, file);
|
|
16
|
+
for (const pattern of v) {
|
|
17
|
+
if (pattern === "") {
|
|
18
|
+
throw new SpecError(`scenario \`${id}\` \`${field}\` patterns must not be empty`, file);
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
new RegExp(pattern, "m");
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
throw new SpecError(`scenario \`${id}\` \`${field}\` contains invalid regular expression ${JSON.stringify(pattern)} — ${e.message}`, file);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return v;
|
|
28
|
+
}
|
|
14
29
|
/**
|
|
15
30
|
* Require a non-empty list of strings, with a targeted error. A common authoring
|
|
16
31
|
* trap: an unquoted "key: value" list item parses as a YAML mapping, not a string
|
|
@@ -176,23 +191,39 @@ export function parseSpec(text, file) {
|
|
|
176
191
|
workspace: "none",
|
|
177
192
|
remote: false,
|
|
178
193
|
};
|
|
179
|
-
//
|
|
180
|
-
|
|
194
|
+
// Output and trace assertions are legal for inline AND seeded scenarios.
|
|
195
|
+
if (s.assert !== undefined && (s.assert === null || typeof s.assert !== "object" || Array.isArray(s.assert))) {
|
|
196
|
+
throw new SpecError(`scenario \`${id}\` \`assert\` must be a mapping`, file);
|
|
197
|
+
}
|
|
181
198
|
const rawAssert = s.assert;
|
|
182
199
|
if (rawAssert?.trajectory !== undefined) {
|
|
183
200
|
throw new SpecError(`scenario \`${id}\` uses removed \`assert.trajectory\`; delete it or replace it with an active objective gate`, file);
|
|
184
201
|
}
|
|
202
|
+
const allowedAssertKeys = new Set([
|
|
203
|
+
"vitest", "diff_contains", "diff_excludes", "post_test", "trace", "output_matches", "output_excludes",
|
|
204
|
+
]);
|
|
205
|
+
for (const key of Object.keys(rawAssert ?? {})) {
|
|
206
|
+
if (!allowedAssertKeys.has(key)) {
|
|
207
|
+
throw new SpecError(`scenario \`${id}\` has unknown \`assert\` key \`${key}\``, file);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
185
210
|
if (rawAssert?.trace !== undefined) {
|
|
186
211
|
scenario.traceAssert = parseTraceAssert(rawAssert.trace, `${file}: scenario \`${id}\``);
|
|
187
212
|
}
|
|
213
|
+
const assertObj = {};
|
|
214
|
+
if (rawAssert?.output_matches !== undefined) {
|
|
215
|
+
assertObj.output_matches = parseRegexList(rawAssert.output_matches, id, "assert.output_matches", file);
|
|
216
|
+
}
|
|
217
|
+
if (rawAssert?.output_excludes !== undefined) {
|
|
218
|
+
assertObj.output_excludes = parseRegexList(rawAssert.output_excludes, id, "assert.output_excludes", file);
|
|
219
|
+
}
|
|
188
220
|
if (mode === "seeded") {
|
|
189
221
|
if (typeof s.fixture !== "string" || s.fixture.length === 0) {
|
|
190
222
|
throw new SpecError(`seeded scenario \`${id}\` requires a \`fixture\` path`, file);
|
|
191
223
|
}
|
|
192
224
|
scenario.fixture = s.fixture;
|
|
193
|
-
const a =
|
|
225
|
+
const a = rawAssert;
|
|
194
226
|
if (a) {
|
|
195
|
-
const assertObj = {};
|
|
196
227
|
if (a.vitest !== undefined)
|
|
197
228
|
assertObj.vitest = a.vitest === true;
|
|
198
229
|
if (a.diff_contains !== undefined) {
|
|
@@ -232,9 +263,10 @@ export function parseSpec(text, file) {
|
|
|
232
263
|
}
|
|
233
264
|
assertObj.post_test = a.post_test.trim();
|
|
234
265
|
}
|
|
235
|
-
scenario.assert = assertObj;
|
|
236
266
|
}
|
|
237
267
|
}
|
|
268
|
+
if (Object.keys(assertObj).length > 0)
|
|
269
|
+
scenario.assert = assertObj;
|
|
238
270
|
if (s.env && typeof s.env === "object" && Object.hasOwn(s.env, "event_sources")) {
|
|
239
271
|
throw new SpecError(`scenario \`${id}\` uses removed \`env.event_sources\`; it is no longer collected`, file);
|
|
240
272
|
}
|
package/package.json
CHANGED
|
@@ -232,6 +232,7 @@
|
|
|
232
232
|
"suspect"
|
|
233
233
|
],
|
|
234
234
|
"properties": {
|
|
235
|
+
"judgeRetries": { "type": "integer", "const": 1 },
|
|
235
236
|
"ordinal": {
|
|
236
237
|
"type": "integer",
|
|
237
238
|
"minimum": 1,
|
|
@@ -353,6 +354,10 @@
|
|
|
353
354
|
"trace_sha256": {
|
|
354
355
|
"type": "string"
|
|
355
356
|
},
|
|
357
|
+
"output_sha256": {
|
|
358
|
+
"type": "string",
|
|
359
|
+
"pattern": "^[a-fA-F0-9]{64}$"
|
|
360
|
+
},
|
|
356
361
|
"rep_trace_sha256": {
|
|
357
362
|
"type": "array",
|
|
358
363
|
"items": {
|
|
@@ -360,6 +365,13 @@
|
|
|
360
365
|
"pattern": "^[a-fA-F0-9]{64}$"
|
|
361
366
|
}
|
|
362
367
|
},
|
|
368
|
+
"rep_output_sha256": {
|
|
369
|
+
"type": "array",
|
|
370
|
+
"items": {
|
|
371
|
+
"type": "string",
|
|
372
|
+
"pattern": "^[a-fA-F0-9]{64}$"
|
|
373
|
+
}
|
|
374
|
+
},
|
|
363
375
|
"assertions": {
|
|
364
376
|
"type": "array",
|
|
365
377
|
"items": {
|
|
@@ -244,6 +244,7 @@
|
|
|
244
244
|
"criteria"
|
|
245
245
|
],
|
|
246
246
|
"properties": {
|
|
247
|
+
"judgeRetries": { "type": "integer", "const": 1 },
|
|
247
248
|
"ordinal": {
|
|
248
249
|
"type": "integer",
|
|
249
250
|
"minimum": 1,
|
|
@@ -380,6 +381,10 @@
|
|
|
380
381
|
"trace_sha256": {
|
|
381
382
|
"type": "string"
|
|
382
383
|
},
|
|
384
|
+
"output_sha256": {
|
|
385
|
+
"type": "string",
|
|
386
|
+
"pattern": "^[a-fA-F0-9]{64}$"
|
|
387
|
+
},
|
|
383
388
|
"trajectory_version": {
|
|
384
389
|
"const": "1.0"
|
|
385
390
|
},
|
|
@@ -400,6 +405,13 @@
|
|
|
400
405
|
"pattern": "^[a-fA-F0-9]{64}$"
|
|
401
406
|
}
|
|
402
407
|
},
|
|
408
|
+
"rep_output_sha256": {
|
|
409
|
+
"type": "array",
|
|
410
|
+
"items": {
|
|
411
|
+
"type": "string",
|
|
412
|
+
"pattern": "^[a-fA-F0-9]{64}$"
|
|
413
|
+
}
|
|
414
|
+
},
|
|
403
415
|
"assertions": {
|
|
404
416
|
"type": "array",
|
|
405
417
|
"items": {
|
|
@@ -157,6 +157,22 @@
|
|
|
157
157
|
"minLength": 1
|
|
158
158
|
}
|
|
159
159
|
},
|
|
160
|
+
"output_matches": {
|
|
161
|
+
"type": "array",
|
|
162
|
+
"minItems": 1,
|
|
163
|
+
"items": {
|
|
164
|
+
"type": "string",
|
|
165
|
+
"minLength": 1
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
"output_excludes": {
|
|
169
|
+
"type": "array",
|
|
170
|
+
"minItems": 1,
|
|
171
|
+
"items": {
|
|
172
|
+
"type": "string",
|
|
173
|
+
"minLength": 1
|
|
174
|
+
}
|
|
175
|
+
},
|
|
160
176
|
"post_test": {
|
|
161
177
|
"type": "string"
|
|
162
178
|
},
|