@skill-harness/core 0.5.0 → 0.7.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 +16 -1
- package/dist/lint.js +52 -0
- package/dist/regate.js +80 -17
- package/dist/regrade.js +17 -3
- package/dist/report.d.ts +48 -0
- package/dist/report.js +39 -1
- package/dist/reps.d.ts +14 -1
- package/dist/reps.js +28 -2
- package/dist/rescore.js +11 -2
- package/dist/results.d.ts +128 -6
- package/dist/results.js +155 -6
- package/dist/run.d.ts +9 -1
- package/dist/run.js +129 -9
- package/dist/seeded.d.ts +11 -0
- package/dist/seeded.js +31 -7
- package/dist/sources.d.ts +26 -0
- package/dist/sources.js +82 -3
- 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.d.ts +144 -0
- package/dist/stability.js +232 -0
- package/dist/trace-gates.d.ts +133 -0
- package/dist/trace-gates.js +519 -0
- package/dist/trends.d.ts +28 -0
- package/dist/trends.js +76 -61
- package/dist/workspace.d.ts +36 -0
- package/dist/workspace.js +61 -0
- package/package.json +1 -1
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
|
/**
|
package/dist/results.js
CHANGED
|
@@ -74,18 +74,58 @@ export function reportPath(runDir) {
|
|
|
74
74
|
export function resultsPath(runDir) {
|
|
75
75
|
return join(runDir, "results.yaml");
|
|
76
76
|
}
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* The verdict that counts: author override when present, else the objective
|
|
79
|
+
* gate, else the judge's.
|
|
80
|
+
*
|
|
81
|
+
* **The objective gate outranks the judge.** `assert.trace` is a mechanical
|
|
82
|
+
* statement about what the model DID — it called `write`, it touched `.env`.
|
|
83
|
+
* The judge is an LLM reading prose. When they disagree, the measurement wins.
|
|
84
|
+
*
|
|
85
|
+
* This is the only place that ordering is enforced, and it has to be here.
|
|
86
|
+
* `objective` used to reach the ship decision solely through `gatePrefix` in
|
|
87
|
+
* `run.ts`, which forces a single rep's verdict — so every path that recomputed
|
|
88
|
+
* a verdict afterwards silently dropped the gate while keeping the `objective`
|
|
89
|
+
* block that claimed it was enforced. Three of them did: `--reps N` out-voted an
|
|
90
|
+
* objective FAIL 2-to-1 (100%, grade A, SHIP, on a CRITICAL scenario that called
|
|
91
|
+
* a forbidden tool), `regrade` re-judged from a transcript the tool calls are
|
|
92
|
+
* absent from, and `regate` recomputed it. `reps.ts` already states the policy —
|
|
93
|
+
* "one rep that called a forbidden tool is a real finding, not a minority draw
|
|
94
|
+
* to be voted away" — and nothing enforced it.
|
|
95
|
+
*
|
|
96
|
+
* An author override still wins, exactly as it does over `suspect`. Overriding a
|
|
97
|
+
* deterministic assertion is a deliberate, recorded human act — and the failure
|
|
98
|
+
* this guards against was never a human deciding, it was nobody deciding.
|
|
99
|
+
*/
|
|
78
100
|
export function effectiveVerdicts(scenarios) {
|
|
79
101
|
return scenarios.map((s) => ({
|
|
80
102
|
id: s.id,
|
|
81
|
-
verdict: s.override ?? s.judge_verdict,
|
|
103
|
+
verdict: s.override ?? objectiveVerdict(s) ?? s.judge_verdict,
|
|
82
104
|
suspect: s.suspect && s.override == null, // an override resolves the misfire
|
|
83
105
|
}));
|
|
84
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The verdict an objective gate forces, or undefined when it forces nothing.
|
|
109
|
+
*
|
|
110
|
+
* ERROR outranks FAIL: "the evidence is missing" must never read as "the
|
|
111
|
+
* assertion held". Absent `objective` forces nothing at all — it means the
|
|
112
|
+
* scenario declared no trace assertions, and treating that as a pass would
|
|
113
|
+
* upgrade every legacy result to "objectively verified".
|
|
114
|
+
*/
|
|
115
|
+
function objectiveVerdict(s) {
|
|
116
|
+
if (!s.objective)
|
|
117
|
+
return undefined;
|
|
118
|
+
if (s.objective.status === "ERROR")
|
|
119
|
+
return "ERROR";
|
|
120
|
+
if (s.objective.status === "FAIL")
|
|
121
|
+
return "FAIL";
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
85
124
|
/**
|
|
86
125
|
* The ONLY place effective_grade is computed. Every writer goes through here,
|
|
87
126
|
* so a persisted grade can never disagree with verdicts + overrides.
|
|
88
|
-
* ctx is null for unscored
|
|
127
|
+
* ctx is null for unscored runs — `red` only, since 0.5.0: `force` is a real
|
|
128
|
+
* deployment and is scored (see SCORED_MODES directly above).
|
|
89
129
|
*/
|
|
90
130
|
export function finalizeResults(draft, ctx) {
|
|
91
131
|
let effective_grade;
|
|
@@ -208,7 +248,11 @@ export function ensureResultsGitignore(resultsRoot) {
|
|
|
208
248
|
}
|
|
209
249
|
// Matches transcript (`.rep<k>.txt`), judge-raw (`.rep<k>.judge.txt`) and
|
|
210
250
|
// staged-diff (`.rep<k>.diff.txt`) rep suffixes.
|
|
211
|
-
|
|
251
|
+
// Every rep-suffixed artifact kind. `.trace.jsonl` was added without updating this,
|
|
252
|
+
// so `repIndexOf` returned null for traces and `regate` looked for an unsuffixed
|
|
253
|
+
// path that does not exist on a multi-rep run — reporting "trace missing" for
|
|
254
|
+
// traces sitting on disk.
|
|
255
|
+
const REP_SUFFIX_RE = /\.rep(\d+)\.(?:judge\.|diff\.)?(?:txt|trace\.jsonl)$/;
|
|
212
256
|
/** The rep index embedded in a transcript / judge-raw / staged-diff filename (`.rep<k>.`), or null for a plain (non-rep) file. */
|
|
213
257
|
export function repIndexOf(filename) {
|
|
214
258
|
const m = REP_SUFFIX_RE.exec(filename);
|
|
@@ -268,9 +312,13 @@ export function findJudgeRawFiles(runDir, scenarioId, mode) {
|
|
|
268
312
|
if (!existsSync(runDir))
|
|
269
313
|
return [];
|
|
270
314
|
const esc = scenarioId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
315
|
+
// `judge2`/`judge3` are the second and third opinions adjudication writes. They
|
|
316
|
+
// were not matched here, so an override on an adjudicated cell committed the
|
|
317
|
+
// first judge's answer and silently dropped the very judgments the adjudication
|
|
318
|
+
// rested on — the audit trail minus its evidence.
|
|
271
319
|
const re = mode === undefined
|
|
272
|
-
? new RegExp(`^${esc}\\..*\\.judge
|
|
273
|
-
: new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.judge
|
|
320
|
+
? new RegExp(`^${esc}\\..*\\.judge\\d*\\.txt$`)
|
|
321
|
+
: new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.judge\\d*\\.txt$`);
|
|
274
322
|
return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
|
|
275
323
|
}
|
|
276
324
|
/**
|
|
@@ -287,6 +335,92 @@ export function diffPath(runDir, scenarioId, mode, rep) {
|
|
|
287
335
|
const base = rep === undefined ? `${scenarioId}.${mode}` : `${scenarioId}.${mode}.rep${rep}`;
|
|
288
336
|
return join(runDir, `${base}.diff.txt`);
|
|
289
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Rebuild a `ScenarioResult` after a command re-measured part of it.
|
|
340
|
+
*
|
|
341
|
+
* **The single choke point for every rewriter**, and exhaustive by construction:
|
|
342
|
+
* every field is destructured below, so adding one to `ScenarioResult` fails the
|
|
343
|
+
* build HERE until someone decides whether it is carried, taken fresh, or
|
|
344
|
+
* dropped. That guard is the whole point of the function.
|
|
345
|
+
*
|
|
346
|
+
* It exists because the ad-hoc version — `{ ...fresh, override: prior.override,
|
|
347
|
+
* note: prior.note }`, written independently in three places — silently dropped
|
|
348
|
+
* `objective` from `grade` and `adjudication` from `regate`. Both failures ran in
|
|
349
|
+
* the dangerous direction: a trace-gated scenario re-read as "no assertions
|
|
350
|
+
* declared", and an unresolved judge disagreement as a settled verdict. 1,036
|
|
351
|
+
* tests passed through it; a real smoke run caught it.
|
|
352
|
+
*
|
|
353
|
+
* The author's `override` and `note` are always carried and are not policy —
|
|
354
|
+
* no command re-measures a human's judgement.
|
|
355
|
+
*/
|
|
356
|
+
export function rebuildScenarioResult(fresh, prior, policy) {
|
|
357
|
+
// Exhaustive destructure. Do not replace with a spread: the spread is what
|
|
358
|
+
// 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;
|
|
360
|
+
const _exhaustive = rest;
|
|
361
|
+
void _exhaustive;
|
|
362
|
+
void _freshOverride;
|
|
363
|
+
void _freshNote;
|
|
364
|
+
const pick = (p, freshValue, priorValue) => {
|
|
365
|
+
if (p === "drop")
|
|
366
|
+
return undefined;
|
|
367
|
+
return p === "fresh" ? freshValue : priorValue;
|
|
368
|
+
};
|
|
369
|
+
const objective = pick(policy.objective, freshObjective, prior?.objective);
|
|
370
|
+
const adjudication = pick(policy.adjudication, freshAdjudication, prior?.adjudication);
|
|
371
|
+
// `unresolved` is carried by the `suspect` flag and by nothing else — the
|
|
372
|
+
// adjudication block records WHY, but `suspect` is what the ship bar reads.
|
|
373
|
+
// Taking `suspect` from `fresh` while carrying the block therefore published a
|
|
374
|
+
// record that said `state: "unresolved"` and scored as a clean SHIP. `regate`
|
|
375
|
+
// did exactly that: it rebuilds verdict and suspect from the saved first-wave
|
|
376
|
+
// judge file, so a free, offline command silently resolved a disagreement in
|
|
377
|
+
// favour of shipping — inverting this module's stated invariant that an
|
|
378
|
+
// unresolved disagreement must not resolve itself.
|
|
379
|
+
const unresolved = adjudication?.state === "unresolved";
|
|
380
|
+
// A settled adjudication outranks a re-read of the first-wave judge file for
|
|
381
|
+
// the same reason: `regate` re-measures GATES, not judgments, so re-reading
|
|
382
|
+
// `<id>.judge.txt` would revert a `confirmed`/`tie_broken` verdict that two or
|
|
383
|
+
// three judges settled — leaving `adjudication.verdict` on the record
|
|
384
|
+
// contradicting the `judge_verdict` beside it.
|
|
385
|
+
const settled = policy.adjudication === "carry" ? adjudication?.verdict : undefined;
|
|
386
|
+
// Field ORDER matches `outcomesToResult`, the writer that produces a run's
|
|
387
|
+
// results.yaml in the first place. It is not cosmetic: `results.yaml` is a
|
|
388
|
+
// committed file, and emitting the same fields in a different order made every
|
|
389
|
+
// `grade`/`regate`/`adjudicate` rewrite every multi-rep scenario block in the
|
|
390
|
+
// corpus with a pure-noise diff.
|
|
391
|
+
return {
|
|
392
|
+
id,
|
|
393
|
+
judge_verdict: settled ?? judge_verdict,
|
|
394
|
+
judge_reason,
|
|
395
|
+
suspect: suspect || unresolved,
|
|
396
|
+
// Aggregation shape always comes from the fresh computation — these describe
|
|
397
|
+
// how THIS result was aggregated, not the previous one.
|
|
398
|
+
...(reps === undefined ? {} : { reps }),
|
|
399
|
+
...(passes === undefined ? {} : { passes }),
|
|
400
|
+
...(clean === undefined ? {} : { clean }),
|
|
401
|
+
...(flakiness === undefined ? {} : { flakiness }),
|
|
402
|
+
...(pass_threshold === undefined ? {} : { pass_threshold }),
|
|
403
|
+
// The author owns the verdict; a re-measurement never discards their call.
|
|
404
|
+
override: prior?.override ?? null,
|
|
405
|
+
note: prior?.note ?? "",
|
|
406
|
+
// Omitted rather than set to undefined: absent must stay absent, so a result
|
|
407
|
+
// with no evidence serialises byte-identically to one from before the field
|
|
408
|
+
// existed.
|
|
409
|
+
...(objective ? { objective } : {}),
|
|
410
|
+
...(adjudication ? { adjudication } : {}),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Where a rep's execution trace is saved: `<id>.<mode>[.rep<k>].trace.jsonl`.
|
|
415
|
+
*
|
|
416
|
+
* `.jsonl` rather than `.txt` so it is distinguishable at a glance from a
|
|
417
|
+
* transcript, and one JSON object per line so a multi-turn scenario's per-turn
|
|
418
|
+
* traces append without a wrapper.
|
|
419
|
+
*/
|
|
420
|
+
export function tracePath(runDir, scenarioId, mode, rep) {
|
|
421
|
+
const base = rep === undefined ? `${scenarioId}.${mode}` : `${scenarioId}.${mode}.rep${rep}`;
|
|
422
|
+
return join(runDir, `${base}.trace.jsonl`);
|
|
423
|
+
}
|
|
290
424
|
/** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
|
|
291
425
|
export function findDiffFiles(runDir, scenarioId, mode) {
|
|
292
426
|
if (!existsSync(runDir))
|
|
@@ -297,6 +431,16 @@ export function findDiffFiles(runDir, scenarioId, mode) {
|
|
|
297
431
|
: new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.diff\\.txt$`);
|
|
298
432
|
return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
|
|
299
433
|
}
|
|
434
|
+
/** A scenario's execution-trace files, sorted (plain first, then numeric rep). Mode-scoped when given. */
|
|
435
|
+
export function findTraceFiles(runDir, scenarioId, mode) {
|
|
436
|
+
if (!existsSync(runDir))
|
|
437
|
+
return [];
|
|
438
|
+
const esc = scenarioId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
439
|
+
const re = mode === undefined
|
|
440
|
+
? new RegExp(`^${esc}\\..*\\.trace\\.jsonl$`)
|
|
441
|
+
: new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.trace\\.jsonl$`);
|
|
442
|
+
return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
|
|
443
|
+
}
|
|
300
444
|
/** A single representative transcript file for a scenario in a run dir. Null if none. */
|
|
301
445
|
export function findTranscriptFile(runDir, scenarioId) {
|
|
302
446
|
return findTranscriptFiles(runDir, scenarioId)[0] ?? null;
|
|
@@ -320,6 +464,11 @@ export function preserveTranscript(resultsRoot, runDir, scenarioId) {
|
|
|
320
464
|
...findTranscriptFiles(runDir, scenarioId),
|
|
321
465
|
...findJudgeRawFiles(runDir, scenarioId),
|
|
322
466
|
...findDiffFiles(runDir, scenarioId),
|
|
467
|
+
// On a trace-gated scenario the trace IS the evidence for the override — the
|
|
468
|
+
// same role the staged diff plays on a seeded one. Omitting it committed an
|
|
469
|
+
// override whose justification was gitignored, and left `regate` with nothing
|
|
470
|
+
// to re-evaluate on the one cell a human had disputed.
|
|
471
|
+
...findTraceFiles(runDir, scenarioId),
|
|
323
472
|
];
|
|
324
473
|
if (files.length === 0)
|
|
325
474
|
return;
|
package/dist/run.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Spec } from "./spec.js";
|
|
|
2
2
|
import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
|
|
3
3
|
import { type ResultsFile } from "./results.js";
|
|
4
4
|
import { type Lift } from "./lift.js";
|
|
5
|
+
import { type ScenarioStability } from "./stability.js";
|
|
5
6
|
export interface RunOptions {
|
|
6
7
|
spec: Spec;
|
|
7
8
|
skillDir: string;
|
|
@@ -55,5 +56,12 @@ export declare function hasEmptyAssistantTurn(transcript: string): boolean;
|
|
|
55
56
|
* exists. Passing it for a green run turns the scorecard from "the skill scored
|
|
56
57
|
* B" into "the skill *did* this much" — without a baseline the grade alone can't
|
|
57
58
|
* distinguish a skill that works from a model that never needed it.
|
|
59
|
+
*
|
|
60
|
+
* `stability` is the run-over-run half of the same argument, and it is derived from
|
|
61
|
+
* history rather than from this run: a cell that flipped its verdict between the last
|
|
62
|
+
* two runs of this skill × model × mode is worth less than the ✓ beside it suggests,
|
|
63
|
+
* and this run's own `flaky 0.00` cannot say so — it only ever looked at one run. Pass
|
|
64
|
+
* the cells for THIS tag and mode; anything else would report another model's history
|
|
65
|
+
* under this model's scorecard.
|
|
58
66
|
*/
|
|
59
|
-
export declare function formatScorecard(summary: RunSummary, lift?: Lift): string;
|
|
67
|
+
export declare function formatScorecard(summary: RunSummary, lift?: Lift, stability?: ScenarioStability[]): string;
|
package/dist/run.js
CHANGED
|
@@ -2,15 +2,18 @@ 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, writeResults, ensureResultsGitignore, scoreContextFor, isScoredMode, } from "./results.js";
|
|
5
|
+
import { runDirFor, transcriptPath, diffPath, tracePath, writeResults, ensureResultsGitignore, scoreContextFor, 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
|
-
import {
|
|
9
|
+
import { serializeTrace, mergeTraces, traceSha256 } from "./execution-trace.js";
|
|
10
|
+
import { evaluateTraceGates } from "./trace-gates.js";
|
|
11
|
+
import { snapshotPaths, diffSnapshots, createWorkspace } from "./workspace.js";
|
|
10
12
|
import { runPool } from "./scheduler.js";
|
|
11
13
|
import { outcomesToResult } from "./reps.js";
|
|
12
14
|
import { judgeOneRep } from "./regrade.js";
|
|
13
15
|
import { runDeliveryCanary, canaryFailure } from "./canary.js";
|
|
16
|
+
import { boundaryCells, stabilityNote } from "./stability.js";
|
|
14
17
|
/** Run one skill against one model: run scenarios, grade, score, persist. */
|
|
15
18
|
export async function runSkillModel(opts) {
|
|
16
19
|
const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
|
|
@@ -76,8 +79,13 @@ export async function runSkillModel(opts) {
|
|
|
76
79
|
});
|
|
77
80
|
if (canary.status === "fail")
|
|
78
81
|
throw new Error(canaryFailure(spec.skill, canary, harnessCliVersion));
|
|
79
|
-
if (canary.status === "skipped")
|
|
82
|
+
if (canary.status === "skipped") {
|
|
83
|
+
// Recorded, not just logged: `journal.jsonl` is gitignored, and the claim
|
|
84
|
+
// "this run's delivery was verified" has to survive a commit — including
|
|
85
|
+
// when it is the claim that it wasn't.
|
|
86
|
+
canaryStatus = "skipped";
|
|
80
87
|
log(` ⚠ delivery canary skipped — ${canary.detail}`);
|
|
88
|
+
}
|
|
81
89
|
else {
|
|
82
90
|
canaryStatus = "pass";
|
|
83
91
|
log(` ✓ delivery canary — the model quoted its skill instructions back (\`${canary.anchor}\`)`);
|
|
@@ -167,6 +175,14 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
167
175
|
transcript = `[workspace setup failed] ${gatePrefix}`;
|
|
168
176
|
}
|
|
169
177
|
let noResponse = false;
|
|
178
|
+
let traces = [];
|
|
179
|
+
let unobservablePaths = false;
|
|
180
|
+
// The pre-run state, captured AFTER `createWorkspace` has applied the
|
|
181
|
+
// fixture's `_staged/` and `_uncommitted/` trees. Those land after the
|
|
182
|
+
// baseline commit, so a fixture that ships a deliberately dirty tree was
|
|
183
|
+
// being reported as changes the model made — a fabricated FAIL, written into
|
|
184
|
+
// a committed results.yaml, naming files the model never touched.
|
|
185
|
+
let before = ws ? snapshotPaths(ws.cwd, scenario.workspace) : null;
|
|
170
186
|
if (ws) {
|
|
171
187
|
// A blank assistant turn is a harness timeout, not model behavior: retry ONCE in a
|
|
172
188
|
// fresh workspace (the first attempt may have half-mutated a seeded repo), and if
|
|
@@ -177,24 +193,46 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
177
193
|
log(` ${scenario.id}${repCount > 1 ? `#${rep}` : ""} empty response — retrying once`);
|
|
178
194
|
ws.cleanup();
|
|
179
195
|
ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
|
|
196
|
+
// A fresh workspace needs a fresh baseline, or the retry's diff would
|
|
197
|
+
// be taken against a directory that no longer exists.
|
|
198
|
+
before = snapshotPaths(ws.cwd, scenario.workspace);
|
|
180
199
|
}
|
|
181
200
|
if (scenario.mode === "seeded") {
|
|
182
201
|
const r = await runSeeded(scenario, {
|
|
183
202
|
skillDir: ctx.skillDir, adapter: ctx.adapter, model: ctx.model, mode, cwd: ws.cwd,
|
|
184
203
|
specDir: dirname(ctx.specPath), // assert.post_test resolves like a fixture
|
|
204
|
+
trace: scenario.traceAssert ? { scenarioId: scenario.id, rep } : undefined,
|
|
185
205
|
});
|
|
186
206
|
transcript = r.transcript;
|
|
187
207
|
gatePrefix = r.gateFailure;
|
|
188
208
|
stagedDiff = r.diff; // a retry replaces the aborted attempt's diff, as it should
|
|
209
|
+
traces = r.traces;
|
|
189
210
|
}
|
|
190
211
|
else {
|
|
191
|
-
|
|
212
|
+
const req = {
|
|
192
213
|
skillDir: ctx.skillDir, model: ctx.model, mode, turns: scenario.turns, cwd: ws.cwd,
|
|
193
214
|
// resolved like fixtures: relative to the spec's dir
|
|
194
215
|
systemPromptFile: scenario.systemPromptFile
|
|
195
216
|
? resolve(dirname(ctx.specPath), scenario.systemPromptFile)
|
|
196
217
|
: undefined,
|
|
197
|
-
|
|
218
|
+
// Absolute before it reaches a child process running in a neutral cwd.
|
|
219
|
+
extensions: scenario.extensions?.map((e) => resolve(dirname(ctx.specPath), e)),
|
|
220
|
+
};
|
|
221
|
+
if (scenario.traceAssert) {
|
|
222
|
+
// Missing required evidence is ERROR, never a silent fallback to the
|
|
223
|
+
// unstructured path: a gate with nothing to read must not look like a
|
|
224
|
+
// gate that passed.
|
|
225
|
+
if (!ctx.adapter.runStructured) {
|
|
226
|
+
throw new Error(`scenario \`${scenario.id}\` declares \`assert.trace\`, but the \`${ctx.adapter.name}\` adapter` +
|
|
227
|
+
` cannot produce execution traces — the gate would have no evidence to read.`);
|
|
228
|
+
}
|
|
229
|
+
const structured = await ctx.adapter.runStructured({ ...req, scenarioId: scenario.id, rep });
|
|
230
|
+
transcript = structured.transcript;
|
|
231
|
+
traces = structured.traces;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
transcript = await ctx.adapter.run(req);
|
|
235
|
+
}
|
|
198
236
|
}
|
|
199
237
|
noResponse = hasEmptyAssistantTurn(transcript);
|
|
200
238
|
if (!noResponse)
|
|
@@ -213,10 +251,77 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
213
251
|
}
|
|
214
252
|
appendJournal(runDir, { event: "gate-result", ts: now(), id: scenario.id, ok: !gatePrefix, detail: gatePrefix ?? "", ...repField });
|
|
215
253
|
}
|
|
254
|
+
// Filesystem evidence for `unchanged_paths`, observed AFTER the model ran.
|
|
255
|
+
//
|
|
256
|
+
// It cannot come through `RunReq`: the request is built before the run, and
|
|
257
|
+
// what changed only exists afterwards. That plumbing existed and no caller
|
|
258
|
+
// ever set it, so `changed_paths` was always `[]` and every
|
|
259
|
+
// `unchanged_paths` assertion passed vacuously — a safety gate reporting
|
|
260
|
+
// green while the model rewrote the workspace.
|
|
261
|
+
// Only when the scenario actually asserts on paths. A scenario using only
|
|
262
|
+
// `require_calls` / `forbid_calls` needs no filesystem evidence, so a
|
|
263
|
+
// workspace it cannot observe is not an error for it.
|
|
264
|
+
if (scenario.traceAssert?.unchanged_paths?.length && traces.length > 0 && ws) {
|
|
265
|
+
const changed = diffSnapshots(before, snapshotPaths(ws.cwd, scenario.workspace));
|
|
266
|
+
if (changed === null) {
|
|
267
|
+
// Missing evidence is ERROR, never a pass — spec.ts refuses the
|
|
268
|
+
// `workspace: none` combination up front, so reaching here means the
|
|
269
|
+
// workspace could not be read.
|
|
270
|
+
unobservablePaths = true;
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
traces = traces.map((t) => {
|
|
274
|
+
const withPaths = { ...t, changed_paths: changed };
|
|
275
|
+
return { ...withPaths, trace_sha256: traceSha256(withPaths) };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Objective evidence is persisted for every rep, pass or fail — a failing gate
|
|
280
|
+
// is exactly when someone wants to read what the model actually did.
|
|
281
|
+
let objective;
|
|
282
|
+
if (scenario.traceAssert) {
|
|
283
|
+
if (traces.length > 0) {
|
|
284
|
+
writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
|
|
285
|
+
}
|
|
286
|
+
const merged = mergeTraces(traces);
|
|
287
|
+
if (unobservablePaths) {
|
|
288
|
+
gatePrefix = "objective: workspace changes could not be observed — `unchanged_paths` has no evidence to check";
|
|
289
|
+
objective = { status: "ERROR", assertions: [] };
|
|
290
|
+
}
|
|
291
|
+
else if (merged === null) {
|
|
292
|
+
// Declared a gate, produced no trace: that is broken infrastructure, and
|
|
293
|
+
// grading it either way would be inventing a result.
|
|
294
|
+
gatePrefix = "objective: no execution trace was produced — cannot evaluate assert.trace";
|
|
295
|
+
objective = { status: "ERROR", assertions: [] };
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
const gate = evaluateTraceGates(scenario.traceAssert, merged);
|
|
299
|
+
objective = {
|
|
300
|
+
status: gate.status,
|
|
301
|
+
trace_version: merged.trace_version,
|
|
302
|
+
trace_sha256: merged.trace_sha256,
|
|
303
|
+
assertions: gate.assertions,
|
|
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("; ")}`;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
appendJournal(runDir, {
|
|
312
|
+
event: "objective-result", ts: now(), id: scenario.id,
|
|
313
|
+
ok: objective.status === "PASS", detail: gatePrefix ?? "", ...repField,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
216
316
|
let verdict;
|
|
217
317
|
let reason;
|
|
218
318
|
let suspect = false;
|
|
219
|
-
if (
|
|
319
|
+
if (objective?.status === "ERROR") {
|
|
320
|
+
verdict = "ERROR";
|
|
321
|
+
reason = gatePrefix ?? "objective evidence missing";
|
|
322
|
+
appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
|
|
323
|
+
}
|
|
324
|
+
else if (noResponse) {
|
|
220
325
|
verdict = "ERROR";
|
|
221
326
|
reason = "model produced no response after a retry (harness timeout?) — infra, not skill behavior";
|
|
222
327
|
appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
|
|
@@ -237,7 +342,7 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
237
342
|
suspect = o.suspect; // judgeOneRep already journaled (verdict + misfire)
|
|
238
343
|
}
|
|
239
344
|
log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
|
|
240
|
-
return { verdict, reason, suspect };
|
|
345
|
+
return { verdict, reason, suspect, objective };
|
|
241
346
|
}
|
|
242
347
|
finally {
|
|
243
348
|
ws?.cleanup();
|
|
@@ -251,8 +356,15 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
251
356
|
* exists. Passing it for a green run turns the scorecard from "the skill scored
|
|
252
357
|
* B" into "the skill *did* this much" — without a baseline the grade alone can't
|
|
253
358
|
* distinguish a skill that works from a model that never needed it.
|
|
359
|
+
*
|
|
360
|
+
* `stability` is the run-over-run half of the same argument, and it is derived from
|
|
361
|
+
* history rather than from this run: a cell that flipped its verdict between the last
|
|
362
|
+
* two runs of this skill × model × mode is worth less than the ✓ beside it suggests,
|
|
363
|
+
* and this run's own `flaky 0.00` cannot say so — it only ever looked at one run. Pass
|
|
364
|
+
* the cells for THIS tag and mode; anything else would report another model's history
|
|
365
|
+
* under this model's scorecard.
|
|
254
366
|
*/
|
|
255
|
-
export function formatScorecard(summary, lift) {
|
|
367
|
+
export function formatScorecard(summary, lift, stability) {
|
|
256
368
|
const { results } = summary;
|
|
257
369
|
const g = results.effective_grade;
|
|
258
370
|
const lines = [];
|
|
@@ -283,12 +395,20 @@ export function formatScorecard(summary, lift) {
|
|
|
283
395
|
// Said on the scorecard, not just in the docs: the one thing that can invalidate
|
|
284
396
|
// a green number is invisible in the number. `harness_cli_version` is recorded
|
|
285
397
|
// beside the verdicts so a reader can tell which pi produced them.
|
|
286
|
-
|
|
398
|
+
// `skipped` counts as unproven here, not as proven: the probe was asked for and
|
|
399
|
+
// could not answer, which leaves delivery exactly as unverified as never asking.
|
|
400
|
+
if (results.mode === "green" && results.delivery_canary !== "pass") {
|
|
287
401
|
lines.push(` NOTE: green delivery is harness-version-dependent` +
|
|
288
402
|
(results.harness_cli_version ? ` (${results.harness} ${results.harness_cli_version})` : "") +
|
|
289
403
|
` — on pi ≥ 0.83.0 \`--skill\` only discloses the description and the body loads on demand.` +
|
|
290
404
|
` Use --mode force for delivery that cannot silently degrade, or --canary to prove it per run.`);
|
|
291
405
|
}
|
|
406
|
+
// Boundary cells last, because they qualify the verdicts above: a ✓ on a cell that
|
|
407
|
+
// flipped between the last two runs is one draw, whatever its rep count said.
|
|
408
|
+
const ran = new Set(results.scenarios.map((s) => s.id));
|
|
409
|
+
for (const s of boundaryCells(stability ?? []).filter((c) => ran.has(c.id))) {
|
|
410
|
+
lines.push(` ⇄ ${stabilityNote(s)}`);
|
|
411
|
+
}
|
|
292
412
|
return lines.join("\n");
|
|
293
413
|
}
|
|
294
414
|
//# sourceMappingURL=run.js.map
|
package/dist/seeded.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Scenario } from "./spec.js";
|
|
2
|
+
import type { ExecutionTraceV1 } from "./capture-trace-types.js";
|
|
2
3
|
import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
|
|
3
4
|
import { type ExecResult } from "./util/exec.js";
|
|
4
5
|
interface SeededOpts {
|
|
@@ -17,6 +18,14 @@ interface SeededOpts {
|
|
|
17
18
|
* deterministically.
|
|
18
19
|
*/
|
|
19
20
|
runVitest?: (args: string[], cwd: string) => Promise<VitestRun>;
|
|
21
|
+
/**
|
|
22
|
+
* Trace metadata. Present when the scenario declares `assert.trace`, which
|
|
23
|
+
* routes the subject through the adapter's structured (`--mode json`) path.
|
|
24
|
+
*/
|
|
25
|
+
trace?: {
|
|
26
|
+
scenarioId: string;
|
|
27
|
+
rep: number;
|
|
28
|
+
};
|
|
20
29
|
}
|
|
21
30
|
/**
|
|
22
31
|
* Result of one vitest invocation — deliberately `ExecResult`, not a narrower
|
|
@@ -34,6 +43,8 @@ export interface SeededOutcome {
|
|
|
34
43
|
transcript: string;
|
|
35
44
|
gateFailure: string | null;
|
|
36
45
|
diff: string;
|
|
46
|
+
/** One per turn; empty unless the scenario declared `assert.trace`. */
|
|
47
|
+
traces: ExecutionTraceV1[];
|
|
37
48
|
}
|
|
38
49
|
/**
|
|
39
50
|
* The added/removed lines of a unified diff — what the model actually *changed*,
|