@skill-harness/core 0.29.0 → 0.31.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 +22 -0
- package/dist/authoring-preflight.js +111 -0
- package/dist/authoring.d.ts +96 -0
- package/dist/authoring.js +451 -0
- package/dist/calibration.d.ts +138 -0
- package/dist/calibration.js +352 -0
- package/dist/canary.d.ts +28 -26
- package/dist/canary.js +109 -92
- package/dist/command-cost.d.ts +1 -1
- package/dist/command-cost.js +2 -0
- package/dist/diagnose.d.ts +17 -0
- package/dist/diagnose.js +70 -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 +37 -8
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/journal.d.ts +3 -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 +21 -3
- package/dist/regrade.d.ts +10 -1
- package/dist/regrade.js +146 -75
- package/dist/rescore.js +1 -0
- package/dist/resource-monitor.d.ts +3 -0
- package/dist/resource-monitor.js +72 -0
- package/dist/results.d.ts +16 -4
- package/dist/results.js +10 -0
- package/dist/run.d.ts +2 -2
- package/dist/run.js +147 -116
- package/dist/saved-run.d.ts +15 -0
- package/dist/saved-run.js +114 -0
- package/dist/scheduler.d.ts +3 -5
- package/dist/scheduler.js +18 -6
- package/dist/seeded.js +1 -0
- package/dist/sources.d.ts +4 -1
- package/dist/sources.js +44 -40
- 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/dist/util/exec.d.ts +5 -0
- package/dist/util/exec.js +16 -32
- package/dist/util/fixture-inventory.d.ts +19 -0
- package/dist/util/fixture-inventory.js +68 -0
- package/dist/util/process.d.ts +78 -0
- package/dist/util/process.js +394 -0
- package/dist/workspace.js +13 -29
- 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
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";
|
|
@@ -17,6 +19,8 @@ import { runDeliveryCanary, canaryFailure } from "./canary.js";
|
|
|
17
19
|
import { boundaryCells, stabilityNote } from "./stability.js";
|
|
18
20
|
import { aggregateMetrics } from "./metrics.js";
|
|
19
21
|
import { providerFailureFromTranscript, executionFailureFromTranscript, withExecutionFailure } from "./provider-failure.js";
|
|
22
|
+
import { withResourceMonitoring } from "./resource-monitor.js";
|
|
23
|
+
import { throwIfProcessAborted, isProcessControlError } from "./util/process.js";
|
|
20
24
|
/**
|
|
21
25
|
* The pi-daddy arm's delivery proof, by convention: `arms.yaml` points
|
|
22
26
|
* `PI_GRANTS_LEDGER` at exactly this filename inside the run directory. A fixed
|
|
@@ -52,7 +56,10 @@ export async function runSkillModel(opts) {
|
|
|
52
56
|
// --only: validate against the spec BEFORE spending anything — a typo'd id must not
|
|
53
57
|
// silently run zero scenarios and report success.
|
|
54
58
|
let scenarios = spec.scenarios;
|
|
55
|
-
const partial =
|
|
59
|
+
const partial = opts.only !== undefined;
|
|
60
|
+
if (partial && (!opts.only.length || opts.only.some(id => !id.trim() || id !== id.trim()) || new Set(opts.only).size !== opts.only.length)) {
|
|
61
|
+
throw new Error("--only requires a nonempty, unique scenario ID list");
|
|
62
|
+
}
|
|
56
63
|
if (partial) {
|
|
57
64
|
const known = new Set(spec.scenarios.map((s) => s.id));
|
|
58
65
|
const unknown = opts.only.filter((id) => !known.has(id));
|
|
@@ -63,6 +70,11 @@ export async function runSkillModel(opts) {
|
|
|
63
70
|
scenarios = spec.scenarios.filter((s) => wanted.has(s.id));
|
|
64
71
|
log(` --only ${opts.only.join(",")} — partial run, will not be ship-graded`);
|
|
65
72
|
}
|
|
73
|
+
const authoringErrors = inspectEvalAuthoring({ ...spec, scenarios }).filter(f => f.severity === "error");
|
|
74
|
+
if (authoringErrors.length)
|
|
75
|
+
throw new Error(authoringErrors.map(f => `${f.scenario}: ${f.message}`).join("\n"));
|
|
76
|
+
const judgeReferences = Object.fromEntries(scenarios.map(s => [s.id, resolveJudgeReferences(s, dirname(opts.specPath))]));
|
|
77
|
+
const hashes = sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona, judgeReferences });
|
|
66
78
|
if (judgeResemblesSubject(judge, model)) {
|
|
67
79
|
log(` ⚠ judge (${judge.provider}:${judge.model}) resembles the model under test ` +
|
|
68
80
|
`(${model.provider}:${model.model}) — verdicts may be inflated. Use a distinct judge.`);
|
|
@@ -71,119 +83,124 @@ export async function runSkillModel(opts) {
|
|
|
71
83
|
const runDir = runDirFor(dirname(opts.testsDir ?? join(skillDir, "tests")), adapter.name, model, timestamp, arm.name);
|
|
72
84
|
mkdirSync(runDir, { recursive: true });
|
|
73
85
|
ensureResultsGitignore(dirname(dirname(runDir))); // .../tests/results/.gitignore
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
skill: spec.skill, harness: adapter.name, model: opts.modelToken,
|
|
82
|
-
harness_cli_version: harnessCliVersion,
|
|
83
|
-
judge: { provider: judge.provider, model: judge.model },
|
|
84
|
-
mode, label: opts.label ?? null,
|
|
85
|
-
});
|
|
86
|
-
// The canary spends one probe before the wave, so a run that isn't measuring the
|
|
87
|
-
// skill dies for the price of a rep instead of producing a plausible scorecard.
|
|
88
|
-
// Green only: red delivers nothing by design, and force delivers through the
|
|
89
|
-
// system prompt, which needs no probe.
|
|
90
|
-
let canaryStatus = null;
|
|
91
|
-
if (opts.canary && mode !== "green") {
|
|
92
|
-
// Ignoring a flag silently is a small version of the bug this whole feature is
|
|
93
|
-
// about. Say it, and say why it isn't needed.
|
|
94
|
-
log(` --canary ignored in mode=${mode} — ${mode === "force" ? "the system prompt delivers the skill unconditionally" : "a baseline delivers no skill by design"}`);
|
|
95
|
-
}
|
|
96
|
-
if (opts.canary && mode === "green") {
|
|
97
|
-
const probeCwd = createWorkspace("none", { specDir: dirname(opts.specPath) });
|
|
98
|
-
let canary;
|
|
99
|
-
try {
|
|
100
|
-
canary = await runDeliveryCanary({
|
|
101
|
-
adapter, model, skillDir, skillName: spec.skill, cwd: probeCwd.cwd,
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
finally {
|
|
105
|
-
probeCwd.cleanup();
|
|
106
|
-
}
|
|
86
|
+
return withResourceMonitoring(runDir, async () => {
|
|
87
|
+
throwIfProcessAborted();
|
|
88
|
+
// Which harness CLI delivered the skill, asked once per run and recorded with the
|
|
89
|
+
// numbers. A pi upgrade (0.80.x → 0.83.0) silently changed what green mode
|
|
90
|
+
// measures, and the incident was invisible in the artifacts because nothing wrote
|
|
91
|
+
// this down. Never fatal: `null` means the adapter couldn't say.
|
|
92
|
+
const harnessCliVersion = (await adapter.version?.()) ?? null;
|
|
107
93
|
appendJournal(runDir, {
|
|
108
|
-
event: "
|
|
109
|
-
|
|
94
|
+
event: "run-started", ts: now(),
|
|
95
|
+
skill: spec.skill, harness: adapter.name, model: opts.modelToken,
|
|
96
|
+
harness_cli_version: harnessCliVersion,
|
|
97
|
+
judge: { provider: judge.provider, model: judge.model },
|
|
98
|
+
mode, label: opts.label ?? null,
|
|
110
99
|
});
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
100
|
+
// One optional instructed loading probe can stop a wave whose delivery check
|
|
101
|
+
// is inconclusive. It does not measure autonomous selection in later scenarios.
|
|
102
|
+
// Green only: red delivers nothing by design, and force delivers through the
|
|
103
|
+
// system prompt, which needs no probe.
|
|
104
|
+
let canaryStatus = null;
|
|
105
|
+
let canaryObservations;
|
|
106
|
+
if (opts.canary && mode !== "green") {
|
|
107
|
+
// Ignoring a flag silently is a small version of the bug this whole feature is
|
|
108
|
+
// about. Say it, and say why it isn't needed.
|
|
109
|
+
log(` --canary ignored in mode=${mode} — ${mode === "force" ? "the system prompt delivers the skill unconditionally" : "a baseline delivers no skill by design"}`);
|
|
119
110
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
111
|
+
if (opts.canary && mode === "green") {
|
|
112
|
+
const probeCwd = createWorkspace("none", { specDir: dirname(opts.specPath) });
|
|
113
|
+
let canary;
|
|
114
|
+
try {
|
|
115
|
+
canary = await runDeliveryCanary({
|
|
116
|
+
adapter, model, skillDir, skillName: spec.skill, cwd: probeCwd.cwd,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
probeCwd.cleanup();
|
|
121
|
+
}
|
|
122
|
+
appendJournal(runDir, {
|
|
123
|
+
event: "delivery-canary", ts: now(),
|
|
124
|
+
status: canary.status, anchor: canary.anchor, detail: canary.detail,
|
|
125
|
+
observations: canary.observations, probeCalls: canary.probeCalls,
|
|
126
|
+
});
|
|
127
|
+
canaryObservations = { ...canary.observations, probeCalls: canary.probeCalls };
|
|
128
|
+
if (canary.status === "fail")
|
|
129
|
+
throw new Error(canaryFailure(spec.skill, canary, harnessCliVersion));
|
|
130
|
+
if (canary.status === "skipped") {
|
|
131
|
+
// Recorded, not just logged: `journal.jsonl` is gitignored, and the claim
|
|
132
|
+
// requested delivery observation has to survive a commit, including a skip.
|
|
133
|
+
canaryStatus = "skipped";
|
|
134
|
+
log(` ⚠ delivery canary skipped — ${canary.detail}`);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
canaryStatus = "pass";
|
|
138
|
+
log(` ✓ delivery canary — ${canary.detail}`);
|
|
139
|
+
}
|
|
123
140
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
141
|
+
// scenario × rep tasks; runPool preserves input order so we can slice per scenario.
|
|
142
|
+
const repCounts = scenarios.map((s) => s.reps ?? opts.reps ?? 1);
|
|
143
|
+
const owners = [];
|
|
144
|
+
const tasks = [];
|
|
145
|
+
const armDefinitions = { count: 0 };
|
|
146
|
+
scenarios.forEach((scenario, si) => {
|
|
147
|
+
for (let k = 0; k < repCounts[si]; k++) {
|
|
148
|
+
const rep = k;
|
|
149
|
+
const total = repCounts[si];
|
|
150
|
+
owners.push(si);
|
|
151
|
+
tasks.push(() => runRep(scenario, rep, total, { ...opts, runDir, now, log, armDefinitions, judgeReferences }));
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
const flat = await runPool(tasks, opts.concurrency ?? 1);
|
|
155
|
+
const grouped = scenarios.map(() => []);
|
|
156
|
+
flat.forEach((outcome, i) => grouped[owners[i]].push(outcome));
|
|
157
|
+
const scenarioResults = scenarios.map((scenario, si) => {
|
|
158
|
+
const threshold = scenario.critical
|
|
159
|
+
? effectiveThreshold(undefined, scenario)
|
|
160
|
+
: scenario.passThreshold ?? opts.passThreshold ?? 0.5;
|
|
161
|
+
return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
|
|
162
|
+
});
|
|
163
|
+
const ctx = scoreContextFor({ mode, partial }, spec);
|
|
164
|
+
const results = writeResults(runDir, {
|
|
165
|
+
skill: spec.skill,
|
|
166
|
+
harness: adapter.name,
|
|
167
|
+
harness_cli_version: harnessCliVersion ?? undefined,
|
|
168
|
+
delivery_canary: canaryStatus ?? undefined,
|
|
169
|
+
delivery_observations: canaryObservations,
|
|
170
|
+
model: opts.modelToken,
|
|
171
|
+
judge: { provider: judge.provider, model: judge.model },
|
|
172
|
+
timestamp,
|
|
173
|
+
label: opts.label ?? null,
|
|
174
|
+
mode,
|
|
175
|
+
...(partial ? { partial: true } : {}),
|
|
176
|
+
// Only the scenarios this run actually measured: a --only run must not claim
|
|
177
|
+
// coverage of scenarios it skipped.
|
|
178
|
+
source_hashes: hashes,
|
|
179
|
+
...(opts.recordSourceRoots ? { source_hash_roots: sourceHashRoots(hashes) } : {}),
|
|
180
|
+
scenarios: scenarioResults,
|
|
181
|
+
...(arm.name === NONE_ARM.name ? {} : {
|
|
182
|
+
arm: {
|
|
183
|
+
name: arm.name,
|
|
184
|
+
extensions: arm.extensions,
|
|
185
|
+
definitions: armDefinitions.count,
|
|
186
|
+
ledger_events: countLedgerEvents(runDir),
|
|
187
|
+
// The DECLARED env, `<run-dir>` left unsubstituted. It is the condition
|
|
188
|
+
// being measured (grant, max depth), so leaving it out made two runs at
|
|
189
|
+
// different settings byte-identical here and inside the same `+<arm>`
|
|
190
|
+
// tag — `stability` then reads the verdict difference between two
|
|
191
|
+
// conditions as one lineage flipping. The substituted form would be the
|
|
192
|
+
// opposite error: it embeds this run's temp path, so re-running the SAME
|
|
193
|
+
// condition would record two different-looking arms.
|
|
194
|
+
env: arm.env,
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
}, ctx);
|
|
198
|
+
if (ctx) {
|
|
199
|
+
const g = results.effective_grade;
|
|
200
|
+
appendJournal(runDir, { event: "score", ts: now(), passed: g.passed, total: g.total, pct: g.pct, letter: g.letter, ship: g.ship, note: g.note });
|
|
136
201
|
}
|
|
202
|
+
return { runDir, results };
|
|
137
203
|
});
|
|
138
|
-
const flat = await runPool(tasks, opts.concurrency ?? 1);
|
|
139
|
-
const grouped = scenarios.map(() => []);
|
|
140
|
-
flat.forEach((outcome, i) => grouped[owners[i]].push(outcome));
|
|
141
|
-
const scenarioResults = scenarios.map((scenario, si) => {
|
|
142
|
-
const threshold = scenario.critical
|
|
143
|
-
? effectiveThreshold(undefined, scenario)
|
|
144
|
-
: scenario.passThreshold ?? opts.passThreshold ?? 0.5;
|
|
145
|
-
return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
|
|
146
|
-
});
|
|
147
|
-
const ctx = scoreContextFor({ mode, partial }, spec);
|
|
148
|
-
const hashes = sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona });
|
|
149
|
-
const results = writeResults(runDir, {
|
|
150
|
-
skill: spec.skill,
|
|
151
|
-
harness: adapter.name,
|
|
152
|
-
harness_cli_version: harnessCliVersion ?? undefined,
|
|
153
|
-
delivery_canary: canaryStatus ?? undefined,
|
|
154
|
-
model: opts.modelToken,
|
|
155
|
-
judge: { provider: judge.provider, model: judge.model },
|
|
156
|
-
timestamp,
|
|
157
|
-
label: opts.label ?? null,
|
|
158
|
-
mode,
|
|
159
|
-
...(partial ? { partial: true } : {}),
|
|
160
|
-
// Only the scenarios this run actually measured: a --only run must not claim
|
|
161
|
-
// coverage of scenarios it skipped.
|
|
162
|
-
source_hashes: hashes,
|
|
163
|
-
...(opts.recordSourceRoots ? { source_hash_roots: sourceHashRoots(hashes) } : {}),
|
|
164
|
-
scenarios: scenarioResults,
|
|
165
|
-
...(arm.name === NONE_ARM.name ? {} : {
|
|
166
|
-
arm: {
|
|
167
|
-
name: arm.name,
|
|
168
|
-
extensions: arm.extensions,
|
|
169
|
-
definitions: armDefinitions.count,
|
|
170
|
-
ledger_events: countLedgerEvents(runDir),
|
|
171
|
-
// The DECLARED env, `<run-dir>` left unsubstituted. It is the condition
|
|
172
|
-
// being measured (grant, max depth), so leaving it out made two runs at
|
|
173
|
-
// different settings byte-identical here and inside the same `+<arm>`
|
|
174
|
-
// tag — `stability` then reads the verdict difference between two
|
|
175
|
-
// conditions as one lineage flipping. The substituted form would be the
|
|
176
|
-
// opposite error: it embeds this run's temp path, so re-running the SAME
|
|
177
|
-
// condition would record two different-looking arms.
|
|
178
|
-
env: arm.env,
|
|
179
|
-
},
|
|
180
|
-
}),
|
|
181
|
-
}, ctx);
|
|
182
|
-
if (ctx) {
|
|
183
|
-
const g = results.effective_grade;
|
|
184
|
-
appendJournal(runDir, { event: "score", ts: now(), passed: g.passed, total: g.total, pct: g.pct, letter: g.letter, ship: g.ship, note: g.note });
|
|
185
|
-
}
|
|
186
|
-
return { runDir, results };
|
|
187
204
|
}
|
|
188
205
|
/**
|
|
189
206
|
* True when any assistant turn in a transcript is blank — the shape a harness timeout
|
|
@@ -282,6 +299,7 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
282
299
|
// it happens again the verdict is ERROR — never a judged FAIL on an empty reply.
|
|
283
300
|
// An adapter that THREW is the same class of event and takes the same retry.
|
|
284
301
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
302
|
+
throwIfProcessAborted();
|
|
285
303
|
if (attempt > 0) {
|
|
286
304
|
const why = adapterFailure ? `adapter failed (${adapterFailure})` : "empty response";
|
|
287
305
|
appendJournal(runDir, { event: "empty-response-retry", ts: now(), id: scenario.id, attempt, reason: why, ...repField });
|
|
@@ -332,6 +350,7 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
332
350
|
else {
|
|
333
351
|
const req = {
|
|
334
352
|
skillDir: ctx.skillDir, model: ctx.model, mode, turns: scenario.turns, cwd: ws.cwd,
|
|
353
|
+
tools: scenario.tools,
|
|
335
354
|
// resolved like fixtures: relative to the spec's dir
|
|
336
355
|
systemPromptFile: scenario.systemPromptFile
|
|
337
356
|
? resolve(dirname(ctx.specPath), scenario.systemPromptFile)
|
|
@@ -361,6 +380,9 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
361
380
|
}
|
|
362
381
|
}
|
|
363
382
|
catch (e) {
|
|
383
|
+
if (isProcessControlError(e))
|
|
384
|
+
throw e;
|
|
385
|
+
throwIfProcessAborted();
|
|
364
386
|
// An adapter that THREW is one rep's infrastructure failure, not the
|
|
365
387
|
// wave's. `runPool` is fail-fast and `runRep` had only a `finally`, so
|
|
366
388
|
// a single pi crash or `PI_TIMEOUT_MS` timeout unwound the whole run:
|
|
@@ -461,23 +483,26 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
461
483
|
// timeout that caused it, and it is that text, not the real reason, that would
|
|
462
484
|
// reach the results record. The verdict is ERROR either way.
|
|
463
485
|
let objective;
|
|
486
|
+
if (traces.length > 0)
|
|
487
|
+
writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
|
|
488
|
+
const execution = executionFacts(traces, {
|
|
489
|
+
scenarioId: scenario.id, mode, repetition: rep, turns: scenario.turns.length,
|
|
490
|
+
subject: ctx.model, responses: transcriptResponses(transcript, scenario),
|
|
491
|
+
}, { source: "live" });
|
|
464
492
|
if ((scenario.traceAssert || hasOutputGates(scenario)) && !adapterFailure && !noResponse && !infrastructureFailure) {
|
|
465
493
|
const assertionResults = [...outputGate.assertions];
|
|
466
494
|
let status = outputGate.status;
|
|
467
495
|
let traceMeta = {};
|
|
468
496
|
const outputMeta = outputGate.outputSha256 ? { output_sha256: outputGate.outputSha256 } : {};
|
|
469
497
|
if (scenario.traceAssert) {
|
|
470
|
-
|
|
471
|
-
writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
|
|
472
|
-
}
|
|
473
|
-
const merged = mergeTraces(traces);
|
|
498
|
+
const merged = execution.status === "invalid" ? null : mergeTraces(traces);
|
|
474
499
|
if (unobservablePaths) {
|
|
475
500
|
status = "ERROR";
|
|
476
501
|
assertionResults.push({ kind: "unchanged_path", status: "ERROR", detail: "workspace changes could not be observed" });
|
|
477
502
|
}
|
|
478
503
|
else if (merged === null) {
|
|
479
504
|
status = "ERROR";
|
|
480
|
-
assertionResults.push({ kind: "trace_evidence", status: "ERROR", detail: "no execution trace was produced" });
|
|
505
|
+
assertionResults.push({ kind: "trace_evidence", status: "ERROR", detail: execution.status === "invalid" ? execution.reason : "no execution trace was produced" });
|
|
481
506
|
}
|
|
482
507
|
else {
|
|
483
508
|
const gate = evaluateTraceGates(scenario.traceAssert, merged);
|
|
@@ -534,6 +559,7 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
534
559
|
const o = await judgeOneRep({
|
|
535
560
|
runDir, spec, scenario, transcript, adapter: ctx.adapter, judge,
|
|
536
561
|
specDir: dirname(ctx.specPath), mode, rep: repCount > 1 ? rep : undefined, now,
|
|
562
|
+
execution, objective, references: ctx.judgeReferences[scenario.id],
|
|
537
563
|
});
|
|
538
564
|
verdict = o.verdict;
|
|
539
565
|
reason = o.reason;
|
|
@@ -542,7 +568,7 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
542
568
|
judgeCalls = o.metrics?.judge_calls ?? 0;
|
|
543
569
|
}
|
|
544
570
|
log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
|
|
545
|
-
const subject = mergeTraces(traces)?.metrics;
|
|
571
|
+
const subject = execution.status === "invalid" ? undefined : mergeTraces(traces)?.metrics;
|
|
546
572
|
return {
|
|
547
573
|
verdict, reason, suspect, objective, judgment,
|
|
548
574
|
metrics: {
|
|
@@ -632,7 +658,12 @@ export function formatScorecard(summary, lift, stability) {
|
|
|
632
658
|
lines.push(` NOTE: green delivery is harness-version-dependent` +
|
|
633
659
|
(results.harness_cli_version ? ` (${results.harness} ${results.harness_cli_version})` : "") +
|
|
634
660
|
` — on pi ≥ 0.83.0 \`--skill\` only discloses the description and the body loads on demand.` +
|
|
635
|
-
` Use --mode force for
|
|
661
|
+
` Use --mode force for explicit instruction delivery, or --canary for one instructed loading check; autonomous selection remains untested.`);
|
|
662
|
+
}
|
|
663
|
+
if (results.mode === "green" && results.delivery_canary === "pass") {
|
|
664
|
+
const observation = results.delivery_observations;
|
|
665
|
+
lines.push(` DELIVERY: instructed probe read ${observation?.successfulSkillRead ?? "unobserved"}; ` +
|
|
666
|
+
`autonomous skill selection in scenarios was not measured by the probe.`);
|
|
636
667
|
}
|
|
637
668
|
// Boundary cells last, because they qualify the verdicts above: a ✓ on a cell that
|
|
638
669
|
// flipped between the last two runs is one draw, whatever its rep count said.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Spec } from "./spec.js";
|
|
2
|
+
export interface SavedAnswerPlan {
|
|
3
|
+
providerCalls: 0;
|
|
4
|
+
resultsChanged: false;
|
|
5
|
+
scenarios: {
|
|
6
|
+
id: string;
|
|
7
|
+
action: "reuse" | "regrade" | "regate" | "rescore" | "rerun" | "inspect";
|
|
8
|
+
changed: string[];
|
|
9
|
+
note: string;
|
|
10
|
+
}[];
|
|
11
|
+
}
|
|
12
|
+
/** This is an input-identity plan, never an approval or a claim that a failed answer improved. */
|
|
13
|
+
export declare function planSavedAnswers(runDir: string, spec: Spec, skillDir: string, specDir: string): SavedAnswerPlan;
|
|
14
|
+
/** Copy a selected run, with limits and byte identities, before revising its judgments. */
|
|
15
|
+
export declare function forkSavedRun(source: string, destination: string): string;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { readResults, findTranscriptFiles, transcriptPath } from "./results.js";
|
|
5
|
+
import { currentHashFor, isSupersededKey, sourceHashes } from "./sources.js";
|
|
6
|
+
/** This is an input-identity plan, never an approval or a claim that a failed answer improved. */
|
|
7
|
+
export function planSavedAnswers(runDir, spec, skillDir, specDir) {
|
|
8
|
+
const previous = readResults(runDir);
|
|
9
|
+
const context = { skillDir, specDir, scenarios: spec.scenarios, judgePersona: spec.judge_persona };
|
|
10
|
+
const recorded = previous.source_hashes;
|
|
11
|
+
const current = sourceHashes(context);
|
|
12
|
+
const changed = Object.keys({ ...recorded, ...current }).filter(key => {
|
|
13
|
+
if (isSupersededKey(key, { ...recorded, ...current }))
|
|
14
|
+
return false;
|
|
15
|
+
return !recorded || !(key in recorded) || currentHashFor(key, context) !== recorded[key];
|
|
16
|
+
});
|
|
17
|
+
return { providerCalls: 0, resultsChanged: false, scenarios: spec.scenarios.map(s => {
|
|
18
|
+
const prior = previous.scenarios.find(p => p.id === s.id);
|
|
19
|
+
const relevant = changed.filter(key => {
|
|
20
|
+
if (/^(stimulus|rubric|policy|gates|scenario):/.test(key) && key !== "rubric:__persona")
|
|
21
|
+
return key.slice(key.indexOf(":") + 1) === s.id;
|
|
22
|
+
if (key.startsWith("fixture:"))
|
|
23
|
+
return key.slice(8) === (typeof s.workspace === "object" ? s.workspace.fixture : s.fixture);
|
|
24
|
+
if (key.startsWith("prompt:"))
|
|
25
|
+
return key.slice(7) === s.systemPromptFile;
|
|
26
|
+
if (!key.includes(":") && key !== "SKILL.md")
|
|
27
|
+
return [s.systemPromptFile, s.assert?.post_test, ...(s.extensions ?? [])].includes(key);
|
|
28
|
+
return true;
|
|
29
|
+
});
|
|
30
|
+
const base = { id: s.id, changed: relevant };
|
|
31
|
+
if (!prior)
|
|
32
|
+
return { ...base, action: "rerun", note: "No saved answer exists for this scenario." };
|
|
33
|
+
const files = findTranscriptFiles(runDir, s.id, previous.mode);
|
|
34
|
+
const reps = prior.reps ?? 1;
|
|
35
|
+
const complete = Number.isSafeInteger(reps) && reps > 0 && files.length === reps && files.every((file, rep) => {
|
|
36
|
+
if (file !== basename(transcriptPath(runDir, s.id, previous.mode, reps === 1 ? undefined : rep)))
|
|
37
|
+
return false;
|
|
38
|
+
try {
|
|
39
|
+
const stat = lstatSync(join(runDir, file));
|
|
40
|
+
return stat.isFile() && !stat.isSymbolicLink() && stat.size > 0;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
if (!complete)
|
|
47
|
+
return { ...base, action: "inspect", note: "Saved answers are incomplete; recover original evidence before planning paid work." };
|
|
48
|
+
if (!recorded || !recorded[`stimulus:${s.id}`])
|
|
49
|
+
return { ...base, action: "inspect", note: "Input identity is incomplete; do not assume a saved answer matches this task." };
|
|
50
|
+
if (relevant.some(key => key in recorded && currentHashFor(key, context) === undefined))
|
|
51
|
+
return { ...base, action: "inspect", note: "An input identity cannot be interpreted by this harness; inspect the original evidence before planning a rerun." };
|
|
52
|
+
if (prior.judge_verdict === "ERROR")
|
|
53
|
+
return { ...base, action: "inspect", note: "Resolve transport/capture errors first; recover saved answers before considering another subject run." };
|
|
54
|
+
const stimulusChanged = relevant.some(key => !/^(rubric|gates|policy):/.test(key));
|
|
55
|
+
if (stimulusChanged)
|
|
56
|
+
return { ...base, action: "rerun", note: "Subject inputs or skill changed. Old answers remain historical and cannot establish current behavior." };
|
|
57
|
+
if (relevant.some(k => k.startsWith("gates:")))
|
|
58
|
+
return { ...base, action: "regate", note: "Replay supported assertions on saved evidence; missing execution evidence remains unknown. Regrade too if the rubric changed." };
|
|
59
|
+
if (relevant.some(k => k.startsWith("rubric:")))
|
|
60
|
+
return { ...base, action: "regrade", note: "Judge the saved answers against corrected references/rubric; no subject call is needed." };
|
|
61
|
+
if (relevant.some(k => k.startsWith("policy:")))
|
|
62
|
+
return { ...base, action: "rescore", note: "Recompute policy using retained verdicts; no provider calls." };
|
|
63
|
+
return { ...base, action: "reuse", note: "Inputs match. Retain the existing verdict, including failures; this is not a new stability sample." };
|
|
64
|
+
}) };
|
|
65
|
+
}
|
|
66
|
+
/** Copy a selected run, with limits and byte identities, before revising its judgments. */
|
|
67
|
+
export function forkSavedRun(source, destination) {
|
|
68
|
+
const root = realpathSync(source), out = join(realpathSync(dirname(resolve(destination))), basename(destination));
|
|
69
|
+
const rel = relative(root, out);
|
|
70
|
+
if (!rel || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\")))
|
|
71
|
+
throw new Error("Saved-run output must be outside the original run");
|
|
72
|
+
const files = [];
|
|
73
|
+
let bytes = 0, entries = 0;
|
|
74
|
+
const sha = (data) => createHash("sha256").update(data).digest("hex");
|
|
75
|
+
const visit = (dir, prefix, depth) => {
|
|
76
|
+
if (depth > 16)
|
|
77
|
+
throw new Error("Saved-run copy exceeds directory depth limit");
|
|
78
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
79
|
+
if (++entries > 4096)
|
|
80
|
+
throw new Error("Saved-run copy exceeds 4096 entries");
|
|
81
|
+
const path = join(dir, entry.name), key = prefix + entry.name, stat = lstatSync(path);
|
|
82
|
+
if (stat.isSymbolicLink())
|
|
83
|
+
throw new Error("Saved-run copy refuses symbolic links");
|
|
84
|
+
if (stat.isDirectory())
|
|
85
|
+
visit(path, key + "/", depth + 1);
|
|
86
|
+
else if (stat.isFile()) {
|
|
87
|
+
bytes += stat.size;
|
|
88
|
+
if (stat.size > 64 * 1024 * 1024 || bytes > 256 * 1024 * 1024)
|
|
89
|
+
throw new Error("Saved-run copy exceeds evidence byte limit; select a smaller run");
|
|
90
|
+
files.push({ path: key, bytes: stat.size, sha256: sha(readFileSync(path)) });
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
throw new Error("Saved-run copy requires regular files");
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
visit(root, "", 0);
|
|
97
|
+
if (!files.some(f => f.path === "results.yaml"))
|
|
98
|
+
throw new Error("Saved run lacks results.yaml");
|
|
99
|
+
mkdirSync(out, { mode: 0o700 }); // Exclusive: never overwrite another result directory.
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
const data = readFileSync(join(root, file.path));
|
|
102
|
+
if (sha(data) !== file.sha256)
|
|
103
|
+
throw new Error("Saved evidence changed during copy; output is incomplete");
|
|
104
|
+
const target = join(out, file.path);
|
|
105
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
106
|
+
writeFileSync(target, data, { flag: "wx", mode: 0o600 });
|
|
107
|
+
}
|
|
108
|
+
for (const file of files)
|
|
109
|
+
if (sha(readFileSync(join(root, file.path))) !== file.sha256 || sha(readFileSync(join(out, file.path))) !== file.sha256)
|
|
110
|
+
throw new Error("Saved evidence verification failed; output is incomplete");
|
|
111
|
+
writeFileSync(join(out, `reuse-source-${Date.now()}.json`), JSON.stringify({ schema: 1, purpose: "saved-answer-regrade", source: root, files, subjectCalls: 0 }, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=saved-run.js.map
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
* Run `tasks` with at most `concurrency` thunks in flight at once, returning
|
|
3
3
|
* their results in input order (not completion order). `concurrency <= 1` runs
|
|
4
4
|
* them strictly sequentially — identical to a plain for-await loop. A thunk that
|
|
5
|
-
* throws
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* further tasks before the rejection unwinds; the guarantee is that runPool
|
|
9
|
-
* rejects, not that dispatch halts.
|
|
5
|
+
* throws stops new dispatch; already claimed tasks settle before rejection.
|
|
6
|
+
* A surrounding process scope supplies cancellation to active subprocesses.
|
|
7
|
+
* Waiting for settlement prevents reporting a stopped run while its workers remain active.
|
|
10
8
|
*/
|
|
11
9
|
export declare function runPool<T>(tasks: Array<() => Promise<T>>, concurrency: number): Promise<T[]>;
|
package/dist/scheduler.js
CHANGED
|
@@ -2,26 +2,38 @@
|
|
|
2
2
|
* Run `tasks` with at most `concurrency` thunks in flight at once, returning
|
|
3
3
|
* their results in input order (not completion order). `concurrency <= 1` runs
|
|
4
4
|
* them strictly sequentially — identical to a plain for-await loop. A thunk that
|
|
5
|
-
* throws
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* further tasks before the rejection unwinds; the guarantee is that runPool
|
|
9
|
-
* rejects, not that dispatch halts.
|
|
5
|
+
* throws stops new dispatch; already claimed tasks settle before rejection.
|
|
6
|
+
* A surrounding process scope supplies cancellation to active subprocesses.
|
|
7
|
+
* Waiting for settlement prevents reporting a stopped run while its workers remain active.
|
|
10
8
|
*/
|
|
11
9
|
export async function runPool(tasks, concurrency) {
|
|
12
10
|
const limit = Math.max(1, Math.floor(concurrency));
|
|
13
11
|
const results = new Array(tasks.length);
|
|
14
12
|
let next = 0;
|
|
13
|
+
let failure;
|
|
14
|
+
let failed = false;
|
|
15
15
|
async function worker() {
|
|
16
16
|
while (true) {
|
|
17
|
+
if (failed)
|
|
18
|
+
return;
|
|
17
19
|
const i = next++;
|
|
18
20
|
if (i >= tasks.length)
|
|
19
21
|
return;
|
|
20
|
-
|
|
22
|
+
try {
|
|
23
|
+
results[i] = await tasks[i]();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (!failed)
|
|
27
|
+
failure = error;
|
|
28
|
+
failed = true;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
21
31
|
}
|
|
22
32
|
}
|
|
23
33
|
const workerCount = Math.min(limit, tasks.length);
|
|
24
34
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
35
|
+
if (failed)
|
|
36
|
+
throw failure;
|
|
25
37
|
return results;
|
|
26
38
|
}
|
|
27
39
|
//# sourceMappingURL=scheduler.js.map
|
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
|