@skill-harness/core 0.4.0 → 0.6.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 +10 -0
- package/dist/canary.d.ts +44 -0
- package/dist/canary.js +123 -0
- package/dist/discover.d.ts +7 -0
- package/dist/discover.js +13 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/journal.d.ts +14 -0
- package/dist/lift.d.ts +13 -0
- package/dist/lift.js +13 -8
- package/dist/lint.d.ts +16 -1
- package/dist/lint.js +39 -3
- package/dist/regate.js +15 -11
- package/dist/regrade.d.ts +21 -11
- package/dist/regrade.js +26 -19
- package/dist/report.d.ts +30 -5
- package/dist/report.js +20 -5
- package/dist/rescore.d.ts +6 -0
- package/dist/rescore.js +12 -4
- package/dist/results.d.ts +78 -0
- package/dist/results.js +51 -0
- package/dist/run.d.ts +16 -1
- package/dist/run.js +75 -8
- package/dist/sources.d.ts +26 -0
- package/dist/sources.js +42 -0
- package/dist/stability.d.ts +144 -0
- package/dist/stability.js +232 -0
- package/dist/trends.d.ts +51 -9
- package/dist/trends.js +89 -65
- package/package.json +1 -1
package/dist/regrade.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { buildJudgePrompt, judgeInWorkspace } from "./grade.js";
|
|
4
|
-
import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, } from "./results.js";
|
|
4
|
+
import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, scoreContextFor, } from "./results.js";
|
|
5
5
|
import { outcomesToResult } from "./reps.js";
|
|
6
6
|
import { appendJournal } from "./journal.js";
|
|
7
7
|
import { rubricDigest, personaDigest, RUBRIC_PREFIX, PERSONA_KEY } from "./sources.js";
|
|
@@ -46,16 +46,18 @@ export async function judgeOneRep(opts) {
|
|
|
46
46
|
return { verdict: g.verdict, reason: g.reason, suspect: g.suspect };
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
|
-
* Re-judge a scenario's saved
|
|
50
|
-
* re-run. Rewrites the judge-raw artifact per rep, emits per-rep
|
|
51
|
-
* (+ misfire-flag) journal events, and returns the aggregated
|
|
52
|
-
* (override/note empty; the caller merges any prior override +
|
|
49
|
+
* Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
|
|
50
|
+
* harness re-run. Rewrites the judge-raw artifact per rep, emits per-rep
|
|
51
|
+
* judge-verdict (+ misfire-flag) journal events, and returns the aggregated
|
|
52
|
+
* ScenarioResult (override/note empty; the caller merges any prior override +
|
|
53
|
+
* persists).
|
|
53
54
|
*/
|
|
54
55
|
export async function regradeScenario(opts) {
|
|
55
56
|
const now = opts.now ?? (() => new Date().toISOString());
|
|
56
|
-
const
|
|
57
|
+
const mode = opts.mode ?? "green";
|
|
58
|
+
const files = findTranscriptFiles(opts.runDir, opts.scenario.id, mode);
|
|
57
59
|
if (files.length === 0)
|
|
58
|
-
throw new Error(`no
|
|
60
|
+
throw new Error(`no ${mode} transcripts for ${opts.scenario.id} in ${opts.runDir}`);
|
|
59
61
|
const repCount = files.length;
|
|
60
62
|
const outcomes = [];
|
|
61
63
|
for (const file of files) {
|
|
@@ -63,19 +65,19 @@ export async function regradeScenario(opts) {
|
|
|
63
65
|
const transcript = readFileSync(join(opts.runDir, file), "utf8");
|
|
64
66
|
outcomes.push(await judgeOneRep({
|
|
65
67
|
runDir: opts.runDir, spec: opts.spec, scenario: opts.scenario, transcript,
|
|
66
|
-
adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode
|
|
68
|
+
adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode, rep, now,
|
|
67
69
|
}));
|
|
68
70
|
}
|
|
69
71
|
return outcomesToResult(opts.scenario.id, outcomes, repCount, opts.threshold);
|
|
70
72
|
}
|
|
71
73
|
/**
|
|
72
|
-
* Re-judge every
|
|
73
|
-
* harness re-run. Targets are the run's RECORDED scenarios
|
|
74
|
-
* the spec for a run with no prior results.yaml), so re-grading
|
|
75
|
-
* whole results.yaml consistently with what the run actually recorded.
|
|
76
|
-
* target must still exist in the spec (for its checklist) AND have a
|
|
77
|
-
* transcript on disk; anything missing fails fast before spending
|
|
78
|
-
* calls. Preserves each prior scenario's override/note, rewrites
|
|
74
|
+
* Re-judge every scenario in a run dir that has a transcript for the run's own
|
|
75
|
+
* mode, with `judge` — no harness re-run. Targets are the run's RECORDED scenarios
|
|
76
|
+
* (falling back to the spec for a run with no prior results.yaml), so re-grading
|
|
77
|
+
* rewrites the whole results.yaml consistently with what the run actually recorded.
|
|
78
|
+
* Each target must still exist in the spec (for its checklist) AND have a
|
|
79
|
+
* transcript on disk for that mode; anything missing fails fast before spending
|
|
80
|
+
* any judge calls. Preserves each prior scenario's override/note, rewrites
|
|
79
81
|
* results.yaml, emits the `score` journal event, and returns the new
|
|
80
82
|
* ResultsFile. Shared by `cmdGrade` and the pi-extension's `judge` command.
|
|
81
83
|
*/
|
|
@@ -107,9 +109,9 @@ export async function regradeRun(opts) {
|
|
|
107
109
|
return prev;
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
|
-
const missing = targets.filter((id) => !specById.has(id) || findTranscriptFiles(runDir, id,
|
|
112
|
+
const missing = targets.filter((id) => !specById.has(id) || findTranscriptFiles(runDir, id, mode).length === 0);
|
|
111
113
|
if (missing.length === targets.length) {
|
|
112
|
-
throw new Error(`no
|
|
114
|
+
throw new Error(`no ${mode} transcripts in ${runDir} — nothing to re-grade`);
|
|
113
115
|
}
|
|
114
116
|
if (missing.length > 0) {
|
|
115
117
|
throw new Error(`cannot re-grade ${missing.join(", ")} in ${runDir} (transcript missing or scenario no longer in the spec) — re-run instead of grading`);
|
|
@@ -126,15 +128,20 @@ export async function regradeRun(opts) {
|
|
|
126
128
|
const prevScenario = prev?.scenarios.find((s) => s.id === id);
|
|
127
129
|
const threshold = effectiveThreshold(prevScenario, scenario);
|
|
128
130
|
const rr = await regradeScenario({
|
|
129
|
-
runDir, spec, scenario, adapter, judge, specDir, threshold, now,
|
|
131
|
+
runDir, spec, scenario, adapter, judge, specDir, threshold, mode, now,
|
|
130
132
|
});
|
|
131
133
|
const carry = overrides.get(id);
|
|
132
134
|
scenarioResults.push({ ...rr, override: carry?.override ?? null, note: carry?.note ?? "" });
|
|
133
135
|
}
|
|
134
|
-
const ctx = mode
|
|
136
|
+
const ctx = scoreContextFor({ mode, partial: prev?.partial }, spec);
|
|
135
137
|
const results = writeResults(runDir, {
|
|
136
138
|
skill: spec.skill,
|
|
137
139
|
harness: prev?.harness ?? "pi",
|
|
140
|
+
// The harness CLI that produced these transcripts, carried verbatim: a re-grade
|
|
141
|
+
// re-asks the judge, it does not re-deliver the skill, so stamping today's pi
|
|
142
|
+
// here would credit the old transcripts to a version that never ran them.
|
|
143
|
+
harness_cli_version: prev?.harness_cli_version,
|
|
144
|
+
delivery_canary: prev?.delivery_canary,
|
|
138
145
|
model: prev?.model ?? "unknown",
|
|
139
146
|
judge: { provider: judge.provider, model: judge.model },
|
|
140
147
|
timestamp: prev?.timestamp ?? now(),
|
package/dist/report.d.ts
CHANGED
|
@@ -20,15 +20,28 @@ export interface RunColumn {
|
|
|
20
20
|
flakiness?: number;
|
|
21
21
|
override: string | null;
|
|
22
22
|
note: string;
|
|
23
|
+
/**
|
|
24
|
+
* Run-over-run history for this cell, derived from the tag's other runs in the
|
|
25
|
+
* SAME mode. Present only for a cell that flipped (`state: "boundary"`) — a
|
|
26
|
+
* marker on every cell would bury the one signal it exists to show, and the
|
|
27
|
+
* per-cell `flakiness` beside it is a within-run number that cannot see this.
|
|
28
|
+
*/
|
|
29
|
+
stability?: {
|
|
30
|
+
flips: number;
|
|
31
|
+
compared: number;
|
|
32
|
+
volatility: number | null;
|
|
33
|
+
note: string;
|
|
34
|
+
};
|
|
23
35
|
}>;
|
|
24
36
|
/**
|
|
25
|
-
*
|
|
26
|
-
* green
|
|
27
|
-
* a zero lift, so the report must not render a 0
|
|
37
|
+
* Baseline-vs-skill lift for this model, when the tag has both a red baseline and
|
|
38
|
+
* a skill-delivered run (green or force). Undefined means "never measured" —
|
|
39
|
+
* which is not the same claim as a zero lift, so the report must not render a 0
|
|
40
|
+
* for it.
|
|
28
41
|
*
|
|
29
|
-
* Only set when THIS column is the
|
|
42
|
+
* Only set when THIS column is the skill-side run the lift was computed from (see
|
|
30
43
|
* collectReport): the review UI recomputes lift from the column's live cells,
|
|
31
|
-
* which is only valid if those cells are the
|
|
44
|
+
* which is only valid if those cells are the skill side of the comparison.
|
|
32
45
|
*/
|
|
33
46
|
lift?: Lift;
|
|
34
47
|
liftHeadline?: string;
|
|
@@ -82,6 +95,18 @@ export declare function publicView(data: ReportData): {
|
|
|
82
95
|
flakiness?: number;
|
|
83
96
|
override: string | null;
|
|
84
97
|
note: string;
|
|
98
|
+
/**
|
|
99
|
+
* Run-over-run history for this cell, derived from the tag's other runs in the
|
|
100
|
+
* SAME mode. Present only for a cell that flipped (`state: "boundary"`) — a
|
|
101
|
+
* marker on every cell would bury the one signal it exists to show, and the
|
|
102
|
+
* per-cell `flakiness` beside it is a within-run number that cannot see this.
|
|
103
|
+
*/
|
|
104
|
+
stability?: {
|
|
105
|
+
flips: number;
|
|
106
|
+
compared: number;
|
|
107
|
+
volatility: number | null;
|
|
108
|
+
note: string;
|
|
109
|
+
};
|
|
85
110
|
}>;
|
|
86
111
|
}[];
|
|
87
112
|
};
|
package/dist/report.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { loadSpec } from "./spec.js";
|
|
4
4
|
import { readResults } from "./results.js";
|
|
5
5
|
import { collectLift, liftHeadline } from "./lift.js";
|
|
6
|
+
import { boundaryCells, collectStability, stabilityNote } from "./stability.js";
|
|
6
7
|
/** Most-recent run dir (by name, which is an ISO-ish slug) under a model-tag dir. */
|
|
7
8
|
function latestRunDir(tagDir) {
|
|
8
9
|
if (!statSync(tagDir).isDirectory())
|
|
@@ -24,6 +25,9 @@ export function collectReport(skillDir) {
|
|
|
24
25
|
const resultsRoot = join(skillDir, "tests", "results");
|
|
25
26
|
// Lift is keyed by model tag, the same key columns are built from.
|
|
26
27
|
const liftByTag = new Map(collectLift(skillDir).map((l) => [l.tag, l]));
|
|
28
|
+
// Stability is keyed by tag + mode + scenario: a column shows one delivery mode, and
|
|
29
|
+
// green and force histories are never one series (placement moves verdicts).
|
|
30
|
+
const boundaryByCell = new Map(boundaryCells(collectStability(skillDir)).map((c) => [`${c.tag}\u0000${c.mode}\u0000${c.id}`, c]));
|
|
27
31
|
const columns = [];
|
|
28
32
|
if (existsSync(resultsRoot)) {
|
|
29
33
|
const tags = readdirSync(resultsRoot)
|
|
@@ -35,9 +39,19 @@ export function collectReport(skillDir) {
|
|
|
35
39
|
if (!runDir)
|
|
36
40
|
continue;
|
|
37
41
|
const r = readResults(runDir);
|
|
42
|
+
const tagName = tagDir.split("/").pop();
|
|
38
43
|
const cells = {};
|
|
39
44
|
for (const s of r.scenarios) {
|
|
45
|
+
const boundary = boundaryByCell.get(`${tagName}\u0000${r.mode}\u0000${s.id}`);
|
|
40
46
|
cells[s.id] = {
|
|
47
|
+
...(boundary
|
|
48
|
+
? {
|
|
49
|
+
stability: {
|
|
50
|
+
flips: boundary.flips, compared: boundary.compared,
|
|
51
|
+
volatility: boundary.volatility, note: stabilityNote(boundary),
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
: {}),
|
|
41
55
|
judge_verdict: s.judge_verdict,
|
|
42
56
|
judge_reason: s.judge_reason,
|
|
43
57
|
suspect: s.suspect ?? false, // suspect defaults false for older results that predate the field
|
|
@@ -49,14 +63,15 @@ export function collectReport(skillDir) {
|
|
|
49
63
|
note: s.note,
|
|
50
64
|
};
|
|
51
65
|
}
|
|
52
|
-
const tag =
|
|
53
|
-
// A column is the tag's LATEST run, which is not necessarily the
|
|
54
|
-
// record a red baseline after a green run and the newest run in the tag
|
|
55
|
-
// red. The review UI recomputes lift from `cells` (so author overrides move
|
|
66
|
+
const tag = tagName;
|
|
67
|
+
// A column is the tag's LATEST run, which is not necessarily the skill-side
|
|
68
|
+
// one — record a red baseline after a green run and the newest run in the tag
|
|
69
|
+
// is red. The review UI recomputes lift from `cells` (so author overrides move
|
|
56
70
|
// it live), so attaching a lift to a column whose cells are the RED run
|
|
57
71
|
// would have it compare red against red and report "no effect" for a skill
|
|
58
72
|
// that in fact gained every scenario. Attach only when this column IS the
|
|
59
|
-
//
|
|
73
|
+
// skill side of the comparison — matched on the timestamp, which also keeps a
|
|
74
|
+
// green column from borrowing a force run's lift and vice versa.
|
|
60
75
|
const tagLift = liftByTag.get(tag);
|
|
61
76
|
const lift = tagLift && tagLift.greenTimestamp === r.timestamp ? tagLift : undefined;
|
|
62
77
|
columns.push({
|
package/dist/rescore.d.ts
CHANGED
|
@@ -24,6 +24,12 @@ export interface RescoreResult {
|
|
|
24
24
|
* When the policy changes, the honest move is to recompute the old measurements under it
|
|
25
25
|
* rather than reconcile two numbers in prose — and to record what moved.
|
|
26
26
|
*
|
|
27
|
+
* It is also how a run gets a grade it never had: every rescore recomputes
|
|
28
|
+
* `effective_grade` under the current scoring policy, and since 0.5.0 that policy
|
|
29
|
+
* scores force runs too (see SCORED_MODES). A corpus holding force-mode runs
|
|
30
|
+
* recorded as "not scored" turns them into real scorecards with `rescore`, at zero
|
|
31
|
+
* model and zero judge spend — verdict changes are then genuinely optional output.
|
|
32
|
+
*
|
|
27
33
|
* Only reps-bearing scenarios can be re-scored: a single-rep verdict has no rate to
|
|
28
34
|
* re-apply a threshold to, and ERROR/JUDGE-AMBIGUOUS carry no trustworthy rate at all —
|
|
29
35
|
* both are carried verbatim. Overrides, notes, and suspect flags are preserved: this
|
package/dist/rescore.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { readResults, writeResults } from "./results.js";
|
|
3
|
+
import { readResults, writeResults, scoreContextFor } from "./results.js";
|
|
4
4
|
import { appendJournal } from "./journal.js";
|
|
5
5
|
import { policyDigest, POLICY_PREFIX } from "./sources.js";
|
|
6
6
|
/**
|
|
@@ -29,6 +29,12 @@ function refreshPolicyHashes(recorded, spec) {
|
|
|
29
29
|
* When the policy changes, the honest move is to recompute the old measurements under it
|
|
30
30
|
* rather than reconcile two numbers in prose — and to record what moved.
|
|
31
31
|
*
|
|
32
|
+
* It is also how a run gets a grade it never had: every rescore recomputes
|
|
33
|
+
* `effective_grade` under the current scoring policy, and since 0.5.0 that policy
|
|
34
|
+
* scores force runs too (see SCORED_MODES). A corpus holding force-mode runs
|
|
35
|
+
* recorded as "not scored" turns them into real scorecards with `rescore`, at zero
|
|
36
|
+
* model and zero judge spend — verdict changes are then genuinely optional output.
|
|
37
|
+
*
|
|
32
38
|
* Only reps-bearing scenarios can be re-scored: a single-rep verdict has no rate to
|
|
33
39
|
* re-apply a threshold to, and ERROR/JUDGE-AMBIGUOUS carry no trustworthy rate at all —
|
|
34
40
|
* both are carried verbatim. Overrides, notes, and suspect flags are preserved: this
|
|
@@ -61,13 +67,15 @@ export function rescoreRun(opts) {
|
|
|
61
67
|
}
|
|
62
68
|
return { ...s, judge_verdict: verdict, pass_threshold: toThreshold };
|
|
63
69
|
});
|
|
64
|
-
const ctx = prev
|
|
65
|
-
? { shipBar: opts.spec.ship_bar, critical: opts.spec.critical }
|
|
66
|
-
: null;
|
|
70
|
+
const ctx = scoreContextFor(prev, opts.spec);
|
|
67
71
|
const results = writeResults(opts.runDir, {
|
|
68
72
|
skill: prev.skill, harness: prev.harness, model: prev.model, judge: prev.judge,
|
|
69
73
|
timestamp: prev.timestamp, label: prev.label, mode: prev.mode,
|
|
70
74
|
partial: prev.partial,
|
|
75
|
+
// Provenance of the measurement, not of this rewrite: a rescore re-applies a
|
|
76
|
+
// threshold to reps the recorded harness already produced.
|
|
77
|
+
harness_cli_version: prev.harness_cli_version,
|
|
78
|
+
delivery_canary: prev.delivery_canary,
|
|
71
79
|
// A rescore re-applies the CURRENT policy (thresholds, critical set) to the
|
|
72
80
|
// recorded reps, so `policy:` drift is genuinely resolved by having run this —
|
|
73
81
|
// that is what makes `rescore` the honest remedy lint names for it. Stimulus,
|
package/dist/results.d.ts
CHANGED
|
@@ -35,6 +35,37 @@ export interface ResultsFile {
|
|
|
35
35
|
* writer can forget it.
|
|
36
36
|
*/
|
|
37
37
|
harness_version?: string;
|
|
38
|
+
/**
|
|
39
|
+
* The version of the harness CLI that produced the transcripts — `pi --version`
|
|
40
|
+
* for `harness: pi`.
|
|
41
|
+
*
|
|
42
|
+
* Provenance for the *delivery*, not for the tool: pi 0.80.x wrapped a `--skill`
|
|
43
|
+
* prompt with the skill body, pi 0.83.0 switched to progressive disclosure, and
|
|
44
|
+
* that upgrade silently changed what `--mode green` measured. Two waves of runs
|
|
45
|
+
* in the reference corpus are indistinguishable from a naked-model baseline, and
|
|
46
|
+
* the incident is invisible in the artifacts precisely because nothing recorded
|
|
47
|
+
* which pi ran.
|
|
48
|
+
*
|
|
49
|
+
* Written only by `run` (the command that actually invokes the harness) and
|
|
50
|
+
* carried verbatim by every rewriter — `grade`/`rescore`/`regate` re-decide
|
|
51
|
+
* verdicts, they do not re-deliver the skill, so re-stamping this field with
|
|
52
|
+
* today's pi would attribute the old transcripts to a version that never
|
|
53
|
+
* produced them. Optional: runs recorded before the field existed have none, and
|
|
54
|
+
* an adapter that cannot report a version writes none rather than guessing.
|
|
55
|
+
*/
|
|
56
|
+
harness_cli_version?: string;
|
|
57
|
+
/**
|
|
58
|
+
* `pass` when this run proved, before spending the wave, that the skill body was
|
|
59
|
+
* reachable in the model's context (see canary.ts). Absent means the probe was
|
|
60
|
+
* not asked for — never that it failed, because a failed canary aborts the run
|
|
61
|
+
* and no results.yaml is written.
|
|
62
|
+
*
|
|
63
|
+
* Only green runs can carry it: red delivers nothing by design and force delivers
|
|
64
|
+
* through the system prompt. It is provenance for the *validity* of a green run,
|
|
65
|
+
* which is why it lives here rather than only in the journal — `journal.jsonl` is
|
|
66
|
+
* gitignored, and this claim has to survive a commit.
|
|
67
|
+
*/
|
|
68
|
+
delivery_canary?: "pass";
|
|
38
69
|
skill: string;
|
|
39
70
|
harness: string;
|
|
40
71
|
model: string;
|
|
@@ -60,6 +91,53 @@ export interface ResultsFile {
|
|
|
60
91
|
effective_grade: GradeSummary;
|
|
61
92
|
scenarios: ScenarioResult[];
|
|
62
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* The run modes whose results carry a real grade: the ones where the skill under
|
|
96
|
+
* test was actually delivered to the model.
|
|
97
|
+
*
|
|
98
|
+
* `green` activates the skill through the harness's own mechanism (`pi --skill`);
|
|
99
|
+
* `force` puts SKILL.md in the system prompt. Both are measurements OF THE SKILL,
|
|
100
|
+
* so both are scored against the ship bar. `red` is the control — the model with
|
|
101
|
+
* no skill — and scoring it would produce a ship grade for the thing the skill is
|
|
102
|
+
* measured against.
|
|
103
|
+
*
|
|
104
|
+
* Force was unscored until 0.5.0, when it stopped being an escape hatch and became
|
|
105
|
+
* a deployment: on pi 0.83.0 `--skill` switched to progressive disclosure (the
|
|
106
|
+
* description is in context, the body loads on demand — "models don't always do
|
|
107
|
+
* this"), so skill-as-system-prompt is the delivery a corpus can actually rely on.
|
|
108
|
+
* Ten committed force runs in the reference corpus read `not scored` for exactly
|
|
109
|
+
* that reason. Scored directly rather than behind a spec flag or a `--score-force`
|
|
110
|
+
* opt-in: "was the skill in front of the model?" is a property of the mode, not a
|
|
111
|
+
* per-repo preference, and a second knob would just be a second thing to forget.
|
|
112
|
+
*
|
|
113
|
+
* Consequence to expect, and it is the intended one: a force run recorded before
|
|
114
|
+
* 0.5.0 carries a "not scored" placeholder grade that a recompute now disagrees
|
|
115
|
+
* with, so `lint` flags it as stale and `rescore` (free) writes the real grade.
|
|
116
|
+
*
|
|
117
|
+
* The two modes are NOT interchangeable measurements of the same thing — placement
|
|
118
|
+
* changes behavior in both directions (measured on identical skill text: `build` A1
|
|
119
|
+
* 0/3 → 3/3, `plan` C2 3/3 → 0/3). Anything that plots or compares runs over time
|
|
120
|
+
* therefore keeps the epochs apart rather than pooling them; see trends.ts.
|
|
121
|
+
*/
|
|
122
|
+
export declare const SCORED_MODES: readonly string[];
|
|
123
|
+
/** Whether a run in this mode delivered the skill, and so has a grade worth computing. */
|
|
124
|
+
export declare function isScoredMode(mode: string): boolean;
|
|
125
|
+
/**
|
|
126
|
+
* The one place "does this run get a grade?" is decided: the scoring mode gate plus
|
|
127
|
+
* the `--only` partial gate, in one predicate every writer shares.
|
|
128
|
+
*
|
|
129
|
+
* Before 0.5.0 this ternary was open-coded in seven places (run, grade, rescore,
|
|
130
|
+
* regate, lint, and both review-server writers) — which is how force runs came to
|
|
131
|
+
* be unscored in all seven at once, and how any future mode would have had to be
|
|
132
|
+
* remembered seven times.
|
|
133
|
+
*/
|
|
134
|
+
export declare function scoreContextFor(run: {
|
|
135
|
+
mode: string;
|
|
136
|
+
partial?: boolean;
|
|
137
|
+
}, spec: {
|
|
138
|
+
ship_bar: ShipBar;
|
|
139
|
+
critical: string[];
|
|
140
|
+
}): ScoreContext | null;
|
|
63
141
|
/** The pass-threshold a re-grade uses: the run's persisted value, else the spec's per-scenario value, else 0.5. */
|
|
64
142
|
export declare function effectiveThreshold(prevScenario: ScenarioResult | undefined, scenario: Scenario): number;
|
|
65
143
|
/** Everything a caller may set. The grade is computed, never supplied. */
|
package/dist/results.js
CHANGED
|
@@ -4,6 +4,53 @@ 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
|
+
/**
|
|
8
|
+
* The run modes whose results carry a real grade: the ones where the skill under
|
|
9
|
+
* test was actually delivered to the model.
|
|
10
|
+
*
|
|
11
|
+
* `green` activates the skill through the harness's own mechanism (`pi --skill`);
|
|
12
|
+
* `force` puts SKILL.md in the system prompt. Both are measurements OF THE SKILL,
|
|
13
|
+
* so both are scored against the ship bar. `red` is the control — the model with
|
|
14
|
+
* no skill — and scoring it would produce a ship grade for the thing the skill is
|
|
15
|
+
* measured against.
|
|
16
|
+
*
|
|
17
|
+
* Force was unscored until 0.5.0, when it stopped being an escape hatch and became
|
|
18
|
+
* a deployment: on pi 0.83.0 `--skill` switched to progressive disclosure (the
|
|
19
|
+
* description is in context, the body loads on demand — "models don't always do
|
|
20
|
+
* this"), so skill-as-system-prompt is the delivery a corpus can actually rely on.
|
|
21
|
+
* Ten committed force runs in the reference corpus read `not scored` for exactly
|
|
22
|
+
* that reason. Scored directly rather than behind a spec flag or a `--score-force`
|
|
23
|
+
* opt-in: "was the skill in front of the model?" is a property of the mode, not a
|
|
24
|
+
* per-repo preference, and a second knob would just be a second thing to forget.
|
|
25
|
+
*
|
|
26
|
+
* Consequence to expect, and it is the intended one: a force run recorded before
|
|
27
|
+
* 0.5.0 carries a "not scored" placeholder grade that a recompute now disagrees
|
|
28
|
+
* with, so `lint` flags it as stale and `rescore` (free) writes the real grade.
|
|
29
|
+
*
|
|
30
|
+
* The two modes are NOT interchangeable measurements of the same thing — placement
|
|
31
|
+
* changes behavior in both directions (measured on identical skill text: `build` A1
|
|
32
|
+
* 0/3 → 3/3, `plan` C2 3/3 → 0/3). Anything that plots or compares runs over time
|
|
33
|
+
* therefore keeps the epochs apart rather than pooling them; see trends.ts.
|
|
34
|
+
*/
|
|
35
|
+
export const SCORED_MODES = ["green", "force"];
|
|
36
|
+
/** Whether a run in this mode delivered the skill, and so has a grade worth computing. */
|
|
37
|
+
export function isScoredMode(mode) {
|
|
38
|
+
return SCORED_MODES.includes(mode);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The one place "does this run get a grade?" is decided: the scoring mode gate plus
|
|
42
|
+
* the `--only` partial gate, in one predicate every writer shares.
|
|
43
|
+
*
|
|
44
|
+
* Before 0.5.0 this ternary was open-coded in seven places (run, grade, rescore,
|
|
45
|
+
* regate, lint, and both review-server writers) — which is how force runs came to
|
|
46
|
+
* be unscored in all seven at once, and how any future mode would have had to be
|
|
47
|
+
* remembered seven times.
|
|
48
|
+
*/
|
|
49
|
+
export function scoreContextFor(run, spec) {
|
|
50
|
+
if (!isScoredMode(run.mode) || run.partial)
|
|
51
|
+
return null;
|
|
52
|
+
return { shipBar: spec.ship_bar, critical: spec.critical };
|
|
53
|
+
}
|
|
7
54
|
/** The pass-threshold a re-grade uses: the run's persisted value, else the spec's per-scenario value, else 0.5. */
|
|
8
55
|
export function effectiveThreshold(prevScenario, scenario) {
|
|
9
56
|
return prevScenario?.pass_threshold ?? scenario.passThreshold ?? 0.5;
|
|
@@ -56,6 +103,10 @@ export function finalizeResults(draft, ctx) {
|
|
|
56
103
|
// `grade`, `rescore` and the review UI's override save all record which tool
|
|
57
104
|
// produced the record they leave behind.
|
|
58
105
|
harness_version: HARNESS_VERSION,
|
|
106
|
+
// Omitted rather than written as null when absent: a run whose adapter could
|
|
107
|
+
// not report a version must not look like one that reported "nothing".
|
|
108
|
+
...(draft.harness_cli_version ? { harness_cli_version: draft.harness_cli_version } : {}),
|
|
109
|
+
...(draft.delivery_canary ? { delivery_canary: draft.delivery_canary } : {}),
|
|
59
110
|
skill: draft.skill,
|
|
60
111
|
harness: draft.harness,
|
|
61
112
|
model: draft.model,
|
package/dist/run.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Spec } from "./spec.js";
|
|
|
2
2
|
import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
|
|
3
3
|
import { type ResultsFile } from "./results.js";
|
|
4
4
|
import { type Lift } from "./lift.js";
|
|
5
|
+
import { type ScenarioStability } from "./stability.js";
|
|
5
6
|
export interface RunOptions {
|
|
6
7
|
spec: Spec;
|
|
7
8
|
skillDir: string;
|
|
@@ -24,6 +25,13 @@ export interface RunOptions {
|
|
|
24
25
|
* ship-graded: a subset passing says nothing about the ship bar.
|
|
25
26
|
*/
|
|
26
27
|
only?: string[];
|
|
28
|
+
/**
|
|
29
|
+
* Green mode only: spend ONE probe up front proving the skill reaches the model,
|
|
30
|
+
* and abort the run if it doesn't (see canary.ts). Off by default — it costs a
|
|
31
|
+
* rep, and the deterministic half of this failure class (a skill dir that isn't
|
|
32
|
+
* there) is already refused by the adapter for free.
|
|
33
|
+
*/
|
|
34
|
+
canary?: boolean;
|
|
27
35
|
}
|
|
28
36
|
export interface RunSummary {
|
|
29
37
|
runDir: string;
|
|
@@ -48,5 +56,12 @@ export declare function hasEmptyAssistantTurn(transcript: string): boolean;
|
|
|
48
56
|
* exists. Passing it for a green run turns the scorecard from "the skill scored
|
|
49
57
|
* B" into "the skill *did* this much" — without a baseline the grade alone can't
|
|
50
58
|
* distinguish a skill that works from a model that never needed it.
|
|
59
|
+
*
|
|
60
|
+
* `stability` is the run-over-run half of the same argument, and it is derived from
|
|
61
|
+
* history rather than from this run: a cell that flipped its verdict between the last
|
|
62
|
+
* two runs of this skill × model × mode is worth less than the ✓ beside it suggests,
|
|
63
|
+
* and this run's own `flaky 0.00` cannot say so — it only ever looked at one run. Pass
|
|
64
|
+
* the cells for THIS tag and mode; anything else would report another model's history
|
|
65
|
+
* under this model's scorecard.
|
|
51
66
|
*/
|
|
52
|
-
export declare function formatScorecard(summary: RunSummary, lift?: Lift): string;
|
|
67
|
+
export declare function formatScorecard(summary: RunSummary, lift?: Lift, stability?: ScenarioStability[]): string;
|
package/dist/run.js
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { sourceHashes } from "./sources.js";
|
|
4
4
|
import { judgeResemblesSubject } from "./grade.js";
|
|
5
|
-
import { runDirFor, transcriptPath, diffPath, writeResults, ensureResultsGitignore, } from "./results.js";
|
|
5
|
+
import { runDirFor, transcriptPath, diffPath, writeResults, ensureResultsGitignore, scoreContextFor, isScoredMode, } from "./results.js";
|
|
6
6
|
import { appendJournal } from "./journal.js";
|
|
7
7
|
import { liftHeadline } from "./lift.js";
|
|
8
8
|
import { runSeeded } from "./seeded.js";
|
|
@@ -10,6 +10,8 @@ import { createWorkspace } from "./workspace.js";
|
|
|
10
10
|
import { runPool } from "./scheduler.js";
|
|
11
11
|
import { outcomesToResult } from "./reps.js";
|
|
12
12
|
import { judgeOneRep } from "./regrade.js";
|
|
13
|
+
import { runDeliveryCanary, canaryFailure } from "./canary.js";
|
|
14
|
+
import { boundaryCells, stabilityNote } from "./stability.js";
|
|
13
15
|
/** Run one skill against one model: run scenarios, grade, score, persist. */
|
|
14
16
|
export async function runSkillModel(opts) {
|
|
15
17
|
const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
|
|
@@ -36,12 +38,52 @@ export async function runSkillModel(opts) {
|
|
|
36
38
|
const runDir = runDirFor(skillDir, adapter.name, model, timestamp);
|
|
37
39
|
mkdirSync(runDir, { recursive: true });
|
|
38
40
|
ensureResultsGitignore(dirname(dirname(runDir))); // .../tests/results/.gitignore
|
|
41
|
+
// Which harness CLI delivered the skill, asked once per run and recorded with the
|
|
42
|
+
// numbers. A pi upgrade (0.80.x → 0.83.0) silently changed what green mode
|
|
43
|
+
// measures, and the incident was invisible in the artifacts because nothing wrote
|
|
44
|
+
// this down. Never fatal: `null` means the adapter couldn't say.
|
|
45
|
+
const harnessCliVersion = (await adapter.version?.()) ?? null;
|
|
39
46
|
appendJournal(runDir, {
|
|
40
47
|
event: "run-started", ts: now(),
|
|
41
48
|
skill: spec.skill, harness: adapter.name, model: opts.modelToken,
|
|
49
|
+
harness_cli_version: harnessCliVersion,
|
|
42
50
|
judge: { provider: judge.provider, model: judge.model },
|
|
43
51
|
mode, label: opts.label ?? null,
|
|
44
52
|
});
|
|
53
|
+
// The canary spends one probe before the wave, so a run that isn't measuring the
|
|
54
|
+
// skill dies for the price of a rep instead of producing a plausible scorecard.
|
|
55
|
+
// Green only: red delivers nothing by design, and force delivers through the
|
|
56
|
+
// system prompt, which needs no probe.
|
|
57
|
+
let canaryStatus = null;
|
|
58
|
+
if (opts.canary && mode !== "green") {
|
|
59
|
+
// Ignoring a flag silently is a small version of the bug this whole feature is
|
|
60
|
+
// about. Say it, and say why it isn't needed.
|
|
61
|
+
log(` --canary ignored in mode=${mode} — ${mode === "force" ? "the system prompt delivers the skill unconditionally" : "a baseline delivers no skill by design"}`);
|
|
62
|
+
}
|
|
63
|
+
if (opts.canary && mode === "green") {
|
|
64
|
+
const probeCwd = createWorkspace("none", { specDir: dirname(opts.specPath) });
|
|
65
|
+
let canary;
|
|
66
|
+
try {
|
|
67
|
+
canary = await runDeliveryCanary({
|
|
68
|
+
adapter, model, skillDir, skillName: spec.skill, cwd: probeCwd.cwd,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
probeCwd.cleanup();
|
|
73
|
+
}
|
|
74
|
+
appendJournal(runDir, {
|
|
75
|
+
event: "delivery-canary", ts: now(),
|
|
76
|
+
status: canary.status, anchor: canary.anchor, detail: canary.detail,
|
|
77
|
+
});
|
|
78
|
+
if (canary.status === "fail")
|
|
79
|
+
throw new Error(canaryFailure(spec.skill, canary, harnessCliVersion));
|
|
80
|
+
if (canary.status === "skipped")
|
|
81
|
+
log(` ⚠ delivery canary skipped — ${canary.detail}`);
|
|
82
|
+
else {
|
|
83
|
+
canaryStatus = "pass";
|
|
84
|
+
log(` ✓ delivery canary — the model quoted its skill instructions back (\`${canary.anchor}\`)`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
45
87
|
// scenario × rep tasks; runPool preserves input order so we can slice per scenario.
|
|
46
88
|
const repCounts = scenarios.map((s) => s.reps ?? opts.reps ?? 1);
|
|
47
89
|
const owners = [];
|
|
@@ -61,10 +103,12 @@ export async function runSkillModel(opts) {
|
|
|
61
103
|
const threshold = scenario.passThreshold ?? opts.passThreshold ?? 0.5;
|
|
62
104
|
return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
|
|
63
105
|
});
|
|
64
|
-
const ctx = mode
|
|
106
|
+
const ctx = scoreContextFor({ mode, partial }, spec);
|
|
65
107
|
const results = writeResults(runDir, {
|
|
66
108
|
skill: spec.skill,
|
|
67
109
|
harness: adapter.name,
|
|
110
|
+
harness_cli_version: harnessCliVersion ?? undefined,
|
|
111
|
+
delivery_canary: canaryStatus ?? undefined,
|
|
68
112
|
model: opts.modelToken,
|
|
69
113
|
judge: { provider: judge.provider, model: judge.model },
|
|
70
114
|
timestamp,
|
|
@@ -208,8 +252,15 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
208
252
|
* exists. Passing it for a green run turns the scorecard from "the skill scored
|
|
209
253
|
* B" into "the skill *did* this much" — without a baseline the grade alone can't
|
|
210
254
|
* distinguish a skill that works from a model that never needed it.
|
|
255
|
+
*
|
|
256
|
+
* `stability` is the run-over-run half of the same argument, and it is derived from
|
|
257
|
+
* history rather than from this run: a cell that flipped its verdict between the last
|
|
258
|
+
* two runs of this skill × model × mode is worth less than the ✓ beside it suggests,
|
|
259
|
+
* and this run's own `flaky 0.00` cannot say so — it only ever looked at one run. Pass
|
|
260
|
+
* the cells for THIS tag and mode; anything else would report another model's history
|
|
261
|
+
* under this model's scorecard.
|
|
211
262
|
*/
|
|
212
|
-
export function formatScorecard(summary, lift) {
|
|
263
|
+
export function formatScorecard(summary, lift, stability) {
|
|
213
264
|
const { results } = summary;
|
|
214
265
|
const g = results.effective_grade;
|
|
215
266
|
const lines = [];
|
|
@@ -226,16 +277,32 @@ export function formatScorecard(summary, lift) {
|
|
|
226
277
|
const ship = g.ship ? "SHIP" : "NOT READY";
|
|
227
278
|
const note = g.note ? ` (${g.note})` : "";
|
|
228
279
|
lines.push(` GRADE: ${g.letter} (${g.pct}%) — ${g.passed}/${g.total} — ${ship}${note}`);
|
|
229
|
-
// Lift is a statement about a green
|
|
230
|
-
// a lift in hand (a
|
|
231
|
-
// baseline scorecard reads as if the baseline itself
|
|
232
|
-
|
|
280
|
+
// Lift is a statement about a skill-delivered run (green or force). On a red run
|
|
281
|
+
// the caller may still have a lift in hand (a scored run exists in the same tag),
|
|
282
|
+
// but printing it under a baseline scorecard reads as if the baseline itself
|
|
283
|
+
// gained something.
|
|
284
|
+
if (lift && isScoredMode(results.mode)) {
|
|
233
285
|
lines.push(` LIFT: ${liftHeadline(lift)} (vs red baseline ${lift.redTimestamp})`);
|
|
234
286
|
}
|
|
235
|
-
else if (results.mode
|
|
287
|
+
else if (isScoredMode(results.mode)) {
|
|
236
288
|
// The grade alone can't answer "does this skill do anything?", so say how.
|
|
237
289
|
lines.push(` LIFT: no red baseline — run with --mode red to measure what the skill adds`);
|
|
238
290
|
}
|
|
291
|
+
// Said on the scorecard, not just in the docs: the one thing that can invalidate
|
|
292
|
+
// a green number is invisible in the number. `harness_cli_version` is recorded
|
|
293
|
+
// beside the verdicts so a reader can tell which pi produced them.
|
|
294
|
+
if (results.mode === "green" && !results.delivery_canary) {
|
|
295
|
+
lines.push(` NOTE: green delivery is harness-version-dependent` +
|
|
296
|
+
(results.harness_cli_version ? ` (${results.harness} ${results.harness_cli_version})` : "") +
|
|
297
|
+
` — on pi ≥ 0.83.0 \`--skill\` only discloses the description and the body loads on demand.` +
|
|
298
|
+
` Use --mode force for delivery that cannot silently degrade, or --canary to prove it per run.`);
|
|
299
|
+
}
|
|
300
|
+
// Boundary cells last, because they qualify the verdicts above: a ✓ on a cell that
|
|
301
|
+
// flipped between the last two runs is one draw, whatever its rep count said.
|
|
302
|
+
const ran = new Set(results.scenarios.map((s) => s.id));
|
|
303
|
+
for (const s of boundaryCells(stability ?? []).filter((c) => ran.has(c.id))) {
|
|
304
|
+
lines.push(` ⇄ ${stabilityNote(s)}`);
|
|
305
|
+
}
|
|
239
306
|
return lines.join("\n");
|
|
240
307
|
}
|
|
241
308
|
//# sourceMappingURL=run.js.map
|
package/dist/sources.d.ts
CHANGED
|
@@ -172,5 +172,31 @@ export declare function describeSourceKey(key: string): string;
|
|
|
172
172
|
* in place. Naming the actual remedy is what converts that into a free command.
|
|
173
173
|
*/
|
|
174
174
|
export declare function remedyForKey(key: string): string;
|
|
175
|
+
/** The skill-text key. Skill-wide: it belongs to every scenario at once. */
|
|
176
|
+
export declare const SKILL_KEY = "SKILL.md";
|
|
177
|
+
/**
|
|
178
|
+
* Every recorded key whose drift could change THIS scenario's verdict — excluding the
|
|
179
|
+
* two skill-wide ones (`SKILL.md`, `rubric:__persona`), which callers handle
|
|
180
|
+
* separately because they move every scenario at once.
|
|
181
|
+
*
|
|
182
|
+
* Written for run-over-run comparison (see stability.ts): "did these two runs ask this
|
|
183
|
+
* scenario the same question, judged by the same rubric?" is answerable from the
|
|
184
|
+
* recorded hashes, and only if you know which keys belong to the scenario. Derived
|
|
185
|
+
* from the spec rather than from the key strings, because the path-shaped keys
|
|
186
|
+
* (`system_prompt_file`, `post_test`) carry no scenario id at all.
|
|
187
|
+
*
|
|
188
|
+
* `policy:<id>` is deliberately NOT here. Its `reps`/`pass_threshold` half is already
|
|
189
|
+
* compared as an *aggregation* shape (1 draw vs a majority of 3 is the comparison a
|
|
190
|
+
* hash cannot express), and its `critical` half changes whether a verdict can block a
|
|
191
|
+
* ship, never what the verdict is. Including it would report a critical-set edit as
|
|
192
|
+
* "these runs measured different things", which is false.
|
|
193
|
+
*
|
|
194
|
+
* Both key generations are returned: 0.4.0+ runs carry the split facet keys, older
|
|
195
|
+
* ones the combined `scenario:<id>`. A caller comparing two runs must not treat a
|
|
196
|
+
* combined digest and a split one as comparable — they hash different byte layouts —
|
|
197
|
+
* so it compares only keys BOTH runs recorded, and treats "no shared key" as
|
|
198
|
+
* unverifiable rather than unchanged.
|
|
199
|
+
*/
|
|
200
|
+
export declare function scenarioSourceKeys(s: Scenario): string[];
|
|
175
201
|
/** The scenario id a key belongs to, for per-scenario lint findings. Undefined for skill-wide keys. */
|
|
176
202
|
export declare function scenarioIdForKey(key: string, scenarios: Scenario[]): string | undefined;
|