@skill-harness/core 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/types.d.ts +37 -0
- package/dist/adjudication.d.ts +210 -0
- package/dist/adjudication.js +392 -0
- package/dist/affected.d.ts +88 -0
- package/dist/affected.js +222 -0
- package/dist/capture-trace-types.d.ts +228 -0
- package/dist/capture-trace-types.js +23 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +344 -0
- package/dist/execution-trace.d.ts +61 -0
- package/dist/execution-trace.js +299 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/instruction-coverage.d.ts +106 -0
- package/dist/instruction-coverage.js +253 -0
- package/dist/journal.d.ts +17 -0
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +23 -0
- package/dist/regate.js +80 -17
- package/dist/regrade.js +17 -3
- package/dist/report.d.ts +24 -0
- package/dist/report.js +24 -0
- package/dist/reps.d.ts +14 -1
- package/dist/reps.js +28 -2
- package/dist/rescore.js +11 -2
- package/dist/results.d.ts +128 -6
- package/dist/results.js +155 -6
- package/dist/run.js +114 -8
- package/dist/seeded.d.ts +11 -0
- package/dist/seeded.js +31 -7
- package/dist/sources.js +40 -3
- package/dist/spec-write.d.ts +62 -0
- package/dist/spec-write.js +106 -0
- package/dist/spec.d.ts +29 -0
- package/dist/spec.js +55 -0
- package/dist/trace-gates.d.ts +133 -0
- package/dist/trace-gates.js +519 -0
- package/dist/workspace.d.ts +36 -0
- package/dist/workspace.js +61 -0
- package/package.json +1 -1
package/dist/adapters/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ExecutionTraceV1 } from "../capture-trace-types.js";
|
|
1
2
|
export type RunMode = "red" | "green" | "force";
|
|
2
3
|
/** A provider+model pair, e.g. { provider: "fireworks", model: "accounts/.../deepseek-v4-pro" }. */
|
|
3
4
|
export interface ModelRef {
|
|
@@ -20,6 +21,25 @@ export interface RunReq {
|
|
|
20
21
|
* single-shot shape it actually runs in; overrides `mode`'s skill flags.
|
|
21
22
|
*/
|
|
22
23
|
systemPromptFile?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Trace metadata, used only by `runStructured`.
|
|
26
|
+
*
|
|
27
|
+
* Optional because `run()` never needs them and every existing caller and test
|
|
28
|
+
* double predates them. A trace with no scenario id is still valid evidence —
|
|
29
|
+
* it just cannot be filed against a scenario. The ADAPTER supplies the
|
|
30
|
+
* fallback (see `pi.ts`); `parseTrace` requires a `scenarioId` and has no
|
|
31
|
+
* default, so a second `runStructured` author must pass one rather than
|
|
32
|
+
* assuming the parser fills it in.
|
|
33
|
+
*/
|
|
34
|
+
scenarioId?: string;
|
|
35
|
+
rep?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Absolute paths of pi extensions to load, already resolved by the caller.
|
|
38
|
+
*
|
|
39
|
+
* When present the adapter loads EXACTLY these and disables discovery, so an
|
|
40
|
+
* extension the developer happens to have installed cannot join the test.
|
|
41
|
+
*/
|
|
42
|
+
extensions?: string[];
|
|
23
43
|
}
|
|
24
44
|
/** A judge request: single prompt, no skills, no session. */
|
|
25
45
|
export interface JudgeReq {
|
|
@@ -27,10 +47,27 @@ export interface JudgeReq {
|
|
|
27
47
|
prompt: string;
|
|
28
48
|
cwd: string;
|
|
29
49
|
}
|
|
50
|
+
/** A structured run: the transcript the judge sees, plus the evidence gates read. */
|
|
51
|
+
export interface StructuredRun {
|
|
52
|
+
transcript: string;
|
|
53
|
+
/** One trace per turn — each `pi` invocation emits an independent event stream. */
|
|
54
|
+
traces: ExecutionTraceV1[];
|
|
55
|
+
}
|
|
30
56
|
export interface HarnessAdapter {
|
|
31
57
|
name: string;
|
|
32
58
|
available(): Promise<boolean>;
|
|
33
59
|
run(req: RunReq): Promise<string>;
|
|
60
|
+
/**
|
|
61
|
+
* Run and additionally return structured execution evidence.
|
|
62
|
+
*
|
|
63
|
+
* Optional, and used ONLY by scenarios that declare trace assertions. Two
|
|
64
|
+
* reasons it is not the default path. Test doubles and any future adapter must
|
|
65
|
+
* keep working without implementing it; and switching every existing scenario
|
|
66
|
+
* onto a different execution mode would be a behavior epoch — the transcript
|
|
67
|
+
* is reconstructed rather than read from stdout, and even a proven-equivalent
|
|
68
|
+
* reconstruction should not be applied to a whole published corpus silently.
|
|
69
|
+
*/
|
|
70
|
+
runStructured?(req: RunReq): Promise<StructuredRun>;
|
|
34
71
|
judge(req: JudgeReq): Promise<string>;
|
|
35
72
|
/**
|
|
36
73
|
* The harness CLI's own version, recorded in `results.yaml` as
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { HarnessAdapter, ModelRef } from "./adapters/types.js";
|
|
2
|
+
import type { ScenarioResult, AdjudicationResult, Judgment, ResultsFile } from "./results.js";
|
|
3
|
+
import type { Scenario, ShipBar, Spec } from "./spec.js";
|
|
4
|
+
import type { Verdict } from "./score.js";
|
|
5
|
+
/**
|
|
6
|
+
* Confidence-aware adjudication: decide which judged cells are untrustworthy
|
|
7
|
+
* enough to be worth asking again, collapse the resulting votes, and project the
|
|
8
|
+
* outcome into the fields the existing scorer already understands.
|
|
9
|
+
*
|
|
10
|
+
* Two rules govern everything here.
|
|
11
|
+
*
|
|
12
|
+
* **Spend is never implicit.** A spec may declare triggers, but only an explicit
|
|
13
|
+
* `--auto-rejudge` (or a confirmed extension dialog) authorizes a second call.
|
|
14
|
+
* `plan(...)` is a pure function that returns what WOULD be spent; nothing in
|
|
15
|
+
* this module calls a judge.
|
|
16
|
+
*
|
|
17
|
+
* **Unresolved disagreement must not resolve itself.** When two judges disagree
|
|
18
|
+
* and no third is authorized, the cell projects to `suspect: true` — which the
|
|
19
|
+
* existing ship bar already treats as blocking. That is deliberate reuse rather
|
|
20
|
+
* than a parallel gate: a second ship rule could drift out of step with the
|
|
21
|
+
* first, and a disagreement that quietly became a PASS is the exact failure this
|
|
22
|
+
* feature exists to prevent.
|
|
23
|
+
*/
|
|
24
|
+
export type TriggerKind = "ambiguous" | "contradictory" | "non_unanimous" | "ship_deciding";
|
|
25
|
+
/** Maximum judgments per cell, first wave included. Hard cap, not a default. */
|
|
26
|
+
export declare const MAX_JUDGMENTS = 3;
|
|
27
|
+
export interface CellState {
|
|
28
|
+
id: string;
|
|
29
|
+
/** First-wave verdict for the cell (already aggregated over reps). */
|
|
30
|
+
verdict: Verdict;
|
|
31
|
+
reason: string;
|
|
32
|
+
/** The judge misfired, or the cell is otherwise untrustworthy. */
|
|
33
|
+
suspect: boolean;
|
|
34
|
+
/** Per-rep verdicts, when the cell ran with reps. Empty for a single-rep cell. */
|
|
35
|
+
repVerdicts?: Verdict[];
|
|
36
|
+
}
|
|
37
|
+
export interface TriggerDecision {
|
|
38
|
+
id: string;
|
|
39
|
+
triggers: TriggerKind[];
|
|
40
|
+
}
|
|
41
|
+
export interface PlanInput {
|
|
42
|
+
cells: CellState[];
|
|
43
|
+
scenarios: Scenario[];
|
|
44
|
+
shipBar: ShipBar;
|
|
45
|
+
critical: string[];
|
|
46
|
+
/** Which trigger classes are enabled. Defaults to all four. */
|
|
47
|
+
enabled?: TriggerKind[];
|
|
48
|
+
/** A tie-break judge exists, so a third call is permitted on disagreement. */
|
|
49
|
+
tieBreakAvailable: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface AdjudicationPlan {
|
|
52
|
+
decisions: TriggerDecision[];
|
|
53
|
+
/** Cells that will be re-judged at least once. */
|
|
54
|
+
triggered: string[];
|
|
55
|
+
/**
|
|
56
|
+
* Triggered cells whose VERDICT this plan provably cannot settle.
|
|
57
|
+
*
|
|
58
|
+
* Their first-wave judgment misfired, so it is not a clean vote (see
|
|
59
|
+
* `collapseJudgments`). With no tie-break judge, one extra call reaches at most
|
|
60
|
+
* one clean vote and a collapse needs two: the cell returns `unresolved` and
|
|
61
|
+
* stays `suspect: true` whatever the second judge says.
|
|
62
|
+
*
|
|
63
|
+
* They are still re-judged, and deliberately so — the second opinion is the
|
|
64
|
+
* evidence an author reads to resolve the misfire by hand, which is the only
|
|
65
|
+
* way these cells ever get resolved. What was missing was saying so: the
|
|
66
|
+
* preflight offered them alongside cells a call could actually settle, so the
|
|
67
|
+
* buyer could not tell which was which. This list is that disclosure.
|
|
68
|
+
*/
|
|
69
|
+
needsTieBreak: string[];
|
|
70
|
+
/**
|
|
71
|
+
* Exact upper bound on ADDITIONAL judge calls this plan can make.
|
|
72
|
+
*
|
|
73
|
+
* Deliberately a call count, not a cost estimate. The default judge runs on a
|
|
74
|
+
* Claude subscription and reports no per-call usage back to the harness, so a
|
|
75
|
+
* dollar figure there would be invented. Count is the only honest unit.
|
|
76
|
+
*/
|
|
77
|
+
maxAdditionalCalls: number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Classify triggers and compute the spend ceiling. Pure — calls nothing.
|
|
81
|
+
*
|
|
82
|
+
* Triggers are computed from the COMPLETE first wave, never incrementally. A
|
|
83
|
+
* `ship_deciding` cell can only be identified once every other cell's verdict is
|
|
84
|
+
* known, and computing it mid-wave would make the trigger set depend on the order
|
|
85
|
+
* scenarios happened to finish in.
|
|
86
|
+
*/
|
|
87
|
+
export declare function planAdjudication(input: PlanInput): AdjudicationPlan;
|
|
88
|
+
export type AdjudicationState = "confirmed" | "tie_broken" | "unresolved";
|
|
89
|
+
/**
|
|
90
|
+
* Collapse the judgments recorded for one cell.
|
|
91
|
+
*
|
|
92
|
+
* A judgment is a CLEAN vote only if it is a plain PASS or FAIL from a judge that
|
|
93
|
+
* did not misfire. Ambiguous, suspect and unreadable judgments are recorded in
|
|
94
|
+
* full but never counted — a malformed answer is not evidence, and letting one
|
|
95
|
+
* cast a deciding vote would launder exactly the unreliability being measured.
|
|
96
|
+
*/
|
|
97
|
+
export declare function collapseJudgments(judgments: Judgment[], trigger: TriggerKind): AdjudicationResult;
|
|
98
|
+
/**
|
|
99
|
+
* Project an adjudication onto the compatibility fields the scorer reads.
|
|
100
|
+
*
|
|
101
|
+
* `unresolved` becomes `suspect: true`, which the existing ship bar already
|
|
102
|
+
* blocks on. The raw judgments survive on the result either way — an author
|
|
103
|
+
* resolving this needs to see what each judge actually said, not just the
|
|
104
|
+
* collapsed answer.
|
|
105
|
+
*/
|
|
106
|
+
export declare function projectAdjudication(result: ScenarioResult, adj: AdjudicationResult): ScenarioResult;
|
|
107
|
+
/** Ask one judge about one cell's saved transcript. Supplied by the caller. */
|
|
108
|
+
export type RejudgeFn = (id: string, judge: ModelRef) => Promise<{
|
|
109
|
+
verdict: Verdict;
|
|
110
|
+
reason: string;
|
|
111
|
+
suspect: boolean;
|
|
112
|
+
}>;
|
|
113
|
+
export interface RunAdjudicationOptions {
|
|
114
|
+
plan: AdjudicationPlan;
|
|
115
|
+
cells: CellState[];
|
|
116
|
+
/** The judge that produced the first wave — recorded as judgment 1. */
|
|
117
|
+
primaryJudge: ModelRef;
|
|
118
|
+
secondaryJudge: ModelRef;
|
|
119
|
+
/** Absent means a disagreement stays unresolved rather than being tie-broken. */
|
|
120
|
+
tieBreakJudge?: ModelRef;
|
|
121
|
+
rejudge: RejudgeFn;
|
|
122
|
+
log?: (msg: string) => void;
|
|
123
|
+
}
|
|
124
|
+
export interface RunAdjudicationResult {
|
|
125
|
+
/** Per triggered cell, the collapsed adjudication. Cells that did not trigger are absent. */
|
|
126
|
+
byId: Map<string, AdjudicationResult>;
|
|
127
|
+
/** Judge calls actually made. Never exceeds `plan.maxAdditionalCalls`. */
|
|
128
|
+
callsMade: number;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Execute an adjudication plan.
|
|
132
|
+
*
|
|
133
|
+
* ONE implementation, called from `run`, `grade` and the review server's rejudge
|
|
134
|
+
* path. The plan's three collapse policies were the most likely thing to be
|
|
135
|
+
* reimplemented three times and to disagree — a cell that shipped from the CLI
|
|
136
|
+
* and blocked in the browser would be worse than no feature.
|
|
137
|
+
*
|
|
138
|
+
* The tie-break call is made only when the secondary genuinely disagreed. A third
|
|
139
|
+
* opinion on a settled cell is spend with no decision attached to it.
|
|
140
|
+
*/
|
|
141
|
+
export declare function runAdjudication(opts: RunAdjudicationOptions): Promise<RunAdjudicationResult>;
|
|
142
|
+
export interface AdjudicateRunOptions {
|
|
143
|
+
runDir: string;
|
|
144
|
+
spec: Spec;
|
|
145
|
+
adapter: HarnessAdapter;
|
|
146
|
+
/** The first-wave results this adjudication refines. */
|
|
147
|
+
results: ResultsFile;
|
|
148
|
+
primaryJudge: ModelRef;
|
|
149
|
+
secondaryJudge: ModelRef;
|
|
150
|
+
tieBreakJudge?: ModelRef;
|
|
151
|
+
specDir: string;
|
|
152
|
+
now: () => string;
|
|
153
|
+
log?: (msg: string) => void;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Adjudicate a completed run: plan, disclose the ceiling, execute, persist.
|
|
157
|
+
*
|
|
158
|
+
* Re-judges the SAME saved transcripts — no subject re-run, so the model's
|
|
159
|
+
* behavior is held fixed and any movement is the judge. That is what makes this
|
|
160
|
+
* a measurement of judge reliability rather than another sample of the model.
|
|
161
|
+
*/
|
|
162
|
+
export declare function adjudicateRun(opts: AdjudicateRunOptions): Promise<ResultsFile>;
|
|
163
|
+
/**
|
|
164
|
+
* Build the plan's input cells from a run.
|
|
165
|
+
*
|
|
166
|
+
* EXPORTED so the CLI, the review server and the pi extension all price the work
|
|
167
|
+
* exactly as `adjudicateRun` will perform it. When the preflight built cells
|
|
168
|
+
* without `repVerdicts` and the executor built them with, the browser could show
|
|
169
|
+
* "no cell triggered" and then spend on non-unanimous cells nobody was told
|
|
170
|
+
* about — inverting the one invariant this feature actually promises.
|
|
171
|
+
*/
|
|
172
|
+
export declare function cellsFromResults(runDir: string, results: ResultsFile): CellState[];
|
|
173
|
+
/**
|
|
174
|
+
* Resolve and validate the extra judges.
|
|
175
|
+
*
|
|
176
|
+
* Every configured judge passes the SAME two gates the primary does: the metered
|
|
177
|
+
* refusal and the judge≠subject check. A secondary judge is still a judge — a
|
|
178
|
+
* feature that multiplies judge calls is the last place to let one slip past the
|
|
179
|
+
* policy that exists because a default once billed a corpus by accident.
|
|
180
|
+
*
|
|
181
|
+
* Returns null when adjudication was not authorized, so callers can treat "not
|
|
182
|
+
* enabled" and "enabled with no triggers" as different things.
|
|
183
|
+
*/
|
|
184
|
+
export declare function resolveAdjudicationJudges(opts: {
|
|
185
|
+
enabled: boolean;
|
|
186
|
+
primary: ModelRef;
|
|
187
|
+
secondaryToken?: string;
|
|
188
|
+
tieBreakToken?: string;
|
|
189
|
+
/**
|
|
190
|
+
* The run's recorded subject, as a raw token.
|
|
191
|
+
*
|
|
192
|
+
* A TOKEN rather than a parsed ref, and parsed only after the `enabled` check:
|
|
193
|
+
* an eagerly-parsed argument threw on any run whose recorded model is not
|
|
194
|
+
* `provider:model` — which killed the whole regrade over a provenance oddity in
|
|
195
|
+
* a field only used for a warning.
|
|
196
|
+
*/
|
|
197
|
+
subjectToken: string;
|
|
198
|
+
parseRef: (token: string) => ModelRef;
|
|
199
|
+
assertAllowed: (judge: ModelRef, source: string) => void;
|
|
200
|
+
resemblesSubject: (judge: ModelRef, subject: ModelRef) => boolean;
|
|
201
|
+
warn: (msg: string) => void;
|
|
202
|
+
}): {
|
|
203
|
+
secondary: ModelRef;
|
|
204
|
+
tieBreak?: ModelRef;
|
|
205
|
+
} | null;
|
|
206
|
+
/** Human-readable preflight line. Counts, never dollars — see `maxAdditionalCalls`. */
|
|
207
|
+
export declare function formatAdjudicationPlan(plan: AdjudicationPlan, judges: {
|
|
208
|
+
secondary: ModelRef;
|
|
209
|
+
tieBreak?: ModelRef;
|
|
210
|
+
}): string;
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { judgeRawPath, writeResults, scoreContextFor, findTranscriptFiles, repIndexOf, rebuildScenarioResult, } from "./results.js";
|
|
4
|
+
import { score } from "./score.js";
|
|
5
|
+
import { buildJudgePrompt, judgeInWorkspace, parseVerdict } from "./grade.js";
|
|
6
|
+
import { appendJournal } from "./journal.js";
|
|
7
|
+
/** Maximum judgments per cell, first wave included. Hard cap, not a default. */
|
|
8
|
+
export const MAX_JUDGMENTS = 3;
|
|
9
|
+
/**
|
|
10
|
+
* Classify triggers and compute the spend ceiling. Pure — calls nothing.
|
|
11
|
+
*
|
|
12
|
+
* Triggers are computed from the COMPLETE first wave, never incrementally. A
|
|
13
|
+
* `ship_deciding` cell can only be identified once every other cell's verdict is
|
|
14
|
+
* known, and computing it mid-wave would make the trigger set depend on the order
|
|
15
|
+
* scenarios happened to finish in.
|
|
16
|
+
*/
|
|
17
|
+
export function planAdjudication(input) {
|
|
18
|
+
const enabled = new Set(input.enabled ?? ["ambiguous", "contradictory", "non_unanimous", "ship_deciding"]);
|
|
19
|
+
const decisions = [];
|
|
20
|
+
for (const cell of input.cells) {
|
|
21
|
+
const triggers = [];
|
|
22
|
+
// `JUDGE-AMBIGUOUS` is what the parser emits when a judge's verdict blocks
|
|
23
|
+
// disagree. `ERROR` is what it emits when nothing parsed at all — and that
|
|
24
|
+
// was matched by no trigger here, so the least readable judgments in the run
|
|
25
|
+
// were the ones never asked again. Both are "the first wave produced no
|
|
26
|
+
// usable answer", which is precisely what a second opinion is for.
|
|
27
|
+
if (enabled.has("ambiguous") && (cell.verdict === "JUDGE-AMBIGUOUS" || cell.verdict === "ERROR")) {
|
|
28
|
+
triggers.push("ambiguous");
|
|
29
|
+
}
|
|
30
|
+
// A misfire IS the contradiction: the overall verdict disagrees with the
|
|
31
|
+
// AND of the per-item grades. `detectMisfire` already found it; this is the
|
|
32
|
+
// decision about what to do with it.
|
|
33
|
+
if (enabled.has("contradictory") && cell.suspect && cell.verdict !== "JUDGE-AMBIGUOUS" && cell.verdict !== "ERROR") {
|
|
34
|
+
triggers.push("contradictory");
|
|
35
|
+
}
|
|
36
|
+
if (enabled.has("non_unanimous") && isNonUnanimous(cell))
|
|
37
|
+
triggers.push("non_unanimous");
|
|
38
|
+
if (enabled.has("ship_deciding") && flipsShipDecision(cell, input))
|
|
39
|
+
triggers.push("ship_deciding");
|
|
40
|
+
decisions.push({ id: cell.id, triggers });
|
|
41
|
+
}
|
|
42
|
+
const suspectById = new Map(input.cells.map((c) => [c.id, c.suspect]));
|
|
43
|
+
const fired = decisions.filter((d) => d.triggers.length > 0);
|
|
44
|
+
// A misfired first-wave judgment is not a clean vote, so with only one extra
|
|
45
|
+
// judge the cell can never reach the two clean votes a collapse needs. The call
|
|
46
|
+
// is still worth making — it records a second opinion for the author — but the
|
|
47
|
+
// buyer has to be told it cannot settle the verdict.
|
|
48
|
+
const needsTieBreak = input.tieBreakAvailable ? [] : fired.filter((d) => suspectById.get(d.id)).map((d) => d.id);
|
|
49
|
+
const triggered = fired.map((d) => d.id);
|
|
50
|
+
// Per triggered cell: one secondary call always, plus one tie-break call only
|
|
51
|
+
// if a third judge is available to make it.
|
|
52
|
+
const perCell = input.tieBreakAvailable ? 2 : 1;
|
|
53
|
+
return { decisions, triggered, needsTieBreak, maxAdditionalCalls: triggered.length * perCell };
|
|
54
|
+
}
|
|
55
|
+
/** A rep set containing both a PASS and a non-PASS is not a settled result. */
|
|
56
|
+
function isNonUnanimous(cell) {
|
|
57
|
+
const reps = cell.repVerdicts ?? [];
|
|
58
|
+
if (reps.length < 2)
|
|
59
|
+
return false;
|
|
60
|
+
const passes = reps.filter((v) => v === "PASS").length;
|
|
61
|
+
return passes > 0 && passes < reps.length;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Would flipping this one cell change SHIP / NOT READY?
|
|
65
|
+
*
|
|
66
|
+
* Counterfactual against the REAL scorer, not a reimplementation of the ship
|
|
67
|
+
* rules. min-pass, critical and B-series all move the answer, and a second copy
|
|
68
|
+
* of that logic would be a copy that drifts. Both directions are tested: a cell
|
|
69
|
+
* that would break a shipping run and one that would rescue a blocked one are
|
|
70
|
+
* equally worth a second opinion.
|
|
71
|
+
*/
|
|
72
|
+
function flipsShipDecision(cell, input) {
|
|
73
|
+
// The target cell's `suspect` is cleared on BOTH sides. It has to be: a suspect
|
|
74
|
+
// cell blocks SHIP on its own, so leaving the flag on the baseline would make
|
|
75
|
+
// the two scores differ because of the flag rather than the verdict — and every
|
|
76
|
+
// suspect cell would report as ship-deciding. Clearing it on both sides asks the
|
|
77
|
+
// question actually being asked: holding everything else fixed, does THIS
|
|
78
|
+
// CELL'S VERDICT decide the ship?
|
|
79
|
+
const verdictsWith = (targetVerdict) => input.cells.map((c) => c.id === cell.id
|
|
80
|
+
? { id: c.id, verdict: targetVerdict, suspect: false }
|
|
81
|
+
: { id: c.id, verdict: c.verdict, suspect: c.suspect });
|
|
82
|
+
const opts = { shipBar: input.shipBar, critical: input.critical };
|
|
83
|
+
const flipped = cell.verdict === "PASS" ? "FAIL" : "PASS";
|
|
84
|
+
return score(verdictsWith(cell.verdict), opts).ship !== score(verdictsWith(flipped), opts).ship;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Collapse the judgments recorded for one cell.
|
|
88
|
+
*
|
|
89
|
+
* A judgment is a CLEAN vote only if it is a plain PASS or FAIL from a judge that
|
|
90
|
+
* did not misfire. Ambiguous, suspect and unreadable judgments are recorded in
|
|
91
|
+
* full but never counted — a malformed answer is not evidence, and letting one
|
|
92
|
+
* cast a deciding vote would launder exactly the unreliability being measured.
|
|
93
|
+
*/
|
|
94
|
+
export function collapseJudgments(judgments, trigger) {
|
|
95
|
+
const clean = judgments.filter((j) => !j.suspect && (j.verdict === "PASS" || j.verdict === "FAIL"));
|
|
96
|
+
const base = { trigger, judgments };
|
|
97
|
+
if (clean.length < 2) {
|
|
98
|
+
// Nothing to compare: one clean vote (or none) cannot confirm anything.
|
|
99
|
+
return { ...base, state: "unresolved" };
|
|
100
|
+
}
|
|
101
|
+
const passes = clean.filter((j) => j.verdict === "PASS").length;
|
|
102
|
+
const fails = clean.length - passes;
|
|
103
|
+
if (passes === 0 || fails === 0) {
|
|
104
|
+
return { ...base, state: "confirmed", verdict: clean[0].verdict };
|
|
105
|
+
}
|
|
106
|
+
// A clean strict majority breaks the tie. With exactly 2 clean votes that
|
|
107
|
+
// disagree there is no majority, so this correctly falls through.
|
|
108
|
+
if (passes > fails)
|
|
109
|
+
return { ...base, state: "tie_broken", verdict: "PASS" };
|
|
110
|
+
if (fails > passes)
|
|
111
|
+
return { ...base, state: "tie_broken", verdict: "FAIL" };
|
|
112
|
+
return { ...base, state: "unresolved" };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Project an adjudication onto the compatibility fields the scorer reads.
|
|
116
|
+
*
|
|
117
|
+
* `unresolved` becomes `suspect: true`, which the existing ship bar already
|
|
118
|
+
* blocks on. The raw judgments survive on the result either way — an author
|
|
119
|
+
* resolving this needs to see what each judge actually said, not just the
|
|
120
|
+
* collapsed answer.
|
|
121
|
+
*/
|
|
122
|
+
export function projectAdjudication(result, adj) {
|
|
123
|
+
if (adj.state === "unresolved") {
|
|
124
|
+
return {
|
|
125
|
+
...result,
|
|
126
|
+
// Verdict is left as recorded rather than forced to FAIL: `suspect` is what
|
|
127
|
+
// blocks the ship, and overwriting the verdict would destroy the
|
|
128
|
+
// first-wave answer an author needs in order to adjudicate.
|
|
129
|
+
judge_reason: `${adj.judgments.length} judgments disagree (${adj.trigger}) — resolve or re-judge`,
|
|
130
|
+
suspect: true,
|
|
131
|
+
adjudication: adj,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
...result,
|
|
136
|
+
judge_verdict: adj.verdict ?? result.judge_verdict,
|
|
137
|
+
judge_reason: reasonFor(adj),
|
|
138
|
+
// A confirmed or tie-broken cell is no longer untrustworthy — that is the
|
|
139
|
+
// entire point of having asked again.
|
|
140
|
+
suspect: false,
|
|
141
|
+
adjudication: adj,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function reasonFor(adj) {
|
|
145
|
+
const n = adj.judgments.length;
|
|
146
|
+
const verb = adj.state === "confirmed" ? "confirmed by" : "resolved by majority of";
|
|
147
|
+
return `${adj.verdict} ${verb} ${n} judgments (${adj.trigger})`;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Execute an adjudication plan.
|
|
151
|
+
*
|
|
152
|
+
* ONE implementation, called from `run`, `grade` and the review server's rejudge
|
|
153
|
+
* path. The plan's three collapse policies were the most likely thing to be
|
|
154
|
+
* reimplemented three times and to disagree — a cell that shipped from the CLI
|
|
155
|
+
* and blocked in the browser would be worse than no feature.
|
|
156
|
+
*
|
|
157
|
+
* The tie-break call is made only when the secondary genuinely disagreed. A third
|
|
158
|
+
* opinion on a settled cell is spend with no decision attached to it.
|
|
159
|
+
*/
|
|
160
|
+
export async function runAdjudication(opts) {
|
|
161
|
+
const byId = new Map();
|
|
162
|
+
const log = opts.log ?? (() => { });
|
|
163
|
+
let callsMade = 0;
|
|
164
|
+
for (const decision of opts.plan.decisions) {
|
|
165
|
+
if (decision.triggers.length === 0)
|
|
166
|
+
continue;
|
|
167
|
+
const cell = opts.cells.find((c) => c.id === decision.id);
|
|
168
|
+
if (!cell)
|
|
169
|
+
continue;
|
|
170
|
+
// A cell can satisfy several triggers; the first is recorded as the reason,
|
|
171
|
+
// in the fixed order `planAdjudication` evaluates them.
|
|
172
|
+
const trigger = decision.triggers[0];
|
|
173
|
+
const judgments = [
|
|
174
|
+
{ ordinal: 1, judge: { ...opts.primaryJudge }, verdict: cell.verdict, reason: cell.reason, suspect: cell.suspect },
|
|
175
|
+
];
|
|
176
|
+
const second = await opts.rejudge(decision.id, opts.secondaryJudge);
|
|
177
|
+
callsMade++;
|
|
178
|
+
judgments.push({ ordinal: 2, judge: { ...opts.secondaryJudge }, ...second });
|
|
179
|
+
let collapsed = collapseJudgments(judgments, trigger);
|
|
180
|
+
if (collapsed.state === "unresolved" && opts.tieBreakJudge) {
|
|
181
|
+
const third = await opts.rejudge(decision.id, opts.tieBreakJudge);
|
|
182
|
+
callsMade++;
|
|
183
|
+
judgments.push({ ordinal: 3, judge: { ...opts.tieBreakJudge }, ...third });
|
|
184
|
+
collapsed = collapseJudgments(judgments, trigger);
|
|
185
|
+
}
|
|
186
|
+
log(` ${decision.id}: ${collapsed.state}${collapsed.verdict ? ` → ${collapsed.verdict}` : ""} (${judgments.length} judgments)`);
|
|
187
|
+
byId.set(decision.id, collapsed);
|
|
188
|
+
}
|
|
189
|
+
return { byId, callsMade };
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Adjudicate a completed run: plan, disclose the ceiling, execute, persist.
|
|
193
|
+
*
|
|
194
|
+
* Re-judges the SAME saved transcripts — no subject re-run, so the model's
|
|
195
|
+
* behavior is held fixed and any movement is the judge. That is what makes this
|
|
196
|
+
* a measurement of judge reliability rather than another sample of the model.
|
|
197
|
+
*/
|
|
198
|
+
export async function adjudicateRun(opts) {
|
|
199
|
+
const log = opts.log ?? (() => { });
|
|
200
|
+
const mode = opts.results.mode;
|
|
201
|
+
const cells = cellsFromResults(opts.runDir, opts.results);
|
|
202
|
+
const plan = planAdjudication({
|
|
203
|
+
cells,
|
|
204
|
+
scenarios: opts.spec.scenarios,
|
|
205
|
+
shipBar: opts.spec.ship_bar,
|
|
206
|
+
critical: opts.spec.critical,
|
|
207
|
+
tieBreakAvailable: opts.tieBreakJudge !== undefined,
|
|
208
|
+
});
|
|
209
|
+
// Disclosed before the first extra call, always — including the zero case, so
|
|
210
|
+
// "nothing triggered" is visibly different from "the feature did not run".
|
|
211
|
+
log(formatAdjudicationPlan(plan, { secondary: opts.secondaryJudge, tieBreak: opts.tieBreakJudge }));
|
|
212
|
+
if (plan.triggered.length === 0)
|
|
213
|
+
return opts.results;
|
|
214
|
+
const byIdScenario = new Map(opts.spec.scenarios.map((s) => [s.id, s]));
|
|
215
|
+
const { byId, callsMade } = await runAdjudication({
|
|
216
|
+
plan,
|
|
217
|
+
cells,
|
|
218
|
+
primaryJudge: opts.primaryJudge,
|
|
219
|
+
secondaryJudge: opts.secondaryJudge,
|
|
220
|
+
tieBreakJudge: opts.tieBreakJudge,
|
|
221
|
+
log,
|
|
222
|
+
rejudge: async (id, judge) => {
|
|
223
|
+
const scenario = byIdScenario.get(id);
|
|
224
|
+
if (!scenario)
|
|
225
|
+
throw new Error(`adjudication: scenario \`${id}\` is not in the spec`);
|
|
226
|
+
return judgeCell({ ...opts, scenario, judge, mode });
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
const scenarios = opts.results.scenarios.map((s) => {
|
|
230
|
+
const adj = byId.get(s.id);
|
|
231
|
+
// Adjudication asks judges again and re-measures nothing else, so the run's
|
|
232
|
+
// objective evidence carries. Overrides survive too — a judge panel does not
|
|
233
|
+
// outvote the author.
|
|
234
|
+
return adj
|
|
235
|
+
? rebuildScenarioResult(projectAdjudication(s, adj), s, { objective: "carry", adjudication: "fresh" })
|
|
236
|
+
: s;
|
|
237
|
+
});
|
|
238
|
+
appendJournal(opts.runDir, {
|
|
239
|
+
event: "adjudication",
|
|
240
|
+
ts: opts.now(),
|
|
241
|
+
triggered: plan.triggered,
|
|
242
|
+
judge_calls: callsMade,
|
|
243
|
+
unresolved: [...byId.entries()].filter(([, a]) => a.state === "unresolved").map(([id]) => id),
|
|
244
|
+
});
|
|
245
|
+
const ctx = scoreContextFor(opts.results, opts.spec);
|
|
246
|
+
return writeResults(opts.runDir, { ...opts.results, scenarios }, ctx);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Ask one judge about one cell, reading the SAME saved transcript the first wave
|
|
250
|
+
* was graded from.
|
|
251
|
+
*
|
|
252
|
+
* A multi-rep cell is adjudicated on its FIRST rep's transcript under one
|
|
253
|
+
* documented policy, rather than whichever rep would move the headline. Picking
|
|
254
|
+
* the convenient rep is how a "second opinion" becomes a way to get the answer
|
|
255
|
+
* you wanted, which would make the whole feature worse than not having it.
|
|
256
|
+
*
|
|
257
|
+
* The extra judgment is written to `.judge2.txt` / `.judge3.txt`, leaving the
|
|
258
|
+
* first-wave `.judge.txt` untouched — the audit trail is the point.
|
|
259
|
+
*/
|
|
260
|
+
async function judgeCell(opts) {
|
|
261
|
+
const files = findTranscriptFiles(opts.runDir, opts.scenario.id, opts.mode);
|
|
262
|
+
if (files.length === 0) {
|
|
263
|
+
throw new Error(`adjudication: no ${opts.mode} transcript for \`${opts.scenario.id}\` in ${opts.runDir} — ` +
|
|
264
|
+
`transcripts are gitignored, so this needs the run dir that produced them`);
|
|
265
|
+
}
|
|
266
|
+
const transcript = readFileSync(join(opts.runDir, files[0]), "utf8");
|
|
267
|
+
const prompt = buildJudgePrompt({
|
|
268
|
+
skill: opts.spec.skill, persona: opts.spec.judge_persona, scenario: opts.scenario, transcript,
|
|
269
|
+
});
|
|
270
|
+
const g = await judgeInWorkspace(opts.adapter, opts.judge, prompt, opts.specDir);
|
|
271
|
+
// Ordinal 2 and 3 get their own artifacts; the first wave's stays as recorded.
|
|
272
|
+
const rep = repIndexOf(files[0]) ?? undefined;
|
|
273
|
+
const base = judgeRawPath(opts.runDir, opts.scenario.id, opts.mode, rep);
|
|
274
|
+
const nth = existsSync(base.replace(/\.judge\.txt$/, ".judge2.txt")) ? 3 : 2;
|
|
275
|
+
writeFileSync(base.replace(/\.judge\.txt$/, `.judge${nth}.txt`), g.raw, "utf8");
|
|
276
|
+
appendJournal(opts.runDir, {
|
|
277
|
+
event: "judge-verdict", ts: opts.now(), id: opts.scenario.id,
|
|
278
|
+
verdict: g.verdict, reason: g.reason, suspect: g.suspect,
|
|
279
|
+
});
|
|
280
|
+
return { verdict: g.verdict, reason: g.reason, suspect: g.suspect };
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Build the plan's input cells from a run.
|
|
284
|
+
*
|
|
285
|
+
* EXPORTED so the CLI, the review server and the pi extension all price the work
|
|
286
|
+
* exactly as `adjudicateRun` will perform it. When the preflight built cells
|
|
287
|
+
* without `repVerdicts` and the executor built them with, the browser could show
|
|
288
|
+
* "no cell triggered" and then spend on non-unanimous cells nobody was told
|
|
289
|
+
* about — inverting the one invariant this feature actually promises.
|
|
290
|
+
*/
|
|
291
|
+
export function cellsFromResults(runDir, results) {
|
|
292
|
+
return results.scenarios.map((s) => ({
|
|
293
|
+
id: s.id,
|
|
294
|
+
verdict: s.judge_verdict,
|
|
295
|
+
reason: s.judge_reason,
|
|
296
|
+
suspect: s.suspect,
|
|
297
|
+
repVerdicts: repVerdictsOf(runDir, s, results.mode),
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
/** Per-rep verdicts from saved judge artifacts, for the non-unanimous trigger. */
|
|
301
|
+
function repVerdictsOf(runDir, s, mode) {
|
|
302
|
+
if (!s.reps || s.reps < 2)
|
|
303
|
+
return undefined;
|
|
304
|
+
const out = [];
|
|
305
|
+
// 0-based: `run.ts` writes rep0..repN-1. Looping from 1 dropped rep0 and probed
|
|
306
|
+
// a repN that never exists, so a 3-rep FAIL/PASS/PASS read as unanimous PASS and
|
|
307
|
+
// a 2-rep cell returned undefined — `non_unanimous` could never fire correctly.
|
|
308
|
+
for (let rep = 0; rep < s.reps; rep++) {
|
|
309
|
+
const path = judgeRawPath(runDir, s.id, mode, rep);
|
|
310
|
+
if (!existsSync(path)) {
|
|
311
|
+
// A rep with no judge artifact is an ABSENT vote, not a vote to skip.
|
|
312
|
+
// `run.ts` deliberately does not call the judge for a rep blocked by a gate
|
|
313
|
+
// or ending in ERROR — so the missing files are exactly the reps that
|
|
314
|
+
// failed hardest, and dropping them made `[FAIL, PASS, PASS]` read as
|
|
315
|
+
// `[PASS, PASS]`: unanimous. The cell whose split was caused by a forbidden
|
|
316
|
+
// tool call was the one cell adjudication declined to look at, and the
|
|
317
|
+
// preflight told the buyer "no cell triggered".
|
|
318
|
+
out.push("ERROR");
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
out.push(parseVerdict(readFileSync(path, "utf8")).verdict);
|
|
322
|
+
}
|
|
323
|
+
return out.length >= 2 ? out : undefined;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Resolve and validate the extra judges.
|
|
327
|
+
*
|
|
328
|
+
* Every configured judge passes the SAME two gates the primary does: the metered
|
|
329
|
+
* refusal and the judge≠subject check. A secondary judge is still a judge — a
|
|
330
|
+
* feature that multiplies judge calls is the last place to let one slip past the
|
|
331
|
+
* policy that exists because a default once billed a corpus by accident.
|
|
332
|
+
*
|
|
333
|
+
* Returns null when adjudication was not authorized, so callers can treat "not
|
|
334
|
+
* enabled" and "enabled with no triggers" as different things.
|
|
335
|
+
*/
|
|
336
|
+
export function resolveAdjudicationJudges(opts) {
|
|
337
|
+
if (!opts.enabled)
|
|
338
|
+
return null;
|
|
339
|
+
// An unreadable subject skips the resemblance warning rather than failing the
|
|
340
|
+
// run. The judge≠subject check is advice; the metered refusal below is the gate.
|
|
341
|
+
let subject = null;
|
|
342
|
+
try {
|
|
343
|
+
subject = opts.parseRef(opts.subjectToken);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
opts.warn(` ⚠ cannot read the run's model (\`${opts.subjectToken}\`) — skipping the judge≠subject check`);
|
|
347
|
+
}
|
|
348
|
+
// With no explicit secondary the primary judge is asked again as an independent
|
|
349
|
+
// draw. That is a real measurement — the judge-variance study found a ~2%
|
|
350
|
+
// disagreement rate on identical transcripts — not a no-op.
|
|
351
|
+
const secondary = opts.secondaryToken ? opts.parseRef(opts.secondaryToken) : opts.primary;
|
|
352
|
+
const tieBreak = opts.tieBreakToken ? opts.parseRef(opts.tieBreakToken) : undefined;
|
|
353
|
+
opts.assertAllowed(secondary, "--secondary-judge");
|
|
354
|
+
if (tieBreak)
|
|
355
|
+
opts.assertAllowed(tieBreak, "--tie-break-judge");
|
|
356
|
+
if (subject) {
|
|
357
|
+
for (const [label, judge] of [["secondary", secondary], ["tie-break", tieBreak]]) {
|
|
358
|
+
if (judge && opts.resemblesSubject(judge, subject)) {
|
|
359
|
+
opts.warn(` ⚠ ${label} judge (${judge.provider}:${judge.model}) resembles the model under test ` +
|
|
360
|
+
`(${subject.provider}:${subject.model}) — same-family grading inflates scores.`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return tieBreak ? { secondary, tieBreak } : { secondary };
|
|
365
|
+
}
|
|
366
|
+
/** Human-readable preflight line. Counts, never dollars — see `maxAdditionalCalls`. */
|
|
367
|
+
export function formatAdjudicationPlan(plan, judges) {
|
|
368
|
+
const stuck = plan.needsTieBreak.length
|
|
369
|
+
? [
|
|
370
|
+
` ${plan.needsTieBreak.length} of those cannot be SETTLED by this plan: ${plan.needsTieBreak.join(", ")}`,
|
|
371
|
+
" (their first judgment misfired, so one more judge cannot reach two clean votes — the call",
|
|
372
|
+
" buys a second opinion to resolve by hand; add a tie-break judge to settle them outright)",
|
|
373
|
+
]
|
|
374
|
+
: [];
|
|
375
|
+
if (plan.triggered.length === 0) {
|
|
376
|
+
return ["adjudication: no cell triggered — no additional judge calls", ...stuck].join("\n");
|
|
377
|
+
}
|
|
378
|
+
const lines = [
|
|
379
|
+
`adjudication: ${plan.triggered.length} cell(s) triggered — up to ${plan.maxAdditionalCalls} additional judge call(s)`,
|
|
380
|
+
` secondary judge: ${judges.secondary.provider}:${judges.secondary.model}`,
|
|
381
|
+
];
|
|
382
|
+
if (judges.tieBreak)
|
|
383
|
+
lines.push(` tie-break judge: ${judges.tieBreak.provider}:${judges.tieBreak.model}`);
|
|
384
|
+
else
|
|
385
|
+
lines.push(" no tie-break judge — a disagreement stays unresolved and blocks SHIP");
|
|
386
|
+
for (const d of plan.decisions) {
|
|
387
|
+
if (d.triggers.length)
|
|
388
|
+
lines.push(` ${d.id}: ${d.triggers.join(", ")}`);
|
|
389
|
+
}
|
|
390
|
+
return [...lines, ...stuck].join("\n");
|
|
391
|
+
}
|
|
392
|
+
//# sourceMappingURL=adjudication.js.map
|