@skill-harness/core 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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 {
@@ -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
@@ -19,15 +80,18 @@ function isDir(p) {
19
80
  * rule: an override resolves a misfire) + reps flakiness. Read-only; no
20
81
  * absolute paths in the result.
21
82
  *
22
- * Only scored (mode === "green") runs are included in the history — a
23
- * red/force run has no real grade (`effective_grade` is a "not scored"
24
- * placeholder; see run.ts) and would otherwise plot as a misleading 0% dip in
25
- * the sparkline/grid. Non-green runs are deliberately excluded, which is
26
- * distinct from `skipped`: a run's mode can only be known after reading its
27
- * results.yaml, so every candidate run-dir in the tag is read (not just the
28
- * most recent `limit`) before filtering to green and applying the `limit`
29
- * window — trends is a bounded, on-demand, local view, so this extra read
30
- * cost is acceptable. If a tag has zero green runs, it's omitted entirely.
83
+ * Only scored runs are included in the history — a red baseline has no real grade
84
+ * (`effective_grade` is a "not scored" placeholder; see run.ts) and would otherwise
85
+ * plot as a misleading 0% dip in the sparkline/grid. Red runs are deliberately
86
+ * excluded, which is distinct from `skipped`: a run's mode can only be known after
87
+ * reading its results.yaml, so every candidate run-dir in the tag is read (not just
88
+ * the most recent `limit`) before filtering and applying the `limit` window
89
+ * trends is a bounded, on-demand, local view, so this extra read cost is
90
+ * acceptable.
91
+ *
92
+ * Green and force runs both count, but never in the same series: a tag with both
93
+ * yields one TrendModel per mode (see `TrendModel.mode`), each with its own
94
+ * `limit` window. A tag with no scored run at all is omitted entirely.
31
95
  *
32
96
  * A run whose `results.yaml` fails to parse (e.g. an interrupted non-atomic
33
97
  * write) is logged via `console.warn` and skipped — never surfaced or thrown —
@@ -39,63 +103,23 @@ export function collectTrends(skillDir, limit = 20) {
39
103
  const specPath = join(skillDir, "tests", "specification.yaml");
40
104
  const spec = loadSpec(specPath);
41
105
  const scenarios = spec.scenarios.map((s) => ({ id: s.id, title: s.title, critical: s.critical }));
42
- const resultsRoot = join(skillDir, "tests", "results");
43
106
  const models = [];
44
- if (existsSync(resultsRoot)) {
45
- const tags = readdirSync(resultsRoot)
46
- .filter((n) => isDir(join(resultsRoot, n)))
47
- .sort();
48
- for (const tag of tags) {
49
- const tagDir = join(resultsRoot, tag);
50
- const runDirs = readdirSync(tagDir)
51
- .map((n) => join(tagDir, n))
52
- .filter((p) => isDir(p) && existsSync(join(p, "results.yaml")))
53
- .sort(); // timestamp-slug dir names ⇒ chronological ascending
54
- if (runDirs.length === 0)
55
- continue;
56
- // Read every candidate run (mode isn't knowable from the dir name) and
57
- // filter to green (scored) runs before applying the `limit` window —
58
- // filtering after the slice would let red/force runs consume window
59
- // slots, undercounting the green history even when more exists.
60
- const greenRuns = [];
61
- let skipped = 0;
62
- for (const rd of runDirs) {
63
- let r;
64
- try {
65
- r = readResults(rd);
66
- }
67
- catch (e) {
68
- // A corrupt/truncated results.yaml must not take down the whole
69
- // trends view — skip that run, but surface the failure.
70
- console.warn(`skill-harness trends: skipping unreadable run ${rd}: ${e instanceof Error ? e.message : e}`);
71
- skipped++;
72
- continue;
73
- }
74
- if (r.mode !== "green")
75
- continue; // not scored — deliberate exclusion, not a skip
76
- greenRuns.push(r);
77
- }
78
- if (greenRuns.length === 0)
79
- continue;
80
- const truncated = greenRuns.length > limit;
81
- const kept = greenRuns.slice(-limit); // most recent `limit`, newest last
82
- const runs = [];
83
- let model = "";
84
- for (const r of kept) {
85
- // effectiveVerdicts is the single source of truth for the
86
- // override-aware verdict/suspect rule (suspect = s.suspect &&
87
- // s.override == null — an override resolves the misfire); zip in
88
- // flakiness from the matching ScenarioResult.
89
- const verdicts = effectiveVerdicts(r.scenarios);
90
- const cells = {};
91
- r.scenarios.forEach((s, i) => {
92
- cells[s.id] = { verdict: verdicts[i].verdict, suspect: verdicts[i].suspect ?? false, flakiness: s.flakiness };
93
- });
94
- runs.push({ timestamp: r.timestamp, label: r.label, grade: r.effective_grade, cells });
95
- model = r.model; // last successfully-read run (kept is ascending) wins
96
- }
97
- models.push({ model, tag, runs, truncated, skipped });
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 });
98
121
  }
122
+ models.push({ model: group.model, tag: group.tag, mode: group.mode, runs, truncated, skipped: group.skipped });
99
123
  }
100
124
  return { skill: spec.skill, scenarios, models };
101
125
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",