@skill-harness/core 0.10.0 → 0.12.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 +2 -1
- package/dist/adapters/types.d.ts +30 -0
- package/dist/adjudication.d.ts +9 -1
- package/dist/adjudication.js +41 -33
- package/dist/arms.d.ts +51 -0
- package/dist/arms.js +255 -0
- package/dist/capture-trace-types.d.ts +2 -0
- package/dist/command-cost.d.ts +9 -0
- package/dist/command-cost.js +23 -0
- package/dist/comparison.d.ts +1 -0
- package/dist/comparison.js +9 -3
- package/dist/execution-trace.js +9 -1
- package/dist/grade.d.ts +2 -0
- package/dist/grade.js +2 -1
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/journal.d.ts +2 -1
- package/dist/judge-agreement.d.ts +19 -0
- package/dist/judge-agreement.js +31 -0
- package/dist/judge-policy.js +5 -3
- package/dist/lift.js +1 -1
- package/dist/lint.js +1 -1
- package/dist/prompt-normalization.d.ts +6 -0
- package/dist/prompt-normalization.js +14 -0
- package/dist/provider-failure.d.ts +56 -0
- package/dist/provider-failure.js +98 -0
- package/dist/qualification-capture.d.ts +44 -0
- package/dist/qualification-capture.js +163 -0
- package/dist/qualification-config.d.ts +233 -0
- package/dist/qualification-config.js +694 -0
- package/dist/qualification-lock.d.ts +15 -0
- package/dist/qualification-lock.js +87 -0
- package/dist/qualification-oauth-directory.d.ts +79 -0
- package/dist/qualification-oauth-directory.js +444 -0
- package/dist/qualification-panel-store.d.ts +14 -0
- package/dist/qualification-panel-store.js +188 -0
- package/dist/qualification-panels.d.ts +99 -0
- package/dist/qualification-panels.js +91 -0
- package/dist/qualification-process.d.ts +13 -0
- package/dist/qualification-process.js +188 -0
- package/dist/qualification-runner.d.ts +203 -0
- package/dist/qualification-runner.js +2037 -0
- package/dist/qualification-store.d.ts +187 -0
- package/dist/qualification-store.js +833 -0
- package/dist/qualification-validation.d.ts +12 -0
- package/dist/qualification-validation.js +8 -0
- package/dist/regate.js +49 -10
- package/dist/regrade.d.ts +3 -1
- package/dist/regrade.js +62 -6
- package/dist/reps.d.ts +4 -2
- package/dist/reps.js +13 -4
- package/dist/rescore.js +9 -1
- package/dist/results.d.ts +99 -7
- package/dist/results.js +228 -10
- package/dist/run.d.ts +14 -0
- package/dist/run.js +260 -57
- package/dist/score.d.ts +4 -3
- package/dist/score.js +13 -4
- package/dist/screen.d.ts +31 -0
- package/dist/screen.js +105 -0
- package/dist/seeded.d.ts +12 -1
- package/dist/seeded.js +9 -2
- package/dist/sources.d.ts +7 -1
- package/dist/sources.js +13 -5
- package/dist/spec.d.ts +1 -1
- package/dist/spec.js +1 -1
- package/dist/stability.js +1 -1
- package/dist/trajectory-gates.d.ts +14 -2
- package/dist/trajectory-gates.js +26 -6
- package/dist/vote-panel.d.ts +33 -0
- package/dist/vote-panel.js +41 -0
- package/dist/workspace.js +34 -3
- package/package.json +3 -3
- package/schemas/qualification-cell-result-v1.schema.json +32 -0
- package/schemas/qualification-config-v1.schema.json +244 -0
- package/schemas/qualification-invocation-request-v1.schema.json +59 -0
- package/schemas/qualification-judge-panel-result-v1.schema.json +42 -0
- package/schemas/results-v2.schema.json +19 -21
- package/schemas/results-v3.schema.json +654 -0
- package/schemas/specification-v1.schema.json +1 -1
- package/schemas/trajectory-event-v1.schema.json +20 -1
package/dist/run.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, resolve } from "node:path";
|
|
3
|
-
import { sourceHashes } from "./sources.js";
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { sourceHashes, PROMPT_NORMALIZATION_SOURCE_KEY, PROMPT_NORMALIZATION_SOURCE_DIGEST } from "./sources.js";
|
|
4
4
|
import { judgeResemblesSubject } from "./grade.js";
|
|
5
|
-
import {
|
|
5
|
+
import { NONE_ARM, seedArmDefinitions } from "./arms.js";
|
|
6
|
+
import { runDirFor, transcriptPath, diffPath, tracePath, trajectoryPath, writeResults, ensureResultsGitignore, scoreContextFor, effectiveThreshold, isScoredMode, deliveryStatusForObservations, } from "./results.js";
|
|
6
7
|
import { appendJournal } from "./journal.js";
|
|
7
8
|
import { liftHeadline } from "./lift.js";
|
|
8
9
|
import { runSeeded } from "./seeded.js";
|
|
@@ -16,6 +17,36 @@ import { judgeOneRep } from "./regrade.js";
|
|
|
16
17
|
import { runDeliveryCanary, canaryFailure } from "./canary.js";
|
|
17
18
|
import { boundaryCells, stabilityNote } from "./stability.js";
|
|
18
19
|
import { aggregateMetrics } from "./comparison.js";
|
|
20
|
+
import { providerFailureFromTranscript } from "./provider-failure.js";
|
|
21
|
+
/**
|
|
22
|
+
* The pi-daddy arm's delivery proof, by convention: `arms.yaml` points
|
|
23
|
+
* `PI_GRANTS_LEDGER` at exactly this filename inside the run dir (see
|
|
24
|
+
* `../principal-pi-skills/tests/arms.yaml` and CODEX-ARMS-RUNBOOK.md §4.3), so
|
|
25
|
+
* a fixed name here is what makes the count readable without parsing the arm's
|
|
26
|
+
* own `env` map back out.
|
|
27
|
+
*/
|
|
28
|
+
const LEDGER_FILENAME = "pi-daddy.ledger.jsonl";
|
|
29
|
+
/**
|
|
30
|
+
* Count the arm's ledger events after the run, or 0 when the file was never
|
|
31
|
+
* written.
|
|
32
|
+
*
|
|
33
|
+
* This is `--canary`'s lesson applied to the arm: the ledger is the arm's
|
|
34
|
+
* delivery proof, and a delivery claim has to survive a commit. A missing file
|
|
35
|
+
* records `0`, not an absent field — "the extension never wrote a ledger" and
|
|
36
|
+
* "the extension wrote one with nothing in it" are both real, reportable
|
|
37
|
+
* outcomes, and a vacuous arm run must not commit a record indistinguishable
|
|
38
|
+
* from one that actually delegated.
|
|
39
|
+
*/
|
|
40
|
+
function countLedgerEvents(runDir) {
|
|
41
|
+
let text;
|
|
42
|
+
try {
|
|
43
|
+
text = readFileSync(join(runDir, LEDGER_FILENAME), "utf8");
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
return text.split("\n").filter((line) => line.trim().length > 0).length;
|
|
49
|
+
}
|
|
19
50
|
/** Run one skill against one model: run scenarios, grade, score, persist. */
|
|
20
51
|
export async function runSkillModel(opts) {
|
|
21
52
|
const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
|
|
@@ -39,7 +70,8 @@ export async function runSkillModel(opts) {
|
|
|
39
70
|
log(` ⚠ judge (${judge.provider}:${judge.model}) resembles the model under test ` +
|
|
40
71
|
`(${model.provider}:${model.model}) — verdicts may be inflated. Use a distinct judge.`);
|
|
41
72
|
}
|
|
42
|
-
const
|
|
73
|
+
const arm = opts.arm ?? NONE_ARM;
|
|
74
|
+
const runDir = runDirFor(skillDir, adapter.name, model, timestamp, arm.name);
|
|
43
75
|
mkdirSync(runDir, { recursive: true });
|
|
44
76
|
ensureResultsGitignore(dirname(dirname(runDir))); // .../tests/results/.gitignore
|
|
45
77
|
// Which harness CLI delivered the skill, asked once per run and recorded with the
|
|
@@ -97,22 +129,30 @@ export async function runSkillModel(opts) {
|
|
|
97
129
|
const repCounts = scenarios.map((s) => s.reps ?? opts.reps ?? 1);
|
|
98
130
|
const owners = [];
|
|
99
131
|
const tasks = [];
|
|
132
|
+
const armDefinitions = { count: 0 };
|
|
100
133
|
scenarios.forEach((scenario, si) => {
|
|
101
134
|
for (let k = 0; k < repCounts[si]; k++) {
|
|
102
135
|
const rep = k;
|
|
103
136
|
const total = repCounts[si];
|
|
104
137
|
owners.push(si);
|
|
105
|
-
tasks.push(() => runRep(scenario, rep, total, { ...opts, runDir, now, log }));
|
|
138
|
+
tasks.push(() => runRep(scenario, rep, total, { ...opts, runDir, now, log, armDefinitions }));
|
|
106
139
|
}
|
|
107
140
|
});
|
|
108
141
|
const flat = await runPool(tasks, opts.concurrency ?? 1);
|
|
142
|
+
const subjectInvocations = flat.flatMap(outcome => outcome.subject_invocations ?? []);
|
|
109
143
|
const grouped = scenarios.map(() => []);
|
|
110
144
|
flat.forEach((outcome, i) => grouped[owners[i]].push(outcome));
|
|
111
145
|
const scenarioResults = scenarios.map((scenario, si) => {
|
|
112
146
|
const threshold = scenario.critical
|
|
113
147
|
? effectiveThreshold(undefined, scenario)
|
|
114
148
|
: scenario.passThreshold ?? opts.passThreshold ?? 0.5;
|
|
115
|
-
|
|
149
|
+
const result = outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
|
|
150
|
+
if (adapter.observesPrompts)
|
|
151
|
+
result.criterion_count = scenario.checklist.length;
|
|
152
|
+
if (adapter.observesPrompts && !result.rep_judgments) {
|
|
153
|
+
result.rep_judgments = grouped[si].map((outcome, repetition) => ({ repetition, judgments: [], recorded_verdict: outcome.verdict, ...(outcome.objective ? { objective: outcome.objective } : {}) }));
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
116
156
|
});
|
|
117
157
|
const ctx = scoreContextFor({ mode, partial }, spec);
|
|
118
158
|
const results = writeResults(runDir, {
|
|
@@ -128,8 +168,28 @@ export async function runSkillModel(opts) {
|
|
|
128
168
|
...(partial ? { partial: true } : {}),
|
|
129
169
|
// Only the scenarios this run actually measured: a --only run must not claim
|
|
130
170
|
// coverage of scenarios it skipped.
|
|
131
|
-
source_hashes:
|
|
171
|
+
source_hashes: {
|
|
172
|
+
...sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona }),
|
|
173
|
+
...(adapter.observesPrompts ? { [PROMPT_NORMALIZATION_SOURCE_KEY]: PROMPT_NORMALIZATION_SOURCE_DIGEST } : {}),
|
|
174
|
+
},
|
|
132
175
|
scenarios: scenarioResults,
|
|
176
|
+
...(adapter.observesPrompts ? { schema: 3, subject_invocations: subjectInvocations } : {}),
|
|
177
|
+
...(arm.name === NONE_ARM.name ? {} : {
|
|
178
|
+
arm: {
|
|
179
|
+
name: arm.name,
|
|
180
|
+
extensions: arm.extensions,
|
|
181
|
+
definitions: armDefinitions.count,
|
|
182
|
+
ledger_events: countLedgerEvents(runDir),
|
|
183
|
+
// The DECLARED env, `<run-dir>` left unsubstituted. It is the condition
|
|
184
|
+
// being measured (grant, max depth), so leaving it out made two runs at
|
|
185
|
+
// different settings byte-identical here and inside the same `+<arm>`
|
|
186
|
+
// tag — `stability` then reads the verdict difference between two
|
|
187
|
+
// conditions as one lineage flipping. The substituted form would be the
|
|
188
|
+
// opposite error: it embeds this run's temp path, so re-running the SAME
|
|
189
|
+
// condition would record two different-looking arms.
|
|
190
|
+
env: arm.env,
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
133
193
|
}, ctx);
|
|
134
194
|
if (ctx) {
|
|
135
195
|
const g = results.effective_grade;
|
|
@@ -159,6 +219,15 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
159
219
|
const startedAt = performance.now();
|
|
160
220
|
const { spec, judge, mode, runDir, now, log } = ctx;
|
|
161
221
|
const repField = repCount > 1 ? { rep } : {};
|
|
222
|
+
const arm = ctx.arm ?? NONE_ARM;
|
|
223
|
+
const skillsRoot = ctx.skillsRoot ?? dirname(ctx.skillDir);
|
|
224
|
+
// Arm paths are per-run/per-workspace values. Plain split/join avoids treating
|
|
225
|
+
// temp-path characters as regex syntax.
|
|
226
|
+
const armEnvFor = (workspace) => Object.keys(arm.env).length
|
|
227
|
+
? Object.fromEntries(Object.entries(arm.env).map(([k, v]) => [k, v
|
|
228
|
+
.split("<run-dir>").join(runDir)
|
|
229
|
+
.split("<workspace>").join(workspace)]))
|
|
230
|
+
: undefined;
|
|
162
231
|
if (rep === 0) {
|
|
163
232
|
log(` ${scenario.id} (${scenario.title})${repCount > 1 ? ` ×${repCount}` : ""} …`);
|
|
164
233
|
appendJournal(runDir, { event: "scenario-started", ts: now(), id: scenario.id, title: scenario.title });
|
|
@@ -171,6 +240,7 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
171
240
|
// produces no diff, and writing an empty artifact there would misreport "the
|
|
172
241
|
// model changed nothing" for a rep that never ran.
|
|
173
242
|
let stagedDiff = null;
|
|
243
|
+
const subjectInvocations = [];
|
|
174
244
|
try {
|
|
175
245
|
try {
|
|
176
246
|
ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
|
|
@@ -194,66 +264,161 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
194
264
|
// a committed results.yaml, naming files the model never touched.
|
|
195
265
|
let before = ws ? snapshotPaths(ws.cwd, scenario.workspace) : null;
|
|
196
266
|
if (ws) {
|
|
267
|
+
// Seed the arm's definitions into THIS workspace before the subject runs.
|
|
268
|
+
// Control (`none`) short-circuits inside `seedArmDefinitions` with no
|
|
269
|
+
// filesystem side effects at all.
|
|
270
|
+
ctx.armDefinitions.count = seedArmDefinitions(arm, skillsRoot, ws.cwd, { ambientSkillsDir: ctx.ambientSkillsDir });
|
|
271
|
+
}
|
|
272
|
+
// Set when the adapter itself threw. Rep-scoped rather than loop-scoped: the
|
|
273
|
+
// evidence blocks below have to know this rep never ran.
|
|
274
|
+
let adapterFailure = null;
|
|
275
|
+
if (ws) {
|
|
276
|
+
// Decided ONCE, before any attempt, and for both the seeded and non-seeded
|
|
277
|
+
// branches — these are spec/adapter facts, not per-attempt outcomes, and
|
|
278
|
+
// deciding them here is what lets the execution below be wrapped in a catch
|
|
279
|
+
// that must not swallow a configuration error.
|
|
280
|
+
//
|
|
281
|
+
// `needsStructuredEvidence` is a gate with nothing to read if the adapter
|
|
282
|
+
// cannot produce traces: ERROR, never a silent fallback to the unstructured
|
|
283
|
+
// path, because a gate that could not be evaluated must not look like one
|
|
284
|
+
// that passed. A bare `--structured` request with no gate depending on it
|
|
285
|
+
// degrades to the plain path instead — on BOTH branches. The seeded branch
|
|
286
|
+
// used to pass `trace: {...}` unconditionally and let `runSeeded` throw
|
|
287
|
+
// ``scenario `X` declares `assert.trace`…`` at a scenario that declares no
|
|
288
|
+
// such thing.
|
|
289
|
+
const needsStructuredEvidence = Boolean(scenario.traceAssert || scenario.trajectoryAssert);
|
|
290
|
+
if (needsStructuredEvidence && !ctx.adapter.runStructured) {
|
|
291
|
+
throw new Error(`scenario \`${scenario.id}\` declares structured objective assertions, but the \`${ctx.adapter.name}\` adapter` +
|
|
292
|
+
` cannot produce execution traces/events — the gate would have no evidence to read.`);
|
|
293
|
+
}
|
|
294
|
+
const useStructured = (Boolean(ctx.structured) || needsStructuredEvidence) && Boolean(ctx.adapter.runStructured);
|
|
197
295
|
// A blank assistant turn is a harness timeout, not model behavior: retry ONCE in a
|
|
198
296
|
// fresh workspace (the first attempt may have half-mutated a seeded repo), and if
|
|
199
297
|
// it happens again the verdict is ERROR — never a judged FAIL on an empty reply.
|
|
298
|
+
// An adapter that THREW is the same class of event and takes the same retry.
|
|
200
299
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
300
|
+
const observe = (prompt) => subjectInvocations.push({ scenario_id: scenario.id, repetition: rep, attempt, prompt });
|
|
201
301
|
if (attempt > 0) {
|
|
202
|
-
|
|
203
|
-
|
|
302
|
+
const why = adapterFailure ? `adapter failed (${adapterFailure})` : "empty response";
|
|
303
|
+
appendJournal(runDir, { event: "empty-response-retry", ts: now(), id: scenario.id, attempt, reason: why, ...repField });
|
|
304
|
+
log(` ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${why} — retrying once`);
|
|
204
305
|
ws.cleanup();
|
|
205
306
|
ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
|
|
206
307
|
// A fresh workspace needs a fresh baseline, or the retry's diff would
|
|
207
308
|
// be taken against a directory that no longer exists.
|
|
208
309
|
before = snapshotPaths(ws.cwd, scenario.workspace);
|
|
310
|
+
// And a fresh workspace has nothing to spawn until it, too, is seeded —
|
|
311
|
+
// a retry that skipped this would silently run the arm with no
|
|
312
|
+
// definitions at all.
|
|
313
|
+
ctx.armDefinitions.count = seedArmDefinitions(arm, skillsRoot, ws.cwd, { ambientSkillsDir: ctx.ambientSkillsDir });
|
|
209
314
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
?
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
transcript = structured.transcript;
|
|
245
|
-
traces = structured.traces;
|
|
246
|
-
events = structured.events ?? [];
|
|
247
|
-
eventErrors = structured.eventErrors ?? [];
|
|
315
|
+
// Cleared per attempt, never carried over. A provider failure always
|
|
316
|
+
// leaves a blank assistant turn, so attempt 0's reason used to survive a
|
|
317
|
+
// retry that then succeeded: the rep was recorded ERROR citing an outage
|
|
318
|
+
// while the persisted transcript was attempt 1's clean one — the record
|
|
319
|
+
// and the artifact disagreeing, and a recovered measurement thrown away.
|
|
320
|
+
infrastructureFailure = null;
|
|
321
|
+
adapterFailure = null;
|
|
322
|
+
try {
|
|
323
|
+
if (scenario.mode === "seeded") {
|
|
324
|
+
const r = await runSeeded(scenario, {
|
|
325
|
+
skillDir: ctx.skillDir, adapter: ctx.adapter, model: ctx.model, mode, cwd: ws.cwd,
|
|
326
|
+
specDir: dirname(ctx.specPath), // assert.post_test resolves like a fixture
|
|
327
|
+
// `ctx.structured` (a bare `--structured` request, with no gate depending
|
|
328
|
+
// on it) must route through the structured path here too, exactly as it
|
|
329
|
+
// does for the non-seeded branch below — without it, `--structured` on a
|
|
330
|
+
// `mode: seeded` scenario silently called the adapter's plain `run()` and
|
|
331
|
+
// recorded zero subject tokens/cost, which is the one thing the flag exists
|
|
332
|
+
// to capture. `useStructured` (not the raw request) so an adapter with no
|
|
333
|
+
// `runStructured` degrades here exactly as it does below.
|
|
334
|
+
trace: useStructured ? { scenarioId: scenario.id, rep } : undefined,
|
|
335
|
+
// `runSeeded` merges these with `scenario.extensions` itself when it
|
|
336
|
+
// builds the RunReq — the arm's extensions and env (with `<run-dir>`
|
|
337
|
+
// already substituted) both must reach pi.
|
|
338
|
+
armExtensions: arm.extensions,
|
|
339
|
+
...(armEnvFor(ws.cwd) ? { armEnv: armEnvFor(ws.cwd) } : {}),
|
|
340
|
+
...(ctx.adapter.observesPrompts ? { onPromptObservation: observe } : {}),
|
|
341
|
+
});
|
|
342
|
+
transcript = r.transcript;
|
|
343
|
+
gatePrefix = r.gateFailure;
|
|
344
|
+
infrastructureFailure = r.gateError;
|
|
345
|
+
stagedDiff = r.diff; // a retry replaces the aborted attempt's diff, as it should
|
|
346
|
+
traces = r.traces;
|
|
347
|
+
events = r.events;
|
|
348
|
+
eventErrors = r.eventErrors;
|
|
248
349
|
}
|
|
249
350
|
else {
|
|
250
|
-
|
|
351
|
+
const req = {
|
|
352
|
+
skillDir: ctx.skillDir, model: ctx.model, mode, turns: scenario.turns, cwd: ws.cwd,
|
|
353
|
+
// resolved like fixtures: relative to the spec's dir
|
|
354
|
+
systemPromptFile: scenario.systemPromptFile
|
|
355
|
+
? resolve(dirname(ctx.specPath), scenario.systemPromptFile)
|
|
356
|
+
: undefined,
|
|
357
|
+
// Absolute before it reaches a child process running in a neutral cwd.
|
|
358
|
+
// The arm's extensions are added alongside whatever the scenario
|
|
359
|
+
// itself declares — both must reach pi.
|
|
360
|
+
extensions: [
|
|
361
|
+
...(scenario.extensions?.map((e) => resolve(dirname(ctx.specPath), e)) ?? []),
|
|
362
|
+
...arm.extensions,
|
|
363
|
+
],
|
|
364
|
+
eventSources: scenario.eventSources,
|
|
365
|
+
...(armEnvFor(ws.cwd) ? { armEnv: armEnvFor(ws.cwd) } : {}),
|
|
366
|
+
...(ctx.adapter.observesPrompts ? { onPromptObservation: observe } : {}),
|
|
367
|
+
};
|
|
368
|
+
if (useStructured) {
|
|
369
|
+
const structured = await ctx.adapter.runStructured({ ...req, scenarioId: scenario.id, rep });
|
|
370
|
+
transcript = structured.transcript;
|
|
371
|
+
traces = structured.traces;
|
|
372
|
+
events = structured.events ?? [];
|
|
373
|
+
eventErrors = structured.eventErrors ?? [];
|
|
374
|
+
if (structured.providerFailure)
|
|
375
|
+
infrastructureFailure = `provider failure — ${structured.providerFailure}`;
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
transcript = await ctx.adapter.run(req);
|
|
379
|
+
}
|
|
251
380
|
}
|
|
252
381
|
}
|
|
382
|
+
catch (e) {
|
|
383
|
+
// An adapter that THREW is one rep's infrastructure failure, not the
|
|
384
|
+
// wave's. `runPool` is fail-fast and `runRep` had only a `finally`, so
|
|
385
|
+
// a single pi crash or `PI_TIMEOUT_MS` timeout unwound the whole run:
|
|
386
|
+
// no results.yaml written and every already-graded scenario's verdict
|
|
387
|
+
// lost. `--structured` made that reachable for EVERY scenario, because
|
|
388
|
+
// `runStructured` throws on a stream with no terminal events where the
|
|
389
|
+
// text path merely recorded `[pi exited N]` and carried on.
|
|
390
|
+
//
|
|
391
|
+
// Configuration errors do not arrive here: the two that used to be
|
|
392
|
+
// thrown from inside this block (adapter cannot produce traces for a
|
|
393
|
+
// gated scenario) are decided above, before the loop.
|
|
394
|
+
adapterFailure = e instanceof Error ? e.message : String(e);
|
|
395
|
+
transcript = `[adapter failure] ${adapterFailure}`;
|
|
396
|
+
// Nothing partial from the failed attempt may be read as evidence: an
|
|
397
|
+
// empty trace list satisfies a `forbid_calls` gate, and a stale diff
|
|
398
|
+
// from a previous attempt would be attributed to this one.
|
|
399
|
+
gatePrefix = null;
|
|
400
|
+
stagedDiff = null;
|
|
401
|
+
traces = [];
|
|
402
|
+
events = [];
|
|
403
|
+
eventErrors = [];
|
|
404
|
+
}
|
|
405
|
+
// A provider outage is infrastructure, never a model verdict. Checked on
|
|
406
|
+
// every path: the text path carries the marker in the transcript, the
|
|
407
|
+
// structured path sets `providerFailure` above and exits 0 while doing it.
|
|
408
|
+
if (!infrastructureFailure) {
|
|
409
|
+
const provider = providerFailureFromTranscript(transcript);
|
|
410
|
+
if (provider)
|
|
411
|
+
infrastructureFailure = `provider failure — ${provider}`;
|
|
412
|
+
}
|
|
253
413
|
noResponse = hasEmptyAssistantTurn(transcript);
|
|
254
|
-
if (!noResponse)
|
|
414
|
+
if (!noResponse && !adapterFailure)
|
|
255
415
|
break;
|
|
256
416
|
}
|
|
417
|
+
// Survived the retry: ERROR for this rep, inside a run that still completes
|
|
418
|
+
// and still writes every other scenario's verdict.
|
|
419
|
+
if (adapterFailure && !infrastructureFailure) {
|
|
420
|
+
infrastructureFailure = `adapter failure — ${adapterFailure}`;
|
|
421
|
+
}
|
|
257
422
|
}
|
|
258
423
|
const repSuffix = repCount > 1 ? rep : undefined;
|
|
259
424
|
writeFileSync(transcriptPath(runDir, scenario.id, mode, repSuffix), transcript, "utf8");
|
|
@@ -292,10 +457,25 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
292
457
|
});
|
|
293
458
|
}
|
|
294
459
|
}
|
|
460
|
+
// Delivery is an objective gate on every Pi subject invocation. It is derived
|
|
461
|
+
// from captured provider payload bytes, never from mode/argv supplied by a caller.
|
|
462
|
+
let deliveryObjective;
|
|
463
|
+
if (ctx.adapter.observesPrompts) {
|
|
464
|
+
const statuses = subjectInvocations.map(observation => observation.prompt.status);
|
|
465
|
+
const status = deliveryStatusForObservations(subjectInvocations);
|
|
466
|
+
deliveryObjective = { status, assertions: [{ kind: "skill_delivered", status, detail: statuses.length === 0 ? "no model-visible prompt observation was retained" : `${statuses.length} provider request(s): ${statuses.join(", ")}` }] };
|
|
467
|
+
if (status !== "PASS")
|
|
468
|
+
gatePrefix = `objective: skill_delivered ${status.toLowerCase()} — ${deliveryObjective.assertions[0].detail}`;
|
|
469
|
+
}
|
|
295
470
|
// Objective evidence is persisted for every rep, pass or fail — a failing gate
|
|
296
471
|
// is exactly when someone wants to read what the model actually did.
|
|
297
|
-
|
|
298
|
-
|
|
472
|
+
// `!adapterFailure`: a rep whose adapter threw never ran, so there is nothing
|
|
473
|
+
// to evaluate. Evaluating anyway reports "no execution trace was produced",
|
|
474
|
+
// which is the symptom — it names the missing artifact instead of the crash or
|
|
475
|
+
// timeout that caused it, and it is that text, not the real reason, that would
|
|
476
|
+
// reach the results record. The verdict is ERROR either way.
|
|
477
|
+
let objective = deliveryObjective;
|
|
478
|
+
if ((scenario.traceAssert || scenario.trajectoryAssert) && !adapterFailure) {
|
|
299
479
|
const assertionResults = [];
|
|
300
480
|
let status = "PASS";
|
|
301
481
|
let traceMeta = {};
|
|
@@ -340,6 +520,11 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
340
520
|
trajectoryMeta = { trajectory_version: gate.event_version, events_sha256: gate.events_sha256 };
|
|
341
521
|
}
|
|
342
522
|
}
|
|
523
|
+
if (deliveryObjective) {
|
|
524
|
+
if (deliveryObjective.status === "ERROR" || (deliveryObjective.status === "NOT-MEASURED" && status !== "ERROR") || (deliveryObjective.status === "FAIL" && status === "PASS"))
|
|
525
|
+
status = deliveryObjective.status;
|
|
526
|
+
assertionResults.unshift(...deliveryObjective.assertions);
|
|
527
|
+
}
|
|
343
528
|
objective = { status, ...traceMeta, ...trajectoryMeta, assertions: assertionResults };
|
|
344
529
|
if (status !== "PASS") {
|
|
345
530
|
const details = assertionResults.filter((result) => result.status === status).map((result) => result.detail);
|
|
@@ -353,12 +538,18 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
353
538
|
let verdict;
|
|
354
539
|
let reason;
|
|
355
540
|
let suspect = false;
|
|
541
|
+
let judgment;
|
|
356
542
|
let judgeCalls = 0;
|
|
357
543
|
if (objective?.status === "ERROR") {
|
|
358
544
|
verdict = "ERROR";
|
|
359
545
|
reason = gatePrefix ?? "objective evidence missing";
|
|
360
546
|
appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
|
|
361
547
|
}
|
|
548
|
+
else if (objective?.status === "NOT-MEASURED") {
|
|
549
|
+
verdict = "NOT-MEASURED";
|
|
550
|
+
reason = gatePrefix ?? "skill delivery was not established";
|
|
551
|
+
appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
|
|
552
|
+
}
|
|
362
553
|
else if (infrastructureFailure) {
|
|
363
554
|
verdict = "ERROR";
|
|
364
555
|
reason = infrastructureFailure;
|
|
@@ -382,13 +573,14 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
382
573
|
});
|
|
383
574
|
verdict = o.verdict;
|
|
384
575
|
reason = o.reason;
|
|
385
|
-
suspect = o.suspect;
|
|
576
|
+
suspect = o.suspect;
|
|
577
|
+
judgment = o.judgment; // judgeOneRep already journaled (verdict + misfire)
|
|
386
578
|
judgeCalls = 1;
|
|
387
579
|
}
|
|
388
580
|
log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
|
|
389
581
|
const subject = mergeTraces(traces)?.metrics;
|
|
390
582
|
return {
|
|
391
|
-
verdict, reason, suspect, objective,
|
|
583
|
+
verdict, reason, suspect, objective, judgment, subject_invocations: subjectInvocations,
|
|
392
584
|
metrics: {
|
|
393
585
|
wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
|
|
394
586
|
judge_calls: judgeCalls,
|
|
@@ -423,8 +615,8 @@ export function formatScorecard(summary, lift, stability) {
|
|
|
423
615
|
const lines = [];
|
|
424
616
|
lines.push(`── ${results.skill} · ${results.harness} · ${results.model} ──`);
|
|
425
617
|
for (const s of results.scenarios) {
|
|
426
|
-
const v = s.override ?? (s.objective?.status === "ERROR" ? "ERROR" : s.objective?.status === "FAIL" ? "FAIL" : s.judge_verdict);
|
|
427
|
-
const mark = v === "PASS" ? "✓" : v === "FAIL" ? "✗" : "?";
|
|
618
|
+
const v = s.override ?? (s.objective?.status === "ERROR" ? "ERROR" : s.objective?.status === "NOT-MEASURED" ? "NOT-MEASURED" : s.objective?.status === "FAIL" ? "FAIL" : s.judge_verdict);
|
|
619
|
+
const mark = v === "PASS" ? "✓" : v === "FAIL" ? "✗" : v === "NOT-MEASURED" ? "∅" : "?";
|
|
428
620
|
const ov = s.override ? " (override)" : "";
|
|
429
621
|
const susp = s.suspect ? " ⚠suspect" : "";
|
|
430
622
|
const misfired = s.clean !== undefined && s.reps !== undefined && s.clean < s.reps ? ` · ${s.reps - s.clean} misfired` : "";
|
|
@@ -440,7 +632,18 @@ export function formatScorecard(summary, lift, stability) {
|
|
|
440
632
|
? "subject tokens unavailable"
|
|
441
633
|
: `subject tokens ${metrics.input_tokens} in / ${metrics.output_tokens ?? 0} out / ${metrics.cache_read_tokens ?? 0} cache-read`;
|
|
442
634
|
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
|
-
|
|
635
|
+
const cost = metrics.cost_source === "subscription"
|
|
636
|
+
? " · subscription ($0 marginal cost recorded)"
|
|
637
|
+
: metrics.cost_source === "unreported"
|
|
638
|
+
? metrics.input_tokens !== null && metrics.input_tokens > 0
|
|
639
|
+
? " · WARNING: subject tokens were used but subject cost was not reported"
|
|
640
|
+
: " · subject cost unavailable"
|
|
641
|
+
: metrics.input_tokens !== null && metrics.input_tokens > 0 && metrics.subject_cost_usd === 0
|
|
642
|
+
? " · WARNING: subject tokens were used but the provider reported $0 cost"
|
|
643
|
+
: metrics.subject_cost_usd === null
|
|
644
|
+
? " · subject cost unavailable"
|
|
645
|
+
: ` · $${metrics.subject_cost_usd.toFixed(6)} provider-reported`;
|
|
646
|
+
lines.push(` COST: ${tokens} (${metrics.subject_metrics_reps}/${metrics.total_reps} reps reported)${cost} · ${metrics.judge_calls} judge + ${metrics.judge_rejudge_calls} re-judge call(s) · ${metrics.wall_time_ms}ms · ${tools}`);
|
|
444
647
|
}
|
|
445
648
|
// Lift is a statement about a skill-delivered run (green or force). On a red run
|
|
446
649
|
// the caller may still have a lift in hand (a scored run exists in the same tag),
|
package/dist/score.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ShipBar } from "./spec.js";
|
|
|
3
3
|
* "JUDGE-AMBIGUOUS": the judge emitted conflicting verdicts for one transcript. It is
|
|
4
4
|
* never a pass and never silently resolved — it marks the run for a rejudge.
|
|
5
5
|
*/
|
|
6
|
-
export type Verdict = "PASS" | "FAIL" | "ERROR" | "JUDGE-AMBIGUOUS";
|
|
6
|
+
export type Verdict = "PASS" | "FAIL" | "ERROR" | "NOT-MEASURED" | "JUDGE-AMBIGUOUS";
|
|
7
7
|
export interface ScenarioVerdict {
|
|
8
8
|
id: string;
|
|
9
9
|
verdict: Verdict;
|
|
@@ -23,13 +23,14 @@ export interface ScoreResult {
|
|
|
23
23
|
bSeriesFails: number;
|
|
24
24
|
suspectCount: number;
|
|
25
25
|
errorCount: number;
|
|
26
|
+
notMeasuredCount: number;
|
|
26
27
|
note: string;
|
|
27
28
|
}
|
|
28
29
|
export declare function letterFor(pct: number): string;
|
|
29
30
|
/**
|
|
30
31
|
* Score a set of green-mode verdicts against the ship bar. A scenario PASSes only
|
|
31
|
-
* on verdict PASS; FAIL is behavioral, while ERROR/JUDGE-AMBIGUOUS
|
|
32
|
-
* infrastructure/indeterminate and independently block SHIP. A `suspect` verdict (an
|
|
32
|
+
* on verdict PASS; FAIL is behavioral, while ERROR/JUDGE-AMBIGUOUS and
|
|
33
|
+
* NOT-MEASURED are excluded as infrastructure/indeterminate and independently block SHIP. A `suspect` verdict (an
|
|
33
34
|
* unresolved judge misfire) is excluded from both `passed` and `total` — it is
|
|
34
35
|
* untrustworthy, neither a pass nor a fail — and any suspect count blocks SHIP
|
|
35
36
|
* until an author override resolves it. SHIP otherwise requires: enough total
|
package/dist/score.js
CHANGED
|
@@ -11,8 +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 is behavioral, while ERROR/JUDGE-AMBIGUOUS
|
|
15
|
-
* infrastructure/indeterminate and independently block SHIP. A `suspect` verdict (an
|
|
14
|
+
* on verdict PASS; FAIL is behavioral, while ERROR/JUDGE-AMBIGUOUS and
|
|
15
|
+
* NOT-MEASURED are excluded as infrastructure/indeterminate and independently block SHIP. A `suspect` verdict (an
|
|
16
16
|
* unresolved judge misfire) is excluded from both `passed` and `total` — it is
|
|
17
17
|
* untrustworthy, neither a pass nor a fail — and any suspect count blocks SHIP
|
|
18
18
|
* until an author override resolves it. SHIP otherwise requires: enough total
|
|
@@ -27,11 +27,16 @@ export function score(verdicts, input) {
|
|
|
27
27
|
let bSeriesFails = 0;
|
|
28
28
|
let suspectCount = 0;
|
|
29
29
|
let errorCount = 0;
|
|
30
|
+
let notMeasuredCount = 0;
|
|
30
31
|
for (const v of verdicts) {
|
|
31
32
|
if (v.suspect) {
|
|
32
33
|
suspectCount++;
|
|
33
34
|
continue; // untrustworthy: neither pass nor fail
|
|
34
35
|
}
|
|
36
|
+
if (v.verdict === "NOT-MEASURED") {
|
|
37
|
+
notMeasuredCount++;
|
|
38
|
+
continue; // known-undelivered experiment: neither behavioral pass nor fail
|
|
39
|
+
}
|
|
35
40
|
if (v.verdict === "ERROR" || v.verdict === "JUDGE-AMBIGUOUS") {
|
|
36
41
|
errorCount++;
|
|
37
42
|
continue; // infrastructure/indeterminate: neither behavioral pass nor fail
|
|
@@ -55,7 +60,8 @@ export function score(verdicts, input) {
|
|
|
55
60
|
(!shipBar.no_critical_fail || criticalFails === 0) &&
|
|
56
61
|
bSeriesFails === 0 &&
|
|
57
62
|
suspectCount === 0 &&
|
|
58
|
-
errorCount === 0
|
|
63
|
+
errorCount === 0 &&
|
|
64
|
+
notMeasuredCount === 0;
|
|
59
65
|
let note = "";
|
|
60
66
|
if (!validBar) {
|
|
61
67
|
note = "invalid ship bar: total/min_pass must be positive integers with min_pass <= total";
|
|
@@ -66,12 +72,15 @@ export function score(verdicts, input) {
|
|
|
66
72
|
else if (errorCount > 0) {
|
|
67
73
|
note = `${errorCount} infrastructure error${errorCount === 1 ? "" : "s"}: retry/repair evidence`;
|
|
68
74
|
}
|
|
75
|
+
else if (notMeasuredCount > 0) {
|
|
76
|
+
note = `${notMeasuredCount} not measured: skill delivery was not established`;
|
|
77
|
+
}
|
|
69
78
|
else if (criticalFails > 0) {
|
|
70
79
|
note = `gated: ${criticalFails} critical fail${criticalFails === 1 ? "" : "s"}`;
|
|
71
80
|
}
|
|
72
81
|
else if (bSeriesFails > 0) {
|
|
73
82
|
note = `gated: ${bSeriesFails} B-series fail${bSeriesFails === 1 ? "" : "s"}`;
|
|
74
83
|
}
|
|
75
|
-
return { passed, total, pct, letter, ship, criticalFails, bSeriesFails, suspectCount, errorCount, note };
|
|
84
|
+
return { passed, total, pct, letter, ship, criticalFails, bSeriesFails, suspectCount, errorCount, notMeasuredCount, note };
|
|
76
85
|
}
|
|
77
86
|
//# sourceMappingURL=score.js.map
|
package/dist/screen.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ResultsFile } from "./results.js";
|
|
2
|
+
export type ScreenClassification = "CEILING" | "FLOOR" | "INFORMATIVE" | "UNKNOWN";
|
|
3
|
+
export interface ScreenRate {
|
|
4
|
+
passes: number;
|
|
5
|
+
n: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ScreenScenario {
|
|
8
|
+
skill: string;
|
|
9
|
+
model: string;
|
|
10
|
+
id: string;
|
|
11
|
+
control: ScreenRate;
|
|
12
|
+
treatment: ScreenRate;
|
|
13
|
+
not_measured: number;
|
|
14
|
+
classification: ScreenClassification;
|
|
15
|
+
}
|
|
16
|
+
export interface ScreenCriterion {
|
|
17
|
+
skill: string;
|
|
18
|
+
model: string;
|
|
19
|
+
scenario_id: string;
|
|
20
|
+
criterion: number;
|
|
21
|
+
failures: number;
|
|
22
|
+
n: number;
|
|
23
|
+
fail_rate: number;
|
|
24
|
+
}
|
|
25
|
+
export interface ScreenReport {
|
|
26
|
+
scenarios: ScreenScenario[];
|
|
27
|
+
criteria: ScreenCriterion[];
|
|
28
|
+
}
|
|
29
|
+
/** Pure/offline: derive only from v3 retained invocation and vote evidence. */
|
|
30
|
+
export declare function screenResults(results: ResultsFile[]): ScreenReport;
|
|
31
|
+
export declare function formatScreen(report: ScreenReport): string;
|