@skill-harness/core 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/types.d.ts +8 -0
- package/dist/adjudication.d.ts +1 -0
- package/dist/adjudication.js +26 -4
- package/dist/capture-trace-types.d.ts +19 -0
- package/dist/comparison.d.ts +96 -0
- package/dist/comparison.js +183 -0
- package/dist/execution-trace.js +93 -3
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lint.d.ts +7 -0
- package/dist/lint.js +14 -2
- package/dist/regate.d.ts +1 -1
- package/dist/regate.js +94 -21
- package/dist/regrade.d.ts +4 -0
- package/dist/regrade.js +29 -5
- package/dist/report.d.ts +7 -0
- package/dist/report.js +6 -0
- package/dist/reps.d.ts +8 -1
- package/dist/reps.js +47 -13
- package/dist/rescore.js +2 -2
- package/dist/restamp.d.ts +68 -0
- package/dist/restamp.js +194 -0
- package/dist/results.d.ts +28 -0
- package/dist/results.js +56 -4
- package/dist/run.js +96 -35
- package/dist/sandbox.d.ts +32 -0
- package/dist/sandbox.js +12 -0
- package/dist/score.d.ts +3 -1
- package/dist/score.js +20 -5
- package/dist/seeded.d.ts +7 -1
- package/dist/seeded.js +29 -8
- package/dist/sources.d.ts +50 -0
- package/dist/sources.js +186 -7
- package/dist/spec.d.ts +15 -0
- package/dist/spec.js +53 -1
- package/dist/stability.js +26 -4
- package/dist/trace-gates.d.ts +2 -1
- package/dist/trace-gates.js +4 -1
- package/dist/trajectory-gates.d.ts +137 -0
- package/dist/trajectory-gates.js +592 -0
- package/package.json +5 -3
- package/schemas/results-v2.schema.json +133 -0
- package/schemas/specification-v1.schema.json +217 -0
- package/schemas/trajectory-event-v1.schema.json +61 -0
package/dist/restamp.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import yaml from "js-yaml";
|
|
6
|
+
import { loadSpec } from "./spec.js";
|
|
7
|
+
import { resultsPath } from "./results.js";
|
|
8
|
+
import { enumerateRunDirs } from "./lint.js";
|
|
9
|
+
import { promptDocDigest, SKILL_KEY, SKILL_PROMPT_KEY, PROMPT_PREFIX, UNREADABLE, } from "./sources.js";
|
|
10
|
+
/**
|
|
11
|
+
* The `source_hashes` map of a raw-loaded results doc, or null when the doc has none.
|
|
12
|
+
* Read from the RAW yaml rather than through `readResults` on purpose: this rewrites the
|
|
13
|
+
* file, and `readResults` migrates schema 1 → 2 in memory, so writing its output back
|
|
14
|
+
* would silently upgrade the schema of records nobody asked to touch.
|
|
15
|
+
*/
|
|
16
|
+
function rawHashes(doc) {
|
|
17
|
+
if (!doc || typeof doc !== "object")
|
|
18
|
+
return null;
|
|
19
|
+
const h = doc.source_hashes;
|
|
20
|
+
if (!h || typeof h !== "object" || Array.isArray(h))
|
|
21
|
+
return null;
|
|
22
|
+
return h;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* `git show <ref>:<path>` as BYTES, or null when the ref, the repo, or the path isn't there.
|
|
26
|
+
*
|
|
27
|
+
* `execFileSync` with a buffer encoding rather than the shared `exec` helper: that one
|
|
28
|
+
* accumulates stdout as decoded strings, so a multi-byte character landing on a chunk
|
|
29
|
+
* boundary would come back with a replacement character and hash to something the run
|
|
30
|
+
* never recorded. This comparison is byte-exact by definition, so it reads bytes.
|
|
31
|
+
*/
|
|
32
|
+
function bytesAtRef(abs, ref) {
|
|
33
|
+
const cwd = dirname(abs);
|
|
34
|
+
try {
|
|
35
|
+
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" }).trim();
|
|
36
|
+
const rel = relative(top, abs).split("\\").join("/");
|
|
37
|
+
return execFileSync("git", ["show", `${ref}:${rel}`], { cwd, maxBuffer: 64 * 1024 * 1024 });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null; // no git, no repo, no such ref, or the file did not exist there
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The bytes a run is claimed to have measured: `<ref>`'s copy, or the working tree's when
|
|
45
|
+
* no ref was given. Null when they cannot be read at all, which proves nothing and so
|
|
46
|
+
* upgrades nothing.
|
|
47
|
+
*/
|
|
48
|
+
function measuredBytes(abs, from) {
|
|
49
|
+
if (from !== undefined)
|
|
50
|
+
return bytesAtRef(abs, from);
|
|
51
|
+
try {
|
|
52
|
+
return readFileSync(abs);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Fail loudly when `--from` names something git cannot resolve.
|
|
60
|
+
*
|
|
61
|
+
* Without this the whole migration degrades into "0 upgraded, everything left alone",
|
|
62
|
+
* which is indistinguishable from a corpus that genuinely cannot be proven — measured on
|
|
63
|
+
* a clone whose only local branch was the feature branch, where `--from main` silently
|
|
64
|
+
* did nothing and read as the tool being broken. A ref the operator got wrong is their
|
|
65
|
+
* mistake to see, not one to absorb.
|
|
66
|
+
*/
|
|
67
|
+
function assertRefResolves(dir, ref) {
|
|
68
|
+
try {
|
|
69
|
+
execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd: dir, stdio: "ignore" });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new Error(`--from \`${ref}\` does not resolve to a commit in the repo holding ${dir} — ` +
|
|
73
|
+
`check the ref exists locally (a fresh clone often has no local \`main\`; try \`origin/main\`)`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Write via a temp file in the same directory, then rename.
|
|
78
|
+
*
|
|
79
|
+
* `rename(2)` is atomic within a filesystem, so a reader sees the old file or the new one
|
|
80
|
+
* and never a half-written one. It matters more here than in the writers that produce a
|
|
81
|
+
* run: this loops over an entire published corpus rewriting records that already hold
|
|
82
|
+
* measurements nobody can reproduce without spending model tokens, so an interrupt or a
|
|
83
|
+
* full disk partway through would truncate exactly the artifacts the command exists to
|
|
84
|
+
* preserve. The temp file is `.tmp`-suffixed beside the target rather than in the system
|
|
85
|
+
* temp dir, because a cross-device rename is not atomic — and is not even permitted.
|
|
86
|
+
*/
|
|
87
|
+
function writeAtomically(path, text) {
|
|
88
|
+
const tmp = `${path}.restamp.tmp`;
|
|
89
|
+
writeFileSync(tmp, text, "utf8");
|
|
90
|
+
try {
|
|
91
|
+
renameSync(tmp, path);
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
try {
|
|
95
|
+
rmSync(tmp, { force: true });
|
|
96
|
+
}
|
|
97
|
+
catch { /* the rename failure is what matters */ }
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Upgrade every provable record under one skill's tests/results. */
|
|
102
|
+
export function restampSkill(skillDir, opts = {}) {
|
|
103
|
+
const report = { runs: 0, upgraded: 0, unprovable: 0, unchanged: 0, partial: 0, added: [] };
|
|
104
|
+
const specPath = join(skillDir, "tests", "specification.yaml");
|
|
105
|
+
const spec = loadSpec(specPath);
|
|
106
|
+
const specDir = dirname(specPath);
|
|
107
|
+
// Only `system_prompt_file` paths, resolved from the CURRENT spec. The other
|
|
108
|
+
// path-shaped keys (`post_test`, `env.extensions`) name code, not prompt documents:
|
|
109
|
+
// every byte of those reaches the run, so they keep hashing raw and are left alone.
|
|
110
|
+
const promptDocs = new Map([[SKILL_KEY, resolve(skillDir, "SKILL.md")]]);
|
|
111
|
+
for (const s of spec.scenarios) {
|
|
112
|
+
if (s.systemPromptFile)
|
|
113
|
+
promptDocs.set(s.systemPromptFile, resolve(specDir, s.systemPromptFile));
|
|
114
|
+
}
|
|
115
|
+
if (opts.from !== undefined)
|
|
116
|
+
assertRefResolves(skillDir, opts.from);
|
|
117
|
+
// Resolved once per document, not once per run: with `--from`, each resolution is two
|
|
118
|
+
// git invocations, and a corpus of 140 runs would otherwise pay for them 140 times over.
|
|
119
|
+
const provable = new Map();
|
|
120
|
+
for (const [legacyKey, abs] of promptDocs) {
|
|
121
|
+
const measured = measuredBytes(abs, opts.from);
|
|
122
|
+
let currentPrompt = null;
|
|
123
|
+
try {
|
|
124
|
+
currentPrompt = promptDocDigest(readFileSync(abs, "utf8"));
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
currentPrompt = null;
|
|
128
|
+
}
|
|
129
|
+
provable.set(legacyKey, {
|
|
130
|
+
measuredRaw: measured === null ? null : createHash("sha256").update(measured).digest("hex"),
|
|
131
|
+
measuredPrompt: measured === null ? null : promptDocDigest(measured.toString("utf8")),
|
|
132
|
+
currentPrompt,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
for (const runDir of enumerateRunDirs(join(skillDir, "tests", "results"))) {
|
|
136
|
+
report.runs++;
|
|
137
|
+
let doc;
|
|
138
|
+
try {
|
|
139
|
+
doc = yaml.load(readFileSync(resultsPath(runDir), "utf8"));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Unreadable/malformed → lint already reports it; never rewrite what we can't read.
|
|
143
|
+
// Still bucketed: every record counted in `runs` has to be accounted for somewhere.
|
|
144
|
+
report.unchanged++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const hashes = rawHashes(doc);
|
|
148
|
+
if (!hashes) {
|
|
149
|
+
report.unchanged++; // predates source_hashes → nothing to upgrade from
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
let blocked = false;
|
|
153
|
+
const additions = [];
|
|
154
|
+
for (const legacyKey of promptDocs.keys()) {
|
|
155
|
+
const recorded = hashes[legacyKey];
|
|
156
|
+
if (recorded === undefined || recorded === UNREADABLE)
|
|
157
|
+
continue; // never measured → nothing proven
|
|
158
|
+
const upgradedKey = legacyKey === SKILL_KEY ? SKILL_PROMPT_KEY : PROMPT_PREFIX + legacyKey;
|
|
159
|
+
if (hashes[upgradedKey] !== undefined)
|
|
160
|
+
continue; // already upgraded
|
|
161
|
+
const p = provable.get(legacyKey);
|
|
162
|
+
if (p.currentPrompt === null)
|
|
163
|
+
continue; // the file is gone; lint reports that itself
|
|
164
|
+
// Two steps, and both are required. First: this run measured the reference bytes —
|
|
165
|
+
// otherwise the reference says nothing about THIS run. Second: those bytes and
|
|
166
|
+
// today's carry the same model-visible text — otherwise what the model reads has
|
|
167
|
+
// genuinely moved and only a re-run can speak for it.
|
|
168
|
+
if (p.measuredRaw === null || recorded !== p.measuredRaw || p.measuredPrompt !== p.currentPrompt) {
|
|
169
|
+
blocked = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
additions.push([upgradedKey, p.currentPrompt]);
|
|
173
|
+
}
|
|
174
|
+
if (additions.length === 0) {
|
|
175
|
+
// Exclusive: a record that gained nothing is `unprovable` only if something
|
|
176
|
+
// actually blocked it, and `unchanged` otherwise.
|
|
177
|
+
if (blocked)
|
|
178
|
+
report.unprovable++;
|
|
179
|
+
else
|
|
180
|
+
report.unchanged++;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
for (const [k, v] of additions) {
|
|
184
|
+
hashes[k] = v;
|
|
185
|
+
report.added.push(`${runDir}: ${k}`);
|
|
186
|
+
}
|
|
187
|
+
writeAtomically(resultsPath(runDir), yaml.dump(doc, { lineWidth: 100 }));
|
|
188
|
+
report.upgraded++;
|
|
189
|
+
if (blocked)
|
|
190
|
+
report.partial++;
|
|
191
|
+
}
|
|
192
|
+
return report;
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=restamp.js.map
|
package/dist/results.d.ts
CHANGED
|
@@ -2,6 +2,23 @@ import { type ModelRef } from "./adapters/types.js";
|
|
|
2
2
|
import { type ScenarioVerdict } from "./score.js";
|
|
3
3
|
import type { Verdict } from "./score.js";
|
|
4
4
|
import type { ShipBar, Scenario } from "./spec.js";
|
|
5
|
+
export interface ScenarioMetrics {
|
|
6
|
+
wall_time_ms: number;
|
|
7
|
+
judge_calls: number;
|
|
8
|
+
judge_rejudge_calls: number;
|
|
9
|
+
subject_metrics_reps: number;
|
|
10
|
+
total_reps: number;
|
|
11
|
+
input_tokens?: number;
|
|
12
|
+
output_tokens?: number;
|
|
13
|
+
cache_read_tokens?: number;
|
|
14
|
+
cache_write_tokens?: number;
|
|
15
|
+
subject_cost_usd?: number;
|
|
16
|
+
tool_calls?: number;
|
|
17
|
+
delegated_children?: number;
|
|
18
|
+
max_concurrency?: number;
|
|
19
|
+
}
|
|
20
|
+
/** Merge bookkeeping from a later judge/gate pass without duplicating subject usage. */
|
|
21
|
+
export declare function mergeScenarioMetrics(prior: ScenarioMetrics | undefined, fresh: ScenarioMetrics | undefined): ScenarioMetrics | undefined;
|
|
5
22
|
export interface ScenarioResult {
|
|
6
23
|
id: string;
|
|
7
24
|
judge_verdict: Verdict;
|
|
@@ -14,6 +31,8 @@ export interface ScenarioResult {
|
|
|
14
31
|
clean?: number;
|
|
15
32
|
flakiness?: number;
|
|
16
33
|
pass_threshold?: number;
|
|
34
|
+
/** Cost/latency counters. Separate from verdicts: cheaper failure is still failure. */
|
|
35
|
+
metrics?: ScenarioMetrics;
|
|
17
36
|
/**
|
|
18
37
|
* Objective trace-gate evidence. ADDITIVE and optional.
|
|
19
38
|
*
|
|
@@ -59,6 +78,11 @@ export interface ObjectiveResult {
|
|
|
59
78
|
status: "PASS" | "FAIL" | "ERROR";
|
|
60
79
|
trace_version?: number;
|
|
61
80
|
trace_sha256?: string;
|
|
81
|
+
trajectory_version?: string;
|
|
82
|
+
events_sha256?: string;
|
|
83
|
+
/** Per-repetition hashes retained when an aggregate has more than one rep. */
|
|
84
|
+
rep_events_sha256?: string[];
|
|
85
|
+
rep_trace_sha256?: string[];
|
|
62
86
|
assertions: {
|
|
63
87
|
kind: string;
|
|
64
88
|
status: "PASS" | "FAIL" | "ERROR";
|
|
@@ -327,6 +351,10 @@ export declare function rebuildScenarioResult(fresh: ScenarioResult, prior: Scen
|
|
|
327
351
|
* traces append without a wrapper.
|
|
328
352
|
*/
|
|
329
353
|
export declare function tracePath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
|
|
354
|
+
/** Saved adapter-neutral workflow events for one scenario rep. */
|
|
355
|
+
export declare function trajectoryPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
|
|
356
|
+
/** A scenario's normalized-event files, sorted (plain first, then numeric rep). */
|
|
357
|
+
export declare function findTrajectoryFiles(runDir: string, scenarioId: string, mode?: string): string[];
|
|
330
358
|
/** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
|
|
331
359
|
export declare function findDiffFiles(runDir: string, scenarioId: string, mode?: string): string[];
|
|
332
360
|
/** A scenario's execution-trace files, sorted (plain first, then numeric rep). Mode-scoped when given. */
|
package/dist/results.js
CHANGED
|
@@ -4,6 +4,29 @@ import yaml from "js-yaml";
|
|
|
4
4
|
import { modelSlug } from "./adapters/types.js";
|
|
5
5
|
import { score } from "./score.js";
|
|
6
6
|
import { HARNESS_VERSION } from "./version.js";
|
|
7
|
+
/** Merge bookkeeping from a later judge/gate pass without duplicating subject usage. */
|
|
8
|
+
export function mergeScenarioMetrics(prior, fresh) {
|
|
9
|
+
if (!prior)
|
|
10
|
+
return fresh;
|
|
11
|
+
if (!fresh)
|
|
12
|
+
return prior;
|
|
13
|
+
const subject = prior.subject_metrics_reps > 0 ? prior : fresh;
|
|
14
|
+
return {
|
|
15
|
+
wall_time_ms: prior.wall_time_ms + fresh.wall_time_ms,
|
|
16
|
+
judge_calls: prior.judge_calls + fresh.judge_calls,
|
|
17
|
+
judge_rejudge_calls: prior.judge_rejudge_calls + fresh.judge_rejudge_calls,
|
|
18
|
+
subject_metrics_reps: subject.subject_metrics_reps,
|
|
19
|
+
total_reps: prior.total_reps,
|
|
20
|
+
...(subject.input_tokens === undefined ? {} : { input_tokens: subject.input_tokens }),
|
|
21
|
+
...(subject.output_tokens === undefined ? {} : { output_tokens: subject.output_tokens }),
|
|
22
|
+
...(subject.cache_read_tokens === undefined ? {} : { cache_read_tokens: subject.cache_read_tokens }),
|
|
23
|
+
...(subject.cache_write_tokens === undefined ? {} : { cache_write_tokens: subject.cache_write_tokens }),
|
|
24
|
+
...(subject.subject_cost_usd === undefined ? {} : { subject_cost_usd: subject.subject_cost_usd }),
|
|
25
|
+
...(subject.tool_calls === undefined ? {} : { tool_calls: subject.tool_calls }),
|
|
26
|
+
...(subject.delegated_children === undefined ? {} : { delegated_children: subject.delegated_children }),
|
|
27
|
+
...(subject.max_concurrency === undefined ? {} : { max_concurrency: subject.max_concurrency }),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
7
30
|
/**
|
|
8
31
|
* The run modes whose results carry a real grade: the ones where the skill under
|
|
9
32
|
* test was actually delivered to the model.
|
|
@@ -53,6 +76,11 @@ export function scoreContextFor(run, spec) {
|
|
|
53
76
|
}
|
|
54
77
|
/** The pass-threshold a re-grade uses: the run's persisted value, else the spec's per-scenario value, else 0.5. */
|
|
55
78
|
export function effectiveThreshold(prevScenario, scenario) {
|
|
79
|
+
// Critical includes destructive/security/capability/data-loss AND right-sizing
|
|
80
|
+
// counterexamples. Every clean repetition must pass; an aggregate majority may
|
|
81
|
+
// not hide one release-blocking failure.
|
|
82
|
+
if (scenario.critical)
|
|
83
|
+
return 1;
|
|
56
84
|
return prevScenario?.pass_threshold ?? scenario.passThreshold ?? 0.5;
|
|
57
85
|
}
|
|
58
86
|
/** Slugify an ISO timestamp into a filesystem-safe directory name. */
|
|
@@ -252,7 +280,7 @@ export function ensureResultsGitignore(resultsRoot) {
|
|
|
252
280
|
// so `repIndexOf` returned null for traces and `regate` looked for an unsuffixed
|
|
253
281
|
// path that does not exist on a multi-rep run — reporting "trace missing" for
|
|
254
282
|
// traces sitting on disk.
|
|
255
|
-
const REP_SUFFIX_RE = /\.rep(\d+)\.(?:judge
|
|
283
|
+
const REP_SUFFIX_RE = /\.rep(\d+)\.(?:(?:judge|diff)\.txt|(?:trace|events)\.jsonl|txt)$/;
|
|
256
284
|
/** The rep index embedded in a transcript / judge-raw / staged-diff filename (`.rep<k>.`), or null for a plain (non-rep) file. */
|
|
257
285
|
export function repIndexOf(filename) {
|
|
258
286
|
const m = REP_SUFFIX_RE.exec(filename);
|
|
@@ -356,7 +384,7 @@ export function diffPath(runDir, scenarioId, mode, rep) {
|
|
|
356
384
|
export function rebuildScenarioResult(fresh, prior, policy) {
|
|
357
385
|
// Exhaustive destructure. Do not replace with a spread: the spread is what
|
|
358
386
|
// allowed a new field to pass through unconsidered in the first place.
|
|
359
|
-
const { id, judge_verdict, judge_reason, suspect, override: _freshOverride, note: _freshNote, reps, passes, clean, flakiness, pass_threshold, objective: freshObjective, adjudication: freshAdjudication, ...rest } = fresh;
|
|
387
|
+
const { id, judge_verdict, judge_reason, suspect, override: _freshOverride, note: _freshNote, reps, passes, clean, flakiness, pass_threshold, metrics: freshMetrics, objective: freshObjective, adjudication: freshAdjudication, ...rest } = fresh;
|
|
360
388
|
const _exhaustive = rest;
|
|
361
389
|
void _exhaustive;
|
|
362
390
|
void _freshOverride;
|
|
@@ -367,7 +395,14 @@ export function rebuildScenarioResult(fresh, prior, policy) {
|
|
|
367
395
|
return p === "fresh" ? freshValue : priorValue;
|
|
368
396
|
};
|
|
369
397
|
const objective = pick(policy.objective, freshObjective, prior?.objective);
|
|
370
|
-
const
|
|
398
|
+
const pickedAdjudication = pick(policy.adjudication, freshAdjudication, prior?.adjudication);
|
|
399
|
+
const conflictsWithFreshEvidence = Boolean(pickedAdjudication?.verdict && (objective?.status === "FAIL" || objective?.status === "ERROR" ||
|
|
400
|
+
(pass_threshold === 1 && (reps ?? 1) > 1 && judge_verdict !== "PASS")));
|
|
401
|
+
// A settled cell-level panel cannot overrule newly evaluated objective gates,
|
|
402
|
+
// nor can one transcript erase a failed repetition under an all-clean policy.
|
|
403
|
+
const adjudication = conflictsWithFreshEvidence && pickedAdjudication
|
|
404
|
+
? { ...pickedAdjudication, state: "unresolved", verdict: undefined }
|
|
405
|
+
: pickedAdjudication;
|
|
371
406
|
// `unresolved` is carried by the `suspect` flag and by nothing else — the
|
|
372
407
|
// adjudication block records WHY, but `suspect` is what the ship bar reads.
|
|
373
408
|
// Taking `suspect` from `fresh` while carrying the block therefore published a
|
|
@@ -382,7 +417,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
|
|
|
382
417
|
// `<id>.judge.txt` would revert a `confirmed`/`tie_broken` verdict that two or
|
|
383
418
|
// three judges settled — leaving `adjudication.verdict` on the record
|
|
384
419
|
// contradicting the `judge_verdict` beside it.
|
|
385
|
-
const settled = policy.adjudication === "carry" ? adjudication?.verdict : undefined;
|
|
420
|
+
const settled = policy.adjudication === "carry" && !conflictsWithFreshEvidence ? adjudication?.verdict : undefined;
|
|
386
421
|
// Field ORDER matches `outcomesToResult`, the writer that produces a run's
|
|
387
422
|
// results.yaml in the first place. It is not cosmetic: `results.yaml` is a
|
|
388
423
|
// committed file, and emitting the same fields in a different order made every
|
|
@@ -400,6 +435,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
|
|
|
400
435
|
...(clean === undefined ? {} : { clean }),
|
|
401
436
|
...(flakiness === undefined ? {} : { flakiness }),
|
|
402
437
|
...(pass_threshold === undefined ? {} : { pass_threshold }),
|
|
438
|
+
...((freshMetrics ?? prior?.metrics) ? { metrics: freshMetrics ?? prior.metrics } : {}),
|
|
403
439
|
// The author owns the verdict; a re-measurement never discards their call.
|
|
404
440
|
override: prior?.override ?? null,
|
|
405
441
|
note: prior?.note ?? "",
|
|
@@ -421,6 +457,21 @@ export function tracePath(runDir, scenarioId, mode, rep) {
|
|
|
421
457
|
const base = rep === undefined ? `${scenarioId}.${mode}` : `${scenarioId}.${mode}.rep${rep}`;
|
|
422
458
|
return join(runDir, `${base}.trace.jsonl`);
|
|
423
459
|
}
|
|
460
|
+
/** Saved adapter-neutral workflow events for one scenario rep. */
|
|
461
|
+
export function trajectoryPath(runDir, scenarioId, mode, rep) {
|
|
462
|
+
const base = rep === undefined ? `${scenarioId}.${mode}` : `${scenarioId}.${mode}.rep${rep}`;
|
|
463
|
+
return join(runDir, `${base}.events.jsonl`);
|
|
464
|
+
}
|
|
465
|
+
/** A scenario's normalized-event files, sorted (plain first, then numeric rep). */
|
|
466
|
+
export function findTrajectoryFiles(runDir, scenarioId, mode) {
|
|
467
|
+
if (!existsSync(runDir))
|
|
468
|
+
return [];
|
|
469
|
+
const esc = scenarioId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
470
|
+
const re = mode === undefined
|
|
471
|
+
? new RegExp(`^${esc}\\..*\\.events\\.jsonl$`)
|
|
472
|
+
: new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.events\\.jsonl$`);
|
|
473
|
+
return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
|
|
474
|
+
}
|
|
424
475
|
/** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
|
|
425
476
|
export function findDiffFiles(runDir, scenarioId, mode) {
|
|
426
477
|
if (!existsSync(runDir))
|
|
@@ -469,6 +520,7 @@ export function preserveTranscript(resultsRoot, runDir, scenarioId) {
|
|
|
469
520
|
// override whose justification was gitignored, and left `regate` with nothing
|
|
470
521
|
// to re-evaluate on the one cell a human had disputed.
|
|
471
522
|
...findTraceFiles(runDir, scenarioId),
|
|
523
|
+
...findTrajectoryFiles(runDir, scenarioId),
|
|
472
524
|
];
|
|
473
525
|
if (files.length === 0)
|
|
474
526
|
return;
|
package/dist/run.js
CHANGED
|
@@ -2,18 +2,20 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { sourceHashes } from "./sources.js";
|
|
4
4
|
import { judgeResemblesSubject } from "./grade.js";
|
|
5
|
-
import { runDirFor, transcriptPath, diffPath, tracePath, writeResults, ensureResultsGitignore, scoreContextFor, isScoredMode, } from "./results.js";
|
|
5
|
+
import { runDirFor, transcriptPath, diffPath, tracePath, trajectoryPath, writeResults, ensureResultsGitignore, scoreContextFor, effectiveThreshold, isScoredMode, } from "./results.js";
|
|
6
6
|
import { appendJournal } from "./journal.js";
|
|
7
7
|
import { liftHeadline } from "./lift.js";
|
|
8
8
|
import { runSeeded } from "./seeded.js";
|
|
9
9
|
import { serializeTrace, mergeTraces, traceSha256 } from "./execution-trace.js";
|
|
10
10
|
import { evaluateTraceGates } from "./trace-gates.js";
|
|
11
|
+
import { evaluateTrajectoryGates, serializeTrajectoryEvents, } from "./trajectory-gates.js";
|
|
11
12
|
import { snapshotPaths, diffSnapshots, createWorkspace } from "./workspace.js";
|
|
12
13
|
import { runPool } from "./scheduler.js";
|
|
13
14
|
import { outcomesToResult } from "./reps.js";
|
|
14
15
|
import { judgeOneRep } from "./regrade.js";
|
|
15
16
|
import { runDeliveryCanary, canaryFailure } from "./canary.js";
|
|
16
17
|
import { boundaryCells, stabilityNote } from "./stability.js";
|
|
18
|
+
import { aggregateMetrics } from "./comparison.js";
|
|
17
19
|
/** Run one skill against one model: run scenarios, grade, score, persist. */
|
|
18
20
|
export async function runSkillModel(opts) {
|
|
19
21
|
const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
|
|
@@ -107,7 +109,9 @@ export async function runSkillModel(opts) {
|
|
|
107
109
|
const grouped = scenarios.map(() => []);
|
|
108
110
|
flat.forEach((outcome, i) => grouped[owners[i]].push(outcome));
|
|
109
111
|
const scenarioResults = scenarios.map((scenario, si) => {
|
|
110
|
-
const threshold = scenario.
|
|
112
|
+
const threshold = scenario.critical
|
|
113
|
+
? effectiveThreshold(undefined, scenario)
|
|
114
|
+
: scenario.passThreshold ?? opts.passThreshold ?? 0.5;
|
|
111
115
|
return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
|
|
112
116
|
});
|
|
113
117
|
const ctx = scoreContextFor({ mode, partial }, spec);
|
|
@@ -152,6 +156,7 @@ export function hasEmptyAssistantTurn(transcript) {
|
|
|
152
156
|
}
|
|
153
157
|
/** Run ONE rep of a scenario in its own isolated workspace. */
|
|
154
158
|
async function runRep(scenario, rep, repCount, ctx) {
|
|
159
|
+
const startedAt = performance.now();
|
|
155
160
|
const { spec, judge, mode, runDir, now, log } = ctx;
|
|
156
161
|
const repField = repCount > 1 ? { rep } : {};
|
|
157
162
|
if (rep === 0) {
|
|
@@ -161,6 +166,7 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
161
166
|
let ws = null;
|
|
162
167
|
let transcript = "";
|
|
163
168
|
let gatePrefix = null;
|
|
169
|
+
let infrastructureFailure = null;
|
|
164
170
|
// Null until a seeded rep actually reaches its gates: a workspace-setup failure
|
|
165
171
|
// produces no diff, and writing an empty artifact there would misreport "the
|
|
166
172
|
// model changed nothing" for a rep that never ran.
|
|
@@ -170,12 +176,16 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
170
176
|
ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
|
|
171
177
|
}
|
|
172
178
|
catch (e) {
|
|
173
|
-
// A setup failure
|
|
179
|
+
// A setup failure means the scenario never executed. It blocks release as
|
|
180
|
+
// infrastructure ERROR; it is not evidence of subject behavior.
|
|
174
181
|
gatePrefix = e instanceof Error ? e.message : String(e);
|
|
182
|
+
infrastructureFailure = gatePrefix;
|
|
175
183
|
transcript = `[workspace setup failed] ${gatePrefix}`;
|
|
176
184
|
}
|
|
177
185
|
let noResponse = false;
|
|
178
186
|
let traces = [];
|
|
187
|
+
let events = [];
|
|
188
|
+
let eventErrors = [];
|
|
179
189
|
let unobservablePaths = false;
|
|
180
190
|
// The pre-run state, captured AFTER `createWorkspace` has applied the
|
|
181
191
|
// fixture's `_staged/` and `_uncommitted/` trees. Those land after the
|
|
@@ -201,12 +211,15 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
201
211
|
const r = await runSeeded(scenario, {
|
|
202
212
|
skillDir: ctx.skillDir, adapter: ctx.adapter, model: ctx.model, mode, cwd: ws.cwd,
|
|
203
213
|
specDir: dirname(ctx.specPath), // assert.post_test resolves like a fixture
|
|
204
|
-
trace: scenario.traceAssert ? { scenarioId: scenario.id, rep } : undefined,
|
|
214
|
+
trace: scenario.traceAssert || scenario.trajectoryAssert ? { scenarioId: scenario.id, rep } : undefined,
|
|
205
215
|
});
|
|
206
216
|
transcript = r.transcript;
|
|
207
217
|
gatePrefix = r.gateFailure;
|
|
218
|
+
infrastructureFailure = r.gateError;
|
|
208
219
|
stagedDiff = r.diff; // a retry replaces the aborted attempt's diff, as it should
|
|
209
220
|
traces = r.traces;
|
|
221
|
+
events = r.events;
|
|
222
|
+
eventErrors = r.eventErrors;
|
|
210
223
|
}
|
|
211
224
|
else {
|
|
212
225
|
const req = {
|
|
@@ -217,18 +230,21 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
217
230
|
: undefined,
|
|
218
231
|
// Absolute before it reaches a child process running in a neutral cwd.
|
|
219
232
|
extensions: scenario.extensions?.map((e) => resolve(dirname(ctx.specPath), e)),
|
|
233
|
+
eventSources: scenario.eventSources,
|
|
220
234
|
};
|
|
221
|
-
if (scenario.traceAssert) {
|
|
235
|
+
if (scenario.traceAssert || scenario.trajectoryAssert) {
|
|
222
236
|
// Missing required evidence is ERROR, never a silent fallback to the
|
|
223
237
|
// unstructured path: a gate with nothing to read must not look like a
|
|
224
238
|
// gate that passed.
|
|
225
239
|
if (!ctx.adapter.runStructured) {
|
|
226
|
-
throw new Error(`scenario \`${scenario.id}\` declares
|
|
227
|
-
` cannot produce execution traces — the gate would have no evidence to read.`);
|
|
240
|
+
throw new Error(`scenario \`${scenario.id}\` declares structured objective assertions, but the \`${ctx.adapter.name}\` adapter` +
|
|
241
|
+
` cannot produce execution traces/events — the gate would have no evidence to read.`);
|
|
228
242
|
}
|
|
229
243
|
const structured = await ctx.adapter.runStructured({ ...req, scenarioId: scenario.id, rep });
|
|
230
244
|
transcript = structured.transcript;
|
|
231
245
|
traces = structured.traces;
|
|
246
|
+
events = structured.events ?? [];
|
|
247
|
+
eventErrors = structured.eventErrors ?? [];
|
|
232
248
|
}
|
|
233
249
|
else {
|
|
234
250
|
transcript = await ctx.adapter.run(req);
|
|
@@ -279,34 +295,55 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
279
295
|
// Objective evidence is persisted for every rep, pass or fail — a failing gate
|
|
280
296
|
// is exactly when someone wants to read what the model actually did.
|
|
281
297
|
let objective;
|
|
282
|
-
if (scenario.traceAssert) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
298
|
+
if (scenario.traceAssert || scenario.trajectoryAssert) {
|
|
299
|
+
const assertionResults = [];
|
|
300
|
+
let status = "PASS";
|
|
301
|
+
let traceMeta = {};
|
|
302
|
+
let trajectoryMeta = {};
|
|
303
|
+
if (scenario.traceAssert) {
|
|
304
|
+
if (traces.length > 0) {
|
|
305
|
+
writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
|
|
306
|
+
}
|
|
307
|
+
const merged = mergeTraces(traces);
|
|
308
|
+
if (unobservablePaths) {
|
|
309
|
+
status = "ERROR";
|
|
310
|
+
assertionResults.push({ kind: "unchanged_path", status: "ERROR", detail: "workspace changes could not be observed" });
|
|
311
|
+
}
|
|
312
|
+
else if (merged === null) {
|
|
313
|
+
status = "ERROR";
|
|
314
|
+
assertionResults.push({ kind: "trace_evidence", status: "ERROR", detail: "no execution trace was produced" });
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
const gate = evaluateTraceGates(scenario.traceAssert, merged);
|
|
318
|
+
status = gate.status;
|
|
319
|
+
assertionResults.push(...gate.assertions);
|
|
320
|
+
traceMeta = { trace_version: merged.trace_version, trace_sha256: merged.trace_sha256 };
|
|
321
|
+
}
|
|
296
322
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
};
|
|
305
|
-
if (gate.status === "FAIL") {
|
|
306
|
-
// Set the same gatePrefix the seeded gates use, so a trace failure
|
|
307
|
-
// short-circuits the judge through the path that already exists.
|
|
308
|
-
gatePrefix = `objective: ${gate.assertions.filter((x) => x.status === "FAIL").map((x) => x.detail).join("; ")}`;
|
|
323
|
+
if (scenario.trajectoryAssert) {
|
|
324
|
+
if (events.length > 0) {
|
|
325
|
+
writeFileSync(trajectoryPath(runDir, scenario.id, mode, repSuffix), serializeTrajectoryEvents(events), "utf8");
|
|
326
|
+
}
|
|
327
|
+
if (eventErrors.length > 0) {
|
|
328
|
+
status = "ERROR";
|
|
329
|
+
assertionResults.push(...eventErrors.map((detail) => ({ kind: "trajectory_evidence", status: "ERROR", detail })));
|
|
309
330
|
}
|
|
331
|
+
else if (events.length === 0) {
|
|
332
|
+
status = "ERROR";
|
|
333
|
+
assertionResults.push({ kind: "trajectory_evidence", status: "ERROR", detail: "no normalized workflow events were produced" });
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
const gate = evaluateTrajectoryGates(scenario.trajectoryAssert, events);
|
|
337
|
+
if (gate.status === "ERROR" || (gate.status === "FAIL" && status === "PASS"))
|
|
338
|
+
status = gate.status;
|
|
339
|
+
assertionResults.push(...gate.assertions);
|
|
340
|
+
trajectoryMeta = { trajectory_version: gate.event_version, events_sha256: gate.events_sha256 };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
objective = { status, ...traceMeta, ...trajectoryMeta, assertions: assertionResults };
|
|
344
|
+
if (status !== "PASS") {
|
|
345
|
+
const details = assertionResults.filter((result) => result.status === status).map((result) => result.detail);
|
|
346
|
+
gatePrefix = `objective: ${details.join("; ") || "structured evidence could not be evaluated"}`;
|
|
310
347
|
}
|
|
311
348
|
appendJournal(runDir, {
|
|
312
349
|
event: "objective-result", ts: now(), id: scenario.id,
|
|
@@ -316,11 +353,17 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
316
353
|
let verdict;
|
|
317
354
|
let reason;
|
|
318
355
|
let suspect = false;
|
|
356
|
+
let judgeCalls = 0;
|
|
319
357
|
if (objective?.status === "ERROR") {
|
|
320
358
|
verdict = "ERROR";
|
|
321
359
|
reason = gatePrefix ?? "objective evidence missing";
|
|
322
360
|
appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
|
|
323
361
|
}
|
|
362
|
+
else if (infrastructureFailure) {
|
|
363
|
+
verdict = "ERROR";
|
|
364
|
+
reason = infrastructureFailure;
|
|
365
|
+
appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
|
|
366
|
+
}
|
|
324
367
|
else if (noResponse) {
|
|
325
368
|
verdict = "ERROR";
|
|
326
369
|
reason = "model produced no response after a retry (harness timeout?) — infra, not skill behavior";
|
|
@@ -340,9 +383,19 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
340
383
|
verdict = o.verdict;
|
|
341
384
|
reason = o.reason;
|
|
342
385
|
suspect = o.suspect; // judgeOneRep already journaled (verdict + misfire)
|
|
386
|
+
judgeCalls = 1;
|
|
343
387
|
}
|
|
344
388
|
log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
|
|
345
|
-
|
|
389
|
+
const subject = mergeTraces(traces)?.metrics;
|
|
390
|
+
return {
|
|
391
|
+
verdict, reason, suspect, objective,
|
|
392
|
+
metrics: {
|
|
393
|
+
wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
|
|
394
|
+
judge_calls: judgeCalls,
|
|
395
|
+
judge_rejudge_calls: 0,
|
|
396
|
+
...(subject ? { subject } : {}),
|
|
397
|
+
},
|
|
398
|
+
};
|
|
346
399
|
}
|
|
347
400
|
finally {
|
|
348
401
|
ws?.cleanup();
|
|
@@ -370,7 +423,7 @@ export function formatScorecard(summary, lift, stability) {
|
|
|
370
423
|
const lines = [];
|
|
371
424
|
lines.push(`── ${results.skill} · ${results.harness} · ${results.model} ──`);
|
|
372
425
|
for (const s of results.scenarios) {
|
|
373
|
-
const v = s.override ?? s.judge_verdict;
|
|
426
|
+
const v = s.override ?? (s.objective?.status === "ERROR" ? "ERROR" : s.objective?.status === "FAIL" ? "FAIL" : s.judge_verdict);
|
|
374
427
|
const mark = v === "PASS" ? "✓" : v === "FAIL" ? "✗" : "?";
|
|
375
428
|
const ov = s.override ? " (override)" : "";
|
|
376
429
|
const susp = s.suspect ? " ⚠suspect" : "";
|
|
@@ -381,6 +434,14 @@ export function formatScorecard(summary, lift, stability) {
|
|
|
381
434
|
const ship = g.ship ? "SHIP" : "NOT READY";
|
|
382
435
|
const note = g.note ? ` (${g.note})` : "";
|
|
383
436
|
lines.push(` GRADE: ${g.letter} (${g.pct}%) — ${g.passed}/${g.total} — ${ship}${note}`);
|
|
437
|
+
const metrics = aggregateMetrics(results.scenarios);
|
|
438
|
+
if (metrics.total_reps > 0) {
|
|
439
|
+
const tokens = metrics.input_tokens === null
|
|
440
|
+
? "subject tokens unavailable"
|
|
441
|
+
: `subject tokens ${metrics.input_tokens} in / ${metrics.output_tokens ?? 0} out / ${metrics.cache_read_tokens ?? 0} cache-read`;
|
|
442
|
+
const tools = metrics.tool_calls === null ? "tool calls unavailable" : `${metrics.tool_calls} tool call(s), ${metrics.delegated_children ?? 0} delegated, max concurrency ${metrics.max_concurrency ?? 0}`;
|
|
443
|
+
lines.push(` COST: ${tokens} (${metrics.subject_metrics_reps}/${metrics.total_reps} reps reported) · ${metrics.judge_calls} judge + ${metrics.judge_rejudge_calls} re-judge call(s) · ${metrics.wall_time_ms}ms · ${tools}`);
|
|
444
|
+
}
|
|
384
445
|
// Lift is a statement about a skill-delivered run (green or force). On a red run
|
|
385
446
|
// the caller may still have a lift in hand (a scored run exists in the same tag),
|
|
386
447
|
// but printing it under a baseline scorecard reads as if the baseline itself
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional OS-containment seam.
|
|
3
|
+
*
|
|
4
|
+
* The current workspace implementation copies fixtures into temp directories;
|
|
5
|
+
* that is isolation for test data, NOT process/filesystem/network containment.
|
|
6
|
+
* No production backend is registered yet. A container/bwrap implementation can
|
|
7
|
+
* satisfy this interface without teaching scoring or trajectory gates about one
|
|
8
|
+
* particular sandbox runtime.
|
|
9
|
+
*/
|
|
10
|
+
export type SandboxNetworkPolicy = "allow" | "deny";
|
|
11
|
+
export interface SandboxRequest {
|
|
12
|
+
sourceWorkspace: string;
|
|
13
|
+
network: SandboxNetworkPolicy;
|
|
14
|
+
}
|
|
15
|
+
export interface SandboxSession {
|
|
16
|
+
cwd: string;
|
|
17
|
+
captureDiff(): Promise<string>;
|
|
18
|
+
cleanup(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface SandboxBackend {
|
|
21
|
+
name: string;
|
|
22
|
+
/** `os` is a backend claim; `none` must never be presented as containment. */
|
|
23
|
+
containment: "os" | "none";
|
|
24
|
+
prepare(request: SandboxRequest): Promise<SandboxSession>;
|
|
25
|
+
}
|
|
26
|
+
export interface SandboxRunResult<T> {
|
|
27
|
+
value: T;
|
|
28
|
+
diff: string;
|
|
29
|
+
backend: string;
|
|
30
|
+
containment: SandboxBackend["containment"];
|
|
31
|
+
}
|
|
32
|
+
export declare function withSandbox<T>(backend: SandboxBackend, request: SandboxRequest, run: (session: SandboxSession) => Promise<T>): Promise<SandboxRunResult<T>>;
|