@skill-harness/core 0.6.0 → 0.8.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 +37 -0
- package/dist/adjudication.d.ts +210 -0
- package/dist/adjudication.js +392 -0
- package/dist/affected.d.ts +88 -0
- package/dist/affected.js +222 -0
- package/dist/capture-trace-types.d.ts +228 -0
- package/dist/capture-trace-types.js +23 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +344 -0
- package/dist/execution-trace.d.ts +61 -0
- package/dist/execution-trace.js +299 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/instruction-coverage.d.ts +106 -0
- package/dist/instruction-coverage.js +253 -0
- package/dist/journal.d.ts +17 -0
- package/dist/lint.d.ts +8 -1
- package/dist/lint.js +31 -2
- package/dist/regate.js +80 -17
- package/dist/regrade.js +17 -3
- package/dist/report.d.ts +24 -0
- package/dist/report.js +24 -0
- package/dist/reps.d.ts +14 -1
- package/dist/reps.js +28 -2
- package/dist/rescore.js +11 -2
- package/dist/restamp.d.ts +68 -0
- package/dist/restamp.js +194 -0
- package/dist/results.d.ts +128 -6
- package/dist/results.js +155 -6
- package/dist/run.js +114 -8
- package/dist/seeded.d.ts +11 -0
- package/dist/seeded.js +31 -7
- package/dist/sources.d.ts +50 -0
- package/dist/sources.js +215 -7
- package/dist/spec-write.d.ts +62 -0
- package/dist/spec-write.js +106 -0
- package/dist/spec.d.ts +29 -0
- package/dist/spec.js +55 -0
- package/dist/stability.js +26 -4
- package/dist/trace-gates.d.ts +133 -0
- package/dist/trace-gates.js +519 -0
- package/dist/workspace.d.ts +36 -0
- package/dist/workspace.js +61 -0
- package/package.json +1 -1
package/dist/reps.js
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collapse per-rep objective results.
|
|
3
|
+
*
|
|
4
|
+
* Strict on purpose, and deliberately NOT the same policy as the judge's
|
|
5
|
+
* pass-threshold aggregation: an objective assertion is a statement about what
|
|
6
|
+
* the model DID, so one rep that called a forbidden tool is a real finding, not
|
|
7
|
+
* a minority draw to be voted away. ERROR dominates (missing evidence is never a
|
|
8
|
+
* pass), then FAIL, then PASS. The retained assertion detail comes from the
|
|
9
|
+
* first non-passing rep, since that is the one worth reading.
|
|
10
|
+
*/
|
|
11
|
+
export function aggregateObjective(outcomes) {
|
|
12
|
+
const present = outcomes.map((o) => o.objective).filter((o) => o !== undefined);
|
|
13
|
+
if (present.length === 0)
|
|
14
|
+
return undefined;
|
|
15
|
+
const errored = present.find((o) => o.status === "ERROR");
|
|
16
|
+
if (errored)
|
|
17
|
+
return errored;
|
|
18
|
+
const failed = present.find((o) => o.status === "FAIL");
|
|
19
|
+
if (failed)
|
|
20
|
+
return failed;
|
|
21
|
+
return present[0];
|
|
22
|
+
}
|
|
1
23
|
/**
|
|
2
24
|
* Collapse N rep outcomes into one scenario verdict. A rep is "clean" when its
|
|
3
25
|
* judge did not misfire. If fewer than half the reps are clean the scenario is
|
|
@@ -31,15 +53,19 @@ export function aggregateReps(outcomes, threshold) {
|
|
|
31
53
|
* caller to merge.
|
|
32
54
|
*/
|
|
33
55
|
export function outcomesToResult(id, outcomes, repCount, threshold) {
|
|
56
|
+
// Spread rather than always-set: a scenario with no trace assertions must
|
|
57
|
+
// produce a result byte-identical to one from before this field existed.
|
|
58
|
+
const objective = aggregateObjective(outcomes);
|
|
59
|
+
const objectiveField = objective ? { objective } : {};
|
|
34
60
|
if (repCount === 1) {
|
|
35
61
|
const o = outcomes[0];
|
|
36
|
-
return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, override: null, note: "" };
|
|
62
|
+
return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, override: null, note: "", ...objectiveField };
|
|
37
63
|
}
|
|
38
64
|
const agg = aggregateReps(outcomes, threshold);
|
|
39
65
|
return {
|
|
40
66
|
id, judge_verdict: agg.verdict, judge_reason: agg.reason, suspect: agg.suspect,
|
|
41
67
|
reps: agg.reps, passes: agg.passes, clean: agg.clean, flakiness: agg.flakiness,
|
|
42
|
-
pass_threshold: threshold, override: null, note: "",
|
|
68
|
+
pass_threshold: threshold, override: null, note: "", ...objectiveField,
|
|
43
69
|
};
|
|
44
70
|
}
|
|
45
71
|
//# sourceMappingURL=reps.js.map
|
package/dist/rescore.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { readResults, writeResults, scoreContextFor } from "./results.js";
|
|
3
|
+
import { readResults, writeResults, scoreContextFor, rebuildScenarioResult } from "./results.js";
|
|
4
4
|
import { appendJournal } from "./journal.js";
|
|
5
5
|
import { policyDigest, POLICY_PREFIX } from "./sources.js";
|
|
6
6
|
/**
|
|
@@ -65,7 +65,16 @@ export function rescoreRun(opts) {
|
|
|
65
65
|
if (verdict !== s.judge_verdict) {
|
|
66
66
|
changes.push({ id: s.id, from: s.judge_verdict, to: verdict, passes: s.passes, clean: s.clean, fromThreshold, toThreshold });
|
|
67
67
|
}
|
|
68
|
-
|
|
68
|
+
// Through the choke point, not a spread. This was the FIFTH rewriter of a
|
|
69
|
+
// `ScenarioResult` and the only one still using `{ ...s }` — so it inherited
|
|
70
|
+
// none of the invariants the others get, and adding `objective` and
|
|
71
|
+
// `adjudication` to the type did not fail the build here. Concretely: a cell
|
|
72
|
+
// that adjudication settled FAIL reverted to PASS when a threshold change
|
|
73
|
+
// recomputed it from rep counters that adjudication never updated.
|
|
74
|
+
//
|
|
75
|
+
// Both blocks are CARRIED: a rescore re-applies a threshold to reps that were
|
|
76
|
+
// already measured. It re-measures nothing, so it may discard nothing.
|
|
77
|
+
return rebuildScenarioResult({ ...s, judge_verdict: verdict, pass_threshold: toThreshold }, s, { objective: "carry", adjudication: "carry" });
|
|
69
78
|
});
|
|
70
79
|
const ctx = scoreContextFor(prev, opts.spec);
|
|
71
80
|
const results = writeResults(opts.runDir, {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upgrade already-committed runs to the model-visible digests, without re-running anything.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a command and not a rule inside lint
|
|
5
|
+
*
|
|
6
|
+
* A stored hash is one-way. A run recorded before 0.8.0 carries only sha256 of SKILL.md's
|
|
7
|
+
* RAW BYTES, so once that file has been edited there is nothing in the record — and
|
|
8
|
+
* nothing saved beside it; run dirs hold transcripts, not a copy of the skill — that can
|
|
9
|
+
* say whether the edit touched the body or only the frontmatter. Lint therefore cannot
|
|
10
|
+
* retroactively forgive a frontmatter-only edit on a legacy record, and pretending
|
|
11
|
+
* otherwise would mean dropping the check that catches real body edits across the whole
|
|
12
|
+
* published corpus.
|
|
13
|
+
*
|
|
14
|
+
* What CAN be established, and is the entire proof this command rests on: when the
|
|
15
|
+
* recorded raw-bytes hash still equals the file's current raw-bytes hash, that run
|
|
16
|
+
* measured the exact bytes on disk right now. The model-visible digest of those same
|
|
17
|
+
* bytes is then a true statement about what that run measured, and recording it is
|
|
18
|
+
* bookkeeping rather than a claim. Any record that fails the check is left untouched —
|
|
19
|
+
* it stays honestly stale, and `run` is the only thing that can clear it.
|
|
20
|
+
*
|
|
21
|
+
* So the migration is: upgrade while the board is green, and every later
|
|
22
|
+
* frontmatter-only edit is free. Skipping it costs nothing either — a legacy record with
|
|
23
|
+
* an unedited skill keeps matching on the raw-bytes key exactly as before.
|
|
24
|
+
*
|
|
25
|
+
* ## `--from <ref>`, for when the edit already landed
|
|
26
|
+
*
|
|
27
|
+
* Nobody discovers a gate over-fires until it has over-fired, so the green worktree the
|
|
28
|
+
* paragraph above assumes is usually already gone. The pre-edit bytes are still in git,
|
|
29
|
+
* and they carry the same proof in two steps: the recorded hash matches `<ref>`'s bytes
|
|
30
|
+
* (so the run measured THOSE), and `<ref>`'s model-visible digest equals today's (so the
|
|
31
|
+
* text the model receives has not moved since). Only then is the digest recorded.
|
|
32
|
+
*
|
|
33
|
+
* It is the same proof, not a weaker one — with no `--from`, "the ref" is just the
|
|
34
|
+
* working tree, which is why both go through one code path.
|
|
35
|
+
*
|
|
36
|
+
* Free and offline: no model, no judge, no network. It rewrites only `source_hashes`.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Three exclusive buckets that SUM to `runs`, plus `partial` as a subset of `upgraded`.
|
|
40
|
+
*
|
|
41
|
+
* Summing is the point. These counters are the only thing an operator can judge a
|
|
42
|
+
* migration by, and "18 upgraded, 20 left alone" out of 140 leaves 102 records
|
|
43
|
+
* unaccounted for — which reads like the command quietly skipped them rather than like
|
|
44
|
+
* the 102 having nothing to upgrade.
|
|
45
|
+
*/
|
|
46
|
+
export interface RestampReport {
|
|
47
|
+
/** Run records examined. `upgraded + unprovable + unchanged`. */
|
|
48
|
+
runs: number;
|
|
49
|
+
/** Records that gained at least one model-visible digest, and were rewritten. */
|
|
50
|
+
upgraded: number;
|
|
51
|
+
/**
|
|
52
|
+
* Records that gained NOTHING because a document they measured has already moved —
|
|
53
|
+
* honestly stale, and `run` is the only remedy.
|
|
54
|
+
*/
|
|
55
|
+
unprovable: number;
|
|
56
|
+
/** Records with nothing to do: no `source_hashes`, unreadable, or already upgraded. */
|
|
57
|
+
unchanged: number;
|
|
58
|
+
/** Of `upgraded`, those still carrying a document that could not be proven. */
|
|
59
|
+
partial: number;
|
|
60
|
+
/** `<runDir>: <key>` for each digest added, for the CLI to print. */
|
|
61
|
+
added: string[];
|
|
62
|
+
}
|
|
63
|
+
export interface RestampOptions {
|
|
64
|
+
/** Git ref holding the bytes the runs measured, for a tree that has already been edited. */
|
|
65
|
+
from?: string;
|
|
66
|
+
}
|
|
67
|
+
/** Upgrade every provable record under one skill's tests/results. */
|
|
68
|
+
export declare function restampSkill(skillDir: string, opts?: RestampOptions): RestampReport;
|
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
|
@@ -14,6 +14,56 @@ export interface ScenarioResult {
|
|
|
14
14
|
clean?: number;
|
|
15
15
|
flakiness?: number;
|
|
16
16
|
pass_threshold?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Objective trace-gate evidence. ADDITIVE and optional.
|
|
19
|
+
*
|
|
20
|
+
* Absent means "the scenario declared no trace assertions" — NOT an objective
|
|
21
|
+
* pass. Anything reading this must treat the two as different; collapsing them
|
|
22
|
+
* would silently upgrade every legacy result to "objectively verified".
|
|
23
|
+
*/
|
|
24
|
+
objective?: ObjectiveResult;
|
|
25
|
+
/**
|
|
26
|
+
* Confidence-aware adjudication. ADDITIVE and optional.
|
|
27
|
+
*
|
|
28
|
+
* Absent means historical single-judge behavior — NOT that judges agreed. An
|
|
29
|
+
* unresolved adjudication additionally sets `suspect: true`, which is what
|
|
30
|
+
* actually blocks SHIP; this field is the audit trail behind that flag.
|
|
31
|
+
*/
|
|
32
|
+
adjudication?: AdjudicationResult;
|
|
33
|
+
}
|
|
34
|
+
/** One judge's answer for a cell, kept verbatim however the collapse turned out. */
|
|
35
|
+
export interface Judgment {
|
|
36
|
+
/** 1-based: judgment 1 is the first-wave judge, 2 the secondary, 3 the tie-break. */
|
|
37
|
+
ordinal: number;
|
|
38
|
+
judge: {
|
|
39
|
+
provider: string;
|
|
40
|
+
model: string;
|
|
41
|
+
};
|
|
42
|
+
verdict: Verdict;
|
|
43
|
+
reason: string;
|
|
44
|
+
/** The judge misfired — recorded, never counted as a clean vote. */
|
|
45
|
+
suspect: boolean;
|
|
46
|
+
}
|
|
47
|
+
export interface AdjudicationResult {
|
|
48
|
+
state: "confirmed" | "tie_broken" | "unresolved";
|
|
49
|
+
/** Why the cell was re-judged. */
|
|
50
|
+
trigger: "ambiguous" | "contradictory" | "non_unanimous" | "ship_deciding";
|
|
51
|
+
/** Every judgment, in order. Never pruned — an author resolving this needs all of them. */
|
|
52
|
+
judgments: Judgment[];
|
|
53
|
+
/** The collapsed answer. Absent when unresolved. */
|
|
54
|
+
verdict?: "PASS" | "FAIL";
|
|
55
|
+
}
|
|
56
|
+
/** Objective gate outcome for one scenario cell. */
|
|
57
|
+
export interface ObjectiveResult {
|
|
58
|
+
/** ERROR means the evidence was missing or malformed — never a pass. */
|
|
59
|
+
status: "PASS" | "FAIL" | "ERROR";
|
|
60
|
+
trace_version?: number;
|
|
61
|
+
trace_sha256?: string;
|
|
62
|
+
assertions: {
|
|
63
|
+
kind: string;
|
|
64
|
+
status: "PASS" | "FAIL" | "ERROR";
|
|
65
|
+
detail: string;
|
|
66
|
+
}[];
|
|
17
67
|
}
|
|
18
68
|
export interface GradeSummary {
|
|
19
69
|
passed: number;
|
|
@@ -56,16 +106,23 @@ export interface ResultsFile {
|
|
|
56
106
|
harness_cli_version?: string;
|
|
57
107
|
/**
|
|
58
108
|
* `pass` when this run proved, before spending the wave, that the skill body was
|
|
59
|
-
* reachable in the model's context (see canary.ts).
|
|
60
|
-
*
|
|
61
|
-
*
|
|
109
|
+
* reachable in the model's context (see canary.ts). `skipped` when the probe was
|
|
110
|
+
* asked for but could not be performed — SKILL.md has no `## ` heading to quote
|
|
111
|
+
* back, so no reply could prove anything. Absent means the probe was not asked
|
|
112
|
+
* for — never that it failed, because a failed canary aborts the run and no
|
|
113
|
+
* results.yaml is written.
|
|
114
|
+
*
|
|
115
|
+
* `skipped` exists because absent and skipped were the same value: a user who
|
|
116
|
+
* passed `--canary` precisely because pi ≥ 0.83.0 delivery is unreliable got a
|
|
117
|
+
* silently degraded probe, a fully billed wave, and a committed results.yaml
|
|
118
|
+
* byte-identical to a run where delivery was never checked at all.
|
|
62
119
|
*
|
|
63
120
|
* Only green runs can carry it: red delivers nothing by design and force delivers
|
|
64
121
|
* through the system prompt. It is provenance for the *validity* of a green run,
|
|
65
122
|
* which is why it lives here rather than only in the journal — `journal.jsonl` is
|
|
66
123
|
* gitignored, and this claim has to survive a commit.
|
|
67
124
|
*/
|
|
68
|
-
delivery_canary?: "pass";
|
|
125
|
+
delivery_canary?: "pass" | "skipped";
|
|
69
126
|
skill: string;
|
|
70
127
|
harness: string;
|
|
71
128
|
model: string;
|
|
@@ -152,12 +209,35 @@ export declare function runDirFor(skillDir: string, harness: string, model: Mode
|
|
|
152
209
|
export declare function transcriptPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
|
|
153
210
|
export declare function reportPath(runDir: string): string;
|
|
154
211
|
export declare function resultsPath(runDir: string): string;
|
|
155
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* The verdict that counts: author override when present, else the objective
|
|
214
|
+
* gate, else the judge's.
|
|
215
|
+
*
|
|
216
|
+
* **The objective gate outranks the judge.** `assert.trace` is a mechanical
|
|
217
|
+
* statement about what the model DID — it called `write`, it touched `.env`.
|
|
218
|
+
* The judge is an LLM reading prose. When they disagree, the measurement wins.
|
|
219
|
+
*
|
|
220
|
+
* This is the only place that ordering is enforced, and it has to be here.
|
|
221
|
+
* `objective` used to reach the ship decision solely through `gatePrefix` in
|
|
222
|
+
* `run.ts`, which forces a single rep's verdict — so every path that recomputed
|
|
223
|
+
* a verdict afterwards silently dropped the gate while keeping the `objective`
|
|
224
|
+
* block that claimed it was enforced. Three of them did: `--reps N` out-voted an
|
|
225
|
+
* objective FAIL 2-to-1 (100%, grade A, SHIP, on a CRITICAL scenario that called
|
|
226
|
+
* a forbidden tool), `regrade` re-judged from a transcript the tool calls are
|
|
227
|
+
* absent from, and `regate` recomputed it. `reps.ts` already states the policy —
|
|
228
|
+
* "one rep that called a forbidden tool is a real finding, not a minority draw
|
|
229
|
+
* to be voted away" — and nothing enforced it.
|
|
230
|
+
*
|
|
231
|
+
* An author override still wins, exactly as it does over `suspect`. Overriding a
|
|
232
|
+
* deterministic assertion is a deliberate, recorded human act — and the failure
|
|
233
|
+
* this guards against was never a human deciding, it was nobody deciding.
|
|
234
|
+
*/
|
|
156
235
|
export declare function effectiveVerdicts(scenarios: ScenarioResult[]): ScenarioVerdict[];
|
|
157
236
|
/**
|
|
158
237
|
* The ONLY place effective_grade is computed. Every writer goes through here,
|
|
159
238
|
* so a persisted grade can never disagree with verdicts + overrides.
|
|
160
|
-
* ctx is null for unscored
|
|
239
|
+
* ctx is null for unscored runs — `red` only, since 0.5.0: `force` is a real
|
|
240
|
+
* deployment and is scored (see SCORED_MODES directly above).
|
|
161
241
|
*/
|
|
162
242
|
export declare function finalizeResults(draft: ResultsDraft, ctx: ScoreContext | null): ResultsFile;
|
|
163
243
|
/** Finalize + persist results.yaml (creating the run dir). Returns what was written. */
|
|
@@ -207,8 +287,50 @@ export declare function findJudgeRawFiles(runDir: string, scenarioId: string, mo
|
|
|
207
287
|
* generated evidence, ignored like transcripts, not committed like results.yaml.
|
|
208
288
|
*/
|
|
209
289
|
export declare function diffPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
|
|
290
|
+
/**
|
|
291
|
+
* What happens to one piece of recorded evidence when a command rebuilds a result.
|
|
292
|
+
*
|
|
293
|
+
* - `carry` — the command did not re-measure this, so the prior value still
|
|
294
|
+
* describes the run and must survive.
|
|
295
|
+
* - `fresh` — the command re-measured it; take the new value.
|
|
296
|
+
* - `drop` — the command invalidated it; a stale value would misinform.
|
|
297
|
+
*/
|
|
298
|
+
export type EvidencePolicy = "carry" | "fresh" | "drop";
|
|
299
|
+
export interface RebuildPolicy {
|
|
300
|
+
objective: EvidencePolicy;
|
|
301
|
+
adjudication: EvidencePolicy;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Rebuild a `ScenarioResult` after a command re-measured part of it.
|
|
305
|
+
*
|
|
306
|
+
* **The single choke point for every rewriter**, and exhaustive by construction:
|
|
307
|
+
* every field is destructured below, so adding one to `ScenarioResult` fails the
|
|
308
|
+
* build HERE until someone decides whether it is carried, taken fresh, or
|
|
309
|
+
* dropped. That guard is the whole point of the function.
|
|
310
|
+
*
|
|
311
|
+
* It exists because the ad-hoc version — `{ ...fresh, override: prior.override,
|
|
312
|
+
* note: prior.note }`, written independently in three places — silently dropped
|
|
313
|
+
* `objective` from `grade` and `adjudication` from `regate`. Both failures ran in
|
|
314
|
+
* the dangerous direction: a trace-gated scenario re-read as "no assertions
|
|
315
|
+
* declared", and an unresolved judge disagreement as a settled verdict. 1,036
|
|
316
|
+
* tests passed through it; a real smoke run caught it.
|
|
317
|
+
*
|
|
318
|
+
* The author's `override` and `note` are always carried and are not policy —
|
|
319
|
+
* no command re-measures a human's judgement.
|
|
320
|
+
*/
|
|
321
|
+
export declare function rebuildScenarioResult(fresh: ScenarioResult, prior: ScenarioResult | undefined, policy: RebuildPolicy): ScenarioResult;
|
|
322
|
+
/**
|
|
323
|
+
* Where a rep's execution trace is saved: `<id>.<mode>[.rep<k>].trace.jsonl`.
|
|
324
|
+
*
|
|
325
|
+
* `.jsonl` rather than `.txt` so it is distinguishable at a glance from a
|
|
326
|
+
* transcript, and one JSON object per line so a multi-turn scenario's per-turn
|
|
327
|
+
* traces append without a wrapper.
|
|
328
|
+
*/
|
|
329
|
+
export declare function tracePath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
|
|
210
330
|
/** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
|
|
211
331
|
export declare function findDiffFiles(runDir: string, scenarioId: string, mode?: string): string[];
|
|
332
|
+
/** A scenario's execution-trace files, sorted (plain first, then numeric rep). Mode-scoped when given. */
|
|
333
|
+
export declare function findTraceFiles(runDir: string, scenarioId: string, mode?: string): string[];
|
|
212
334
|
/** A single representative transcript file for a scenario in a run dir. Null if none. */
|
|
213
335
|
export declare function findTranscriptFile(runDir: string, scenarioId: string): string | null;
|
|
214
336
|
/**
|