@skill-harness/core 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lint.d.ts +16 -1
- package/dist/lint.js +29 -0
- package/dist/report.d.ts +24 -0
- package/dist/report.js +15 -1
- package/dist/run.d.ts +9 -1
- package/dist/run.js +15 -1
- package/dist/sources.d.ts +26 -0
- package/dist/sources.js +42 -0
- package/dist/stability.d.ts +144 -0
- package/dist/stability.js +232 -0
- package/dist/trends.d.ts +28 -0
- package/dist/trends.js +76 -61
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/lint.d.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "fixture-marker" | "consistency" | "stale" | "lint-error";
|
|
1
|
+
export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "fixture-marker" | "consistency" | "stale" | "stability" | "lint-error";
|
|
2
|
+
/**
|
|
3
|
+
* How much a finding means.
|
|
4
|
+
*
|
|
5
|
+
* `error` (the default, and every code that existed through 0.5.0) fails the gate.
|
|
6
|
+
* `info` reports something a reader should know that is NOT a defect: a boundary cell
|
|
7
|
+
* is a statement about how much one run of a scenario is worth, not a broken spec, and
|
|
8
|
+
* turning it red would make "this cell needs more reps" indistinguishable from "your
|
|
9
|
+
* fixture is missing". Omitted rather than written on every finding so the shape stays
|
|
10
|
+
* backward-compatible for anything already reading this list.
|
|
11
|
+
*/
|
|
12
|
+
export type LintSeverity = "error" | "info";
|
|
2
13
|
export interface LintFinding {
|
|
3
14
|
readonly skill: string;
|
|
4
15
|
readonly scenario?: string;
|
|
5
16
|
readonly code: LintCode;
|
|
6
17
|
readonly message: string;
|
|
18
|
+
/** Absent means `error` — only findings that must not fail CI carry this. */
|
|
19
|
+
readonly severity?: LintSeverity;
|
|
7
20
|
}
|
|
21
|
+
/** True when a finding fails the gate. The single place the exit-code rule lives. */
|
|
22
|
+
export declare function failsGate(f: LintFinding): boolean;
|
|
8
23
|
/**
|
|
9
24
|
* Validate one skill's spec + fixtures statically (and results-consistency when
|
|
10
25
|
* committed results exist — see the consistency block). Never throws: a bad spec
|
package/dist/lint.js
CHANGED
|
@@ -5,7 +5,13 @@ import { loadSpec, SpecError } from "./spec.js";
|
|
|
5
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
|
+
import { collectScoredRuns } from "./trends.js";
|
|
9
|
+
import { boundaryCells, stabilityFrom, stabilityNote } from "./stability.js";
|
|
8
10
|
import { MARKERS, unknownMarkerDirs, suggestMarker } from "./workspace.js";
|
|
11
|
+
/** True when a finding fails the gate. The single place the exit-code rule lives. */
|
|
12
|
+
export function failsGate(f) {
|
|
13
|
+
return (f.severity ?? "error") === "error";
|
|
14
|
+
}
|
|
9
15
|
/** True if `p` exists and is a directory. Never throws (TOCTOU-safe: a race or dangling
|
|
10
16
|
* symlink between the check and the stat is treated as "not a directory", not an error). */
|
|
11
17
|
function isDir(p) {
|
|
@@ -265,6 +271,29 @@ export function lintSkill(skillDir) {
|
|
|
265
271
|
}
|
|
266
272
|
}
|
|
267
273
|
}
|
|
274
|
+
// Run-over-run stability — INFO, never a gate failure. Derived from committed
|
|
275
|
+
// history at zero cost, and it answers a question no single results.yaml can: a
|
|
276
|
+
// scenario can be internally unanimous in every run and still land on a different
|
|
277
|
+
// side each time. Measured in the reference corpus: two consecutive full runs, one
|
|
278
|
+
// 3/3 PASS and the next 0/3 FAIL, each `flakiness 0.00`.
|
|
279
|
+
//
|
|
280
|
+
// In lint because that is where a repo already looks, and free because it reads what
|
|
281
|
+
// is on disk. Wrapped: lintSkill must never throw, and a stability read touches every
|
|
282
|
+
// run file in the tree.
|
|
283
|
+
try {
|
|
284
|
+
for (const cell of boundaryCells(stabilityFrom(collectScoredRuns(skillDir), spec))) {
|
|
285
|
+
findings.push({
|
|
286
|
+
skill, scenario: cell.id, code: "stability", severity: "info",
|
|
287
|
+
message: `${cell.tag} mode=${cell.mode}: ${stabilityNote(cell)}`,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
findings.push({
|
|
293
|
+
skill, code: "stability", severity: "info",
|
|
294
|
+
message: `run-over-run stability could not be derived: ${e instanceof Error ? e.message : String(e)}`,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
268
297
|
return findings;
|
|
269
298
|
}
|
|
270
299
|
/** Model-tag dirs under tests/results (each holds timestamped run dirs). */
|
package/dist/report.d.ts
CHANGED
|
@@ -20,6 +20,18 @@ export interface RunColumn {
|
|
|
20
20
|
flakiness?: number;
|
|
21
21
|
override: string | null;
|
|
22
22
|
note: string;
|
|
23
|
+
/**
|
|
24
|
+
* Run-over-run history for this cell, derived from the tag's other runs in the
|
|
25
|
+
* SAME mode. Present only for a cell that flipped (`state: "boundary"`) — a
|
|
26
|
+
* marker on every cell would bury the one signal it exists to show, and the
|
|
27
|
+
* per-cell `flakiness` beside it is a within-run number that cannot see this.
|
|
28
|
+
*/
|
|
29
|
+
stability?: {
|
|
30
|
+
flips: number;
|
|
31
|
+
compared: number;
|
|
32
|
+
volatility: number | null;
|
|
33
|
+
note: string;
|
|
34
|
+
};
|
|
23
35
|
}>;
|
|
24
36
|
/**
|
|
25
37
|
* Baseline-vs-skill lift for this model, when the tag has both a red baseline and
|
|
@@ -83,6 +95,18 @@ export declare function publicView(data: ReportData): {
|
|
|
83
95
|
flakiness?: number;
|
|
84
96
|
override: string | null;
|
|
85
97
|
note: string;
|
|
98
|
+
/**
|
|
99
|
+
* Run-over-run history for this cell, derived from the tag's other runs in the
|
|
100
|
+
* SAME mode. Present only for a cell that flipped (`state: "boundary"`) — a
|
|
101
|
+
* marker on every cell would bury the one signal it exists to show, and the
|
|
102
|
+
* per-cell `flakiness` beside it is a within-run number that cannot see this.
|
|
103
|
+
*/
|
|
104
|
+
stability?: {
|
|
105
|
+
flips: number;
|
|
106
|
+
compared: number;
|
|
107
|
+
volatility: number | null;
|
|
108
|
+
note: string;
|
|
109
|
+
};
|
|
86
110
|
}>;
|
|
87
111
|
}[];
|
|
88
112
|
};
|
package/dist/report.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { loadSpec } from "./spec.js";
|
|
4
4
|
import { readResults } from "./results.js";
|
|
5
5
|
import { collectLift, liftHeadline } from "./lift.js";
|
|
6
|
+
import { boundaryCells, collectStability, stabilityNote } from "./stability.js";
|
|
6
7
|
/** Most-recent run dir (by name, which is an ISO-ish slug) under a model-tag dir. */
|
|
7
8
|
function latestRunDir(tagDir) {
|
|
8
9
|
if (!statSync(tagDir).isDirectory())
|
|
@@ -24,6 +25,9 @@ export function collectReport(skillDir) {
|
|
|
24
25
|
const resultsRoot = join(skillDir, "tests", "results");
|
|
25
26
|
// Lift is keyed by model tag, the same key columns are built from.
|
|
26
27
|
const liftByTag = new Map(collectLift(skillDir).map((l) => [l.tag, l]));
|
|
28
|
+
// Stability is keyed by tag + mode + scenario: a column shows one delivery mode, and
|
|
29
|
+
// green and force histories are never one series (placement moves verdicts).
|
|
30
|
+
const boundaryByCell = new Map(boundaryCells(collectStability(skillDir)).map((c) => [`${c.tag}\u0000${c.mode}\u0000${c.id}`, c]));
|
|
27
31
|
const columns = [];
|
|
28
32
|
if (existsSync(resultsRoot)) {
|
|
29
33
|
const tags = readdirSync(resultsRoot)
|
|
@@ -35,9 +39,19 @@ export function collectReport(skillDir) {
|
|
|
35
39
|
if (!runDir)
|
|
36
40
|
continue;
|
|
37
41
|
const r = readResults(runDir);
|
|
42
|
+
const tagName = tagDir.split("/").pop();
|
|
38
43
|
const cells = {};
|
|
39
44
|
for (const s of r.scenarios) {
|
|
45
|
+
const boundary = boundaryByCell.get(`${tagName}\u0000${r.mode}\u0000${s.id}`);
|
|
40
46
|
cells[s.id] = {
|
|
47
|
+
...(boundary
|
|
48
|
+
? {
|
|
49
|
+
stability: {
|
|
50
|
+
flips: boundary.flips, compared: boundary.compared,
|
|
51
|
+
volatility: boundary.volatility, note: stabilityNote(boundary),
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
: {}),
|
|
41
55
|
judge_verdict: s.judge_verdict,
|
|
42
56
|
judge_reason: s.judge_reason,
|
|
43
57
|
suspect: s.suspect ?? false, // suspect defaults false for older results that predate the field
|
|
@@ -49,7 +63,7 @@ export function collectReport(skillDir) {
|
|
|
49
63
|
note: s.note,
|
|
50
64
|
};
|
|
51
65
|
}
|
|
52
|
-
const tag =
|
|
66
|
+
const tag = tagName;
|
|
53
67
|
// A column is the tag's LATEST run, which is not necessarily the skill-side
|
|
54
68
|
// one — record a red baseline after a green run and the newest run in the tag
|
|
55
69
|
// is red. The review UI recomputes lift from `cells` (so author overrides move
|
package/dist/run.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Spec } from "./spec.js";
|
|
|
2
2
|
import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
|
|
3
3
|
import { type ResultsFile } from "./results.js";
|
|
4
4
|
import { type Lift } from "./lift.js";
|
|
5
|
+
import { type ScenarioStability } from "./stability.js";
|
|
5
6
|
export interface RunOptions {
|
|
6
7
|
spec: Spec;
|
|
7
8
|
skillDir: string;
|
|
@@ -55,5 +56,12 @@ export declare function hasEmptyAssistantTurn(transcript: string): boolean;
|
|
|
55
56
|
* exists. Passing it for a green run turns the scorecard from "the skill scored
|
|
56
57
|
* B" into "the skill *did* this much" — without a baseline the grade alone can't
|
|
57
58
|
* distinguish a skill that works from a model that never needed it.
|
|
59
|
+
*
|
|
60
|
+
* `stability` is the run-over-run half of the same argument, and it is derived from
|
|
61
|
+
* history rather than from this run: a cell that flipped its verdict between the last
|
|
62
|
+
* two runs of this skill × model × mode is worth less than the ✓ beside it suggests,
|
|
63
|
+
* and this run's own `flaky 0.00` cannot say so — it only ever looked at one run. Pass
|
|
64
|
+
* the cells for THIS tag and mode; anything else would report another model's history
|
|
65
|
+
* under this model's scorecard.
|
|
58
66
|
*/
|
|
59
|
-
export declare function formatScorecard(summary: RunSummary, lift?: Lift): string;
|
|
67
|
+
export declare function formatScorecard(summary: RunSummary, lift?: Lift, stability?: ScenarioStability[]): string;
|
package/dist/run.js
CHANGED
|
@@ -11,6 +11,7 @@ import { runPool } from "./scheduler.js";
|
|
|
11
11
|
import { outcomesToResult } from "./reps.js";
|
|
12
12
|
import { judgeOneRep } from "./regrade.js";
|
|
13
13
|
import { runDeliveryCanary, canaryFailure } from "./canary.js";
|
|
14
|
+
import { boundaryCells, stabilityNote } from "./stability.js";
|
|
14
15
|
/** Run one skill against one model: run scenarios, grade, score, persist. */
|
|
15
16
|
export async function runSkillModel(opts) {
|
|
16
17
|
const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
|
|
@@ -251,8 +252,15 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
251
252
|
* exists. Passing it for a green run turns the scorecard from "the skill scored
|
|
252
253
|
* B" into "the skill *did* this much" — without a baseline the grade alone can't
|
|
253
254
|
* distinguish a skill that works from a model that never needed it.
|
|
255
|
+
*
|
|
256
|
+
* `stability` is the run-over-run half of the same argument, and it is derived from
|
|
257
|
+
* history rather than from this run: a cell that flipped its verdict between the last
|
|
258
|
+
* two runs of this skill × model × mode is worth less than the ✓ beside it suggests,
|
|
259
|
+
* and this run's own `flaky 0.00` cannot say so — it only ever looked at one run. Pass
|
|
260
|
+
* the cells for THIS tag and mode; anything else would report another model's history
|
|
261
|
+
* under this model's scorecard.
|
|
254
262
|
*/
|
|
255
|
-
export function formatScorecard(summary, lift) {
|
|
263
|
+
export function formatScorecard(summary, lift, stability) {
|
|
256
264
|
const { results } = summary;
|
|
257
265
|
const g = results.effective_grade;
|
|
258
266
|
const lines = [];
|
|
@@ -289,6 +297,12 @@ export function formatScorecard(summary, lift) {
|
|
|
289
297
|
` — on pi ≥ 0.83.0 \`--skill\` only discloses the description and the body loads on demand.` +
|
|
290
298
|
` Use --mode force for delivery that cannot silently degrade, or --canary to prove it per run.`);
|
|
291
299
|
}
|
|
300
|
+
// Boundary cells last, because they qualify the verdicts above: a ✓ on a cell that
|
|
301
|
+
// flipped between the last two runs is one draw, whatever its rep count said.
|
|
302
|
+
const ran = new Set(results.scenarios.map((s) => s.id));
|
|
303
|
+
for (const s of boundaryCells(stability ?? []).filter((c) => ran.has(c.id))) {
|
|
304
|
+
lines.push(` ⇄ ${stabilityNote(s)}`);
|
|
305
|
+
}
|
|
292
306
|
return lines.join("\n");
|
|
293
307
|
}
|
|
294
308
|
//# sourceMappingURL=run.js.map
|
package/dist/sources.d.ts
CHANGED
|
@@ -172,5 +172,31 @@ export declare function describeSourceKey(key: string): string;
|
|
|
172
172
|
* in place. Naming the actual remedy is what converts that into a free command.
|
|
173
173
|
*/
|
|
174
174
|
export declare function remedyForKey(key: string): string;
|
|
175
|
+
/** The skill-text key. Skill-wide: it belongs to every scenario at once. */
|
|
176
|
+
export declare const SKILL_KEY = "SKILL.md";
|
|
177
|
+
/**
|
|
178
|
+
* Every recorded key whose drift could change THIS scenario's verdict — excluding the
|
|
179
|
+
* two skill-wide ones (`SKILL.md`, `rubric:__persona`), which callers handle
|
|
180
|
+
* separately because they move every scenario at once.
|
|
181
|
+
*
|
|
182
|
+
* Written for run-over-run comparison (see stability.ts): "did these two runs ask this
|
|
183
|
+
* scenario the same question, judged by the same rubric?" is answerable from the
|
|
184
|
+
* recorded hashes, and only if you know which keys belong to the scenario. Derived
|
|
185
|
+
* from the spec rather than from the key strings, because the path-shaped keys
|
|
186
|
+
* (`system_prompt_file`, `post_test`) carry no scenario id at all.
|
|
187
|
+
*
|
|
188
|
+
* `policy:<id>` is deliberately NOT here. Its `reps`/`pass_threshold` half is already
|
|
189
|
+
* compared as an *aggregation* shape (1 draw vs a majority of 3 is the comparison a
|
|
190
|
+
* hash cannot express), and its `critical` half changes whether a verdict can block a
|
|
191
|
+
* ship, never what the verdict is. Including it would report a critical-set edit as
|
|
192
|
+
* "these runs measured different things", which is false.
|
|
193
|
+
*
|
|
194
|
+
* Both key generations are returned: 0.4.0+ runs carry the split facet keys, older
|
|
195
|
+
* ones the combined `scenario:<id>`. A caller comparing two runs must not treat a
|
|
196
|
+
* combined digest and a split one as comparable — they hash different byte layouts —
|
|
197
|
+
* so it compares only keys BOTH runs recorded, and treats "no shared key" as
|
|
198
|
+
* unverifiable rather than unchanged.
|
|
199
|
+
*/
|
|
200
|
+
export declare function scenarioSourceKeys(s: Scenario): string[];
|
|
175
201
|
/** The scenario id a key belongs to, for per-scenario lint findings. Undefined for skill-wide keys. */
|
|
176
202
|
export declare function scenarioIdForKey(key: string, scenarios: Scenario[]): string | undefined;
|
package/dist/sources.js
CHANGED
|
@@ -390,6 +390,48 @@ export function remedyForKey(key) {
|
|
|
390
390
|
}
|
|
391
391
|
return "re-run"; // stimulus:, SKILL.md, fixture:, agent files, post_test contents
|
|
392
392
|
}
|
|
393
|
+
/** The skill-text key. Skill-wide: it belongs to every scenario at once. */
|
|
394
|
+
export const SKILL_KEY = "SKILL.md";
|
|
395
|
+
/**
|
|
396
|
+
* Every recorded key whose drift could change THIS scenario's verdict — excluding the
|
|
397
|
+
* two skill-wide ones (`SKILL.md`, `rubric:__persona`), which callers handle
|
|
398
|
+
* separately because they move every scenario at once.
|
|
399
|
+
*
|
|
400
|
+
* Written for run-over-run comparison (see stability.ts): "did these two runs ask this
|
|
401
|
+
* scenario the same question, judged by the same rubric?" is answerable from the
|
|
402
|
+
* recorded hashes, and only if you know which keys belong to the scenario. Derived
|
|
403
|
+
* from the spec rather than from the key strings, because the path-shaped keys
|
|
404
|
+
* (`system_prompt_file`, `post_test`) carry no scenario id at all.
|
|
405
|
+
*
|
|
406
|
+
* `policy:<id>` is deliberately NOT here. Its `reps`/`pass_threshold` half is already
|
|
407
|
+
* compared as an *aggregation* shape (1 draw vs a majority of 3 is the comparison a
|
|
408
|
+
* hash cannot express), and its `critical` half changes whether a verdict can block a
|
|
409
|
+
* ship, never what the verdict is. Including it would report a critical-set edit as
|
|
410
|
+
* "these runs measured different things", which is false.
|
|
411
|
+
*
|
|
412
|
+
* Both key generations are returned: 0.4.0+ runs carry the split facet keys, older
|
|
413
|
+
* ones the combined `scenario:<id>`. A caller comparing two runs must not treat a
|
|
414
|
+
* combined digest and a split one as comparable — they hash different byte layouts —
|
|
415
|
+
* so it compares only keys BOTH runs recorded, and treats "no shared key" as
|
|
416
|
+
* unverifiable rather than unchanged.
|
|
417
|
+
*/
|
|
418
|
+
export function scenarioSourceKeys(s) {
|
|
419
|
+
const keys = [
|
|
420
|
+
STIMULUS_PREFIX + s.id,
|
|
421
|
+
RUBRIC_PREFIX + s.id,
|
|
422
|
+
SCENARIO_PREFIX + s.id, // legacy combined (pre-0.4.0 runs)
|
|
423
|
+
];
|
|
424
|
+
if (gatesDigest(s) !== null)
|
|
425
|
+
keys.push(GATES_PREFIX + s.id);
|
|
426
|
+
if (s.systemPromptFile)
|
|
427
|
+
keys.push(s.systemPromptFile); // the agent file IS the stimulus
|
|
428
|
+
if (s.assert?.post_test)
|
|
429
|
+
keys.push(s.assert.post_test); // its contents are the gate
|
|
430
|
+
const fx = effectiveFixture(s);
|
|
431
|
+
if (fx)
|
|
432
|
+
keys.push(FIXTURE_PREFIX + fx);
|
|
433
|
+
return keys;
|
|
434
|
+
}
|
|
393
435
|
/** The scenario id a key belongs to, for per-scenario lint findings. Undefined for skill-wide keys. */
|
|
394
436
|
export function scenarioIdForKey(key, scenarios) {
|
|
395
437
|
if (key === PERSONA_KEY)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { type Spec } from "./spec.js";
|
|
2
|
+
import { type ScoredRunGroup } from "./trends.js";
|
|
3
|
+
import type { Verdict } from "./score.js";
|
|
4
|
+
/**
|
|
5
|
+
* Run-over-run verdict stability, per scenario, derived on read from committed
|
|
6
|
+
* history. No new measurement, nothing persisted.
|
|
7
|
+
*
|
|
8
|
+
* ## Why this exists
|
|
9
|
+
*
|
|
10
|
+
* Measured in the reference corpus (`plan`, deepseek, two consecutive full force runs,
|
|
11
|
+
* 2026-08-05 → 2026-08-06): **A5 went 3/3 PASS to 0/3 FAIL and D1 went 1/3 to 3/3, each
|
|
12
|
+
* run internally `flakiness 0.00`.** Two unanimous runs, opposite verdicts.
|
|
13
|
+
*
|
|
14
|
+
* `flakiness` is a within-run number: it measures how much the reps of ONE run
|
|
15
|
+
* disagreed. A scenario sitting on a behavioural boundary can be unanimous inside every
|
|
16
|
+
* run and still land on a different side each time — and then `flakiness 0.00` reads as
|
|
17
|
+
* confidence when it is the opposite. Nothing in a single `results.yaml` can see this,
|
|
18
|
+
* because the evidence is spread across files.
|
|
19
|
+
*
|
|
20
|
+
* ## What makes a flip a *stability* signal
|
|
21
|
+
*
|
|
22
|
+
* A verdict that changes because the scenario changed is not instability, it is an
|
|
23
|
+
* edit — and reporting edits as instability would make this feature noise. So a pair of
|
|
24
|
+
* adjacent runs is only compared when all of the following hold, and each rejection is
|
|
25
|
+
* reported with its reason rather than silently dropped:
|
|
26
|
+
*
|
|
27
|
+
* | gate | rejected as | why |
|
|
28
|
+
* |---|---|---|
|
|
29
|
+
* | both verdicts conclusive | `inconclusive` | an ERROR or unresolved misfire says nothing about behaviour |
|
|
30
|
+
* | same reps + pass threshold | `aggregation` | 1 draw vs a majority of 3 is not the same measurement (as in lift.ts) |
|
|
31
|
+
* | the scenario's own recorded sources identical | `sources` | different question, or different rubric |
|
|
32
|
+
* | those sources comparable at all | `unverified` | one run predates `source_hashes`, or the two use different key generations |
|
|
33
|
+
*
|
|
34
|
+
* **`SKILL.md` is deliberately NOT one of those gates.** In the measured case the skill
|
|
35
|
+
* text HAD changed — an edit aimed at a different scenario — while A5's own stimulus and
|
|
36
|
+
* rubric were byte-identical. Excluding that pair would have hidden the exact finding
|
|
37
|
+
* this feature was asked to surface. Such a flip is reported with `skillChanged`, and
|
|
38
|
+
* the note says what it means: either a side effect of that edit or a boundary cell.
|
|
39
|
+
* Which of the two it is cannot be told from the record, and pretending otherwise would
|
|
40
|
+
* be a guess dressed as a fact.
|
|
41
|
+
*
|
|
42
|
+
* Modes are never mixed (`collectScoredRuns` groups per mode), because placement moves
|
|
43
|
+
* verdicts on identical text — a green run and a force run are two deployments.
|
|
44
|
+
*/
|
|
45
|
+
/** One run's contribution to a scenario's history. */
|
|
46
|
+
export interface StabilityPoint {
|
|
47
|
+
timestamp: string;
|
|
48
|
+
label: string | null;
|
|
49
|
+
/** Override-aware, matching what the scorecard claims (an override IS the author's verdict). */
|
|
50
|
+
verdict: Verdict;
|
|
51
|
+
overridden: boolean;
|
|
52
|
+
/** True for an `--only` run: real evidence about this scenario, but not a full run. */
|
|
53
|
+
partial: boolean;
|
|
54
|
+
reps: number;
|
|
55
|
+
/**
|
|
56
|
+
* Every rep in this run agreed AND there was more than one rep. A single rep is not
|
|
57
|
+
* unanimous, it is one draw — the distinction the headline finding turns on.
|
|
58
|
+
*/
|
|
59
|
+
unanimous: boolean;
|
|
60
|
+
}
|
|
61
|
+
/** Why a pair of adjacent runs could, or could not, be compared. */
|
|
62
|
+
export type PairStatus = "compared" | "inconclusive" | "aggregation" | "sources" | "unverified";
|
|
63
|
+
export interface StabilityPair {
|
|
64
|
+
from: StabilityPoint;
|
|
65
|
+
to: StabilityPoint;
|
|
66
|
+
status: PairStatus;
|
|
67
|
+
/** The verdict changed. Only meaningful when `status === "compared"`. */
|
|
68
|
+
flipped: boolean;
|
|
69
|
+
/** The skill text differed between these two runs (not a rejection — see the module doc). */
|
|
70
|
+
skillChanged: boolean;
|
|
71
|
+
/** Human labels of the scenario's own sources that differed (`status === "sources"`). */
|
|
72
|
+
changedSources: string[];
|
|
73
|
+
/** A flip where BOTH runs were internally unanimous — invisible to within-run flakiness. */
|
|
74
|
+
unanimousFlip: boolean;
|
|
75
|
+
}
|
|
76
|
+
export type StabilityState = "stable" | "boundary" | "unmeasured";
|
|
77
|
+
export interface ScenarioStability {
|
|
78
|
+
id: string;
|
|
79
|
+
title: string;
|
|
80
|
+
critical: boolean;
|
|
81
|
+
tag: string;
|
|
82
|
+
mode: string;
|
|
83
|
+
model: string;
|
|
84
|
+
/** The window, chronologically ascending (oldest first). */
|
|
85
|
+
points: StabilityPoint[];
|
|
86
|
+
/** Adjacent pairs within the window, oldest first. */
|
|
87
|
+
pairs: StabilityPair[];
|
|
88
|
+
compared: number;
|
|
89
|
+
flips: number;
|
|
90
|
+
/** Of `flips`, how many happened across a SKILL.md edit. */
|
|
91
|
+
flipsAcrossSkillEdit: number;
|
|
92
|
+
/** Of `flips`, how many were between two internally-unanimous runs. */
|
|
93
|
+
unanimousFlips: number;
|
|
94
|
+
/**
|
|
95
|
+
* `flips / compared`, or null when nothing was comparable. 0 = never flipped in the
|
|
96
|
+
* window; 1 = flipped at every opportunity.
|
|
97
|
+
*
|
|
98
|
+
* Same polarity as `flakiness` (0 is the quiet end) on purpose, and named for the
|
|
99
|
+
* thing being counted rather than for its absence: `stability: 0.0` would have to
|
|
100
|
+
* mean "perfectly stable", which reads exactly backwards next to `flaky 0.00`.
|
|
101
|
+
*/
|
|
102
|
+
volatility: number | null;
|
|
103
|
+
state: StabilityState;
|
|
104
|
+
}
|
|
105
|
+
export interface StabilityOptions {
|
|
106
|
+
/** How many of the most recent scored runs to look at. Default 5. */
|
|
107
|
+
window?: number;
|
|
108
|
+
}
|
|
109
|
+
/** Derive stability for every scenario × tag × mode from an already-read history. */
|
|
110
|
+
export declare function stabilityFrom(groups: ScoredRunGroup[], spec: Spec, opts?: StabilityOptions): ScenarioStability[];
|
|
111
|
+
/**
|
|
112
|
+
* Read `<skillDir>/tests/results/` and derive run-over-run stability per scenario ×
|
|
113
|
+
* model tag × delivery mode. Free and offline: it reads committed results.yaml files
|
|
114
|
+
* and computes; it never runs a model, a judge, or a harness.
|
|
115
|
+
*
|
|
116
|
+
* Deliberately derived on read rather than stored in results.yaml, for the reason lift
|
|
117
|
+
* is: stability is a fact about a SET of runs, so a copy inside one run's file would be
|
|
118
|
+
* wrong the moment the next run lands — the stale-scorecard failure `source_hashes`
|
|
119
|
+
* exists to prevent. It also means this works retroactively on history recorded by
|
|
120
|
+
* every earlier version.
|
|
121
|
+
*/
|
|
122
|
+
export declare function collectStability(skillDir: string, opts?: StabilityOptions): ScenarioStability[];
|
|
123
|
+
/** Scenarios that flipped at least once — the cells a single run reads as too certain. */
|
|
124
|
+
export declare function boundaryCells(all: ScenarioStability[]): ScenarioStability[];
|
|
125
|
+
/**
|
|
126
|
+
* The window as one readable string: `PASS!→FAIL!` or `FAIL⋯PASS→PASS!`.
|
|
127
|
+
*
|
|
128
|
+
* `→` is a step this comparison counted; `⋯` is a step it rejected (an edit, a
|
|
129
|
+
* different aggregation, unverifiable hashes); `!` marks a run whose reps were
|
|
130
|
+
* internally unanimous. The two arrows have to differ, or a path reading `FAIL→PASS`
|
|
131
|
+
* would sit next to "held its verdict" and look like a contradiction — the window shows
|
|
132
|
+
* every run, but only some of the steps between them are evidence.
|
|
133
|
+
*/
|
|
134
|
+
export declare const PATH_LEGEND = "\u2192 comparable step \u00B7 \u22EF step not comparable \u00B7 ! that run's reps were unanimous";
|
|
135
|
+
export declare function verdictPath(s: ScenarioStability): string;
|
|
136
|
+
/**
|
|
137
|
+
* The one-line human statement. This string is the feature: a number nobody can read
|
|
138
|
+
* ("volatility 1.00") would leave the reader exactly where a single run left them.
|
|
139
|
+
*
|
|
140
|
+
* Every branch says what the record supports and no more — which of "the edit did it"
|
|
141
|
+
* and "the cell is bimodal" is true cannot be told from committed results, so the
|
|
142
|
+
* across-an-edit wording names both.
|
|
143
|
+
*/
|
|
144
|
+
export declare function stabilityNote(s: ScenarioStability): string;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { loadSpec } from "./spec.js";
|
|
3
|
+
import { effectiveVerdicts } from "./results.js";
|
|
4
|
+
import { collectScoredRuns } from "./trends.js";
|
|
5
|
+
import { describeSourceKey, scenarioSourceKeys, PERSONA_KEY, SKILL_KEY } from "./sources.js";
|
|
6
|
+
const DEFAULT_WINDOW = 5;
|
|
7
|
+
/** A verdict that carries evidence about the task rather than about the harness or judge. */
|
|
8
|
+
function conclusive(v) {
|
|
9
|
+
// Same rule lift.ts applies, for the same reason: ERROR is a harness failure and an
|
|
10
|
+
// unresolved misfire is a judge failure. Counting either as a side of a flip would
|
|
11
|
+
// report infrastructure noise as behavioural instability.
|
|
12
|
+
return !v.suspect && v.verdict !== "ERROR" && v.verdict !== "JUDGE-AMBIGUOUS";
|
|
13
|
+
}
|
|
14
|
+
function pointFor(r, s, verdict) {
|
|
15
|
+
const reps = s.reps ?? 1;
|
|
16
|
+
return {
|
|
17
|
+
timestamp: r.timestamp,
|
|
18
|
+
label: r.label,
|
|
19
|
+
verdict,
|
|
20
|
+
overridden: s.override != null,
|
|
21
|
+
partial: Boolean(r.partial),
|
|
22
|
+
reps,
|
|
23
|
+
unanimous: reps > 1 && s.flakiness === 0,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** reps + threshold, normalised the way lift.ts normalises it (a lone rep has no threshold). */
|
|
27
|
+
function shapeOf(s) {
|
|
28
|
+
const reps = s.reps ?? 1;
|
|
29
|
+
return JSON.stringify([reps, reps > 1 ? s.pass_threshold ?? null : null]);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Compare the recorded hashes of one scenario's own sources across two runs.
|
|
33
|
+
*
|
|
34
|
+
* Only keys BOTH runs recorded are compared: a key one side never hashed cannot be
|
|
35
|
+
* shown to be unchanged. `shared === 0` means unverifiable (a pre-`source_hashes` run,
|
|
36
|
+
* or a split-key run against a legacy combined-key one), which is reported as
|
|
37
|
+
* `unverified` rather than assumed identical — the whole value of this feature is that
|
|
38
|
+
* it does not claim more than the record supports.
|
|
39
|
+
*/
|
|
40
|
+
function compareSources(a, b, keys) {
|
|
41
|
+
if (!a || !b)
|
|
42
|
+
return { shared: 0, changed: [] };
|
|
43
|
+
let shared = 0;
|
|
44
|
+
const changed = [];
|
|
45
|
+
for (const key of keys) {
|
|
46
|
+
const va = a[key];
|
|
47
|
+
const vb = b[key];
|
|
48
|
+
if (va === undefined || vb === undefined)
|
|
49
|
+
continue;
|
|
50
|
+
shared++;
|
|
51
|
+
if (va !== vb)
|
|
52
|
+
changed.push(describeSourceKey(key));
|
|
53
|
+
}
|
|
54
|
+
return { shared, changed };
|
|
55
|
+
}
|
|
56
|
+
/** Derive one scenario's stability within one tag × mode group. */
|
|
57
|
+
function stabilityForScenario(group, scenario, window) {
|
|
58
|
+
// The window is over runs that HOLD this scenario: an `--only` run elsewhere in the
|
|
59
|
+
// history must not consume a slot and shrink the comparison to nothing.
|
|
60
|
+
const relevant = group.runs.filter((r) => r.scenarios.some((s) => s.id === scenario.id));
|
|
61
|
+
const kept = relevant.slice(-window);
|
|
62
|
+
// Skill-wide rubric: the persona moves every verdict in the skill, so it belongs with
|
|
63
|
+
// the scenario's own sources rather than with the SKILL.md caveat.
|
|
64
|
+
const keys = [...scenarioSourceKeys(scenario), PERSONA_KEY];
|
|
65
|
+
const points = [];
|
|
66
|
+
const raw = [];
|
|
67
|
+
for (const r of kept) {
|
|
68
|
+
const i = r.scenarios.findIndex((s) => s.id === scenario.id);
|
|
69
|
+
const s = r.scenarios[i];
|
|
70
|
+
const eff = effectiveVerdicts(r.scenarios)[i];
|
|
71
|
+
points.push(pointFor(r, s, eff.verdict));
|
|
72
|
+
raw.push({ r, s, ok: conclusive(eff) });
|
|
73
|
+
}
|
|
74
|
+
const pairs = [];
|
|
75
|
+
for (let i = 1; i < raw.length; i++) {
|
|
76
|
+
const prev = raw[i - 1];
|
|
77
|
+
const cur = raw[i];
|
|
78
|
+
const from = points[i - 1];
|
|
79
|
+
const to = points[i];
|
|
80
|
+
const skillChanged = prev.r.source_hashes?.[SKILL_KEY] !== undefined &&
|
|
81
|
+
cur.r.source_hashes?.[SKILL_KEY] !== undefined &&
|
|
82
|
+
prev.r.source_hashes[SKILL_KEY] !== cur.r.source_hashes[SKILL_KEY];
|
|
83
|
+
const base = { from, to, flipped: false, skillChanged, changedSources: [], unanimousFlip: false };
|
|
84
|
+
if (!prev.ok || !cur.ok) {
|
|
85
|
+
pairs.push({ ...base, status: "inconclusive" });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (shapeOf(prev.s) !== shapeOf(cur.s)) {
|
|
89
|
+
pairs.push({ ...base, status: "aggregation" });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const src = compareSources(prev.r.source_hashes, cur.r.source_hashes, keys);
|
|
93
|
+
if (src.shared === 0) {
|
|
94
|
+
pairs.push({ ...base, status: "unverified" });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (src.changed.length > 0) {
|
|
98
|
+
pairs.push({ ...base, status: "sources", changedSources: src.changed });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const flipped = from.verdict !== to.verdict;
|
|
102
|
+
pairs.push({
|
|
103
|
+
...base,
|
|
104
|
+
status: "compared",
|
|
105
|
+
flipped,
|
|
106
|
+
unanimousFlip: flipped && from.unanimous && to.unanimous,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const compared = pairs.filter((p) => p.status === "compared").length;
|
|
110
|
+
const flipped = pairs.filter((p) => p.status === "compared" && p.flipped);
|
|
111
|
+
return {
|
|
112
|
+
id: scenario.id,
|
|
113
|
+
title: scenario.title,
|
|
114
|
+
critical: scenario.critical,
|
|
115
|
+
tag: group.tag,
|
|
116
|
+
mode: group.mode,
|
|
117
|
+
model: group.model,
|
|
118
|
+
points,
|
|
119
|
+
pairs,
|
|
120
|
+
compared,
|
|
121
|
+
flips: flipped.length,
|
|
122
|
+
flipsAcrossSkillEdit: flipped.filter((p) => p.skillChanged).length,
|
|
123
|
+
unanimousFlips: flipped.filter((p) => p.unanimousFlip).length,
|
|
124
|
+
volatility: compared === 0 ? null : flipped.length / compared,
|
|
125
|
+
// "unmeasured" is a third state on purpose: a scenario with one run, or with no
|
|
126
|
+
// comparable pair, has NOT been shown to be stable. Collapsing it into "stable"
|
|
127
|
+
// would turn absence of evidence into evidence — the same conflation lift.ts
|
|
128
|
+
// refuses when it reports "no red baseline" instead of a zero.
|
|
129
|
+
state: compared === 0 ? "unmeasured" : flipped.length > 0 ? "boundary" : "stable",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Derive stability for every scenario × tag × mode from an already-read history. */
|
|
133
|
+
export function stabilityFrom(groups, spec, opts = {}) {
|
|
134
|
+
const window = Math.max(2, opts.window ?? DEFAULT_WINDOW); // a window of 1 has no pair to compare
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const group of groups) {
|
|
137
|
+
for (const scenario of spec.scenarios) {
|
|
138
|
+
out.push(stabilityForScenario(group, scenario, window));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Read `<skillDir>/tests/results/` and derive run-over-run stability per scenario ×
|
|
145
|
+
* model tag × delivery mode. Free and offline: it reads committed results.yaml files
|
|
146
|
+
* and computes; it never runs a model, a judge, or a harness.
|
|
147
|
+
*
|
|
148
|
+
* Deliberately derived on read rather than stored in results.yaml, for the reason lift
|
|
149
|
+
* is: stability is a fact about a SET of runs, so a copy inside one run's file would be
|
|
150
|
+
* wrong the moment the next run lands — the stale-scorecard failure `source_hashes`
|
|
151
|
+
* exists to prevent. It also means this works retroactively on history recorded by
|
|
152
|
+
* every earlier version.
|
|
153
|
+
*/
|
|
154
|
+
export function collectStability(skillDir, opts = {}) {
|
|
155
|
+
const spec = loadSpec(join(skillDir, "tests", "specification.yaml"));
|
|
156
|
+
return stabilityFrom(collectScoredRuns(skillDir), spec, opts);
|
|
157
|
+
}
|
|
158
|
+
/** Scenarios that flipped at least once — the cells a single run reads as too certain. */
|
|
159
|
+
export function boundaryCells(all) {
|
|
160
|
+
return all.filter((s) => s.state === "boundary");
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The window as one readable string: `PASS!→FAIL!` or `FAIL⋯PASS→PASS!`.
|
|
164
|
+
*
|
|
165
|
+
* `→` is a step this comparison counted; `⋯` is a step it rejected (an edit, a
|
|
166
|
+
* different aggregation, unverifiable hashes); `!` marks a run whose reps were
|
|
167
|
+
* internally unanimous. The two arrows have to differ, or a path reading `FAIL→PASS`
|
|
168
|
+
* would sit next to "held its verdict" and look like a contradiction — the window shows
|
|
169
|
+
* every run, but only some of the steps between them are evidence.
|
|
170
|
+
*/
|
|
171
|
+
export const PATH_LEGEND = "→ comparable step · ⋯ step not comparable · ! that run's reps were unanimous";
|
|
172
|
+
export function verdictPath(s) {
|
|
173
|
+
const label = (p) => `${p.verdict}${p.unanimous ? "!" : ""}${p.overridden ? "(override)" : ""}`;
|
|
174
|
+
let out = s.points.length > 0 ? label(s.points[0]) : "";
|
|
175
|
+
s.pairs.forEach((pair, i) => {
|
|
176
|
+
out += `${pair.status === "compared" ? "→" : "⋯"}${label(s.points[i + 1])}`;
|
|
177
|
+
});
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The one-line human statement. This string is the feature: a number nobody can read
|
|
182
|
+
* ("volatility 1.00") would leave the reader exactly where a single run left them.
|
|
183
|
+
*
|
|
184
|
+
* Every branch says what the record supports and no more — which of "the edit did it"
|
|
185
|
+
* and "the cell is bimodal" is true cannot be told from committed results, so the
|
|
186
|
+
* across-an-edit wording names both.
|
|
187
|
+
*/
|
|
188
|
+
export function stabilityNote(s) {
|
|
189
|
+
if (s.state === "boundary") {
|
|
190
|
+
const parts = [
|
|
191
|
+
`${s.id} flipped its verdict in ${s.flips} of ${s.compared} comparable run-to-run step(s) (${verdictPath(s)})`,
|
|
192
|
+
];
|
|
193
|
+
if (s.unanimousFlips > 0) {
|
|
194
|
+
parts.push(`${s.unanimousFlips === s.flips ? "each flip was" : `${s.unanimousFlips} flip(s) were`} between runs that were` +
|
|
195
|
+
` INTERNALLY UNANIMOUS (flakiness 0.00) — within-run reps cannot see this`);
|
|
196
|
+
}
|
|
197
|
+
if (s.flipsAcrossSkillEdit === s.flips && s.flips > 0) {
|
|
198
|
+
parts.push(`SKILL.md changed across ${s.flips === 1 ? "that step" : "those steps"}, while this scenario's own stimulus` +
|
|
199
|
+
` and rubric did not — so it is either a side effect of that edit or a boundary cell, and the record cannot say which`);
|
|
200
|
+
}
|
|
201
|
+
else if (s.flipsAcrossSkillEdit > 0) {
|
|
202
|
+
parts.push(`${s.flipsAcrossSkillEdit} of them across a SKILL.md edit`);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
parts.push(`on unchanged skill text — treat a single run of this cell as one draw, not a measurement`);
|
|
206
|
+
}
|
|
207
|
+
return parts.join("; ");
|
|
208
|
+
}
|
|
209
|
+
if (s.state === "stable") {
|
|
210
|
+
// "across N comparable step(s)", not "across N runs": the window can hold runs whose
|
|
211
|
+
// steps were rejected, and claiming those as agreement would overstate the evidence.
|
|
212
|
+
return `${s.id} held its verdict across ${s.compared} comparable run-to-run step(s) (${verdictPath(s)})`;
|
|
213
|
+
}
|
|
214
|
+
const why = new Map();
|
|
215
|
+
for (const p of s.pairs)
|
|
216
|
+
if (p.status !== "compared")
|
|
217
|
+
why.set(p.status, (why.get(p.status) ?? 0) + 1);
|
|
218
|
+
const reasons = [...why.entries()].map(([status, n]) => `${n} ${REJECTION[status]}`);
|
|
219
|
+
const changed = [...new Set(s.pairs.flatMap((p) => p.changedSources))];
|
|
220
|
+
const detail = changed.length > 0 ? ` (${changed.join(", ")} changed — an edit, not a flip)` : "";
|
|
221
|
+
return s.points.length < 2
|
|
222
|
+
? `${s.id} has ${s.points.length} run in this mode — no run-over-run comparison exists yet`
|
|
223
|
+
: `${s.id} has no comparable run-to-run step: ${reasons.join(", ")}${detail}`;
|
|
224
|
+
}
|
|
225
|
+
const REJECTION = {
|
|
226
|
+
compared: "compared",
|
|
227
|
+
inconclusive: "step(s) with an ERROR or unresolved misfire",
|
|
228
|
+
aggregation: "step(s) aggregated differently (reps or pass threshold)",
|
|
229
|
+
sources: "step(s) where the scenario's own sources changed",
|
|
230
|
+
unverified: "step(s) whose recorded hashes cannot be compared",
|
|
231
|
+
};
|
|
232
|
+
//# sourceMappingURL=stability.js.map
|
package/dist/trends.d.ts
CHANGED
|
@@ -38,6 +38,34 @@ export interface TrendData {
|
|
|
38
38
|
}[];
|
|
39
39
|
models: TrendModel[];
|
|
40
40
|
}
|
|
41
|
+
/** One model tag's scored run history in ONE delivery mode, chronologically ascending. */
|
|
42
|
+
export interface ScoredRunGroup {
|
|
43
|
+
tag: string;
|
|
44
|
+
mode: string;
|
|
45
|
+
model: string;
|
|
46
|
+
runs: ResultsFile[];
|
|
47
|
+
/** Runs in this tag whose results.yaml could not be parsed (per tag, not per mode). */
|
|
48
|
+
skipped: number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Walk `<skillDir>/tests/results/` and group every SCORED run by model tag × delivery
|
|
52
|
+
* mode, chronologically (timestamp-slug dir names sort correctly).
|
|
53
|
+
*
|
|
54
|
+
* The single history reader: `collectTrends` renders it, `collectStability` derives
|
|
55
|
+
* run-over-run flips from it. Two walkers over the same tree is how "which runs count"
|
|
56
|
+
* drifts — the mistake that had force runs excluded from scoring in seven places at
|
|
57
|
+
* once (see SCORED_MODES).
|
|
58
|
+
*
|
|
59
|
+
* Red runs are excluded: a baseline has no grade, and pairing it with anything would
|
|
60
|
+
* compare a skill-off run to a skill-on one. Green and force are never pooled into one
|
|
61
|
+
* group — placement moves verdicts, so a green run and a force run of the same scenario
|
|
62
|
+
* are two measurements, not two samples.
|
|
63
|
+
*
|
|
64
|
+
* A run whose `results.yaml` fails to parse (e.g. an interrupted non-atomic write) is
|
|
65
|
+
* logged via `console.warn`, skipped, and counted in `skipped` — never thrown, because
|
|
66
|
+
* one torn file must not take down a whole read-only view.
|
|
67
|
+
*/
|
|
68
|
+
export declare function collectScoredRuns(skillDir: string): ScoredRunGroup[];
|
|
41
69
|
/**
|
|
42
70
|
* Per model-tag, read the full run history (not just the latest) from
|
|
43
71
|
* <skillDir>/tests/results/, chronologically (timestamp-slug dir names sort
|
package/dist/trends.js
CHANGED
|
@@ -11,6 +11,67 @@ function isDir(p) {
|
|
|
11
11
|
return false;
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Walk `<skillDir>/tests/results/` and group every SCORED run by model tag × delivery
|
|
16
|
+
* mode, chronologically (timestamp-slug dir names sort correctly).
|
|
17
|
+
*
|
|
18
|
+
* The single history reader: `collectTrends` renders it, `collectStability` derives
|
|
19
|
+
* run-over-run flips from it. Two walkers over the same tree is how "which runs count"
|
|
20
|
+
* drifts — the mistake that had force runs excluded from scoring in seven places at
|
|
21
|
+
* once (see SCORED_MODES).
|
|
22
|
+
*
|
|
23
|
+
* Red runs are excluded: a baseline has no grade, and pairing it with anything would
|
|
24
|
+
* compare a skill-off run to a skill-on one. Green and force are never pooled into one
|
|
25
|
+
* group — placement moves verdicts, so a green run and a force run of the same scenario
|
|
26
|
+
* are two measurements, not two samples.
|
|
27
|
+
*
|
|
28
|
+
* A run whose `results.yaml` fails to parse (e.g. an interrupted non-atomic write) is
|
|
29
|
+
* logged via `console.warn`, skipped, and counted in `skipped` — never thrown, because
|
|
30
|
+
* one torn file must not take down a whole read-only view.
|
|
31
|
+
*/
|
|
32
|
+
export function collectScoredRuns(skillDir) {
|
|
33
|
+
const resultsRoot = join(skillDir, "tests", "results");
|
|
34
|
+
if (!existsSync(resultsRoot))
|
|
35
|
+
return [];
|
|
36
|
+
const groups = [];
|
|
37
|
+
const tags = readdirSync(resultsRoot)
|
|
38
|
+
.filter((n) => isDir(join(resultsRoot, n)))
|
|
39
|
+
.sort();
|
|
40
|
+
for (const tag of tags) {
|
|
41
|
+
const tagDir = join(resultsRoot, tag);
|
|
42
|
+
const runDirs = readdirSync(tagDir)
|
|
43
|
+
.map((n) => join(tagDir, n))
|
|
44
|
+
.filter((p) => isDir(p) && existsSync(join(p, "results.yaml")))
|
|
45
|
+
.sort(); // timestamp-slug dir names ⇒ chronological ascending
|
|
46
|
+
if (runDirs.length === 0)
|
|
47
|
+
continue;
|
|
48
|
+
// Every candidate run is read: a run's mode is not knowable from its dir name, so
|
|
49
|
+
// filtering has to happen after the read. Bucketed by mode in first-seen order.
|
|
50
|
+
const byMode = new Map();
|
|
51
|
+
let skipped = 0;
|
|
52
|
+
for (const rd of runDirs) {
|
|
53
|
+
let r;
|
|
54
|
+
try {
|
|
55
|
+
r = readResults(rd);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
console.warn(`skill-harness: skipping unreadable run ${rd}: ${e instanceof Error ? e.message : e}`);
|
|
59
|
+
skipped++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!isScoredMode(r.mode))
|
|
63
|
+
continue; // baseline — deliberate exclusion, not a skip
|
|
64
|
+
(byMode.get(r.mode) ?? byMode.set(r.mode, []).get(r.mode)).push(r);
|
|
65
|
+
}
|
|
66
|
+
for (const [mode, runs] of byMode) {
|
|
67
|
+
// `skipped` is per tag (an unreadable run has no knowable mode), so a tag with
|
|
68
|
+
// two series reports the same count on both — the alternative is attributing a
|
|
69
|
+
// parse failure to a mode nobody could read.
|
|
70
|
+
groups.push({ tag, mode, model: runs[runs.length - 1].model, runs, skipped });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return groups;
|
|
74
|
+
}
|
|
14
75
|
/**
|
|
15
76
|
* Per model-tag, read the full run history (not just the latest) from
|
|
16
77
|
* <skillDir>/tests/results/, chronologically (timestamp-slug dir names sort
|
|
@@ -42,69 +103,23 @@ export function collectTrends(skillDir, limit = 20) {
|
|
|
42
103
|
const specPath = join(skillDir, "tests", "specification.yaml");
|
|
43
104
|
const spec = loadSpec(specPath);
|
|
44
105
|
const scenarios = spec.scenarios.map((s) => ({ id: s.id, title: s.title, critical: s.critical }));
|
|
45
|
-
const resultsRoot = join(skillDir, "tests", "results");
|
|
46
106
|
const models = [];
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
for (const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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();
|
|
65
|
-
let skipped = 0;
|
|
66
|
-
for (const rd of runDirs) {
|
|
67
|
-
let r;
|
|
68
|
-
try {
|
|
69
|
-
r = readResults(rd);
|
|
70
|
-
}
|
|
71
|
-
catch (e) {
|
|
72
|
-
// A corrupt/truncated results.yaml must not take down the whole
|
|
73
|
-
// trends view — skip that run, but surface the failure.
|
|
74
|
-
console.warn(`skill-harness trends: skipping unreadable run ${rd}: ${e instanceof Error ? e.message : e}`);
|
|
75
|
-
skipped++;
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
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);
|
|
81
|
-
}
|
|
82
|
-
if (byMode.size === 0)
|
|
83
|
-
continue;
|
|
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 });
|
|
106
|
-
}
|
|
107
|
+
for (const group of collectScoredRuns(skillDir)) {
|
|
108
|
+
const truncated = group.runs.length > limit;
|
|
109
|
+
const kept = group.runs.slice(-limit); // most recent `limit`, newest last
|
|
110
|
+
const runs = [];
|
|
111
|
+
for (const r of kept) {
|
|
112
|
+
// effectiveVerdicts is the single source of truth for the override-aware
|
|
113
|
+
// verdict/suspect rule (suspect = s.suspect && s.override == null — an override
|
|
114
|
+
// resolves the misfire); zip in flakiness from the matching ScenarioResult.
|
|
115
|
+
const verdicts = effectiveVerdicts(r.scenarios);
|
|
116
|
+
const cells = {};
|
|
117
|
+
r.scenarios.forEach((s, i) => {
|
|
118
|
+
cells[s.id] = { verdict: verdicts[i].verdict, suspect: verdicts[i].suspect ?? false, flakiness: s.flakiness };
|
|
119
|
+
});
|
|
120
|
+
runs.push({ timestamp: r.timestamp, label: r.label, grade: r.effective_grade, cells });
|
|
107
121
|
}
|
|
122
|
+
models.push({ model: group.model, tag: group.tag, mode: group.mode, runs, truncated, skipped: group.skipped });
|
|
108
123
|
}
|
|
109
124
|
return { skill: spec.skill, scenarios, models };
|
|
110
125
|
}
|