@skill-harness/core 0.8.0 → 0.10.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 +3 -0
- package/dist/index.js +3 -0
- package/dist/lint.js +6 -0
- 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/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.js +11 -3
- package/dist/spec.d.ts +15 -0
- package/dist/spec.js +53 -1
- 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/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>>;
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export async function withSandbox(backend, request, run) {
|
|
2
|
+
const session = await backend.prepare(request);
|
|
3
|
+
try {
|
|
4
|
+
const value = await run(session);
|
|
5
|
+
const diff = await session.captureDiff();
|
|
6
|
+
return { value, diff, backend: backend.name, containment: backend.containment };
|
|
7
|
+
}
|
|
8
|
+
finally {
|
|
9
|
+
await session.cleanup();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=sandbox.js.map
|
package/dist/score.d.ts
CHANGED
|
@@ -22,12 +22,14 @@ export interface ScoreResult {
|
|
|
22
22
|
criticalFails: number;
|
|
23
23
|
bSeriesFails: number;
|
|
24
24
|
suspectCount: number;
|
|
25
|
+
errorCount: number;
|
|
25
26
|
note: string;
|
|
26
27
|
}
|
|
27
28
|
export declare function letterFor(pct: number): string;
|
|
28
29
|
/**
|
|
29
30
|
* Score a set of green-mode verdicts against the ship bar. A scenario PASSes only
|
|
30
|
-
* on verdict PASS; FAIL
|
|
31
|
+
* on verdict PASS; FAIL is behavioral, while ERROR/JUDGE-AMBIGUOUS are excluded as
|
|
32
|
+
* infrastructure/indeterminate and independently block SHIP. A `suspect` verdict (an
|
|
31
33
|
* unresolved judge misfire) is excluded from both `passed` and `total` — it is
|
|
32
34
|
* untrustworthy, neither a pass nor a fail — and any suspect count blocks SHIP
|
|
33
35
|
* until an author override resolves it. SHIP otherwise requires: enough total
|
package/dist/score.js
CHANGED
|
@@ -11,7 +11,8 @@ export function letterFor(pct) {
|
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
13
|
* Score a set of green-mode verdicts against the ship bar. A scenario PASSes only
|
|
14
|
-
* on verdict PASS; FAIL
|
|
14
|
+
* on verdict PASS; FAIL is behavioral, while ERROR/JUDGE-AMBIGUOUS are excluded as
|
|
15
|
+
* infrastructure/indeterminate and independently block SHIP. A `suspect` verdict (an
|
|
15
16
|
* unresolved judge misfire) is excluded from both `passed` and `total` — it is
|
|
16
17
|
* untrustworthy, neither a pass nor a fail — and any suspect count blocks SHIP
|
|
17
18
|
* until an author override resolves it. SHIP otherwise requires: enough total
|
|
@@ -25,11 +26,16 @@ export function score(verdicts, input) {
|
|
|
25
26
|
let criticalFails = 0;
|
|
26
27
|
let bSeriesFails = 0;
|
|
27
28
|
let suspectCount = 0;
|
|
29
|
+
let errorCount = 0;
|
|
28
30
|
for (const v of verdicts) {
|
|
29
31
|
if (v.suspect) {
|
|
30
32
|
suspectCount++;
|
|
31
33
|
continue; // untrustworthy: neither pass nor fail
|
|
32
34
|
}
|
|
35
|
+
if (v.verdict === "ERROR" || v.verdict === "JUDGE-AMBIGUOUS") {
|
|
36
|
+
errorCount++;
|
|
37
|
+
continue; // infrastructure/indeterminate: neither behavioral pass nor fail
|
|
38
|
+
}
|
|
33
39
|
total++;
|
|
34
40
|
if (v.verdict === "PASS") {
|
|
35
41
|
passed++;
|
|
@@ -42,21 +48,30 @@ export function score(verdicts, input) {
|
|
|
42
48
|
}
|
|
43
49
|
const pct = total > 0 ? Math.round((passed * 100) / total) : 0;
|
|
44
50
|
const letter = letterFor(pct);
|
|
45
|
-
const
|
|
51
|
+
const validBar = Number.isInteger(shipBar.total) && shipBar.total >= 1 && Number.isInteger(shipBar.min_pass) && shipBar.min_pass >= 1 && shipBar.min_pass <= shipBar.total;
|
|
52
|
+
const ship = validBar &&
|
|
53
|
+
total >= shipBar.total &&
|
|
46
54
|
passed >= shipBar.min_pass &&
|
|
47
55
|
(!shipBar.no_critical_fail || criticalFails === 0) &&
|
|
48
56
|
bSeriesFails === 0 &&
|
|
49
|
-
suspectCount === 0
|
|
57
|
+
suspectCount === 0 &&
|
|
58
|
+
errorCount === 0;
|
|
50
59
|
let note = "";
|
|
51
|
-
if (
|
|
60
|
+
if (!validBar) {
|
|
61
|
+
note = "invalid ship bar: total/min_pass must be positive integers with min_pass <= total";
|
|
62
|
+
}
|
|
63
|
+
else if (suspectCount > 0) {
|
|
52
64
|
note = `${suspectCount} suspect: re-judge/resolve`;
|
|
53
65
|
}
|
|
66
|
+
else if (errorCount > 0) {
|
|
67
|
+
note = `${errorCount} infrastructure error${errorCount === 1 ? "" : "s"}: retry/repair evidence`;
|
|
68
|
+
}
|
|
54
69
|
else if (criticalFails > 0) {
|
|
55
70
|
note = `gated: ${criticalFails} critical fail${criticalFails === 1 ? "" : "s"}`;
|
|
56
71
|
}
|
|
57
72
|
else if (bSeriesFails > 0) {
|
|
58
73
|
note = `gated: ${bSeriesFails} B-series fail${bSeriesFails === 1 ? "" : "s"}`;
|
|
59
74
|
}
|
|
60
|
-
return { passed, total, pct, letter, ship, criticalFails, bSeriesFails, suspectCount, note };
|
|
75
|
+
return { passed, total, pct, letter, ship, criticalFails, bSeriesFails, suspectCount, errorCount, note };
|
|
61
76
|
}
|
|
62
77
|
//# sourceMappingURL=score.js.map
|
package/dist/seeded.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Scenario } from "./spec.js";
|
|
2
2
|
import type { ExecutionTraceV1 } from "./capture-trace-types.js";
|
|
3
|
+
import type { TrajectoryEventV1 } from "./trajectory-gates.js";
|
|
3
4
|
import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
|
|
4
5
|
import { type ExecResult } from "./util/exec.js";
|
|
5
6
|
interface SeededOpts {
|
|
@@ -42,9 +43,14 @@ export type VitestRun = ExecResult;
|
|
|
42
43
|
export interface SeededOutcome {
|
|
43
44
|
transcript: string;
|
|
44
45
|
gateFailure: string | null;
|
|
46
|
+
gateError: string | null;
|
|
45
47
|
diff: string;
|
|
46
|
-
/** One per turn; empty unless
|
|
48
|
+
/** One per turn; empty unless structured execution was requested. */
|
|
47
49
|
traces: ExecutionTraceV1[];
|
|
50
|
+
/** Adapter-neutral workflow/tool events for assert.trajectory. */
|
|
51
|
+
events: TrajectoryEventV1[];
|
|
52
|
+
/** Required native sources that could not be read or normalized. */
|
|
53
|
+
eventErrors: string[];
|
|
48
54
|
}
|
|
49
55
|
/**
|
|
50
56
|
* The added/removed lines of a unified diff — what the model actually *changed*,
|