@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/results.js
CHANGED
|
@@ -4,6 +4,7 @@ import yaml from "js-yaml";
|
|
|
4
4
|
import { modelSlug } from "./adapters/types.js";
|
|
5
5
|
import { score } from "./score.js";
|
|
6
6
|
import { HARNESS_VERSION } from "./version.js";
|
|
7
|
+
import { collapseVotePanel } from "./vote-panel.js";
|
|
7
8
|
/** Merge bookkeeping from a later judge/gate pass without duplicating subject usage. */
|
|
8
9
|
export function mergeScenarioMetrics(prior, fresh) {
|
|
9
10
|
if (!prior)
|
|
@@ -22,11 +23,20 @@ export function mergeScenarioMetrics(prior, fresh) {
|
|
|
22
23
|
...(subject.cache_read_tokens === undefined ? {} : { cache_read_tokens: subject.cache_read_tokens }),
|
|
23
24
|
...(subject.cache_write_tokens === undefined ? {} : { cache_write_tokens: subject.cache_write_tokens }),
|
|
24
25
|
...(subject.subject_cost_usd === undefined ? {} : { subject_cost_usd: subject.subject_cost_usd }),
|
|
26
|
+
...(subject.cost_source === undefined ? {} : { cost_source: subject.cost_source }),
|
|
25
27
|
...(subject.tool_calls === undefined ? {} : { tool_calls: subject.tool_calls }),
|
|
26
28
|
...(subject.delegated_children === undefined ? {} : { delegated_children: subject.delegated_children }),
|
|
27
29
|
...(subject.max_concurrency === undefined ? {} : { max_concurrency: subject.max_concurrency }),
|
|
28
30
|
};
|
|
29
31
|
}
|
|
32
|
+
export function carryRepObjectives(fresh, prior) {
|
|
33
|
+
if (!fresh)
|
|
34
|
+
return prior;
|
|
35
|
+
return fresh.map(panel => {
|
|
36
|
+
const objective = prior?.find(candidate => candidate.repetition === panel.repetition)?.objective;
|
|
37
|
+
return objective ? { ...panel, objective } : panel;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
30
40
|
/**
|
|
31
41
|
* The run modes whose results carry a real grade: the ones where the skill under
|
|
32
42
|
* test was actually delivered to the model.
|
|
@@ -87,9 +97,23 @@ export function effectiveThreshold(prevScenario, scenario) {
|
|
|
87
97
|
function timestampSlug(iso) {
|
|
88
98
|
return iso.replace(/[:.]/g, "-");
|
|
89
99
|
}
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
/**
|
|
101
|
+
* `<skillDir>/tests/results/<harness>-<model-slug>[+<arm>]/<timestamp-slug>/`
|
|
102
|
+
*
|
|
103
|
+
* The arm is part of the run's IDENTITY, not of the spec's digest. `lint` and
|
|
104
|
+
* `stability` both key on this tag, so two arms become separate lineages that can
|
|
105
|
+
* never be misread as one lineage flipping run-over-run — and no
|
|
106
|
+
* `specification.yaml` byte moves, so every committed run stays valid.
|
|
107
|
+
*
|
|
108
|
+
* `+` is deliberate and is appended OUTSIDE `modelSlug`, whose character class
|
|
109
|
+
* (`[^A-Za-z0-9._-]`) cannot emit one. So the separator can never occur inside a
|
|
110
|
+
* slug and the tag stays unambiguously splittable back into model and arm. The
|
|
111
|
+
* control arm appends nothing, so its paths are byte-identical to before arms
|
|
112
|
+
* existed.
|
|
113
|
+
*/
|
|
114
|
+
export function runDirFor(skillDir, harness, model, timestamp, armName) {
|
|
115
|
+
const arm = armName && armName !== "none" ? `+${armName}` : "";
|
|
116
|
+
return join(skillDir, "tests", "results", `${harness}-${modelSlug(model)}${arm}`, timestampSlug(timestamp));
|
|
93
117
|
}
|
|
94
118
|
/** Path of a transcript file within a run dir. A rep index (for --reps N>1) is suffixed. */
|
|
95
119
|
export function transcriptPath(runDir, scenarioId, mode, rep) {
|
|
@@ -145,6 +169,8 @@ function objectiveVerdict(s) {
|
|
|
145
169
|
return undefined;
|
|
146
170
|
if (s.objective.status === "ERROR")
|
|
147
171
|
return "ERROR";
|
|
172
|
+
if (s.objective.status === "NOT-MEASURED")
|
|
173
|
+
return "NOT-MEASURED";
|
|
148
174
|
if (s.objective.status === "FAIL")
|
|
149
175
|
return "FAIL";
|
|
150
176
|
return undefined;
|
|
@@ -165,8 +191,9 @@ export function finalizeResults(draft, ctx) {
|
|
|
165
191
|
const why = draft.partial ? "partial run (--only) — not scored" : `mode=${draft.mode} (not scored)`;
|
|
166
192
|
effective_grade = { passed: 0, total: 0, pct: 0, letter: "-", ship: false, note: why };
|
|
167
193
|
}
|
|
194
|
+
const schema = draft.schema ?? (draft.subject_invocations ? 3 : 2);
|
|
168
195
|
return {
|
|
169
|
-
schema
|
|
196
|
+
schema,
|
|
170
197
|
// Stamped here, the single place every writer passes through, so `run`,
|
|
171
198
|
// `grade`, `rescore` and the review UI's override save all record which tool
|
|
172
199
|
// produced the record they leave behind.
|
|
@@ -182,15 +209,18 @@ export function finalizeResults(draft, ctx) {
|
|
|
182
209
|
timestamp: draft.timestamp,
|
|
183
210
|
label: draft.label,
|
|
184
211
|
mode: draft.mode,
|
|
212
|
+
...(draft.arm ? { arm: draft.arm } : {}),
|
|
185
213
|
...(draft.partial ? { partial: true } : {}),
|
|
186
214
|
...(draft.source_hashes ? { source_hashes: draft.source_hashes } : {}),
|
|
187
215
|
effective_grade,
|
|
188
216
|
scenarios: draft.scenarios,
|
|
217
|
+
...(draft.subject_invocations ? { subject_invocations: draft.subject_invocations } : {}),
|
|
189
218
|
};
|
|
190
219
|
}
|
|
191
220
|
/** Finalize + persist results.yaml (creating the run dir). Returns what was written. */
|
|
192
221
|
export function writeResults(runDir, draft, ctx) {
|
|
193
222
|
const results = finalizeResults(draft, ctx);
|
|
223
|
+
validateResults(results);
|
|
194
224
|
mkdirSync(runDir, { recursive: true });
|
|
195
225
|
writeFileSync(resultsPath(runDir), yaml.dump(results, { lineWidth: 100 }), "utf8");
|
|
196
226
|
return results;
|
|
@@ -202,8 +232,8 @@ export function migrateResults(raw) {
|
|
|
202
232
|
throw new Error("empty or invalid results.yaml");
|
|
203
233
|
}
|
|
204
234
|
const o = raw;
|
|
205
|
-
if (o.schema === 2)
|
|
206
|
-
return raw;
|
|
235
|
+
if (o.schema === 2 || o.schema === 3)
|
|
236
|
+
return validateResults(raw);
|
|
207
237
|
const v1 = raw;
|
|
208
238
|
const modeMatch = /^mode=(\w+)/.exec(v1.grade?.note ?? "");
|
|
209
239
|
return {
|
|
@@ -235,6 +265,191 @@ export function readResults(runDir) {
|
|
|
235
265
|
const text = readFileSync(resultsPath(runDir), "utf8");
|
|
236
266
|
return migrateResults(yaml.load(text));
|
|
237
267
|
}
|
|
268
|
+
const CRITERION_RE = /^\s*(\d+)[.)]\s*\**\s*(PASS|FAIL)\b\**\s*(.*)$/gim;
|
|
269
|
+
/** Parse rubric-item votes for retention only; verdict semantics remain in grade.ts. */
|
|
270
|
+
export function parseCriterionVotes(raw) {
|
|
271
|
+
return [...raw.matchAll(CRITERION_RE)].map((match) => ({
|
|
272
|
+
index: Number(match[1]), verdict: match[2].toUpperCase(),
|
|
273
|
+
reason: match[3].trim().replace(/^[-—:]\s*/, ""),
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
export function completeCriterionVotes(votes, expected) {
|
|
277
|
+
return Array.from({ length: expected }, (_, offset) => votes.find(vote => vote.index === offset + 1) ?? {
|
|
278
|
+
index: offset + 1, verdict: "ERROR", reason: "judge emitted no parseable vote for this criterion",
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
export function deliveryStatusForObservations(observations) {
|
|
282
|
+
if (observations.length === 0)
|
|
283
|
+
return "ERROR";
|
|
284
|
+
const terminalAttempt = Math.max(...observations.map(observation => observation.attempt ?? 0));
|
|
285
|
+
const terminal = observations.filter(observation => (observation.attempt ?? 0) === terminalAttempt);
|
|
286
|
+
if (terminal.some(observation => observation.prompt.status === "ERROR"))
|
|
287
|
+
return "ERROR";
|
|
288
|
+
const mechanism = terminal[0].prompt.mechanism;
|
|
289
|
+
if (terminal.some(observation => observation.prompt.mechanism !== mechanism))
|
|
290
|
+
return "ERROR";
|
|
291
|
+
const counts = terminal.map(observation => observation.prompt.contract_occurrences);
|
|
292
|
+
if (mechanism === "none")
|
|
293
|
+
return counts.every(count => count === 0) ? "PASS" : "NOT-MEASURED";
|
|
294
|
+
if (mechanism === "pi-skill") {
|
|
295
|
+
const firstDelivered = counts.indexOf(1);
|
|
296
|
+
return firstDelivered >= 0 && counts.slice(0, firstDelivered).every(count => count === 0) && counts.slice(firstDelivered).every(count => count === 1) ? "PASS" : "NOT-MEASURED";
|
|
297
|
+
}
|
|
298
|
+
return counts.every(count => count === 1) ? "PASS" : "NOT-MEASURED";
|
|
299
|
+
}
|
|
300
|
+
export function recomputeRecordedPanels(results) {
|
|
301
|
+
const out = [];
|
|
302
|
+
for (const scenario of results.scenarios)
|
|
303
|
+
for (const panel of scenario.rep_judgments ?? []) {
|
|
304
|
+
const clean = panel.judgments.filter(j => !j.suspect && (j.verdict === "PASS" || j.verdict === "FAIL"));
|
|
305
|
+
let verdict = panel.recorded_verdict;
|
|
306
|
+
if (clean.length === 1)
|
|
307
|
+
verdict = clean[0].verdict;
|
|
308
|
+
else if (clean.length >= 2)
|
|
309
|
+
verdict = collapseVotePanel(panel.judgments).verdict ?? "JUDGE-AMBIGUOUS";
|
|
310
|
+
out.push({ scenario_id: scenario.id, repetition: panel.repetition, verdict });
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
/** Runtime v2/v3 validator. v2 remains permissive; v3 fails closed on observation gaps. */
|
|
315
|
+
export function validateResults(raw) {
|
|
316
|
+
if (!raw || typeof raw !== "object")
|
|
317
|
+
throw new Error("invalid results");
|
|
318
|
+
const result = raw;
|
|
319
|
+
if (result.schema !== 2 && result.schema !== 3)
|
|
320
|
+
throw new Error(`unsupported results schema ${String(raw.schema)}`);
|
|
321
|
+
if (!Array.isArray(result.scenarios))
|
|
322
|
+
throw new Error("results scenarios missing");
|
|
323
|
+
if (result.schema === 2)
|
|
324
|
+
return result;
|
|
325
|
+
if (!Array.isArray(result.subject_invocations))
|
|
326
|
+
throw new Error("schema v3 delivery observations missing");
|
|
327
|
+
for (const observation of result.subject_invocations) {
|
|
328
|
+
const p = observation?.prompt;
|
|
329
|
+
if (!p || !["PASS", "NOT-MEASURED", "ERROR"].includes(p.status))
|
|
330
|
+
throw new Error("delivery status missing");
|
|
331
|
+
if (p.normalization_rule !== "cwd-line-v1")
|
|
332
|
+
throw new Error("unknown prompt normalization rule");
|
|
333
|
+
if (!/^[a-f0-9]{64}$/i.test(p.raw_sha256) || !/^[a-f0-9]{64}$/i.test(p.normalized_sha256) || !/^[a-f0-9]{64}$/i.test(p.contract_sha256))
|
|
334
|
+
throw new Error("invalid prompt provenance digest");
|
|
335
|
+
if (!Number.isInteger(p.bytes) || p.bytes < 0 || !Number.isInteger(p.contract_bytes) || p.contract_bytes < 0)
|
|
336
|
+
throw new Error("invalid prompt provenance byte length");
|
|
337
|
+
if (!Number.isInteger(p.contract_occurrences) || p.contract_occurrences < 0)
|
|
338
|
+
throw new Error("invalid delivery occurrence count");
|
|
339
|
+
const expected = p.mechanism === "none" ? 0 : 1;
|
|
340
|
+
const computed = p.contract_occurrences === expected ? "PASS" : "NOT-MEASURED";
|
|
341
|
+
if (p.status !== "ERROR" && p.status !== computed)
|
|
342
|
+
throw new Error("delivery status contradicts occurrence count");
|
|
343
|
+
}
|
|
344
|
+
const scenarioIds = new Set(result.scenarios.map(scenario => scenario.id));
|
|
345
|
+
const repsByScenario = new Map(result.scenarios.map(scenario => [scenario.id, scenario.reps ?? 1]));
|
|
346
|
+
if (result.subject_invocations.some(observation => !scenarioIds.has(observation.scenario_id) || !Number.isInteger(observation.repetition) || observation.repetition < 0 || observation.repetition >= (repsByScenario.get(observation.scenario_id) ?? 0)))
|
|
347
|
+
throw new Error("schema v3 contains orphan or out-of-range subject invocation observation");
|
|
348
|
+
const recomputed = recomputeRecordedPanels(result);
|
|
349
|
+
for (const scenario of result.scenarios) {
|
|
350
|
+
const reps = scenario.reps ?? 1;
|
|
351
|
+
if (!Array.isArray(scenario.rep_judgments) || scenario.rep_judgments.length !== reps)
|
|
352
|
+
throw new Error(`schema v3 repetition judgments missing for ${scenario.id}`);
|
|
353
|
+
const panelRepetitions = scenario.rep_judgments.map(panel => panel.repetition).sort((a, b) => a - b);
|
|
354
|
+
if (panelRepetitions.some((repetition, index) => repetition !== index))
|
|
355
|
+
throw new Error(`schema v3 repetition judgments duplicate, missing, or out of range for ${scenario.id}`);
|
|
356
|
+
for (let repetition = 0; repetition < reps; repetition++) {
|
|
357
|
+
const observations = result.subject_invocations.filter(o => o.scenario_id === scenario.id && o.repetition === repetition);
|
|
358
|
+
// A pre-provider infrastructure failure legitimately has zero requests;
|
|
359
|
+
// its per-repetition skill_delivered objective must remain explicit ERROR.
|
|
360
|
+
const attempts = [...new Set(observations.map(observation => observation.attempt ?? 0))].sort((a, b) => a - b);
|
|
361
|
+
if (attempts.some((attempt, index) => !Number.isInteger(attempt) || attempt !== index))
|
|
362
|
+
throw new Error(`delivery attempts are duplicate, missing, or out of range for ${scenario.id}#${repetition}`);
|
|
363
|
+
for (const attempt of attempts) {
|
|
364
|
+
const indexes = observations.filter(observation => (observation.attempt ?? 0) === attempt).map(observation => observation.prompt.request_index).sort((a, b) => a - b);
|
|
365
|
+
if (indexes.some((requestIndex, index) => !Number.isInteger(requestIndex) || requestIndex !== index))
|
|
366
|
+
throw new Error(`prompt request indexes are duplicate, missing, or out of range for ${scenario.id}#${repetition} attempt ${attempt}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const repStatuses = Array.from({ length: reps }, (_, repetition) => deliveryStatusForObservations(result.subject_invocations.filter(o => o.scenario_id === scenario.id && o.repetition === repetition)));
|
|
370
|
+
const expectedStatus = repStatuses.includes("ERROR") ? "ERROR" : repStatuses.includes("NOT-MEASURED") ? "NOT-MEASURED" : "PASS";
|
|
371
|
+
const gate = scenario.objective?.assertions.find(assertion => assertion.kind === "skill_delivered");
|
|
372
|
+
if (!gate || gate.status !== expectedStatus)
|
|
373
|
+
throw new Error(`skill_delivered gate missing or inconsistent for ${scenario.id}`);
|
|
374
|
+
if (!Number.isInteger(scenario.criterion_count) || scenario.criterion_count < 1)
|
|
375
|
+
throw new Error(`schema v3 criterion count missing for ${scenario.id}`);
|
|
376
|
+
const assertCriteria = (judgment) => {
|
|
377
|
+
if (!Array.isArray(judgment.criteria))
|
|
378
|
+
throw new Error("schema v3 criterion votes missing");
|
|
379
|
+
const indexes = judgment.criteria.map(vote => vote.index).sort((a, b) => a - b);
|
|
380
|
+
if (indexes.length !== scenario.criterion_count || indexes.some((index, offset) => index !== offset + 1))
|
|
381
|
+
throw new Error(`schema v3 criterion votes are not complete contiguous indexes for ${scenario.id}`);
|
|
382
|
+
};
|
|
383
|
+
for (const panel of scenario.rep_judgments) {
|
|
384
|
+
for (const judgment of panel.judgments)
|
|
385
|
+
assertCriteria(judgment);
|
|
386
|
+
const cleanJudgments = panel.judgments.filter(judgment => !judgment.suspect && (judgment.verdict === "PASS" || judgment.verdict === "FAIL"));
|
|
387
|
+
const objectiveBehavioralFail = panel.objective?.status === "FAIL" && panel.recorded_verdict === "FAIL";
|
|
388
|
+
if ((panel.recorded_verdict === "PASS" || panel.recorded_verdict === "FAIL") && cleanJudgments.length === 0 && !objectiveBehavioralFail)
|
|
389
|
+
throw new Error(`recorded behavioral verdict is unsupported by a clean judgment for ${scenario.id}#${panel.repetition}`);
|
|
390
|
+
const actual = recomputed.find(x => x.scenario_id === scenario.id && x.repetition === panel.repetition);
|
|
391
|
+
if (panel.judgments.length > 0 && actual.verdict !== panel.recorded_verdict)
|
|
392
|
+
throw new Error(`recorded panel verdict diverges from recomputed votes for ${scenario.id}#${panel.repetition}`);
|
|
393
|
+
if (!panel.objective)
|
|
394
|
+
throw new Error(`schema v3 per-repetition objective missing for ${scenario.id}#${panel.repetition}`);
|
|
395
|
+
const observations = result.subject_invocations.filter(o => o.scenario_id === scenario.id && o.repetition === panel.repetition);
|
|
396
|
+
const deliveryStatus = deliveryStatusForObservations(observations);
|
|
397
|
+
const delivery = panel.objective.assertions.find(assertion => assertion.kind === "skill_delivered");
|
|
398
|
+
if (!delivery || delivery.status !== deliveryStatus)
|
|
399
|
+
throw new Error(`per-repetition skill_delivered gate missing or inconsistent for ${scenario.id}#${panel.repetition}`);
|
|
400
|
+
if (deliveryStatus === "ERROR" && panel.objective.status !== "ERROR")
|
|
401
|
+
throw new Error(`per-repetition objective weakens delivery ERROR for ${scenario.id}#${panel.repetition}`);
|
|
402
|
+
if (deliveryStatus === "NOT-MEASURED" && !["ERROR", "NOT-MEASURED"].includes(panel.objective.status))
|
|
403
|
+
throw new Error(`per-repetition objective weakens delivery NOT-MEASURED for ${scenario.id}#${panel.repetition}`);
|
|
404
|
+
const objectiveVerdict = panel.objective.status === "ERROR" ? "ERROR" : panel.objective.status === "NOT-MEASURED" ? "NOT-MEASURED" : panel.objective.status === "FAIL" ? "FAIL" : undefined;
|
|
405
|
+
if (objectiveVerdict && panel.recorded_verdict !== objectiveVerdict)
|
|
406
|
+
throw new Error(`recorded panel verdict weakens objective evidence for ${scenario.id}#${panel.repetition}`);
|
|
407
|
+
}
|
|
408
|
+
const objectiveStatuses = scenario.rep_judgments.map(panel => panel.objective.status);
|
|
409
|
+
const aggregateObjectiveStatus = objectiveStatuses.includes("ERROR") ? "ERROR" : objectiveStatuses.includes("NOT-MEASURED") ? "NOT-MEASURED" : objectiveStatuses.includes("FAIL") ? "FAIL" : "PASS";
|
|
410
|
+
if (!scenario.objective || scenario.objective.status !== aggregateObjectiveStatus)
|
|
411
|
+
throw new Error(`scenario objective diverges from per-repetition objectives for ${scenario.id}`);
|
|
412
|
+
for (const judgment of [...(scenario.judge_history ?? []), ...(scenario.adjudication?.judgments ?? [])])
|
|
413
|
+
assertCriteria(judgment);
|
|
414
|
+
let adjudicatedVerdict;
|
|
415
|
+
if (scenario.adjudication) {
|
|
416
|
+
if (!Number.isInteger(scenario.adjudication.repetition) || scenario.adjudication.repetition < 0 || scenario.adjudication.repetition >= reps)
|
|
417
|
+
throw new Error(`schema v3 adjudication repetition missing or out of range for ${scenario.id}`);
|
|
418
|
+
const collapsed = collapseVotePanel(scenario.adjudication.judgments);
|
|
419
|
+
const boundedCriticalAggregate = (scenario.reps ?? 1) > 1 && scenario.pass_threshold === 1 && scenario.judge_verdict !== "PASS" && collapsed.verdict === "PASS" && scenario.adjudication.state === "unresolved" && scenario.adjudication.verdict === undefined;
|
|
420
|
+
if (!boundedCriticalAggregate && (scenario.adjudication.state !== collapsed.state || scenario.adjudication.verdict !== collapsed.verdict))
|
|
421
|
+
throw new Error(`recorded adjudication state/verdict diverges from recomputed votes for ${scenario.id}`);
|
|
422
|
+
if (scenario.adjudication.state !== "unresolved")
|
|
423
|
+
adjudicatedVerdict = scenario.adjudication.verdict;
|
|
424
|
+
}
|
|
425
|
+
const panels = scenario.rep_judgments;
|
|
426
|
+
const errorCount = panels.filter(panel => panel.recorded_verdict === "ERROR").length;
|
|
427
|
+
const notMeasuredCount = panels.filter(panel => panel.recorded_verdict === "NOT-MEASURED").length;
|
|
428
|
+
const cleanPanels = panels.filter(panel => !(panel.judgments[0]?.suspect ?? false));
|
|
429
|
+
const passes = cleanPanels.filter(panel => panel.recorded_verdict === "PASS").length;
|
|
430
|
+
let aggregateVerdict;
|
|
431
|
+
let aggregateSuspect = false;
|
|
432
|
+
if (panels.length === 1) {
|
|
433
|
+
aggregateVerdict = panels[0].recorded_verdict;
|
|
434
|
+
aggregateSuspect = panels[0].judgments[0]?.suspect ?? false;
|
|
435
|
+
}
|
|
436
|
+
else if (errorCount > 0)
|
|
437
|
+
aggregateVerdict = "ERROR";
|
|
438
|
+
else if (notMeasuredCount > 0)
|
|
439
|
+
aggregateVerdict = "NOT-MEASURED";
|
|
440
|
+
else if (cleanPanels.length * 2 < panels.length) {
|
|
441
|
+
aggregateVerdict = "FAIL";
|
|
442
|
+
aggregateSuspect = true;
|
|
443
|
+
}
|
|
444
|
+
else
|
|
445
|
+
aggregateVerdict = passes / cleanPanels.length >= (scenario.pass_threshold ?? 0.5) ? "PASS" : "FAIL";
|
|
446
|
+
const expectedVerdict = adjudicatedVerdict ?? aggregateVerdict;
|
|
447
|
+
const expectedSuspect = scenario.adjudication ? scenario.adjudication.state === "unresolved" : aggregateSuspect;
|
|
448
|
+
if (scenario.judge_verdict !== expectedVerdict || scenario.suspect !== expectedSuspect)
|
|
449
|
+
throw new Error(`schema v3 scenario verdict/suspect diverges from repetition aggregate for ${scenario.id}`);
|
|
450
|
+
}
|
|
451
|
+
return result;
|
|
452
|
+
}
|
|
238
453
|
/** Pure: return a copy with override + note applied to one scenario. */
|
|
239
454
|
export function applyOverride(results, scenarioId, override, note) {
|
|
240
455
|
if (override !== null && note.trim() === "") {
|
|
@@ -384,7 +599,7 @@ export function diffPath(runDir, scenarioId, mode, rep) {
|
|
|
384
599
|
export function rebuildScenarioResult(fresh, prior, policy) {
|
|
385
600
|
// Exhaustive destructure. Do not replace with a spread: the spread is what
|
|
386
601
|
// allowed a new field to pass through unconsidered in the first place.
|
|
387
|
-
const { id, judge_verdict, judge_reason, suspect, override: _freshOverride, note: _freshNote, reps, passes, clean, flakiness, pass_threshold, metrics: freshMetrics, objective: freshObjective, adjudication: freshAdjudication, ...rest } = fresh;
|
|
602
|
+
const { id, criterion_count: freshCriterionCount, judge_verdict, judge_reason, suspect, override: _freshOverride, note: _freshNote, reps, passes, clean, flakiness, pass_threshold, metrics: freshMetrics, objective: freshObjective, adjudication: freshAdjudication, judge_history: freshJudgeHistory, rep_judgments: freshRepJudgments, ...rest } = fresh;
|
|
388
603
|
const _exhaustive = rest;
|
|
389
604
|
void _exhaustive;
|
|
390
605
|
void _freshOverride;
|
|
@@ -396,7 +611,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
|
|
|
396
611
|
};
|
|
397
612
|
const objective = pick(policy.objective, freshObjective, prior?.objective);
|
|
398
613
|
const pickedAdjudication = pick(policy.adjudication, freshAdjudication, prior?.adjudication);
|
|
399
|
-
const conflictsWithFreshEvidence = Boolean(pickedAdjudication?.verdict && (objective?.status === "FAIL" || objective?.status === "ERROR" ||
|
|
614
|
+
const conflictsWithFreshEvidence = Boolean(pickedAdjudication?.verdict && (objective?.status === "FAIL" || objective?.status === "ERROR" || objective?.status === "NOT-MEASURED" ||
|
|
400
615
|
(pass_threshold === 1 && (reps ?? 1) > 1 && judge_verdict !== "PASS")));
|
|
401
616
|
// A settled cell-level panel cannot overrule newly evaluated objective gates,
|
|
402
617
|
// nor can one transcript erase a failed repetition under an all-clean policy.
|
|
@@ -408,8 +623,8 @@ export function rebuildScenarioResult(fresh, prior, policy) {
|
|
|
408
623
|
// Taking `suspect` from `fresh` while carrying the block therefore published a
|
|
409
624
|
// record that said `state: "unresolved"` and scored as a clean SHIP. `regate`
|
|
410
625
|
// did exactly that: it rebuilds verdict and suspect from the saved first-wave
|
|
411
|
-
// judge file, so a
|
|
412
|
-
//
|
|
626
|
+
// judge file, so a gate-only rewrite silently resolved a disagreement in favour
|
|
627
|
+
// of shipping — inverting this module's stated invariant that an
|
|
413
628
|
// unresolved disagreement must not resolve itself.
|
|
414
629
|
const unresolved = adjudication?.state === "unresolved";
|
|
415
630
|
// A settled adjudication outranks a re-read of the first-wave judge file for
|
|
@@ -425,6 +640,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
|
|
|
425
640
|
// corpus with a pure-noise diff.
|
|
426
641
|
return {
|
|
427
642
|
id,
|
|
643
|
+
...((freshCriterionCount ?? prior?.criterion_count) === undefined ? {} : { criterion_count: freshCriterionCount ?? prior.criterion_count }),
|
|
428
644
|
judge_verdict: settled ?? judge_verdict,
|
|
429
645
|
judge_reason,
|
|
430
646
|
suspect: suspect || unresolved,
|
|
@@ -444,6 +660,8 @@ export function rebuildScenarioResult(fresh, prior, policy) {
|
|
|
444
660
|
// existed.
|
|
445
661
|
...(objective ? { objective } : {}),
|
|
446
662
|
...(adjudication ? { adjudication } : {}),
|
|
663
|
+
...((freshJudgeHistory ?? prior?.judge_history) ? { judge_history: freshJudgeHistory ?? prior.judge_history } : {}),
|
|
664
|
+
...((freshRepJudgments ?? prior?.rep_judgments) ? { rep_judgments: freshRepJudgments ?? prior.rep_judgments } : {}),
|
|
447
665
|
};
|
|
448
666
|
}
|
|
449
667
|
/**
|
package/dist/run.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Spec } from "./spec.js";
|
|
2
2
|
import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
|
|
3
|
+
import { type Arm } from "./arms.js";
|
|
3
4
|
import { type ResultsFile } from "./results.js";
|
|
4
5
|
import { type Lift } from "./lift.js";
|
|
5
6
|
import { type ScenarioStability } from "./stability.js";
|
|
@@ -32,6 +33,19 @@ export interface RunOptions {
|
|
|
32
33
|
* there) is already refused by the adapter for free.
|
|
33
34
|
*/
|
|
34
35
|
canary?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Take the structured path even when no scenario declares a trace or trajectory
|
|
38
|
+
* assertion. The subject half of `ScenarioMetrics` — tokens, `subject_cost_usd` —
|
|
39
|
+
* is only ever populated from traces, so without this a run records no cost or
|
|
40
|
+
* token data at all, which is why the reference corpus has none.
|
|
41
|
+
*/
|
|
42
|
+
structured?: boolean;
|
|
43
|
+
/** The arm this run is measured under. Absent means the control (`none`). */
|
|
44
|
+
arm?: Arm;
|
|
45
|
+
/** Skills root — `seed_skills` paths resolve against it. Defaults to the skill's parent. */
|
|
46
|
+
skillsRoot?: string;
|
|
47
|
+
/** Injectable ambient skill root, for tests. Production reads `~/.pi/agent/skills`. */
|
|
48
|
+
ambientSkillsDir?: string;
|
|
35
49
|
}
|
|
36
50
|
export interface RunSummary {
|
|
37
51
|
runDir: string;
|