@skill-harness/core 0.29.0 → 0.30.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/README.md +16 -2
- package/dist/adapters/types.d.ts +2 -0
- package/dist/authoring-preflight.d.ts +14 -0
- package/dist/authoring-preflight.js +58 -0
- package/dist/authoring.d.ts +96 -0
- package/dist/authoring.js +442 -0
- package/dist/calibration.d.ts +81 -0
- package/dist/calibration.js +212 -0
- package/dist/command-cost.d.ts +1 -1
- package/dist/command-cost.js +2 -0
- package/dist/diagnose.d.ts +15 -0
- package/dist/diagnose.js +61 -0
- package/dist/execution-trace.d.ts +2 -1
- package/dist/execution-trace.js +4 -3
- package/dist/grade.d.ts +8 -0
- package/dist/grade.js +34 -8
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/judge-evidence.d.ts +40 -0
- package/dist/judge-evidence.js +120 -0
- package/dist/judge-references.d.ts +8 -0
- package/dist/judge-references.js +34 -0
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +22 -0
- package/dist/regate.js +20 -3
- package/dist/regrade.d.ts +8 -1
- package/dist/regrade.js +61 -7
- package/dist/results.d.ts +7 -0
- package/dist/results.js +6 -0
- package/dist/run.js +20 -9
- package/dist/seeded.js +1 -0
- package/dist/sources.d.ts +4 -1
- package/dist/sources.js +31 -6
- package/dist/spec.d.ts +8 -0
- package/dist/spec.js +27 -0
- package/dist/trace-gates.d.ts +2 -0
- package/dist/trace-gates.js +13 -3
- package/package.json +1 -1
- package/schemas/results-v2.schema.json +10 -0
- package/schemas/results-v3.schema.json +10 -0
- package/schemas/specification-v1.schema.json +21 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { EXECUTION_TRACE_VERSION } from "./capture-trace-types.js";
|
|
3
|
+
import { deserializeTrace, mergeTraces, traceSha256 } from "./execution-trace.js";
|
|
4
|
+
import { redactText } from "./redaction.js";
|
|
5
|
+
import { finalAssistantMessage } from "./seeded.js";
|
|
6
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
7
|
+
const MAX_CALLS = 32;
|
|
8
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
const index = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
10
|
+
/** Validate the fields consumed by merging and the evidence projection before using them. */
|
|
11
|
+
function validTrace(trace) {
|
|
12
|
+
if (!object(trace) || trace.trace_version !== EXECUTION_TRACE_VERSION || !object(trace.subject) || typeof trace.subject.provider !== "string" || typeof trace.subject.model !== "string" ||
|
|
13
|
+
!(trace.pi_version === null || typeof trace.pi_version === "string") || typeof trace.final_text !== "string" ||
|
|
14
|
+
!index(trace.turn) || !index(trace.rep) || !Array.isArray(trace.tool_calls) ||
|
|
15
|
+
!(trace.changed_paths === null || Array.isArray(trace.changed_paths) && trace.changed_paths.every(p => typeof p === "string")) ||
|
|
16
|
+
!(trace.cost_usd === null || typeof trace.cost_usd === "number" && Number.isFinite(trace.cost_usd)) ||
|
|
17
|
+
!(trace.capture_errors === undefined || Array.isArray(trace.capture_errors) && trace.capture_errors.every(e => typeof e === "string")))
|
|
18
|
+
return false;
|
|
19
|
+
if (trace.metrics !== undefined && (!object(trace.metrics) ||
|
|
20
|
+
["input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", "cost_usd"].some(key => {
|
|
21
|
+
const value = trace.metrics[key];
|
|
22
|
+
return value !== null && !(typeof value === "number" && Number.isFinite(value) && value >= 0);
|
|
23
|
+
}) || [trace.metrics.tool_calls, trace.metrics.delegated_children, trace.metrics.max_concurrency].some(value => !index(value))))
|
|
24
|
+
return false;
|
|
25
|
+
const ids = new Set();
|
|
26
|
+
const completions = new Set();
|
|
27
|
+
for (const [position, call] of trace.tool_calls.entries()) {
|
|
28
|
+
if (!object(call) || typeof call.id !== "string" || !call.id || ids.has(call.id) ||
|
|
29
|
+
typeof call.name !== "string" || !call.name || !object(call.args) || call.issueIndex !== position ||
|
|
30
|
+
!(call.completionIndex === -1 || index(call.completionIndex)) || typeof call.isError !== "boolean" ||
|
|
31
|
+
!object(call.result) || !index(call.result.bytes) || typeof call.result.sha256 !== "string" || !HASH.test(call.result.sha256))
|
|
32
|
+
return false;
|
|
33
|
+
ids.add(call.id);
|
|
34
|
+
if (call.completionIndex >= 0) {
|
|
35
|
+
if (completions.has(call.completionIndex))
|
|
36
|
+
return false;
|
|
37
|
+
completions.add(call.completionIndex);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return [...completions].sort((a, b) => a - b).every((value, position) => value === position);
|
|
41
|
+
}
|
|
42
|
+
export function unavailableExecution(reason = "No bound execution trace was supplied.", status = "unknown") {
|
|
43
|
+
return { schema: 1, status, coverage: "unknown", reason, observedCalls: null, omittedCalls: 0, calls: [] };
|
|
44
|
+
}
|
|
45
|
+
/** Tool inputs are untrusted labels. Keep only bounded local read paths, never commands, URLs, tasks or results. */
|
|
46
|
+
function readPath(call) {
|
|
47
|
+
if (call.name !== "read")
|
|
48
|
+
return undefined;
|
|
49
|
+
const path = call.args.path ?? call.args.file_path;
|
|
50
|
+
if (typeof path !== "string")
|
|
51
|
+
return undefined;
|
|
52
|
+
if (path.length > 256 || /[\x00-\x1f\x7f?#]/.test(path) || /^[a-z][a-z0-9+.-]*:\/\//i.test(path))
|
|
53
|
+
return "[path withheld]";
|
|
54
|
+
return redactText(path);
|
|
55
|
+
}
|
|
56
|
+
/** Digest binding establishes artifact continuity, not a signature or proof of file contents. */
|
|
57
|
+
export function executionFacts(traces, identity, binding) {
|
|
58
|
+
if (binding.source === "saved" && !binding.expectedSha256)
|
|
59
|
+
return unavailableExecution("Legacy trace has no recorded digest binding; execution is unverified.");
|
|
60
|
+
const invalid = (reason) => unavailableExecution(reason, "invalid");
|
|
61
|
+
if (!traces.length)
|
|
62
|
+
return binding.source === "live" ? unavailableExecution() : invalid("Recorded execution trace is missing or unreadable.");
|
|
63
|
+
if (traces.some(trace => !validTrace(trace)))
|
|
64
|
+
return invalid("Execution trace has malformed structure.");
|
|
65
|
+
if (traces.some((trace, turn) => trace.scenario_id !== identity.scenarioId || trace.mode !== identity.mode || trace.rep !== identity.repetition || trace.turn !== turn ||
|
|
66
|
+
identity.subject && (trace.subject.provider !== identity.subject.provider || trace.subject.model !== identity.subject.model) ||
|
|
67
|
+
identity.piVersion !== undefined && trace.pi_version !== identity.piVersion ||
|
|
68
|
+
trace.subject.provider !== traces[0].subject.provider || trace.subject.model !== traces[0].subject.model || trace.pi_version !== traces[0].pi_version)) {
|
|
69
|
+
return invalid("Execution trace identity or turn sequence does not match this repetition.");
|
|
70
|
+
}
|
|
71
|
+
if (identity.responses !== undefined && (identity.responses === null || identity.responses.length !== traces.length || traces.some((trace, turn) => trace.final_text.trim() !== identity.responses[turn]))) {
|
|
72
|
+
return invalid("Execution trace final answers do not match the judged transcript.");
|
|
73
|
+
}
|
|
74
|
+
if (traces.some(trace => trace.trace_sha256 !== undefined && trace.trace_sha256 !== traceSha256(trace)))
|
|
75
|
+
return invalid("Execution trace has a mismatching turn digest.");
|
|
76
|
+
// Use the existing canonical merge/digest; only the projection below reaches the judge.
|
|
77
|
+
const merged = mergeTraces(traces);
|
|
78
|
+
const digest = merged?.trace_sha256;
|
|
79
|
+
if (!merged || !digest)
|
|
80
|
+
return invalid("Execution trace could not be merged.");
|
|
81
|
+
if (binding.source === "saved" && binding.expectedSha256 !== digest)
|
|
82
|
+
return invalid("Saved trace no longer matches the digest recorded by the run.");
|
|
83
|
+
const complete = traces.length === identity.turns && traces.every(trace => trace.final_status === "complete" && !trace.capture_errors?.length && trace.tool_calls.every(call => call.completionIndex >= 0));
|
|
84
|
+
const calls = merged.tool_calls.slice(0, MAX_CALLS).map(call => ({
|
|
85
|
+
tool: /^[A-Za-z0-9_.:/-]{1,80}$/.test(call.name) ? call.name : "[tool name withheld]",
|
|
86
|
+
state: call.completionIndex < 0 ? "unfinished" : call.isError ? "error" : "completed",
|
|
87
|
+
...(readPath(call) === undefined ? {} : { path: readPath(call) }),
|
|
88
|
+
...(call.completionIndex < 0 ? {} : { resultBytes: call.result.bytes, resultSha256: call.result.sha256 }),
|
|
89
|
+
}));
|
|
90
|
+
const omittedCalls = merged.tool_calls.length - calls.length;
|
|
91
|
+
return {
|
|
92
|
+
schema: 1, status: "verified", coverage: complete && !omittedCalls ? "complete" : "partial",
|
|
93
|
+
reason: complete ? omittedCalls ? "Call list is bounded; omitted calls may include relevant actions." : "All expected turns settled; all recorded calls are listed." : "Turn settlement or capture coverage is incomplete; do not infer that unlisted actions did not occur.",
|
|
94
|
+
traceSha256: digest, observedCalls: merged.tool_calls.length, omittedCalls, calls,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Reuse the transcript ambiguity check, then compare every assistant turn, excluding harness-owned trailers. */
|
|
98
|
+
export function transcriptResponses(transcript, scenario) {
|
|
99
|
+
if (finalAssistantMessage(transcript, scenario) === null)
|
|
100
|
+
return null;
|
|
101
|
+
return transcript.split(/^<<< ASSISTANT:[ \t]*$/m).slice(1).map(section => section.split(/^(?:>>> USER(?: \(turn \d+\/\d+\))?:[ \t]*|=== SEEDED GATES ===[ \t]*)$/m)[0].trim());
|
|
102
|
+
}
|
|
103
|
+
/** Read every JSONL turn or decline the whole artifact; never silently drop malformed lines. */
|
|
104
|
+
export function loadExecutionFacts(path, identity, expectedSha256) {
|
|
105
|
+
if (!expectedSha256)
|
|
106
|
+
return unavailableExecution("Legacy run has no recorded trace digest; execution is unverified.");
|
|
107
|
+
if (!existsSync(path))
|
|
108
|
+
return unavailableExecution("Recorded execution trace is missing.", "invalid");
|
|
109
|
+
try {
|
|
110
|
+
const lines = readFileSync(path, "utf8").split("\n").filter(line => line.trim());
|
|
111
|
+
const traces = lines.map(deserializeTrace);
|
|
112
|
+
if (traces.some(trace => trace === null))
|
|
113
|
+
return unavailableExecution("Execution trace contains unreadable turns.", "invalid");
|
|
114
|
+
return executionFacts(traces, identity, { source: "saved", expectedSha256 });
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return unavailableExecution("Execution trace could not be read or validated.", "invalid");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=judge-evidence.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Scenario } from "./spec.js";
|
|
2
|
+
export interface JudgeReference {
|
|
3
|
+
file: string;
|
|
4
|
+
sha256: string;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
/** Only explicitly selected local UTF-8 files are read. Never discover sibling files. */
|
|
8
|
+
export declare function resolveJudgeReferences(scenario: Scenario, specDir: string): JudgeReference[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
const LIMIT = 65_536;
|
|
5
|
+
/** Only explicitly selected local UTF-8 files are read. Never discover sibling files. */
|
|
6
|
+
export function resolveJudgeReferences(scenario, specDir) {
|
|
7
|
+
const selected = scenario.judgeReferences ?? [];
|
|
8
|
+
if (!selected.length)
|
|
9
|
+
return [];
|
|
10
|
+
if (selected.length > 16 || new Set(selected).size !== selected.length)
|
|
11
|
+
throw new Error("Invalid judge reference selection");
|
|
12
|
+
const root = realpathSync(specDir);
|
|
13
|
+
let total = 0;
|
|
14
|
+
return selected.map(file => {
|
|
15
|
+
if (!file || /[\\:\x00-\x1f]/.test(file) || file.startsWith("/") || file.split("/").some(part => !part || part === "." || part === ".."))
|
|
16
|
+
throw new Error(`Unsafe judge reference: ${file}`);
|
|
17
|
+
const path = realpathSync(resolve(root, file));
|
|
18
|
+
const rel = relative(root, path);
|
|
19
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith("../") || rel.startsWith("..\\"))
|
|
20
|
+
throw new Error(`Judge reference escapes specification directory: ${file}`);
|
|
21
|
+
const info = statSync(path);
|
|
22
|
+
if (!info.isFile() || info.size > LIMIT)
|
|
23
|
+
throw new Error(`Judge reference must be a regular file of at most ${LIMIT} bytes: ${file}`);
|
|
24
|
+
const bytes = readFileSync(path);
|
|
25
|
+
total += bytes.length;
|
|
26
|
+
if (bytes.length > LIMIT || total > LIMIT)
|
|
27
|
+
throw new Error(`Selected judge references exceed ${LIMIT} bytes`);
|
|
28
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
29
|
+
if (text.includes("\0"))
|
|
30
|
+
throw new Error(`Judge reference contains binary content: ${file}`);
|
|
31
|
+
return { file, sha256: createHash("sha256").update(bytes).digest("hex"), text };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=judge-references.js.map
|
package/dist/lint.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "fixture-marker" | "consistency" | "stale" | "stability" | "covers" | "lint-error";
|
|
1
|
+
export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "fixture-marker" | "consistency" | "stale" | "stability" | "covers" | "lint-error" | "authoring";
|
|
2
2
|
/**
|
|
3
3
|
* How much a finding means.
|
|
4
4
|
*
|
package/dist/lint.js
CHANGED
|
@@ -9,6 +9,8 @@ import { downgradeWarning } from "./downgrade.js";
|
|
|
9
9
|
import { collectScoredRuns } from "./trends.js";
|
|
10
10
|
import { boundaryCells, stabilityFrom, stabilityNote } from "./stability.js";
|
|
11
11
|
import { MARKERS, unknownMarkerDirs, suggestMarker } from "./workspace.js";
|
|
12
|
+
import { fixtureInventory, inspectEvalAuthoring } from "./authoring-preflight.js";
|
|
13
|
+
import { resolveJudgeReferences } from "./judge-references.js";
|
|
12
14
|
/** True when a finding fails the gate. The single place the exit-code rule lives. */
|
|
13
15
|
export function failsGate(f) {
|
|
14
16
|
return (f.severity ?? "error") === "error";
|
|
@@ -49,6 +51,20 @@ export function lintSkill(skillDir, testsDir = join(skillDir, "tests")) {
|
|
|
49
51
|
return [{ skill: basename(skillDir), code: "spec", message }];
|
|
50
52
|
}
|
|
51
53
|
const skill = spec.skill;
|
|
54
|
+
for (const hint of inspectEvalAuthoring(spec, { fixtureFiles: scenario => {
|
|
55
|
+
const fixture = effectiveFixture(scenario);
|
|
56
|
+
if (!fixture)
|
|
57
|
+
return [];
|
|
58
|
+
// Unreadable/missing fixtures are reported by the fixture checks below.
|
|
59
|
+
try {
|
|
60
|
+
return fixtureInventory(resolve(testsDir, fixture));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
} })) {
|
|
66
|
+
findings.push({ skill, scenario: hint.scenario, code: "authoring", severity: hint.severity ?? "info", message: `${hint.code}: ${hint.message}` });
|
|
67
|
+
}
|
|
52
68
|
// A lint from an older tool than the records it is checking cannot be trusted to
|
|
53
69
|
// check them: a 0.1.0 binary reported 38 spurious `consistency` findings against
|
|
54
70
|
// partial runs that 0.3.x handles correctly, and it cannot read source-hash key
|
|
@@ -91,6 +107,12 @@ export function lintSkill(skillDir, testsDir = join(skillDir, "tests")) {
|
|
|
91
107
|
// relative to the spec's dir, matching workspace.ts resolve(specDir, fixture) where specDir = <skillDir>/tests.
|
|
92
108
|
const specDir = dirname(specPath);
|
|
93
109
|
for (const s of spec.scenarios) {
|
|
110
|
+
try {
|
|
111
|
+
resolveJudgeReferences(s, specDir);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
findings.push({ skill, scenario: s.id, code: "fixture", message: String(error) });
|
|
115
|
+
}
|
|
94
116
|
const fx = effectiveFixture(s); // shared with sources.ts so hashing and linting can't drift
|
|
95
117
|
if (fx) {
|
|
96
118
|
const abs = isAbsolute(fx) ? fx : resolve(specDir, fx);
|
package/dist/regate.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { parseModelRef } from "./adapters/types.js";
|
|
4
|
+
import { parseVerdict, detectMisfire, resolveJudgeReferences } from "./grade.js";
|
|
4
5
|
import { evaluateNeedleGates, evaluateOutputGates, hasNeedleGates, hasOutputGates } from "./seeded.js";
|
|
5
6
|
import { evaluateTraceGates } from "./trace-gates.js";
|
|
6
7
|
import { mergeTraces, deserializeTrace } from "./execution-trace.js";
|
|
7
8
|
import { judgeOneRep } from "./regrade.js";
|
|
9
|
+
import { loadExecutionFacts, transcriptResponses, unavailableExecution } from "./judge-evidence.js";
|
|
8
10
|
import { readResults, writeResults, diffPath, transcriptPath, judgeRawPath, findDiffFiles, findTranscriptFiles, findTraceFiles, tracePath, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, deliveryStatusForObservations, } from "./results.js";
|
|
9
11
|
import { outcomesToResult } from "./reps.js";
|
|
10
12
|
import { appendJournal } from "./journal.js";
|
|
@@ -150,6 +152,7 @@ export async function regateRun(opts) {
|
|
|
150
152
|
throw new Error(`nothing to regate in ${opts.runDir}` +
|
|
151
153
|
(blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff/output assertions or assert.trace"));
|
|
152
154
|
}
|
|
155
|
+
const references = Object.fromEntries(targets.map(s => [s.id, resolveJudgeReferences(s, opts.specDir)]));
|
|
153
156
|
const changes = [];
|
|
154
157
|
let judgeCalls = 0;
|
|
155
158
|
const scenarios = [];
|
|
@@ -176,6 +179,16 @@ export async function regateRun(opts) {
|
|
|
176
179
|
const tPath = transcriptPath(opts.runDir, scenario.id, mode, rep);
|
|
177
180
|
const before = existsSync(tPath) ? readFileSync(tPath, "utf8") : "";
|
|
178
181
|
const outputGate = evaluateOutputGates(scenario, before);
|
|
182
|
+
const retainedPanel = rec.rep_judgments?.find(panel => panel.repetition === (rep ?? 0));
|
|
183
|
+
const recordedTraceHashes = [retainedPanel?.judgments[0]?.input?.trace_sha256, retainedPanel?.objective?.trace_sha256,
|
|
184
|
+
rec.objective?.rep_trace_sha256?.[rep ?? 0], rec.reps === undefined ? rec.objective?.trace_sha256 : undefined].filter((hash) => hash !== undefined);
|
|
185
|
+
const execution = new Set(recordedTraceHashes).size > 1
|
|
186
|
+
? unavailableExecution("Conflicting recorded trace digests.", "invalid")
|
|
187
|
+
: loadExecutionFacts(tracePath(opts.runDir, scenario.id, mode, rep), {
|
|
188
|
+
scenarioId: scenario.id, mode, repetition: rep ?? 0, turns: scenario.turns.length,
|
|
189
|
+
...(prev.model.includes(":") ? { subject: parseModelRef(prev.model) } : {}), piVersion: prev.harness_cli_version,
|
|
190
|
+
responses: transcriptResponses(before, scenario),
|
|
191
|
+
}, recordedTraceHashes[0]);
|
|
179
192
|
// Output and trace gates are re-decided from saved artifacts. Free: no model, no judge.
|
|
180
193
|
let traceFailure = null;
|
|
181
194
|
const expectedOutputHash = rec.objective?.rep_output_sha256?.[rep ?? 0]
|
|
@@ -191,7 +204,11 @@ export async function regateRun(opts) {
|
|
|
191
204
|
assertions: [...outputIntegrityAssertion, ...outputGate.assertions],
|
|
192
205
|
}
|
|
193
206
|
: undefined;
|
|
194
|
-
if (
|
|
207
|
+
if (execution.status === "invalid") {
|
|
208
|
+
traceFailure = `objective: ${execution.reason}`;
|
|
209
|
+
objective = { ...(objective ?? {}), status: "ERROR", assertions: [...(objective?.assertions ?? []), { kind: "trace_evidence", status: "ERROR", detail: execution.reason }] };
|
|
210
|
+
}
|
|
211
|
+
else if (scenario.traceAssert) {
|
|
195
212
|
const tp = tracePath(opts.runDir, scenario.id, mode, rep);
|
|
196
213
|
// A PARTIAL read is refused, not graded. `deserializeTrace` returns null
|
|
197
214
|
// for a malformed line and for a version it declines, and dropping those
|
|
@@ -303,7 +320,7 @@ export async function regateRun(opts) {
|
|
|
303
320
|
const judged = await judgeOneRep({
|
|
304
321
|
runDir: opts.runDir, spec: opts.spec, scenario, transcript,
|
|
305
322
|
adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir,
|
|
306
|
-
mode, rep, now, rejudge: true,
|
|
323
|
+
mode, rep, now, rejudge: true, execution, objective, references: references[scenario.id],
|
|
307
324
|
});
|
|
308
325
|
outcomes.push({ ...judged, objective });
|
|
309
326
|
judgeCalls += judged.metrics?.judge_calls ?? 0;
|
package/dist/regrade.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Spec, Scenario } from "./spec.js";
|
|
2
2
|
import type { HarnessAdapter, ModelRef } from "./adapters/types.js";
|
|
3
|
+
import { type JudgeReference } from "./grade.js";
|
|
3
4
|
import { type ScenarioResult, type ResultsFile } from "./results.js";
|
|
4
5
|
import { type RepOutcome } from "./reps.js";
|
|
6
|
+
import { type JudgeExecutionFacts } from "./judge-evidence.js";
|
|
5
7
|
/**
|
|
6
8
|
* Carry a run's recorded hashes forward, refreshing only the `rubric:` keys this
|
|
7
9
|
* re-grade actually judged under (plus the persona, which applies to all of them).
|
|
@@ -14,8 +16,9 @@ import { type RepOutcome } from "./reps.js";
|
|
|
14
16
|
* No hashes recorded (a pre-`source_hashes` run) stays that way: inventing hashes for
|
|
15
17
|
* a run that never recorded any would claim a coverage it does not have.
|
|
16
18
|
*/
|
|
17
|
-
export declare function refreshRubricHashes(recorded: Record<string, string> | undefined, spec: Spec, judgedIds: string[]): Record<string, string> | undefined;
|
|
19
|
+
export declare function refreshRubricHashes(recorded: Record<string, string> | undefined, spec: Spec, judgedIds: string[], specDir?: string, references?: Record<string, JudgeReference[]>): Record<string, string> | undefined;
|
|
18
20
|
export interface RegradeOptions {
|
|
21
|
+
references?: JudgeReference[];
|
|
19
22
|
runDir: string;
|
|
20
23
|
spec: Spec;
|
|
21
24
|
scenario: Scenario;
|
|
@@ -37,6 +40,8 @@ export interface RegradeOptions {
|
|
|
37
40
|
/** Select only repetitions with retained ERROR criterion votes. */
|
|
38
41
|
onlyUnparsed?: boolean;
|
|
39
42
|
prior?: ScenarioResult;
|
|
43
|
+
subject?: ModelRef;
|
|
44
|
+
piVersion?: string;
|
|
40
45
|
now?: () => string;
|
|
41
46
|
}
|
|
42
47
|
/** 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. */
|
|
@@ -55,6 +60,8 @@ export declare function judgeOneRep(opts: {
|
|
|
55
60
|
rejudge?: boolean;
|
|
56
61
|
/** Retained objective evidence must precede missing-vote normalization. */
|
|
57
62
|
objective?: RepOutcome["objective"];
|
|
63
|
+
execution?: JudgeExecutionFacts;
|
|
64
|
+
references?: JudgeReference[];
|
|
58
65
|
}): Promise<RepOutcome>;
|
|
59
66
|
/**
|
|
60
67
|
* Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
|
package/dist/regrade.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { parseModelRef } from "./adapters/types.js";
|
|
5
|
+
import { prepareJudgePrompt, resolveJudgeReferences, formatJudgeRawReplies, judgeInWorkspace } from "./grade.js";
|
|
6
|
+
import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, tracePath, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, carryRepObjectives, completeCriterionVotes, } from "./results.js";
|
|
5
7
|
import { outcomesToResult, normalizeRepOutcome } from "./reps.js";
|
|
6
8
|
import { appendJournal } from "./journal.js";
|
|
7
9
|
import { rubricDigest, personaDigest, RUBRIC_PREFIX, PERSONA_KEY } from "./sources.js";
|
|
8
10
|
import { providerFailureFromTranscript, executionFailureFromTranscript } from "./provider-failure.js";
|
|
11
|
+
import { loadExecutionFacts, transcriptResponses } from "./judge-evidence.js";
|
|
9
12
|
/**
|
|
10
13
|
* Carry a run's recorded hashes forward, refreshing only the `rubric:` keys this
|
|
11
14
|
* re-grade actually judged under (plus the persona, which applies to all of them).
|
|
@@ -18,7 +21,7 @@ import { providerFailureFromTranscript, executionFailureFromTranscript } from ".
|
|
|
18
21
|
* No hashes recorded (a pre-`source_hashes` run) stays that way: inventing hashes for
|
|
19
22
|
* a run that never recorded any would claim a coverage it does not have.
|
|
20
23
|
*/
|
|
21
|
-
export function refreshRubricHashes(recorded, spec, judgedIds) {
|
|
24
|
+
export function refreshRubricHashes(recorded, spec, judgedIds, specDir, references) {
|
|
22
25
|
if (!recorded)
|
|
23
26
|
return undefined;
|
|
24
27
|
const next = { ...recorded };
|
|
@@ -28,12 +31,27 @@ export function refreshRubricHashes(recorded, spec, judgedIds) {
|
|
|
28
31
|
// Only refresh a key the run already carried: adding one for a scenario whose
|
|
29
32
|
// rubric was never hashed would fabricate coverage.
|
|
30
33
|
if (s && RUBRIC_PREFIX + id in next)
|
|
31
|
-
next[RUBRIC_PREFIX + id] = rubricDigest(s);
|
|
34
|
+
next[RUBRIC_PREFIX + id] = rubricDigest(s, specDir, references?.[id]);
|
|
32
35
|
}
|
|
33
36
|
if (PERSONA_KEY in next)
|
|
34
37
|
next[PERSONA_KEY] = personaDigest(spec.judge_persona);
|
|
35
38
|
return next;
|
|
36
39
|
}
|
|
40
|
+
function savedExecution(opts, mode, rep, transcript) {
|
|
41
|
+
const repetition = rep ?? 0;
|
|
42
|
+
const panel = opts.prior?.rep_judgments?.find(panel => panel.repetition === repetition);
|
|
43
|
+
const hashes = [panel?.judgments[0]?.input?.trace_sha256, panel?.objective?.trace_sha256, opts.prior?.objective?.rep_trace_sha256?.[repetition],
|
|
44
|
+
(opts.prior?.reps ?? 1) === 1 ? opts.prior?.objective?.trace_sha256 : undefined].filter((value) => value !== undefined);
|
|
45
|
+
if (new Set(hashes).size > 1)
|
|
46
|
+
throw new Error(`${opts.scenario.id}#${repetition}: conflicting recorded trace digests; regrade refused.`);
|
|
47
|
+
const execution = loadExecutionFacts(tracePath(opts.runDir, opts.scenario.id, mode, rep), {
|
|
48
|
+
scenarioId: opts.scenario.id, mode, repetition, turns: opts.scenario.turns.length,
|
|
49
|
+
subject: opts.subject, piVersion: opts.piVersion, responses: transcriptResponses(transcript, opts.scenario),
|
|
50
|
+
}, hashes[0]);
|
|
51
|
+
if (execution.status === "invalid")
|
|
52
|
+
throw new Error(`${opts.scenario.id}#${repetition}: ${execution.reason} Regrade refused; retained results were not replaced.`);
|
|
53
|
+
return execution;
|
|
54
|
+
}
|
|
37
55
|
/** 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. */
|
|
38
56
|
export async function judgeOneRep(opts) {
|
|
39
57
|
const { runDir, spec, scenario, transcript, adapter, judge, specDir, mode, rep, now } = opts;
|
|
@@ -56,12 +74,30 @@ export async function judgeOneRep(opts) {
|
|
|
56
74
|
metrics: { wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)), judge_calls: 0, judge_rejudge_calls: 0 },
|
|
57
75
|
};
|
|
58
76
|
}
|
|
59
|
-
|
|
77
|
+
if (opts.execution?.status === "invalid") {
|
|
78
|
+
const reason = `execution evidence: ${opts.execution.reason}`;
|
|
79
|
+
appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: "ERROR", reason, suspect: false, ...repField });
|
|
80
|
+
return { verdict: "ERROR", reason, suspect: false, objective: opts.objective,
|
|
81
|
+
metrics: { wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)), judge_calls: 0, judge_rejudge_calls: 0 } };
|
|
82
|
+
}
|
|
83
|
+
const prompt = prepareJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript, execution: opts.execution, references: opts.references }, specDir);
|
|
60
84
|
const g = await judgeInWorkspace(adapter, judge, prompt, specDir, scenario.checklist.length);
|
|
85
|
+
const inputBytes = JSON.stringify({ schema: 1, judge, execution: opts.execution ?? null, prompts: g.prompts }) + "\n";
|
|
86
|
+
const inputHash = createHash("sha256").update(inputBytes).digest("hex");
|
|
87
|
+
const artifact = `judge-input.${inputHash}.txt`;
|
|
88
|
+
const inputPath = join(runDir, artifact);
|
|
89
|
+
if (existsSync(inputPath)) {
|
|
90
|
+
if (readFileSync(inputPath, "utf8") !== inputBytes)
|
|
91
|
+
throw new Error("judge input artifact has conflicting bytes");
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
writeFileSync(inputPath, inputBytes, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
95
|
+
const input = { artifact, sha256: inputHash, execution: opts.execution?.status === "verified" ? "verified" : "unknown",
|
|
96
|
+
...(opts.execution?.traceSha256 ? { trace_sha256: opts.execution.traceSha256 } : {}) };
|
|
61
97
|
writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), formatJudgeRawReplies(g.rawReplies), "utf8");
|
|
62
98
|
const outcome = normalizeRepOutcome({
|
|
63
99
|
verdict: g.verdict, reason: g.reason, suspect: g.suspect, objective: opts.objective,
|
|
64
|
-
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 } : {}) },
|
|
100
|
+
judgment: { ordinal: 1, judge: { ...judge }, verdict: g.verdict, reason: g.reason, suspect: g.suspect, criteria: completeCriterionVotes(g.criteria, scenario.checklist.length), judgeFormat: g.judgeFormat, input, ...(g.judgeRetries ? { judgeRetries: g.judgeRetries } : {}) },
|
|
65
101
|
metrics: {
|
|
66
102
|
wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
|
|
67
103
|
judge_calls: 1 + (g.judgeRetries ?? 0),
|
|
@@ -81,6 +117,7 @@ export async function judgeOneRep(opts) {
|
|
|
81
117
|
* persists).
|
|
82
118
|
*/
|
|
83
119
|
export async function regradeScenario(opts) {
|
|
120
|
+
const references = opts.references ?? resolveJudgeReferences(opts.scenario, opts.specDir);
|
|
84
121
|
const now = opts.now ?? (() => new Date().toISOString());
|
|
85
122
|
const mode = opts.mode ?? "green";
|
|
86
123
|
let files = findTranscriptFiles(opts.runDir, opts.scenario.id, mode);
|
|
@@ -109,10 +146,12 @@ export async function regradeScenario(opts) {
|
|
|
109
146
|
const file = files.find(file => (repIndexOf(file) ?? 0) === repetition);
|
|
110
147
|
const rep = repIndexOf(file) ?? undefined;
|
|
111
148
|
const transcript = readFileSync(join(opts.runDir, file), "utf8");
|
|
149
|
+
const execution = savedExecution(opts, mode, rep, transcript);
|
|
112
150
|
outcomes.push(await judgeOneRep({
|
|
113
151
|
runDir: opts.runDir, spec: opts.spec, scenario: opts.scenario, transcript,
|
|
114
152
|
adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode, rep, now, rejudge: true,
|
|
115
153
|
objective: panel?.objective ?? (expected === 1 ? opts.prior?.objective : undefined),
|
|
154
|
+
execution, references,
|
|
116
155
|
}));
|
|
117
156
|
}
|
|
118
157
|
const result = outcomesToResult(opts.scenario.id, outcomes, repCount, opts.threshold);
|
|
@@ -214,6 +253,19 @@ export async function regradeRun(opts) {
|
|
|
214
253
|
if (drifted.length)
|
|
215
254
|
throw new Error(`criterion count changed for ${drifted.join(", ")} — use full grade instead of --unparsed-only`);
|
|
216
255
|
}
|
|
256
|
+
// Refuse the whole selected batch before spending on any earlier cell. Recheck
|
|
257
|
+
// again immediately before each call so a later filesystem edit is not trusted.
|
|
258
|
+
for (const id of targets) {
|
|
259
|
+
const prior = recordedById.get(id);
|
|
260
|
+
for (const file of findTranscriptFiles(runDir, id, mode)) {
|
|
261
|
+
const rep = repIndexOf(file) ?? undefined;
|
|
262
|
+
if (opts.onlyUnparsed && !hasUnparsedVotes(prior.rep_judgments?.find(panel => panel.repetition === (rep ?? 0))))
|
|
263
|
+
continue;
|
|
264
|
+
savedExecution({ runDir, scenario: specById.get(id), prior,
|
|
265
|
+
...(prev?.model.includes(":") ? { subject: parseModelRef(prev.model) } : {}), piVersion: prev?.harness_cli_version, }, mode, rep, readFileSync(join(runDir, file), "utf8"));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const references = Object.fromEntries(targets.map(id => [id, resolveJudgeReferences(specById.get(id), specDir)]));
|
|
217
269
|
const targetSet = new Set(targets);
|
|
218
270
|
const scenarioResults = [];
|
|
219
271
|
for (const rec of recorded) {
|
|
@@ -227,7 +279,9 @@ export async function regradeRun(opts) {
|
|
|
227
279
|
const threshold = effectiveThreshold(prevScenario, scenario);
|
|
228
280
|
const rr = await regradeScenario({
|
|
229
281
|
runDir, spec, scenario, adapter, judge, specDir, threshold, mode, now,
|
|
282
|
+
references: references[id],
|
|
230
283
|
expectedReps: prevScenario?.reps ?? 1, onlyUnparsed: opts.onlyUnparsed, prior: prevScenario,
|
|
284
|
+
...(prev?.model.includes(":") ? { subject: parseModelRef(prev.model) } : {}), piVersion: prev?.harness_cli_version,
|
|
231
285
|
});
|
|
232
286
|
const carry = overrides.get(id);
|
|
233
287
|
if (prev?.schema === 3)
|
|
@@ -270,7 +324,7 @@ export async function regradeRun(opts) {
|
|
|
270
324
|
// say so. Doctrine narrowed 0.4.0, from "recorded hashes stay" to "recorded
|
|
271
325
|
// *stimulus* hashes stay" — see refreshRubricHashes.
|
|
272
326
|
partial: prev?.partial,
|
|
273
|
-
source_hashes: opts.onlyUnparsed ? prev?.source_hashes : refreshRubricHashes(prev?.source_hashes, spec, targets),
|
|
327
|
+
source_hashes: opts.onlyUnparsed ? prev?.source_hashes : refreshRubricHashes(prev?.source_hashes, spec, targets, opts.specDir, references),
|
|
274
328
|
source_hash_roots: prev?.source_hash_roots,
|
|
275
329
|
scenarios: scenarioResults,
|
|
276
330
|
}, ctx);
|
package/dist/results.d.ts
CHANGED
|
@@ -105,6 +105,13 @@ export interface Judgment {
|
|
|
105
105
|
judgeFormat?: "json" | "prose";
|
|
106
106
|
/** One validation-guided retry after an invalid structured reply. */
|
|
107
107
|
judgeRetries?: 1;
|
|
108
|
+
/** Content-addressed private input artifact; preserves exact prompts and execution coverage. */
|
|
109
|
+
input?: {
|
|
110
|
+
artifact: string;
|
|
111
|
+
sha256: string;
|
|
112
|
+
execution: "verified" | "unknown";
|
|
113
|
+
trace_sha256?: string;
|
|
114
|
+
};
|
|
108
115
|
}
|
|
109
116
|
export interface AdjudicationResult {
|
|
110
117
|
/** The saved transcript repetition judged by every panel member (first rep by policy). */
|
package/dist/results.js
CHANGED
|
@@ -743,6 +743,11 @@ export function findTranscriptFile(runDir, scenarioId) {
|
|
|
743
743
|
* for that claim is the code the model wrote.
|
|
744
744
|
*/
|
|
745
745
|
export function preserveTranscript(resultsRoot, runDir, scenarioId) {
|
|
746
|
+
const record = existsSync(resultsPath(runDir)) ? readResults(runDir).scenarios.find(scenario => scenario.id === scenarioId) : undefined;
|
|
747
|
+
const judgeInputs = (record?.rep_judgments ?? []).flatMap(panel => panel.judgments.flatMap(judgment => {
|
|
748
|
+
const input = judgment.input;
|
|
749
|
+
return input && input.artifact === `judge-input.${input.sha256}.txt` && /^[a-f0-9]{64}$/.test(input.sha256) && existsSync(join(runDir, input.artifact)) ? [input.artifact] : [];
|
|
750
|
+
}));
|
|
746
751
|
const files = [
|
|
747
752
|
...findTranscriptFiles(runDir, scenarioId),
|
|
748
753
|
...findJudgeRawFiles(runDir, scenarioId),
|
|
@@ -752,6 +757,7 @@ export function preserveTranscript(resultsRoot, runDir, scenarioId) {
|
|
|
752
757
|
// override whose justification was gitignored, and left `regate` with nothing
|
|
753
758
|
// to re-evaluate on the one cell a human had disputed.
|
|
754
759
|
...findTraceFiles(runDir, scenarioId),
|
|
760
|
+
...judgeInputs,
|
|
755
761
|
];
|
|
756
762
|
if (files.length === 0)
|
|
757
763
|
return;
|
package/dist/run.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { sourceHashes, sourceHashRoots } from "./sources.js";
|
|
4
|
-
import { judgeResemblesSubject } from "./grade.js";
|
|
4
|
+
import { judgeResemblesSubject, resolveJudgeReferences } from "./grade.js";
|
|
5
|
+
import { inspectEvalAuthoring } from "./authoring-preflight.js";
|
|
5
6
|
import { NONE_ARM, seedArmDefinitions } from "./arms.js";
|
|
6
7
|
import { runDirFor, transcriptPath, diffPath, tracePath, writeResults, ensureResultsGitignore, scoreContextFor, effectiveThreshold, isScoredMode, } from "./results.js";
|
|
7
8
|
import { appendJournal } from "./journal.js";
|
|
8
9
|
import { liftHeadline } from "./lift.js";
|
|
9
10
|
import { evaluateOutputGates, hasOutputGates, runSeeded } from "./seeded.js";
|
|
10
11
|
import { serializeTrace, mergeTraces, traceSha256 } from "./execution-trace.js";
|
|
12
|
+
import { executionFacts, transcriptResponses } from "./judge-evidence.js";
|
|
11
13
|
import { evaluateTraceGates } from "./trace-gates.js";
|
|
12
14
|
import { snapshotPaths, diffSnapshots, createWorkspace } from "./workspace.js";
|
|
13
15
|
import { runPool } from "./scheduler.js";
|
|
@@ -63,6 +65,11 @@ export async function runSkillModel(opts) {
|
|
|
63
65
|
scenarios = spec.scenarios.filter((s) => wanted.has(s.id));
|
|
64
66
|
log(` --only ${opts.only.join(",")} — partial run, will not be ship-graded`);
|
|
65
67
|
}
|
|
68
|
+
const authoringErrors = inspectEvalAuthoring({ ...spec, scenarios }).filter(f => f.severity === "error");
|
|
69
|
+
if (authoringErrors.length)
|
|
70
|
+
throw new Error(authoringErrors.map(f => `${f.scenario}: ${f.message}`).join("\n"));
|
|
71
|
+
const judgeReferences = Object.fromEntries(scenarios.map(s => [s.id, resolveJudgeReferences(s, dirname(opts.specPath))]));
|
|
72
|
+
const hashes = sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona, judgeReferences });
|
|
66
73
|
if (judgeResemblesSubject(judge, model)) {
|
|
67
74
|
log(` ⚠ judge (${judge.provider}:${judge.model}) resembles the model under test ` +
|
|
68
75
|
`(${model.provider}:${model.model}) — verdicts may be inflated. Use a distinct judge.`);
|
|
@@ -132,7 +139,7 @@ export async function runSkillModel(opts) {
|
|
|
132
139
|
const rep = k;
|
|
133
140
|
const total = repCounts[si];
|
|
134
141
|
owners.push(si);
|
|
135
|
-
tasks.push(() => runRep(scenario, rep, total, { ...opts, runDir, now, log, armDefinitions }));
|
|
142
|
+
tasks.push(() => runRep(scenario, rep, total, { ...opts, runDir, now, log, armDefinitions, judgeReferences }));
|
|
136
143
|
}
|
|
137
144
|
});
|
|
138
145
|
const flat = await runPool(tasks, opts.concurrency ?? 1);
|
|
@@ -145,7 +152,6 @@ export async function runSkillModel(opts) {
|
|
|
145
152
|
return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
|
|
146
153
|
});
|
|
147
154
|
const ctx = scoreContextFor({ mode, partial }, spec);
|
|
148
|
-
const hashes = sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona });
|
|
149
155
|
const results = writeResults(runDir, {
|
|
150
156
|
skill: spec.skill,
|
|
151
157
|
harness: adapter.name,
|
|
@@ -332,6 +338,7 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
332
338
|
else {
|
|
333
339
|
const req = {
|
|
334
340
|
skillDir: ctx.skillDir, model: ctx.model, mode, turns: scenario.turns, cwd: ws.cwd,
|
|
341
|
+
tools: scenario.tools,
|
|
335
342
|
// resolved like fixtures: relative to the spec's dir
|
|
336
343
|
systemPromptFile: scenario.systemPromptFile
|
|
337
344
|
? resolve(dirname(ctx.specPath), scenario.systemPromptFile)
|
|
@@ -461,23 +468,26 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
461
468
|
// timeout that caused it, and it is that text, not the real reason, that would
|
|
462
469
|
// reach the results record. The verdict is ERROR either way.
|
|
463
470
|
let objective;
|
|
471
|
+
if (traces.length > 0)
|
|
472
|
+
writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
|
|
473
|
+
const execution = executionFacts(traces, {
|
|
474
|
+
scenarioId: scenario.id, mode, repetition: rep, turns: scenario.turns.length,
|
|
475
|
+
subject: ctx.model, responses: transcriptResponses(transcript, scenario),
|
|
476
|
+
}, { source: "live" });
|
|
464
477
|
if ((scenario.traceAssert || hasOutputGates(scenario)) && !adapterFailure && !noResponse && !infrastructureFailure) {
|
|
465
478
|
const assertionResults = [...outputGate.assertions];
|
|
466
479
|
let status = outputGate.status;
|
|
467
480
|
let traceMeta = {};
|
|
468
481
|
const outputMeta = outputGate.outputSha256 ? { output_sha256: outputGate.outputSha256 } : {};
|
|
469
482
|
if (scenario.traceAssert) {
|
|
470
|
-
|
|
471
|
-
writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
|
|
472
|
-
}
|
|
473
|
-
const merged = mergeTraces(traces);
|
|
483
|
+
const merged = execution.status === "invalid" ? null : mergeTraces(traces);
|
|
474
484
|
if (unobservablePaths) {
|
|
475
485
|
status = "ERROR";
|
|
476
486
|
assertionResults.push({ kind: "unchanged_path", status: "ERROR", detail: "workspace changes could not be observed" });
|
|
477
487
|
}
|
|
478
488
|
else if (merged === null) {
|
|
479
489
|
status = "ERROR";
|
|
480
|
-
assertionResults.push({ kind: "trace_evidence", status: "ERROR", detail: "no execution trace was produced" });
|
|
490
|
+
assertionResults.push({ kind: "trace_evidence", status: "ERROR", detail: execution.status === "invalid" ? execution.reason : "no execution trace was produced" });
|
|
481
491
|
}
|
|
482
492
|
else {
|
|
483
493
|
const gate = evaluateTraceGates(scenario.traceAssert, merged);
|
|
@@ -534,6 +544,7 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
534
544
|
const o = await judgeOneRep({
|
|
535
545
|
runDir, spec, scenario, transcript, adapter: ctx.adapter, judge,
|
|
536
546
|
specDir: dirname(ctx.specPath), mode, rep: repCount > 1 ? rep : undefined, now,
|
|
547
|
+
execution, objective, references: ctx.judgeReferences[scenario.id],
|
|
537
548
|
});
|
|
538
549
|
verdict = o.verdict;
|
|
539
550
|
reason = o.reason;
|
|
@@ -542,7 +553,7 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
542
553
|
judgeCalls = o.metrics?.judge_calls ?? 0;
|
|
543
554
|
}
|
|
544
555
|
log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
|
|
545
|
-
const subject = mergeTraces(traces)?.metrics;
|
|
556
|
+
const subject = execution.status === "invalid" ? undefined : mergeTraces(traces)?.metrics;
|
|
546
557
|
return {
|
|
547
558
|
verdict, reason, suspect, objective, judgment,
|
|
548
559
|
metrics: {
|
package/dist/seeded.js
CHANGED
|
@@ -234,6 +234,7 @@ export async function runSeeded(scenario, opts) {
|
|
|
234
234
|
model: opts.model,
|
|
235
235
|
mode: opts.mode,
|
|
236
236
|
turns: scenario.turns,
|
|
237
|
+
tools: scenario.tools,
|
|
237
238
|
cwd: repo,
|
|
238
239
|
// Resolved against the spec dir, exactly like fixtures and post-tests —
|
|
239
240
|
// the arm's extensions (already absolute) join alongside, same as the
|
package/dist/sources.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Scenario } from "./spec.js";
|
|
2
|
+
import { type JudgeReference } from "./judge-references.js";
|
|
2
3
|
export { PROMPT_NORMALIZATION_SOURCE_DIGEST, PROMPT_NORMALIZATION_SOURCE_KEY } from "./prompt-normalization.js";
|
|
3
4
|
/**
|
|
4
5
|
* What a run measured, as a map of `key -> sha256`, recorded in results.yaml so
|
|
@@ -103,7 +104,7 @@ export declare function fileSha256(path: string): string | null;
|
|
|
103
104
|
*/
|
|
104
105
|
export declare function dirSha256(dir: string): string | null;
|
|
105
106
|
export declare function stimulusDigest(s: Scenario): string;
|
|
106
|
-
export declare function rubricDigest(s: Scenario): string;
|
|
107
|
+
export declare function rubricDigest(s: Scenario, specDir?: string, references?: JudgeReference[]): string;
|
|
107
108
|
export declare function policyDigest(s: Scenario): string;
|
|
108
109
|
/** Null when the scenario declares no offline-replayable gates — no key is recorded for it. */
|
|
109
110
|
export declare function gatesDigest(s: Scenario): string | null;
|
|
@@ -143,6 +144,8 @@ export interface SourceContext {
|
|
|
143
144
|
* caller instead of leaving one quietly un-checked.
|
|
144
145
|
*/
|
|
145
146
|
judgePersona: string;
|
|
147
|
+
/** Snapshot resolved before execution, shared by prompt construction and recorded rubric hashes. */
|
|
148
|
+
judgeReferences?: Record<string, JudgeReference[]>;
|
|
146
149
|
}
|
|
147
150
|
/**
|
|
148
151
|
* Hash every source this run measures: SKILL.md, each distinct
|