@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.
Files changed (81) hide show
  1. package/README.md +2 -1
  2. package/dist/adapters/types.d.ts +30 -0
  3. package/dist/adjudication.d.ts +9 -1
  4. package/dist/adjudication.js +41 -33
  5. package/dist/arms.d.ts +51 -0
  6. package/dist/arms.js +255 -0
  7. package/dist/capture-trace-types.d.ts +2 -0
  8. package/dist/command-cost.d.ts +9 -0
  9. package/dist/command-cost.js +23 -0
  10. package/dist/comparison.d.ts +1 -0
  11. package/dist/comparison.js +9 -3
  12. package/dist/execution-trace.js +9 -1
  13. package/dist/grade.d.ts +2 -0
  14. package/dist/grade.js +2 -1
  15. package/dist/index.d.ts +14 -0
  16. package/dist/index.js +14 -0
  17. package/dist/journal.d.ts +2 -1
  18. package/dist/judge-agreement.d.ts +19 -0
  19. package/dist/judge-agreement.js +31 -0
  20. package/dist/judge-policy.js +5 -3
  21. package/dist/lift.js +1 -1
  22. package/dist/lint.js +1 -1
  23. package/dist/prompt-normalization.d.ts +6 -0
  24. package/dist/prompt-normalization.js +14 -0
  25. package/dist/provider-failure.d.ts +56 -0
  26. package/dist/provider-failure.js +98 -0
  27. package/dist/qualification-capture.d.ts +44 -0
  28. package/dist/qualification-capture.js +163 -0
  29. package/dist/qualification-config.d.ts +233 -0
  30. package/dist/qualification-config.js +694 -0
  31. package/dist/qualification-lock.d.ts +15 -0
  32. package/dist/qualification-lock.js +87 -0
  33. package/dist/qualification-oauth-directory.d.ts +79 -0
  34. package/dist/qualification-oauth-directory.js +444 -0
  35. package/dist/qualification-panel-store.d.ts +14 -0
  36. package/dist/qualification-panel-store.js +188 -0
  37. package/dist/qualification-panels.d.ts +99 -0
  38. package/dist/qualification-panels.js +91 -0
  39. package/dist/qualification-process.d.ts +13 -0
  40. package/dist/qualification-process.js +188 -0
  41. package/dist/qualification-runner.d.ts +203 -0
  42. package/dist/qualification-runner.js +2037 -0
  43. package/dist/qualification-store.d.ts +187 -0
  44. package/dist/qualification-store.js +833 -0
  45. package/dist/qualification-validation.d.ts +12 -0
  46. package/dist/qualification-validation.js +8 -0
  47. package/dist/regate.js +49 -10
  48. package/dist/regrade.d.ts +3 -1
  49. package/dist/regrade.js +62 -6
  50. package/dist/reps.d.ts +4 -2
  51. package/dist/reps.js +13 -4
  52. package/dist/rescore.js +9 -1
  53. package/dist/results.d.ts +99 -7
  54. package/dist/results.js +228 -10
  55. package/dist/run.d.ts +14 -0
  56. package/dist/run.js +260 -57
  57. package/dist/score.d.ts +4 -3
  58. package/dist/score.js +13 -4
  59. package/dist/screen.d.ts +31 -0
  60. package/dist/screen.js +105 -0
  61. package/dist/seeded.d.ts +12 -1
  62. package/dist/seeded.js +9 -2
  63. package/dist/sources.d.ts +7 -1
  64. package/dist/sources.js +13 -5
  65. package/dist/spec.d.ts +1 -1
  66. package/dist/spec.js +1 -1
  67. package/dist/stability.js +1 -1
  68. package/dist/trajectory-gates.d.ts +14 -2
  69. package/dist/trajectory-gates.js +26 -6
  70. package/dist/vote-panel.d.ts +33 -0
  71. package/dist/vote-panel.js +41 -0
  72. package/dist/workspace.js +34 -3
  73. package/package.json +3 -3
  74. package/schemas/qualification-cell-result-v1.schema.json +32 -0
  75. package/schemas/qualification-config-v1.schema.json +244 -0
  76. package/schemas/qualification-invocation-request-v1.schema.json +59 -0
  77. package/schemas/qualification-judge-panel-result-v1.schema.json +42 -0
  78. package/schemas/results-v2.schema.json +19 -21
  79. package/schemas/results-v3.schema.json +654 -0
  80. package/schemas/specification-v1.schema.json +1 -1
  81. 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 { runDirFor, transcriptPath, diffPath, tracePath, trajectoryPath, writeResults, ensureResultsGitignore, scoreContextFor, effectiveThreshold, isScoredMode, } from "./results.js";
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 runDir = runDirFor(skillDir, adapter.name, model, timestamp);
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
- return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
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: sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona }),
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
- appendJournal(runDir, { event: "empty-response-retry", ts: now(), id: scenario.id, attempt, ...repField });
203
- log(` ${scenario.id}${repCount > 1 ? `#${rep}` : ""} empty response — retrying once`);
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
- if (scenario.mode === "seeded") {
211
- const r = await runSeeded(scenario, {
212
- skillDir: ctx.skillDir, adapter: ctx.adapter, model: ctx.model, mode, cwd: ws.cwd,
213
- specDir: dirname(ctx.specPath), // assert.post_test resolves like a fixture
214
- trace: scenario.traceAssert || scenario.trajectoryAssert ? { scenarioId: scenario.id, rep } : undefined,
215
- });
216
- transcript = r.transcript;
217
- gatePrefix = r.gateFailure;
218
- infrastructureFailure = r.gateError;
219
- stagedDiff = r.diff; // a retry replaces the aborted attempt's diff, as it should
220
- traces = r.traces;
221
- events = r.events;
222
- eventErrors = r.eventErrors;
223
- }
224
- else {
225
- const req = {
226
- skillDir: ctx.skillDir, model: ctx.model, mode, turns: scenario.turns, cwd: ws.cwd,
227
- // resolved like fixtures: relative to the spec's dir
228
- systemPromptFile: scenario.systemPromptFile
229
- ? resolve(dirname(ctx.specPath), scenario.systemPromptFile)
230
- : undefined,
231
- // Absolute before it reaches a child process running in a neutral cwd.
232
- extensions: scenario.extensions?.map((e) => resolve(dirname(ctx.specPath), e)),
233
- eventSources: scenario.eventSources,
234
- };
235
- if (scenario.traceAssert || scenario.trajectoryAssert) {
236
- // Missing required evidence is ERROR, never a silent fallback to the
237
- // unstructured path: a gate with nothing to read must not look like a
238
- // gate that passed.
239
- if (!ctx.adapter.runStructured) {
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.`);
242
- }
243
- const structured = await ctx.adapter.runStructured({ ...req, scenarioId: scenario.id, rep });
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
- transcript = await ctx.adapter.run(req);
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
- let objective;
298
- if (scenario.traceAssert || scenario.trajectoryAssert) {
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; // judgeOneRep already journaled (verdict + misfire)
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
- 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}`);
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 are excluded as
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 are excluded as
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
@@ -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;