@skill-harness/core 0.4.0 → 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/discover.d.ts +7 -0
- package/dist/discover.js +13 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/journal.d.ts +14 -0
- package/dist/lift.d.ts +13 -0
- package/dist/lift.js +13 -8
- package/dist/lint.js +10 -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 +6 -5
- package/dist/report.js +5 -4
- 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 +7 -0
- package/dist/run.js +60 -7
- package/dist/trends.d.ts +23 -9
- package/dist/trends.js +44 -35
- package/package.json +1 -1
package/dist/adapters/types.d.ts
CHANGED
|
@@ -32,4 +32,14 @@ export interface HarnessAdapter {
|
|
|
32
32
|
available(): Promise<boolean>;
|
|
33
33
|
run(req: RunReq): Promise<string>;
|
|
34
34
|
judge(req: JudgeReq): Promise<string>;
|
|
35
|
+
/**
|
|
36
|
+
* The harness CLI's own version, recorded in `results.yaml` as
|
|
37
|
+
* `harness_cli_version`. Null when it cannot be determined — a version this
|
|
38
|
+
* adapter had to guess at is worse than none, because the whole point of the
|
|
39
|
+
* field is to identify which CLI produced a transcript.
|
|
40
|
+
*
|
|
41
|
+
* Optional so a test double or a future adapter need not implement it; callers
|
|
42
|
+
* treat a missing method exactly like a null answer.
|
|
43
|
+
*/
|
|
44
|
+
version?(): Promise<string | null>;
|
|
35
45
|
}
|
package/dist/canary.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { HarnessAdapter, ModelRef } from "./adapters/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The probe target: the longest `## ` heading in the skill's body.
|
|
4
|
+
*
|
|
5
|
+
* Body-only by construction — the frontmatter `description` is always in context
|
|
6
|
+
* under progressive disclosure, so anything quotable from it would pass against a
|
|
7
|
+
* model that never read the instructions. Longest rather than first because the
|
|
8
|
+
* check is "did you see this text", and `## Overview` is guessable while
|
|
9
|
+
* `## Refuse a metered judge, whatever chose it` is not.
|
|
10
|
+
*
|
|
11
|
+
* Null when the body has no `## ` heading: there is then nothing to ask for that a
|
|
12
|
+
* plausible-sounding answer couldn't fake, and a canary that can be bluffed is
|
|
13
|
+
* worse than none.
|
|
14
|
+
*/
|
|
15
|
+
export declare function deliveryAnchor(skillMd: string): string | null;
|
|
16
|
+
export interface CanaryResult {
|
|
17
|
+
/** `pass` = the anchor came back; `fail` = it did not; `skipped` = nothing safe to probe for. */
|
|
18
|
+
status: "pass" | "fail" | "skipped";
|
|
19
|
+
/** What the probe looked for (null when skipped). */
|
|
20
|
+
anchor: string | null;
|
|
21
|
+
/** Why it was skipped, or what the model said instead (trimmed) — for the log and the journal. */
|
|
22
|
+
detail: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function canaryPrompt(skillName: string, anchor: string): string;
|
|
25
|
+
export interface CanaryOptions {
|
|
26
|
+
adapter: HarnessAdapter;
|
|
27
|
+
model: ModelRef;
|
|
28
|
+
skillDir: string;
|
|
29
|
+
skillName: string;
|
|
30
|
+
/** Neutral cwd, same as a scenario gets — the probe must not see a repo either. */
|
|
31
|
+
cwd: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Run the probe in green mode and report whether the skill body was reachable.
|
|
35
|
+
*
|
|
36
|
+
* Never throws for a model-side outcome: an empty or off-format reply is a `fail`
|
|
37
|
+
* with the reply in `detail`, because "the harness answered without the skill" and
|
|
38
|
+
* "the model said something odd" are both reasons not to spend a wave. An adapter
|
|
39
|
+
* that throws (pi missing, the skill-dir tripwire) is left to propagate — those are
|
|
40
|
+
* setup errors with their own messages.
|
|
41
|
+
*/
|
|
42
|
+
export declare function runDeliveryCanary(opts: CanaryOptions): Promise<CanaryResult>;
|
|
43
|
+
/** The abort message for a failed canary: what was measured, and what to do instead. */
|
|
44
|
+
export declare function canaryFailure(skillName: string, result: CanaryResult, cliVersion: string | null): string;
|
package/dist/canary.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* The delivery canary: one cheap probe, before any scenario runs, that the skill
|
|
5
|
+
* under test actually reached the model.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists. `--mode green` asks the harness to activate the skill, and the
|
|
8
|
+
* harness can decline without saying so. Measured on pi: 0.80.x wrapped the prompt
|
|
9
|
+
* with the skill body; 0.83.0 switched to progressive disclosure (only the
|
|
10
|
+
* description is in context, the body loads on demand — "models don't always do
|
|
11
|
+
* this", pi's own docs); and a nonexistent `--skill` path is accepted silently,
|
|
12
|
+
* exit 0 with a normal answer. The reference corpus ran two full waves in that
|
|
13
|
+
* state: `architect` came back 7/14, ≈ its no-skill baseline, and looked entirely
|
|
14
|
+
* plausible — the only tell was a contradictory failure mix (over-ceremony AND
|
|
15
|
+
* capitulation at once) that no single skill edit produces.
|
|
16
|
+
*
|
|
17
|
+
* What it can and cannot prove. A pass means the skill's body is *reachable* in
|
|
18
|
+
* this exact invocation — which is what kills the whole silent-non-delivery class:
|
|
19
|
+
* a dropped flag, a wrong path, a harness that stopped honoring the mode. It does
|
|
20
|
+
* NOT prove the body entered context for every later scenario; under progressive
|
|
21
|
+
* disclosure that is the model's choice per turn, and no probe can promise it. The
|
|
22
|
+
* mode whose delivery needs no promise is `force` (SKILL.md as system prompt),
|
|
23
|
+
* which is why `run` recommends it rather than pretending the canary is equivalent.
|
|
24
|
+
*
|
|
25
|
+
* Cost: exactly one subject call per run, and only when asked for (`--canary`).
|
|
26
|
+
* A run that aborts here has spent one rep instead of a wave.
|
|
27
|
+
*/
|
|
28
|
+
/** Frontmatter-stripped body of a SKILL.md. */
|
|
29
|
+
function skillBody(text) {
|
|
30
|
+
const m = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(text);
|
|
31
|
+
return m ? text.slice(m[0].length) : text;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The probe target: the longest `## ` heading in the skill's body.
|
|
35
|
+
*
|
|
36
|
+
* Body-only by construction — the frontmatter `description` is always in context
|
|
37
|
+
* under progressive disclosure, so anything quotable from it would pass against a
|
|
38
|
+
* model that never read the instructions. Longest rather than first because the
|
|
39
|
+
* check is "did you see this text", and `## Overview` is guessable while
|
|
40
|
+
* `## Refuse a metered judge, whatever chose it` is not.
|
|
41
|
+
*
|
|
42
|
+
* Null when the body has no `## ` heading: there is then nothing to ask for that a
|
|
43
|
+
* plausible-sounding answer couldn't fake, and a canary that can be bluffed is
|
|
44
|
+
* worse than none.
|
|
45
|
+
*/
|
|
46
|
+
export function deliveryAnchor(skillMd) {
|
|
47
|
+
const headings = [...skillBody(skillMd).matchAll(/^##[ \t]+(.+?)[ \t]*$/gm)].map((m) => m[1].trim());
|
|
48
|
+
if (headings.length === 0)
|
|
49
|
+
return null;
|
|
50
|
+
return headings.reduce((a, b) => (b.length > a.length ? b : a));
|
|
51
|
+
}
|
|
52
|
+
/** Normalize for comparison: case, whitespace runs, and markdown emphasis/backticks. */
|
|
53
|
+
function normalize(s) {
|
|
54
|
+
return s.toLowerCase().replace(/[`*_]/g, "").replace(/\s+/g, " ").trim();
|
|
55
|
+
}
|
|
56
|
+
export function canaryPrompt(skillName, anchor) {
|
|
57
|
+
// Asks for every heading, not just the anchor, because that is a question a model
|
|
58
|
+
// WITH the instructions answers naturally and a model without them can only
|
|
59
|
+
// invent. The instruction to say NOT_AVAILABLE keeps an honest miss from looking
|
|
60
|
+
// like a refusal to follow format.
|
|
61
|
+
return (`Answer from the instructions you have loaded — do not perform any task.\n\n` +
|
|
62
|
+
`List every level-2 markdown heading (lines starting with "## ") in the instructions of ` +
|
|
63
|
+
`the skill named "${skillName}", verbatim, one per line, with no other text.\n` +
|
|
64
|
+
`If you have no such instructions available, reply exactly: NOT_AVAILABLE\n\n` +
|
|
65
|
+
`(The heading text is what matters; keep it exact.)`);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* What the model actually said, per the adapters' shared transcript convention
|
|
69
|
+
* (`>>> USER:` / `<<< ASSISTANT:`). Falls back to the whole text for an adapter
|
|
70
|
+
* that doesn't use the markers.
|
|
71
|
+
*/
|
|
72
|
+
function assistantReply(transcript) {
|
|
73
|
+
const parts = transcript.split(/^<<< ASSISTANT:\s*$/m);
|
|
74
|
+
return (parts.length > 1 ? parts[parts.length - 1] : transcript).trim();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Run the probe in green mode and report whether the skill body was reachable.
|
|
78
|
+
*
|
|
79
|
+
* Never throws for a model-side outcome: an empty or off-format reply is a `fail`
|
|
80
|
+
* with the reply in `detail`, because "the harness answered without the skill" and
|
|
81
|
+
* "the model said something odd" are both reasons not to spend a wave. An adapter
|
|
82
|
+
* that throws (pi missing, the skill-dir tripwire) is left to propagate — those are
|
|
83
|
+
* setup errors with their own messages.
|
|
84
|
+
*/
|
|
85
|
+
export async function runDeliveryCanary(opts) {
|
|
86
|
+
const skillMd = readFileSync(join(opts.skillDir, "SKILL.md"), "utf8");
|
|
87
|
+
const anchor = deliveryAnchor(skillMd);
|
|
88
|
+
if (!anchor) {
|
|
89
|
+
return {
|
|
90
|
+
status: "skipped",
|
|
91
|
+
anchor: null,
|
|
92
|
+
detail: `${opts.skillName}/SKILL.md has no \`## \` heading to probe for — nothing a reply could prove`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const transcript = await opts.adapter.run({
|
|
96
|
+
skillDir: opts.skillDir,
|
|
97
|
+
model: opts.model,
|
|
98
|
+
mode: "green",
|
|
99
|
+
turns: [canaryPrompt(opts.skillName, anchor)],
|
|
100
|
+
cwd: opts.cwd,
|
|
101
|
+
});
|
|
102
|
+
const ok = normalize(transcript).includes(normalize(anchor));
|
|
103
|
+
return {
|
|
104
|
+
status: ok ? "pass" : "fail",
|
|
105
|
+
anchor,
|
|
106
|
+
// The reply, not the transcript: the transcript opens with our own prompt, and a
|
|
107
|
+
// failure report whose first 400 characters are the question is useless.
|
|
108
|
+
detail: ok ? "" : assistantReply(transcript).slice(0, 400),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** The abort message for a failed canary: what was measured, and what to do instead. */
|
|
112
|
+
export function canaryFailure(skillName, result, cliVersion) {
|
|
113
|
+
return (`delivery canary FAILED for ${skillName}: the model could not quote its own skill instructions ` +
|
|
114
|
+
`(looked for the heading \`${result.anchor}\`).\n` +
|
|
115
|
+
` The skill is not reaching the model, so every scenario in this run would measure a naked ` +
|
|
116
|
+
`model and score like a result. Nothing has been spent beyond this one probe.\n` +
|
|
117
|
+
` harness CLI: ${cliVersion ?? "unknown"}. On pi ≥ 0.83.0 \`--skill\` is progressive disclosure ` +
|
|
118
|
+
`(description in context, body on demand) and a nonexistent path is accepted silently.\n` +
|
|
119
|
+
` Fix: re-run with \`--mode force\` (SKILL.md as the system prompt — delivery no version has made ` +
|
|
120
|
+
`conditional), or check that the skill dir is the one you meant.\n` +
|
|
121
|
+
` What the model said instead: ${result.detail || "(nothing)"}`);
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=canary.js.map
|
package/dist/discover.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ export interface DiscoveredSkill {
|
|
|
8
8
|
* Scan a skills root. A "skill" is any immediate subdirectory containing a
|
|
9
9
|
* SKILL.md. It is testable iff `<skill>/tests/specification.yaml` exists.
|
|
10
10
|
* Returns skills sorted by name (testable or not).
|
|
11
|
+
*
|
|
12
|
+
* `dir` and `specPath` are ABSOLUTE, whatever `root` was. They are handed to child
|
|
13
|
+
* processes that run in a neutral cwd of the harness's choosing (`pi --skill
|
|
14
|
+
* <dir>`), so a relative `--skills .` used to produce a path that resolved to
|
|
15
|
+
* nothing over there — and pi accepts a nonexistent `--skill` path silently, exit 0
|
|
16
|
+
* and a normal answer. The adapter refuses such a path too (see requireSkillDir),
|
|
17
|
+
* but the honest fix is here, where the path is built.
|
|
11
18
|
*/
|
|
12
19
|
export declare function discover(root: string): DiscoveredSkill[];
|
|
13
20
|
/**
|
package/dist/discover.js
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
3
|
/**
|
|
4
4
|
* Scan a skills root. A "skill" is any immediate subdirectory containing a
|
|
5
5
|
* SKILL.md. It is testable iff `<skill>/tests/specification.yaml` exists.
|
|
6
6
|
* Returns skills sorted by name (testable or not).
|
|
7
|
+
*
|
|
8
|
+
* `dir` and `specPath` are ABSOLUTE, whatever `root` was. They are handed to child
|
|
9
|
+
* processes that run in a neutral cwd of the harness's choosing (`pi --skill
|
|
10
|
+
* <dir>`), so a relative `--skills .` used to produce a path that resolved to
|
|
11
|
+
* nothing over there — and pi accepts a nonexistent `--skill` path silently, exit 0
|
|
12
|
+
* and a normal answer. The adapter refuses such a path too (see requireSkillDir),
|
|
13
|
+
* but the honest fix is here, where the path is built.
|
|
7
14
|
*/
|
|
8
15
|
export function discover(root) {
|
|
9
|
-
|
|
16
|
+
const absRoot = resolve(root);
|
|
17
|
+
if (!existsSync(absRoot) || !statSync(absRoot).isDirectory()) {
|
|
10
18
|
throw new Error(`skills root is not a directory: ${root}`);
|
|
11
19
|
}
|
|
12
20
|
const skills = [];
|
|
13
|
-
for (const name of readdirSync(
|
|
21
|
+
for (const name of readdirSync(absRoot)) {
|
|
14
22
|
if (name.startsWith("."))
|
|
15
23
|
continue;
|
|
16
|
-
const dir = join(
|
|
24
|
+
const dir = join(absRoot, name);
|
|
17
25
|
if (!statSync(dir).isDirectory())
|
|
18
26
|
continue;
|
|
19
27
|
if (!existsSync(join(dir, "SKILL.md")))
|
|
@@ -32,7 +40,7 @@ export function discover(root) {
|
|
|
32
40
|
export function resolveSkill(root, name) {
|
|
33
41
|
const skill = discover(root).find((s) => s.name === name);
|
|
34
42
|
if (!skill) {
|
|
35
|
-
const dir = join(root, name);
|
|
43
|
+
const dir = join(resolve(root), name);
|
|
36
44
|
if (existsSync(dir) && statSync(dir).isDirectory() && !existsSync(join(dir, "SKILL.md"))) {
|
|
37
45
|
throw new Error(`skill \`${name}\` has no SKILL.md (looked in ${dir})`);
|
|
38
46
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/journal.d.ts
CHANGED
|
@@ -22,12 +22,26 @@ export type JournalEvent = {
|
|
|
22
22
|
skill: string;
|
|
23
23
|
harness: string;
|
|
24
24
|
model: string;
|
|
25
|
+
/** The harness CLI's own version (`pi --version`), or null when it could not be asked. */
|
|
26
|
+
harness_cli_version?: string | null;
|
|
25
27
|
judge: {
|
|
26
28
|
provider: string;
|
|
27
29
|
model: string;
|
|
28
30
|
};
|
|
29
31
|
mode: string;
|
|
30
32
|
label: string | null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The pre-flight delivery probe (green mode, `--canary`): did the model quote a
|
|
36
|
+
* body-only heading of its own skill back? `fail` aborts the run, so a journal
|
|
37
|
+
* carrying a failed canary is the record of a wave that was NOT spent.
|
|
38
|
+
*/
|
|
39
|
+
| {
|
|
40
|
+
event: "delivery-canary";
|
|
41
|
+
ts: string;
|
|
42
|
+
status: "pass" | "fail" | "skipped";
|
|
43
|
+
anchor: string | null;
|
|
44
|
+
detail: string;
|
|
31
45
|
} | {
|
|
32
46
|
event: "scenario-started";
|
|
33
47
|
ts: string;
|
package/dist/lift.d.ts
CHANGED
|
@@ -24,6 +24,19 @@ export interface Lift {
|
|
|
24
24
|
model: string;
|
|
25
25
|
redTimestamp: string;
|
|
26
26
|
greenTimestamp: string;
|
|
27
|
+
/**
|
|
28
|
+
* How the skill was delivered on the non-baseline side: `green` (the harness
|
|
29
|
+
* activated it) or `force` (SKILL.md as the system prompt).
|
|
30
|
+
*
|
|
31
|
+
* The `green*` field names are kept as the *skill-active side*, not as a claim
|
|
32
|
+
* about the mode — they are the wire format the committed report assets and the
|
|
33
|
+
* review UI read, and renaming them would break every published report to say
|
|
34
|
+
* something the `mode` field already says. A red baseline is mode-independent
|
|
35
|
+
* (`--no-skills` either way), so red-vs-force is as valid a comparison as
|
|
36
|
+
* red-vs-green — but which one you are looking at changes what the number means,
|
|
37
|
+
* so it is recorded rather than implied.
|
|
38
|
+
*/
|
|
39
|
+
mode: string;
|
|
27
40
|
/** Scenario ids present in both runs — the only ones a lift can speak to. */
|
|
28
41
|
compared: number;
|
|
29
42
|
gained: number;
|
package/dist/lift.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { readResults, effectiveVerdicts } from "./results.js";
|
|
3
|
+
import { readResults, effectiveVerdicts, isScoredMode } from "./results.js";
|
|
4
4
|
import { loadSpec } from "./spec.js";
|
|
5
5
|
function aggregationShape(s) {
|
|
6
6
|
const reps = s.reps ?? 1;
|
|
@@ -98,6 +98,7 @@ export function computeLift(red, green, opts = {}) {
|
|
|
98
98
|
return {
|
|
99
99
|
tag: "",
|
|
100
100
|
model: green.model,
|
|
101
|
+
mode: green.mode,
|
|
101
102
|
redTimestamp: red.timestamp,
|
|
102
103
|
greenTimestamp: green.timestamp,
|
|
103
104
|
compared: Object.keys(cells).length,
|
|
@@ -193,7 +194,7 @@ function isDir(p) {
|
|
|
193
194
|
}
|
|
194
195
|
/**
|
|
195
196
|
* Per model-tag under <skillDir>/tests/results/, pair the most recent red run
|
|
196
|
-
* with the most recent
|
|
197
|
+
* with the most recent skill-delivered run (green or force) and compute the lift.
|
|
197
198
|
*
|
|
198
199
|
* Deliberately derived on read rather than persisted into results.yaml: a lift
|
|
199
200
|
* is a fact about a *pair* of runs, so caching it inside one run's file would go
|
|
@@ -237,9 +238,13 @@ export function collectLift(skillDir) {
|
|
|
237
238
|
.filter((p) => isDir(p) && existsSync(join(p, "results.yaml")))
|
|
238
239
|
.sort(); // timestamp-slug names ⇒ chronological ascending
|
|
239
240
|
// Mode is only knowable after reading results.yaml, so every run in the tag
|
|
240
|
-
// is read; last-wins gives the most recent
|
|
241
|
+
// is read; last-wins gives the most recent baseline and the most recent
|
|
242
|
+
// skill-delivered run. The skill side is whichever scored mode ran most
|
|
243
|
+
// recently — a corpus that moved from green to force delivery should see its
|
|
244
|
+
// lift follow, and the baseline it is measured against is the same either way
|
|
245
|
+
// (`--no-skills` in both).
|
|
241
246
|
let red;
|
|
242
|
-
let
|
|
247
|
+
let skillOn;
|
|
243
248
|
for (const rd of runDirs) {
|
|
244
249
|
let r;
|
|
245
250
|
try {
|
|
@@ -252,12 +257,12 @@ export function collectLift(skillDir) {
|
|
|
252
257
|
}
|
|
253
258
|
if (r.mode === "red")
|
|
254
259
|
red = r;
|
|
255
|
-
else if (r.mode
|
|
256
|
-
|
|
260
|
+
else if (isScoredMode(r.mode))
|
|
261
|
+
skillOn = r;
|
|
257
262
|
}
|
|
258
|
-
if (!red || !
|
|
263
|
+
if (!red || !skillOn)
|
|
259
264
|
continue;
|
|
260
|
-
lifts.push({ ...computeLift(red,
|
|
265
|
+
lifts.push({ ...computeLift(red, skillOn, { modeInsensitive }), tag });
|
|
261
266
|
}
|
|
262
267
|
return lifts;
|
|
263
268
|
}
|
package/dist/lint.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import yaml from "js-yaml";
|
|
4
4
|
import { loadSpec, SpecError } from "./spec.js";
|
|
5
|
-
import { readResults, finalizeResults, findTranscriptFiles, resultsPath } from "./results.js";
|
|
5
|
+
import { readResults, finalizeResults, findTranscriptFiles, resultsPath, scoreContextFor } from "./results.js";
|
|
6
6
|
import { currentHashFor, describeSourceKey, remedyForKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, STIMULUS_PREFIX, UNREADABLE } from "./sources.js";
|
|
7
7
|
import { downgradeWarning } from "./downgrade.js";
|
|
8
8
|
import { MARKERS, unknownMarkerDirs, suggestMarker } from "./workspace.js";
|
|
@@ -163,10 +163,17 @@ export function lintSkill(skillDir) {
|
|
|
163
163
|
// can actually re-score. Override/transcript rules below still apply.
|
|
164
164
|
const specIds = new Set(spec.scenarios.map((sc) => sc.id));
|
|
165
165
|
const sameSet = r.scenarios.length === specIds.size && r.scenarios.every((sc) => specIds.has(sc.id));
|
|
166
|
-
const ctx = r
|
|
166
|
+
const ctx = scoreContextFor(r, spec);
|
|
167
167
|
const recomputed = !sameSet ? null : finalizeResults({ skill: r.skill, harness: r.harness, model: r.model, judge: r.judge, timestamp: r.timestamp, label: r.label, mode: r.mode, partial: r.partial, source_hashes: r.source_hashes, scenarios: r.scenarios }, ctx).effective_grade;
|
|
168
168
|
if (recomputed && JSON.stringify(recomputed) !== JSON.stringify(r.effective_grade)) {
|
|
169
|
-
|
|
169
|
+
// The remedy is named because this finding now has a benign, expected cause
|
|
170
|
+
// as well as a suspicious one: a force run recorded before 0.5.0 carries a
|
|
171
|
+
// "not scored" placeholder, and today's policy scores it (see SCORED_MODES).
|
|
172
|
+
// `rescore` is free and offline, so the fix is never a reason to re-measure.
|
|
173
|
+
findings.push({
|
|
174
|
+
skill, code: "consistency",
|
|
175
|
+
message: `results.yaml effective_grade is stale in ${runDir} (recompute differs) — re-apply the current scoring policy: rescore (free, offline)`,
|
|
176
|
+
});
|
|
170
177
|
}
|
|
171
178
|
for (const s of r.scenarios) {
|
|
172
179
|
if (s.override != null) {
|
package/dist/regate.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { parseVerdict, detectMisfire } from "./grade.js";
|
|
4
4
|
import { evaluateNeedleGates, hasNeedleGates } from "./seeded.js";
|
|
5
5
|
import { judgeOneRep } from "./regrade.js";
|
|
6
|
-
import { readResults, writeResults, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, effectiveThreshold, } from "./results.js";
|
|
6
|
+
import { readResults, writeResults, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, effectiveThreshold, scoreContextFor, } from "./results.js";
|
|
7
7
|
import { outcomesToResult } from "./reps.js";
|
|
8
8
|
import { appendJournal } from "./journal.js";
|
|
9
9
|
import { gatesDigest, GATES_PREFIX } from "./sources.js";
|
|
@@ -32,8 +32,8 @@ function rewriteTranscript(path, gateLines) {
|
|
|
32
32
|
writeFileSync(path, `${head}${TRAILER}\n${gateLines.join("\n")}\n\n${tail}`, "utf8");
|
|
33
33
|
}
|
|
34
34
|
/** Recover a rep's judge verdict from its saved judge-raw artifact — free, and exact. */
|
|
35
|
-
function verdictFromSavedJudgement(runDir, id, rep) {
|
|
36
|
-
const path = judgeRawPath(runDir, id,
|
|
35
|
+
function verdictFromSavedJudgement(runDir, id, mode, rep) {
|
|
36
|
+
const path = judgeRawPath(runDir, id, mode, rep);
|
|
37
37
|
if (!existsSync(path))
|
|
38
38
|
return null;
|
|
39
39
|
const raw = readFileSync(path, "utf8");
|
|
@@ -72,6 +72,10 @@ function verdictFromSavedJudgement(runDir, id, rep) {
|
|
|
72
72
|
export async function regateRun(opts) {
|
|
73
73
|
const now = opts.now ?? (() => new Date().toISOString());
|
|
74
74
|
const prev = readResults(opts.runDir);
|
|
75
|
+
// The run's own mode names its artifacts (`<id>.<mode>[.rep<k>].diff.txt`). Read
|
|
76
|
+
// from the record rather than assumed green: force runs are scored measurements
|
|
77
|
+
// too, and looking for green artifacts under a force run finds nothing at all.
|
|
78
|
+
const mode = prev.mode;
|
|
75
79
|
const specById = new Map(opts.spec.scenarios.map((s) => [s.id, s]));
|
|
76
80
|
// Why a scenario cannot be regated, collected rather than thrown one at a time: a
|
|
77
81
|
// mixed spec (needles here, vitest there) should regate what it can, and only a run
|
|
@@ -87,7 +91,7 @@ export async function regateRun(opts) {
|
|
|
87
91
|
`no saved artifact can stand in for it, so this scenario needs a re-run`);
|
|
88
92
|
continue;
|
|
89
93
|
}
|
|
90
|
-
if (findDiffFiles(opts.runDir, s.id,
|
|
94
|
+
if (findDiffFiles(opts.runDir, s.id, mode).length === 0) {
|
|
91
95
|
blocked.push(`${s.id}: no staged-diff artifact on disk (\`.diff.txt\` is gitignored — regate needs the run dir that produced it)`);
|
|
92
96
|
continue;
|
|
93
97
|
}
|
|
@@ -106,7 +110,7 @@ export async function regateRun(opts) {
|
|
|
106
110
|
scenarios.push(rec); // untouched: not regatable, or no gates
|
|
107
111
|
continue;
|
|
108
112
|
}
|
|
109
|
-
const diffFiles = findDiffFiles(opts.runDir, scenario.id,
|
|
113
|
+
const diffFiles = findDiffFiles(opts.runDir, scenario.id, mode);
|
|
110
114
|
const outcomes = [];
|
|
111
115
|
// Per scenario, not run-wide: with several regated scenarios, a global counter
|
|
112
116
|
// would report every change as "re-judged" because some other scenario was.
|
|
@@ -116,7 +120,7 @@ export async function regateRun(opts) {
|
|
|
116
120
|
const rep = repIndexOf(file) ?? undefined;
|
|
117
121
|
const diff = readFileSync(join(opts.runDir, file), "utf8");
|
|
118
122
|
const gate = evaluateNeedleGates(scenario, diff);
|
|
119
|
-
const tPath = transcriptPath(opts.runDir, scenario.id,
|
|
123
|
+
const tPath = transcriptPath(opts.runDir, scenario.id, mode, rep);
|
|
120
124
|
const before = existsSync(tPath) ? readFileSync(tPath, "utf8") : "";
|
|
121
125
|
const oldGateFailed = GATE_FAILED_RE.test(before.slice(before.indexOf(TRAILER)));
|
|
122
126
|
// The trailer is regenerated whatever the outcome: leaving a stale
|
|
@@ -132,7 +136,7 @@ export async function regateRun(opts) {
|
|
|
132
136
|
if (!oldGateFailed) {
|
|
133
137
|
// The judge already saw this rep. Its verdict is on disk — re-read it rather
|
|
134
138
|
// than paying to ask the same question again.
|
|
135
|
-
const saved = verdictFromSavedJudgement(opts.runDir, scenario.id, rep);
|
|
139
|
+
const saved = verdictFromSavedJudgement(opts.runDir, scenario.id, mode, rep);
|
|
136
140
|
outcomes.push(saved ?? { verdict: rec.judge_verdict, reason: rec.judge_reason, suspect: rec.suspect });
|
|
137
141
|
continue;
|
|
138
142
|
}
|
|
@@ -141,7 +145,7 @@ export async function regateRun(opts) {
|
|
|
141
145
|
outcomes.push(await judgeOneRep({
|
|
142
146
|
runDir: opts.runDir, spec: opts.spec, scenario, transcript,
|
|
143
147
|
adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir,
|
|
144
|
-
mode
|
|
148
|
+
mode, rep, now,
|
|
145
149
|
}));
|
|
146
150
|
judgeCalls++;
|
|
147
151
|
judgedHere++;
|
|
@@ -160,11 +164,11 @@ export async function regateRun(opts) {
|
|
|
160
164
|
});
|
|
161
165
|
}
|
|
162
166
|
}
|
|
163
|
-
const ctx = prev
|
|
164
|
-
? { shipBar: opts.spec.ship_bar, critical: opts.spec.critical }
|
|
165
|
-
: null;
|
|
167
|
+
const ctx = scoreContextFor(prev, opts.spec);
|
|
166
168
|
const results = writeResults(opts.runDir, {
|
|
167
169
|
skill: prev.skill, harness: prev.harness, model: prev.model,
|
|
170
|
+
// Carried verbatim: a regate re-reads saved diffs, it never re-runs the harness.
|
|
171
|
+
harness_cli_version: prev.harness_cli_version, delivery_canary: prev.delivery_canary,
|
|
168
172
|
judge: { provider: opts.judge.provider, model: opts.judge.model },
|
|
169
173
|
timestamp: prev.timestamp, label: prev.label, mode: prev.mode, partial: prev.partial,
|
|
170
174
|
// Only the `gates:` keys of the scenarios actually re-evaluated. Stimulus, rubric
|
package/dist/regrade.d.ts
CHANGED
|
@@ -23,6 +23,15 @@ export interface RegradeOptions {
|
|
|
23
23
|
judge: ModelRef;
|
|
24
24
|
specDir: string;
|
|
25
25
|
threshold: number;
|
|
26
|
+
/**
|
|
27
|
+
* Which mode's saved transcripts to re-judge — the run's own mode, since
|
|
28
|
+
* transcript filenames are `<id>.<mode>[.rep<k>].txt`.
|
|
29
|
+
*
|
|
30
|
+
* Defaults to `green` for callers that predate force being a scored mode. A
|
|
31
|
+
* force-mode run whose transcripts were looked up as green found none and failed
|
|
32
|
+
* with "nothing to re-grade", which is how ten scorable runs stayed ungraded.
|
|
33
|
+
*/
|
|
34
|
+
mode?: string;
|
|
26
35
|
now?: () => string;
|
|
27
36
|
}
|
|
28
37
|
/** Judge one saved transcript: writes the judge-raw artifact, emits a `judge-verdict` journal event (plus `misfire-flag` when the verdict is suspect), and returns the outcome. */
|
|
@@ -39,10 +48,11 @@ export declare function judgeOneRep(opts: {
|
|
|
39
48
|
now: () => string;
|
|
40
49
|
}): Promise<RepOutcome>;
|
|
41
50
|
/**
|
|
42
|
-
* Re-judge a scenario's saved
|
|
43
|
-
* re-run. Rewrites the judge-raw artifact per rep, emits per-rep
|
|
44
|
-
* (+ misfire-flag) journal events, and returns the aggregated
|
|
45
|
-
* (override/note empty; the caller merges any prior override +
|
|
51
|
+
* Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
|
|
52
|
+
* harness re-run. Rewrites the judge-raw artifact per rep, emits per-rep
|
|
53
|
+
* judge-verdict (+ misfire-flag) journal events, and returns the aggregated
|
|
54
|
+
* ScenarioResult (override/note empty; the caller merges any prior override +
|
|
55
|
+
* persists).
|
|
46
56
|
*/
|
|
47
57
|
export declare function regradeScenario(opts: RegradeOptions): Promise<ScenarioResult>;
|
|
48
58
|
export interface RegradeRunOptions {
|
|
@@ -60,13 +70,13 @@ export interface RegradeRunOptions {
|
|
|
60
70
|
onlySuspect?: boolean;
|
|
61
71
|
}
|
|
62
72
|
/**
|
|
63
|
-
* Re-judge every
|
|
64
|
-
* harness re-run. Targets are the run's RECORDED scenarios
|
|
65
|
-
* the spec for a run with no prior results.yaml), so re-grading
|
|
66
|
-
* whole results.yaml consistently with what the run actually recorded.
|
|
67
|
-
* target must still exist in the spec (for its checklist) AND have a
|
|
68
|
-
* transcript on disk; anything missing fails fast before spending
|
|
69
|
-
* calls. Preserves each prior scenario's override/note, rewrites
|
|
73
|
+
* Re-judge every scenario in a run dir that has a transcript for the run's own
|
|
74
|
+
* mode, with `judge` — no harness re-run. Targets are the run's RECORDED scenarios
|
|
75
|
+
* (falling back to the spec for a run with no prior results.yaml), so re-grading
|
|
76
|
+
* rewrites the whole results.yaml consistently with what the run actually recorded.
|
|
77
|
+
* Each target must still exist in the spec (for its checklist) AND have a
|
|
78
|
+
* transcript on disk for that mode; anything missing fails fast before spending
|
|
79
|
+
* any judge calls. Preserves each prior scenario's override/note, rewrites
|
|
70
80
|
* results.yaml, emits the `score` journal event, and returns the new
|
|
71
81
|
* ResultsFile. Shared by `cmdGrade` and the pi-extension's `judge` command.
|
|
72
82
|
*/
|
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
|
@@ -22,13 +22,14 @@ export interface RunColumn {
|
|
|
22
22
|
note: string;
|
|
23
23
|
}>;
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
* green
|
|
27
|
-
* a zero lift, so the report must not render a 0
|
|
25
|
+
* Baseline-vs-skill lift for this model, when the tag has both a red baseline and
|
|
26
|
+
* a skill-delivered run (green or force). Undefined means "never measured" —
|
|
27
|
+
* which is not the same claim as a zero lift, so the report must not render a 0
|
|
28
|
+
* for it.
|
|
28
29
|
*
|
|
29
|
-
* Only set when THIS column is the
|
|
30
|
+
* Only set when THIS column is the skill-side run the lift was computed from (see
|
|
30
31
|
* collectReport): the review UI recomputes lift from the column's live cells,
|
|
31
|
-
* which is only valid if those cells are the
|
|
32
|
+
* which is only valid if those cells are the skill side of the comparison.
|
|
32
33
|
*/
|
|
33
34
|
lift?: Lift;
|
|
34
35
|
liftHeadline?: string;
|
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,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
|
@@ -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,
|
|
@@ -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/trends.d.ts
CHANGED
|
@@ -14,6 +14,17 @@ export interface TrendRun {
|
|
|
14
14
|
export interface TrendModel {
|
|
15
15
|
model: string;
|
|
16
16
|
tag: string;
|
|
17
|
+
/**
|
|
18
|
+
* The delivery mode every run in this series shares (`green` or `force`).
|
|
19
|
+
*
|
|
20
|
+
* A series is per tag AND per mode, never pooled: the two modes are different
|
|
21
|
+
* deliveries of the same text, and placement moves verdicts in both directions at
|
|
22
|
+
* once (measured on identical skill text: `build` A1 0/3 → 3/3 with force, `plan`
|
|
23
|
+
* C2 3/3 → 0/3). A sparkline that ran green then force would draw that epoch
|
|
24
|
+
* change as skill progress — or regression — which is the one thing a trend line
|
|
25
|
+
* must not invent.
|
|
26
|
+
*/
|
|
27
|
+
mode: string;
|
|
17
28
|
runs: TrendRun[];
|
|
18
29
|
truncated: boolean;
|
|
19
30
|
skipped: number;
|
|
@@ -35,15 +46,18 @@ export interface TrendData {
|
|
|
35
46
|
* rule: an override resolves a misfire) + reps flakiness. Read-only; no
|
|
36
47
|
* absolute paths in the result.
|
|
37
48
|
*
|
|
38
|
-
* Only scored
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
49
|
+
* Only scored runs are included in the history — a red baseline has no real grade
|
|
50
|
+
* (`effective_grade` is a "not scored" placeholder; see run.ts) and would otherwise
|
|
51
|
+
* plot as a misleading 0% dip in the sparkline/grid. Red runs are deliberately
|
|
52
|
+
* excluded, which is distinct from `skipped`: a run's mode can only be known after
|
|
53
|
+
* reading its results.yaml, so every candidate run-dir in the tag is read (not just
|
|
54
|
+
* the most recent `limit`) before filtering and applying the `limit` window —
|
|
55
|
+
* trends is a bounded, on-demand, local view, so this extra read cost is
|
|
56
|
+
* acceptable.
|
|
57
|
+
*
|
|
58
|
+
* Green and force runs both count, but never in the same series: a tag with both
|
|
59
|
+
* yields one TrendModel per mode (see `TrendModel.mode`), each with its own
|
|
60
|
+
* `limit` window. A tag with no scored run at all is omitted entirely.
|
|
47
61
|
*
|
|
48
62
|
* A run whose `results.yaml` fails to parse (e.g. an interrupted non-atomic
|
|
49
63
|
* write) is logged via `console.warn` and skipped — never surfaced or thrown —
|
package/dist/trends.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { loadSpec } from "./spec.js";
|
|
4
|
-
import { readResults, effectiveVerdicts } from "./results.js";
|
|
4
|
+
import { readResults, effectiveVerdicts, isScoredMode } from "./results.js";
|
|
5
5
|
/** A directory that exists right now; false (never throws) if it vanished concurrently (e.g. ENOENT). */
|
|
6
6
|
function isDir(p) {
|
|
7
7
|
try {
|
|
@@ -19,15 +19,18 @@ function isDir(p) {
|
|
|
19
19
|
* rule: an override resolves a misfire) + reps flakiness. Read-only; no
|
|
20
20
|
* absolute paths in the result.
|
|
21
21
|
*
|
|
22
|
-
* Only scored
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
22
|
+
* Only scored runs are included in the history — a red baseline has no real grade
|
|
23
|
+
* (`effective_grade` is a "not scored" placeholder; see run.ts) and would otherwise
|
|
24
|
+
* plot as a misleading 0% dip in the sparkline/grid. Red runs are deliberately
|
|
25
|
+
* excluded, which is distinct from `skipped`: a run's mode can only be known after
|
|
26
|
+
* reading its results.yaml, so every candidate run-dir in the tag is read (not just
|
|
27
|
+
* the most recent `limit`) before filtering and applying the `limit` window —
|
|
28
|
+
* trends is a bounded, on-demand, local view, so this extra read cost is
|
|
29
|
+
* acceptable.
|
|
30
|
+
*
|
|
31
|
+
* Green and force runs both count, but never in the same series: a tag with both
|
|
32
|
+
* yields one TrendModel per mode (see `TrendModel.mode`), each with its own
|
|
33
|
+
* `limit` window. A tag with no scored run at all is omitted entirely.
|
|
31
34
|
*
|
|
32
35
|
* A run whose `results.yaml` fails to parse (e.g. an interrupted non-atomic
|
|
33
36
|
* write) is logged via `console.warn` and skipped — never surfaced or thrown —
|
|
@@ -54,10 +57,11 @@ export function collectTrends(skillDir, limit = 20) {
|
|
|
54
57
|
if (runDirs.length === 0)
|
|
55
58
|
continue;
|
|
56
59
|
// Read every candidate run (mode isn't knowable from the dir name) and
|
|
57
|
-
// filter to
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
60
|
+
// filter to scored runs before applying the `limit` window — filtering
|
|
61
|
+
// after the slice would let red runs consume window slots, undercounting
|
|
62
|
+
// the history even when more exists. Bucketed by mode, in first-seen
|
|
63
|
+
// order, so each delivery epoch gets its own series and its own window.
|
|
64
|
+
const byMode = new Map();
|
|
61
65
|
let skipped = 0;
|
|
62
66
|
for (const rd of runDirs) {
|
|
63
67
|
let r;
|
|
@@ -71,30 +75,35 @@ export function collectTrends(skillDir, limit = 20) {
|
|
|
71
75
|
skipped++;
|
|
72
76
|
continue;
|
|
73
77
|
}
|
|
74
|
-
if (r.mode
|
|
75
|
-
continue; //
|
|
76
|
-
|
|
78
|
+
if (!isScoredMode(r.mode))
|
|
79
|
+
continue; // baseline — deliberate exclusion, not a skip
|
|
80
|
+
(byMode.get(r.mode) ?? byMode.set(r.mode, []).get(r.mode)).push(r);
|
|
77
81
|
}
|
|
78
|
-
if (
|
|
82
|
+
if (byMode.size === 0)
|
|
79
83
|
continue;
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
84
|
+
for (const [mode, scoredRuns] of byMode) {
|
|
85
|
+
const truncated = scoredRuns.length > limit;
|
|
86
|
+
const kept = scoredRuns.slice(-limit); // most recent `limit`, newest last
|
|
87
|
+
const runs = [];
|
|
88
|
+
let model = "";
|
|
89
|
+
for (const r of kept) {
|
|
90
|
+
// effectiveVerdicts is the single source of truth for the
|
|
91
|
+
// override-aware verdict/suspect rule (suspect = s.suspect &&
|
|
92
|
+
// s.override == null — an override resolves the misfire); zip in
|
|
93
|
+
// flakiness from the matching ScenarioResult.
|
|
94
|
+
const verdicts = effectiveVerdicts(r.scenarios);
|
|
95
|
+
const cells = {};
|
|
96
|
+
r.scenarios.forEach((s, i) => {
|
|
97
|
+
cells[s.id] = { verdict: verdicts[i].verdict, suspect: verdicts[i].suspect ?? false, flakiness: s.flakiness };
|
|
98
|
+
});
|
|
99
|
+
runs.push({ timestamp: r.timestamp, label: r.label, grade: r.effective_grade, cells });
|
|
100
|
+
model = r.model; // last successfully-read run (kept is ascending) wins
|
|
101
|
+
}
|
|
102
|
+
// `skipped` is per tag (an unreadable run has no knowable mode), so a tag with
|
|
103
|
+
// two series reports the same count on both — the alternative is attributing a
|
|
104
|
+
// parse failure to a mode nobody could read.
|
|
105
|
+
models.push({ model, tag, mode, runs, truncated, skipped });
|
|
96
106
|
}
|
|
97
|
-
models.push({ model, tag, runs, truncated, skipped });
|
|
98
107
|
}
|
|
99
108
|
}
|
|
100
109
|
return { skill: spec.skill, scenarios, models };
|