@skill-harness/core 0.3.2 → 0.5.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/defaults.d.ts +30 -0
- package/dist/defaults.js +34 -0
- package/dist/discover.d.ts +7 -0
- package/dist/discover.js +13 -5
- package/dist/downgrade.d.ts +41 -0
- package/dist/downgrade.js +100 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/journal.d.ts +28 -0
- package/dist/judge-policy.d.ts +42 -0
- package/dist/judge-policy.js +61 -0
- package/dist/lift.d.ts +13 -0
- package/dist/lift.js +13 -8
- package/dist/lint.js +37 -8
- package/dist/regate.d.ts +57 -0
- package/dist/regate.js +199 -0
- package/dist/regrade.d.ts +34 -11
- package/dist/regrade.js +62 -21
- package/dist/report.d.ts +6 -5
- package/dist/report.js +5 -4
- package/dist/rescore.d.ts +6 -0
- package/dist/rescore.js +40 -5
- package/dist/results.d.ts +88 -0
- package/dist/results.js +56 -0
- package/dist/run.d.ts +7 -0
- package/dist/run.js +61 -8
- package/dist/seeded.d.ts +18 -0
- package/dist/seeded.js +40 -15
- package/dist/sources.d.ts +67 -6
- package/dist/sources.js +185 -26
- package/dist/trends.d.ts +23 -9
- package/dist/trends.js +44 -35
- package/dist/version.d.ts +1 -0
- package/dist/version.js +22 -0
- package/dist/workspace.js +32 -1
- package/package.json +1 -1
package/dist/report.js
CHANGED
|
@@ -50,13 +50,14 @@ export function collectReport(skillDir) {
|
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
const tag = tagDir.split("/").pop();
|
|
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
|
|
53
|
+
// A column is the tag's LATEST run, which is not necessarily the skill-side
|
|
54
|
+
// one — record a red baseline after a green run and the newest run in the tag
|
|
55
|
+
// is red. The review UI recomputes lift from `cells` (so author overrides move
|
|
56
56
|
// it live), so attaching a lift to a column whose cells are the RED run
|
|
57
57
|
// would have it compare red against red and report "no effect" for a skill
|
|
58
58
|
// that in fact gained every scenario. Attach only when this column IS the
|
|
59
|
-
//
|
|
59
|
+
// skill side of the comparison — matched on the timestamp, which also keeps a
|
|
60
|
+
// green column from borrowing a force run's lift and vice versa.
|
|
60
61
|
const tagLift = liftByTag.get(tag);
|
|
61
62
|
const lift = tagLift && tagLift.greenTimestamp === r.timestamp ? tagLift : undefined;
|
|
62
63
|
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,13 +1,40 @@
|
|
|
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
|
+
import { policyDigest, POLICY_PREFIX } from "./sources.js";
|
|
6
|
+
/**
|
|
7
|
+
* Refresh the `policy:` keys a run recorded, since a rescore has just re-applied the
|
|
8
|
+
* current policy to every rep it holds.
|
|
9
|
+
*
|
|
10
|
+
* Unlike a re-grade, this needs no id list: `rescoreRun` walks every recorded
|
|
11
|
+
* scenario, and the ones it carries verbatim (single-rep, ERROR, JUDGE-AMBIGUOUS) are
|
|
12
|
+
* carried *because the current threshold cannot change their verdict* — they are
|
|
13
|
+
* scored under today's policy too. Only keys the run already carried are touched, so
|
|
14
|
+
* this never fabricates coverage.
|
|
15
|
+
*/
|
|
16
|
+
function refreshPolicyHashes(recorded, spec) {
|
|
17
|
+
if (!recorded)
|
|
18
|
+
return undefined;
|
|
19
|
+
const next = { ...recorded };
|
|
20
|
+
for (const s of spec.scenarios) {
|
|
21
|
+
if (POLICY_PREFIX + s.id in next)
|
|
22
|
+
next[POLICY_PREFIX + s.id] = policyDigest(s);
|
|
23
|
+
}
|
|
24
|
+
return next;
|
|
25
|
+
}
|
|
5
26
|
/**
|
|
6
27
|
* Re-score a run against the CURRENT spec's pass thresholds — no model calls, no judge
|
|
7
28
|
* calls. Reps are the raw measurement (`passes` of `clean`); a threshold is *policy*.
|
|
8
29
|
* When the policy changes, the honest move is to recompute the old measurements under it
|
|
9
30
|
* rather than reconcile two numbers in prose — and to record what moved.
|
|
10
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
|
+
*
|
|
11
38
|
* Only reps-bearing scenarios can be re-scored: a single-rep verdict has no rate to
|
|
12
39
|
* re-apply a threshold to, and ERROR/JUDGE-AMBIGUOUS carry no trustworthy rate at all —
|
|
13
40
|
* both are carried verbatim. Overrides, notes, and suspect flags are preserved: this
|
|
@@ -40,13 +67,21 @@ export function rescoreRun(opts) {
|
|
|
40
67
|
}
|
|
41
68
|
return { ...s, judge_verdict: verdict, pass_threshold: toThreshold };
|
|
42
69
|
});
|
|
43
|
-
const ctx = prev
|
|
44
|
-
? { shipBar: opts.spec.ship_bar, critical: opts.spec.critical }
|
|
45
|
-
: null;
|
|
70
|
+
const ctx = scoreContextFor(prev, opts.spec);
|
|
46
71
|
const results = writeResults(opts.runDir, {
|
|
47
72
|
skill: prev.skill, harness: prev.harness, model: prev.model, judge: prev.judge,
|
|
48
73
|
timestamp: prev.timestamp, label: prev.label, mode: prev.mode,
|
|
49
|
-
partial: prev.partial,
|
|
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,
|
|
79
|
+
// A rescore re-applies the CURRENT policy (thresholds, critical set) to the
|
|
80
|
+
// recorded reps, so `policy:` drift is genuinely resolved by having run this —
|
|
81
|
+
// that is what makes `rescore` the honest remedy lint names for it. Stimulus,
|
|
82
|
+
// rubric and gate hashes are untouched: none of them was re-evaluated here.
|
|
83
|
+
source_hashes: refreshPolicyHashes(prev.source_hashes, opts.spec),
|
|
84
|
+
scenarios,
|
|
50
85
|
}, ctx);
|
|
51
86
|
appendJournal(opts.runDir, {
|
|
52
87
|
event: "rescore", ts: now(),
|
package/dist/results.d.ts
CHANGED
|
@@ -25,6 +25,47 @@ export interface GradeSummary {
|
|
|
25
25
|
}
|
|
26
26
|
export interface ResultsFile {
|
|
27
27
|
schema: 2;
|
|
28
|
+
/**
|
|
29
|
+
* The harness version that wrote this file — provenance for the numbers in it.
|
|
30
|
+
*
|
|
31
|
+
* Optional because runs recorded before the field existed do not have one, and
|
|
32
|
+
* inventing a version for them would fabricate provenance. Not part of `schema`:
|
|
33
|
+
* 0.2.1 → 0.3.0 kept `schema: 2` while changing what a verdict means, which is
|
|
34
|
+
* exactly the drift `schema` cannot express. Written by `finalizeResults`, so no
|
|
35
|
+
* writer can forget it.
|
|
36
|
+
*/
|
|
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";
|
|
28
69
|
skill: string;
|
|
29
70
|
harness: string;
|
|
30
71
|
model: string;
|
|
@@ -50,6 +91,53 @@ export interface ResultsFile {
|
|
|
50
91
|
effective_grade: GradeSummary;
|
|
51
92
|
scenarios: ScenarioResult[];
|
|
52
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;
|
|
53
141
|
/** The pass-threshold a re-grade uses: the run's persisted value, else the spec's per-scenario value, else 0.5. */
|
|
54
142
|
export declare function effectiveThreshold(prevScenario: ScenarioResult | undefined, scenario: Scenario): number;
|
|
55
143
|
/** Everything a caller may set. The grade is computed, never supplied. */
|
package/dist/results.js
CHANGED
|
@@ -3,6 +3,54 @@ import { join, relative, sep } from "node:path";
|
|
|
3
3
|
import yaml from "js-yaml";
|
|
4
4
|
import { modelSlug } from "./adapters/types.js";
|
|
5
5
|
import { score } from "./score.js";
|
|
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
|
+
}
|
|
6
54
|
/** The pass-threshold a re-grade uses: the run's persisted value, else the spec's per-scenario value, else 0.5. */
|
|
7
55
|
export function effectiveThreshold(prevScenario, scenario) {
|
|
8
56
|
return prevScenario?.pass_threshold ?? scenario.passThreshold ?? 0.5;
|
|
@@ -51,6 +99,14 @@ export function finalizeResults(draft, ctx) {
|
|
|
51
99
|
}
|
|
52
100
|
return {
|
|
53
101
|
schema: 2,
|
|
102
|
+
// Stamped here, the single place every writer passes through, so `run`,
|
|
103
|
+
// `grade`, `rescore` and the review UI's override save all record which tool
|
|
104
|
+
// produced the record they leave behind.
|
|
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 } : {}),
|
|
54
110
|
skill: draft.skill,
|
|
55
111
|
harness: draft.harness,
|
|
56
112
|
model: draft.model,
|
package/dist/run.d.ts
CHANGED
|
@@ -24,6 +24,13 @@ export interface RunOptions {
|
|
|
24
24
|
* ship-graded: a subset passing says nothing about the ship bar.
|
|
25
25
|
*/
|
|
26
26
|
only?: string[];
|
|
27
|
+
/**
|
|
28
|
+
* Green mode only: spend ONE probe up front proving the skill reaches the model,
|
|
29
|
+
* and abort the run if it doesn't (see canary.ts). Off by default — it costs a
|
|
30
|
+
* rep, and the deterministic half of this failure class (a skill dir that isn't
|
|
31
|
+
* there) is already refused by the adapter for free.
|
|
32
|
+
*/
|
|
33
|
+
canary?: boolean;
|
|
27
34
|
}
|
|
28
35
|
export interface RunSummary {
|
|
29
36
|
runDir: 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,7 @@ 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";
|
|
13
14
|
/** Run one skill against one model: run scenarios, grade, score, persist. */
|
|
14
15
|
export async function runSkillModel(opts) {
|
|
15
16
|
const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
|
|
@@ -36,12 +37,52 @@ export async function runSkillModel(opts) {
|
|
|
36
37
|
const runDir = runDirFor(skillDir, adapter.name, model, timestamp);
|
|
37
38
|
mkdirSync(runDir, { recursive: true });
|
|
38
39
|
ensureResultsGitignore(dirname(dirname(runDir))); // .../tests/results/.gitignore
|
|
40
|
+
// Which harness CLI delivered the skill, asked once per run and recorded with the
|
|
41
|
+
// numbers. A pi upgrade (0.80.x → 0.83.0) silently changed what green mode
|
|
42
|
+
// measures, and the incident was invisible in the artifacts because nothing wrote
|
|
43
|
+
// this down. Never fatal: `null` means the adapter couldn't say.
|
|
44
|
+
const harnessCliVersion = (await adapter.version?.()) ?? null;
|
|
39
45
|
appendJournal(runDir, {
|
|
40
46
|
event: "run-started", ts: now(),
|
|
41
47
|
skill: spec.skill, harness: adapter.name, model: opts.modelToken,
|
|
48
|
+
harness_cli_version: harnessCliVersion,
|
|
42
49
|
judge: { provider: judge.provider, model: judge.model },
|
|
43
50
|
mode, label: opts.label ?? null,
|
|
44
51
|
});
|
|
52
|
+
// The canary spends one probe before the wave, so a run that isn't measuring the
|
|
53
|
+
// skill dies for the price of a rep instead of producing a plausible scorecard.
|
|
54
|
+
// Green only: red delivers nothing by design, and force delivers through the
|
|
55
|
+
// system prompt, which needs no probe.
|
|
56
|
+
let canaryStatus = null;
|
|
57
|
+
if (opts.canary && mode !== "green") {
|
|
58
|
+
// Ignoring a flag silently is a small version of the bug this whole feature is
|
|
59
|
+
// about. Say it, and say why it isn't needed.
|
|
60
|
+
log(` --canary ignored in mode=${mode} — ${mode === "force" ? "the system prompt delivers the skill unconditionally" : "a baseline delivers no skill by design"}`);
|
|
61
|
+
}
|
|
62
|
+
if (opts.canary && mode === "green") {
|
|
63
|
+
const probeCwd = createWorkspace("none", { specDir: dirname(opts.specPath) });
|
|
64
|
+
let canary;
|
|
65
|
+
try {
|
|
66
|
+
canary = await runDeliveryCanary({
|
|
67
|
+
adapter, model, skillDir, skillName: spec.skill, cwd: probeCwd.cwd,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
probeCwd.cleanup();
|
|
72
|
+
}
|
|
73
|
+
appendJournal(runDir, {
|
|
74
|
+
event: "delivery-canary", ts: now(),
|
|
75
|
+
status: canary.status, anchor: canary.anchor, detail: canary.detail,
|
|
76
|
+
});
|
|
77
|
+
if (canary.status === "fail")
|
|
78
|
+
throw new Error(canaryFailure(spec.skill, canary, harnessCliVersion));
|
|
79
|
+
if (canary.status === "skipped")
|
|
80
|
+
log(` ⚠ delivery canary skipped — ${canary.detail}`);
|
|
81
|
+
else {
|
|
82
|
+
canaryStatus = "pass";
|
|
83
|
+
log(` ✓ delivery canary — the model quoted its skill instructions back (\`${canary.anchor}\`)`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
45
86
|
// scenario × rep tasks; runPool preserves input order so we can slice per scenario.
|
|
46
87
|
const repCounts = scenarios.map((s) => s.reps ?? opts.reps ?? 1);
|
|
47
88
|
const owners = [];
|
|
@@ -61,10 +102,12 @@ export async function runSkillModel(opts) {
|
|
|
61
102
|
const threshold = scenario.passThreshold ?? opts.passThreshold ?? 0.5;
|
|
62
103
|
return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
|
|
63
104
|
});
|
|
64
|
-
const ctx = mode
|
|
105
|
+
const ctx = scoreContextFor({ mode, partial }, spec);
|
|
65
106
|
const results = writeResults(runDir, {
|
|
66
107
|
skill: spec.skill,
|
|
67
108
|
harness: adapter.name,
|
|
109
|
+
harness_cli_version: harnessCliVersion ?? undefined,
|
|
110
|
+
delivery_canary: canaryStatus ?? undefined,
|
|
68
111
|
model: opts.modelToken,
|
|
69
112
|
judge: { provider: judge.provider, model: judge.model },
|
|
70
113
|
timestamp,
|
|
@@ -73,7 +116,7 @@ export async function runSkillModel(opts) {
|
|
|
73
116
|
...(partial ? { partial: true } : {}),
|
|
74
117
|
// Only the scenarios this run actually measured: a --only run must not claim
|
|
75
118
|
// coverage of scenarios it skipped.
|
|
76
|
-
source_hashes: sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios }),
|
|
119
|
+
source_hashes: sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona }),
|
|
77
120
|
scenarios: scenarioResults,
|
|
78
121
|
}, ctx);
|
|
79
122
|
if (ctx) {
|
|
@@ -226,16 +269,26 @@ export function formatScorecard(summary, lift) {
|
|
|
226
269
|
const ship = g.ship ? "SHIP" : "NOT READY";
|
|
227
270
|
const note = g.note ? ` (${g.note})` : "";
|
|
228
271
|
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
|
-
|
|
272
|
+
// Lift is a statement about a skill-delivered run (green or force). On a red run
|
|
273
|
+
// the caller may still have a lift in hand (a scored run exists in the same tag),
|
|
274
|
+
// but printing it under a baseline scorecard reads as if the baseline itself
|
|
275
|
+
// gained something.
|
|
276
|
+
if (lift && isScoredMode(results.mode)) {
|
|
233
277
|
lines.push(` LIFT: ${liftHeadline(lift)} (vs red baseline ${lift.redTimestamp})`);
|
|
234
278
|
}
|
|
235
|
-
else if (results.mode
|
|
279
|
+
else if (isScoredMode(results.mode)) {
|
|
236
280
|
// The grade alone can't answer "does this skill do anything?", so say how.
|
|
237
281
|
lines.push(` LIFT: no red baseline — run with --mode red to measure what the skill adds`);
|
|
238
282
|
}
|
|
283
|
+
// Said on the scorecard, not just in the docs: the one thing that can invalidate
|
|
284
|
+
// a green number is invisible in the number. `harness_cli_version` is recorded
|
|
285
|
+
// beside the verdicts so a reader can tell which pi produced them.
|
|
286
|
+
if (results.mode === "green" && !results.delivery_canary) {
|
|
287
|
+
lines.push(` NOTE: green delivery is harness-version-dependent` +
|
|
288
|
+
(results.harness_cli_version ? ` (${results.harness} ${results.harness_cli_version})` : "") +
|
|
289
|
+
` — on pi ≥ 0.83.0 \`--skill\` only discloses the description and the body loads on demand.` +
|
|
290
|
+
` Use --mode force for delivery that cannot silently degrade, or --canary to prove it per run.`);
|
|
291
|
+
}
|
|
239
292
|
return lines.join("\n");
|
|
240
293
|
}
|
|
241
294
|
//# sourceMappingURL=run.js.map
|
package/dist/seeded.d.ts
CHANGED
|
@@ -58,6 +58,24 @@ export interface SeededOutcome {
|
|
|
58
58
|
* guessing from prefixes.
|
|
59
59
|
*/
|
|
60
60
|
export declare function changedLines(diff: string): string;
|
|
61
|
+
/** Whether a scenario declares any needle gate at all. */
|
|
62
|
+
export declare function hasNeedleGates(scenario: Scenario): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Evaluate `diff_contains` / `diff_excludes` against a staged diff: the trailer lines
|
|
65
|
+
* to report, and the first failure (null when every needle is satisfied).
|
|
66
|
+
*
|
|
67
|
+
* A **pure function of the diff**, which is what makes `regate` possible — the saved
|
|
68
|
+
* `.diff.txt` artifact holds everything these gates need, so correcting a needle never
|
|
69
|
+
* requires re-running the model. Shared with `regate` deliberately: two copies of this
|
|
70
|
+
* loop would let a regated verdict disagree with what a fresh run would have produced,
|
|
71
|
+
* which is the same drift the fixture-marker check refuses between lint and runtime.
|
|
72
|
+
*
|
|
73
|
+
* Both gates read the CHANGED lines only, never context — see `changedLines`.
|
|
74
|
+
*/
|
|
75
|
+
export declare function evaluateNeedleGates(scenario: Scenario, diff: string): {
|
|
76
|
+
lines: string[];
|
|
77
|
+
failure: string | null;
|
|
78
|
+
};
|
|
61
79
|
/**
|
|
62
80
|
* Cut a diff to a byte budget on a line boundary, appending an explicit marker
|
|
63
81
|
* naming how much was dropped.
|
package/dist/seeded.js
CHANGED
|
@@ -56,6 +56,42 @@ export function changedLines(diff) {
|
|
|
56
56
|
}
|
|
57
57
|
return out.join("\n");
|
|
58
58
|
}
|
|
59
|
+
/** Whether a scenario declares any needle gate at all. */
|
|
60
|
+
export function hasNeedleGates(scenario) {
|
|
61
|
+
return (scenario.assert?.diff_contains?.length ?? 0) > 0 || (scenario.assert?.diff_excludes?.length ?? 0) > 0;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Evaluate `diff_contains` / `diff_excludes` against a staged diff: the trailer lines
|
|
65
|
+
* to report, and the first failure (null when every needle is satisfied).
|
|
66
|
+
*
|
|
67
|
+
* A **pure function of the diff**, which is what makes `regate` possible — the saved
|
|
68
|
+
* `.diff.txt` artifact holds everything these gates need, so correcting a needle never
|
|
69
|
+
* requires re-running the model. Shared with `regate` deliberately: two copies of this
|
|
70
|
+
* loop would let a regated verdict disagree with what a fresh run would have produced,
|
|
71
|
+
* which is the same drift the fixture-marker check refuses between lint and runtime.
|
|
72
|
+
*
|
|
73
|
+
* Both gates read the CHANGED lines only, never context — see `changedLines`.
|
|
74
|
+
*/
|
|
75
|
+
export function evaluateNeedleGates(scenario, diff) {
|
|
76
|
+
const changed = changedLines(diff);
|
|
77
|
+
const lines = [];
|
|
78
|
+
let failure = null;
|
|
79
|
+
for (const needle of scenario.assert?.diff_contains ?? []) {
|
|
80
|
+
const ok = changed.includes(needle);
|
|
81
|
+
lines.push(` diff_contains ${JSON.stringify(needle)}: ${ok ? "OK" : "MISSING"}`);
|
|
82
|
+
if (!ok && !failure)
|
|
83
|
+
failure = `staged diff missing ${JSON.stringify(needle)}`;
|
|
84
|
+
}
|
|
85
|
+
// Scope discipline, stated as a fact about the diff rather than inferred from
|
|
86
|
+
// whether the model remembered to say "I left lastIndex alone".
|
|
87
|
+
for (const needle of scenario.assert?.diff_excludes ?? []) {
|
|
88
|
+
const ok = !changed.includes(needle);
|
|
89
|
+
lines.push(` diff_excludes ${JSON.stringify(needle)}: ${ok ? "OK" : "PRESENT"}`);
|
|
90
|
+
if (!ok && !failure)
|
|
91
|
+
failure = `staged diff touches forbidden ${JSON.stringify(needle)}`;
|
|
92
|
+
}
|
|
93
|
+
return { lines, failure };
|
|
94
|
+
}
|
|
59
95
|
/**
|
|
60
96
|
* Byte cap on the diff copy embedded in the judged transcript. The judge prompt
|
|
61
97
|
* is a single request, so an unbounded diff (a fixture-wide refactor, a
|
|
@@ -167,21 +203,10 @@ export async function runSeeded(scenario, opts) {
|
|
|
167
203
|
// either token. Every published A4 result recorded that as an objective pass.
|
|
168
204
|
// Read against changed lines it means what the checklist means: the model
|
|
169
205
|
// returned a Result.
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (!ok && !gateFailure)
|
|
175
|
-
gateFailure = `staged diff missing ${JSON.stringify(needle)}`;
|
|
176
|
-
}
|
|
177
|
-
// Scope discipline, stated as a fact about the diff rather than inferred from
|
|
178
|
-
// whether the model remembered to say "I left lastIndex alone".
|
|
179
|
-
for (const needle of scenario.assert?.diff_excludes ?? []) {
|
|
180
|
-
const ok = !changed.includes(needle);
|
|
181
|
-
parts.push(` diff_excludes ${JSON.stringify(needle)}: ${ok ? "OK" : "PRESENT"}`);
|
|
182
|
-
if (!ok && !gateFailure)
|
|
183
|
-
gateFailure = `staged diff touches forbidden ${JSON.stringify(needle)}`;
|
|
184
|
-
}
|
|
206
|
+
const needles = evaluateNeedleGates(scenario, diff);
|
|
207
|
+
parts.push(...needles.lines);
|
|
208
|
+
if (needles.failure && !gateFailure)
|
|
209
|
+
gateFailure = needles.failure;
|
|
185
210
|
if (scenario.assert?.vitest) {
|
|
186
211
|
const v = await runVitest([], repo);
|
|
187
212
|
// code === null means exec SIGKILLed it at the timeout. That is infrastructure,
|
package/dist/sources.d.ts
CHANGED
|
@@ -37,8 +37,44 @@ import type { Scenario } from "./spec.js";
|
|
|
37
37
|
* reindenting the YAML or reordering scenarios is correctly a no-op, while
|
|
38
38
|
* changing a single checklist word is correctly a change.
|
|
39
39
|
*/
|
|
40
|
+
/**
|
|
41
|
+
* The pre-0.4.0 combined key: one digest over a scenario's stimulus, rubric, policy
|
|
42
|
+
* and gates together. Still read (runs recorded with it must keep comparing), never
|
|
43
|
+
* written. See `scenarioDigest`.
|
|
44
|
+
*/
|
|
40
45
|
export declare const SCENARIO_PREFIX = "scenario:";
|
|
41
46
|
export declare const FIXTURE_PREFIX = "fixture:";
|
|
47
|
+
/**
|
|
48
|
+
* The split: three (four, with gates) digests per scenario, each mapped to the
|
|
49
|
+
* cheapest tool that can honestly restore freshness.
|
|
50
|
+
*
|
|
51
|
+
* | key | contents | drift means | remedy |
|
|
52
|
+
* |---|---|---|---|
|
|
53
|
+
* | `stimulus:<id>` | mode, turns, workspace, remote, agent-file path, fixture path, `assert.vitest`, `post_test` path | the transcripts answer a different question | `run` (model + judge) |
|
|
54
|
+
* | `rubric:<id>` | title, checklist | transcripts fine, verdicts wrong | `grade` (judge only) |
|
|
55
|
+
* | `policy:<id>` | critical, reps, pass_threshold | only the scoring moved | `rescore` (free) |
|
|
56
|
+
* | `gates:<id>` | `diff_contains`, `diff_excludes` | needle wrong, behavior fine | `regate` (free; judges only flipped reps) |
|
|
57
|
+
* | `rubric:__persona` | spec-level `judge_persona` | every verdict in the skill | `grade` per model |
|
|
58
|
+
*
|
|
59
|
+
* Why this matters more than it looks: with one key, lint had exactly one remedy for
|
|
60
|
+
* any drift — "re-run" — so **correcting a rubric cost model spend**. Measured on the
|
|
61
|
+
* reference corpus, two parked branches (one needle, one checklist rewrite) demanded
|
|
62
|
+
* 135 rep-executions to restore freshness while producing zero new information about
|
|
63
|
+
* the models. A gate that charges that much to fix a known-bad rubric is pressure to
|
|
64
|
+
* leave the rubric in place, which inverts the point of having a gate.
|
|
65
|
+
*
|
|
66
|
+
* The strictness is unchanged: every edit still marks something stale. Only the price
|
|
67
|
+
* of getting back to fresh changed.
|
|
68
|
+
*/
|
|
69
|
+
export declare const STIMULUS_PREFIX = "stimulus:";
|
|
70
|
+
export declare const RUBRIC_PREFIX = "rubric:";
|
|
71
|
+
export declare const POLICY_PREFIX = "policy:";
|
|
72
|
+
export declare const GATES_PREFIX = "gates:";
|
|
73
|
+
/**
|
|
74
|
+
* The spec-level rubric key. `__persona` cannot collide with a scenario id: ids are
|
|
75
|
+
* validated as `[A-Za-z][A-Za-z0-9_-]*`, so none can begin with an underscore.
|
|
76
|
+
*/
|
|
77
|
+
export declare const PERSONA_KEY = "rubric:__persona";
|
|
42
78
|
/**
|
|
43
79
|
* Recorded in place of a hash when a source existed but could not be read.
|
|
44
80
|
*
|
|
@@ -65,14 +101,24 @@ export declare function fileSha256(path: string): string | null;
|
|
|
65
101
|
* normalised so a Linux-recorded hash still matches on Windows.
|
|
66
102
|
*/
|
|
67
103
|
export declare function dirSha256(dir: string): string | null;
|
|
104
|
+
export declare function stimulusDigest(s: Scenario): string;
|
|
105
|
+
export declare function rubricDigest(s: Scenario): string;
|
|
106
|
+
export declare function policyDigest(s: Scenario): string;
|
|
107
|
+
/** Null when the scenario declares no needle gates — no key is recorded for it. */
|
|
108
|
+
export declare function gatesDigest(s: Scenario): string | null;
|
|
109
|
+
/** The spec-level judge persona, which is rubric for every scenario at once. */
|
|
110
|
+
export declare function personaDigest(persona: string): string;
|
|
68
111
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
112
|
+
* The pre-0.4.0 combined digest: everything about a scenario in one hash.
|
|
113
|
+
*
|
|
114
|
+
* **Read-only now** — `sourceHashes` writes the four split keys instead. Kept because
|
|
115
|
+
* `lint` must still compare runs that recorded `scenario:<id>`, and those runs are
|
|
116
|
+
* every scorecard published before 0.4.0. Deleting it would turn "no findings" into
|
|
117
|
+
* "no comparison" for the entire existing corpus, silently.
|
|
71
118
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* believes was tested.
|
|
119
|
+
* Its bytes must therefore never change again: this is a stored-hash format, not an
|
|
120
|
+
* implementation detail. The facet digests take new fields; this one is frozen at the
|
|
121
|
+
* 0.3.x field set, which is why it does not go through `facets()`.
|
|
76
122
|
*/
|
|
77
123
|
export declare function scenarioDigest(s: Scenario): string;
|
|
78
124
|
/**
|
|
@@ -89,6 +135,13 @@ export interface SourceContext {
|
|
|
89
135
|
skillDir: string;
|
|
90
136
|
specDir: string;
|
|
91
137
|
scenarios: Scenario[];
|
|
138
|
+
/**
|
|
139
|
+
* The spec's `judge_persona`. Required rather than optional: an optional field that
|
|
140
|
+
* silently disables the `rubric:__persona` comparison is the blind-spot shape this
|
|
141
|
+
* module exists to prevent, and making it required lets the compiler find every
|
|
142
|
+
* caller instead of leaving one quietly un-checked.
|
|
143
|
+
*/
|
|
144
|
+
judgePersona: string;
|
|
92
145
|
}
|
|
93
146
|
/**
|
|
94
147
|
* Hash every source this run measures: SKILL.md, each distinct
|
|
@@ -111,5 +164,13 @@ export declare function sourceHashes(ctx: SourceContext): Record<string, string>
|
|
|
111
164
|
export declare function currentHashFor(key: string, ctx: SourceContext): string | null | undefined;
|
|
112
165
|
/** Human label for a recorded key, used in lint messages. */
|
|
113
166
|
export declare function describeSourceKey(key: string): string;
|
|
167
|
+
/**
|
|
168
|
+
* The cheapest command that honestly restores freshness for this key kind.
|
|
169
|
+
*
|
|
170
|
+
* This string is the feature. Before the split, lint's only remedy was "re-run", so a
|
|
171
|
+
* one-word checklist fix cost a full model pass — pressure to leave a known-bad rubric
|
|
172
|
+
* in place. Naming the actual remedy is what converts that into a free command.
|
|
173
|
+
*/
|
|
174
|
+
export declare function remedyForKey(key: string): string;
|
|
114
175
|
/** The scenario id a key belongs to, for per-scenario lint findings. Undefined for skill-wide keys. */
|
|
115
176
|
export declare function scenarioIdForKey(key: string, scenarios: Scenario[]): string | undefined;
|