@skill-harness/core 0.1.2 → 0.3.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 +6 -0
- package/dist/discover.d.ts +5 -1
- package/dist/discover.js +9 -1
- package/dist/grade.d.ts +14 -1
- package/dist/grade.js +74 -11
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/journal.d.ts +14 -0
- package/dist/lift.d.ts +69 -0
- package/dist/lift.js +163 -0
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +168 -4
- package/dist/regrade.d.ts +6 -0
- package/dist/regrade.js +25 -3
- package/dist/report.d.ts +14 -0
- package/dist/report.js +16 -1
- package/dist/rescore.d.ts +34 -0
- package/dist/rescore.js +66 -0
- package/dist/results.d.ts +41 -9
- package/dist/results.js +58 -14
- package/dist/run.d.ts +25 -1
- package/dist/run.js +115 -22
- package/dist/scaffold.d.ts +28 -0
- package/dist/scaffold.js +188 -0
- package/dist/score.d.ts +5 -1
- package/dist/seeded.d.ts +83 -3
- package/dist/seeded.js +315 -15
- package/dist/sources.d.ts +115 -0
- package/dist/sources.js +253 -0
- package/dist/spec.d.ts +17 -0
- package/dist/spec.js +63 -0
- package/dist/util/env.d.ts +32 -0
- package/dist/util/env.js +67 -0
- package/dist/workspace.d.ts +33 -1
- package/dist/workspace.js +125 -6
- package/package.json +1 -1
package/dist/adapters/types.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface RunReq {
|
|
|
14
14
|
mode: RunMode;
|
|
15
15
|
turns: string[];
|
|
16
16
|
cwd: string;
|
|
17
|
+
/**
|
|
18
|
+
* Abs path to a markdown file to use AS the system prompt, instead of activating
|
|
19
|
+
* skillDir as a skill. Used to test a subagent definition (agents/<name>.md) in the
|
|
20
|
+
* single-shot shape it actually runs in; overrides `mode`'s skill flags.
|
|
21
|
+
*/
|
|
22
|
+
systemPromptFile?: string;
|
|
17
23
|
}
|
|
18
24
|
/** A judge request: single prompt, no skills, no session. */
|
|
19
25
|
export interface JudgeReq {
|
package/dist/discover.d.ts
CHANGED
|
@@ -10,5 +10,9 @@ export interface DiscoveredSkill {
|
|
|
10
10
|
* Returns skills sorted by name (testable or not).
|
|
11
11
|
*/
|
|
12
12
|
export declare function discover(root: string): DiscoveredSkill[];
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a single skill by name; throws a helpful error if absent or specless.
|
|
15
|
+
* A directory that exists but lacks a SKILL.md gets a specific error (rather than
|
|
16
|
+
* the generic "no skill") so callers don't reimplement the SKILL.md existence check.
|
|
17
|
+
*/
|
|
14
18
|
export declare function resolveSkill(root: string, name: string): DiscoveredSkill;
|
package/dist/discover.js
CHANGED
|
@@ -24,10 +24,18 @@ export function discover(root) {
|
|
|
24
24
|
skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
25
25
|
return skills;
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a single skill by name; throws a helpful error if absent or specless.
|
|
29
|
+
* A directory that exists but lacks a SKILL.md gets a specific error (rather than
|
|
30
|
+
* the generic "no skill") so callers don't reimplement the SKILL.md existence check.
|
|
31
|
+
*/
|
|
28
32
|
export function resolveSkill(root, name) {
|
|
29
33
|
const skill = discover(root).find((s) => s.name === name);
|
|
30
34
|
if (!skill) {
|
|
35
|
+
const dir = join(root, name);
|
|
36
|
+
if (existsSync(dir) && statSync(dir).isDirectory() && !existsSync(join(dir, "SKILL.md"))) {
|
|
37
|
+
throw new Error(`skill \`${name}\` has no SKILL.md (looked in ${dir})`);
|
|
38
|
+
}
|
|
31
39
|
throw new Error(`no skill \`${name}\` under ${root}`);
|
|
32
40
|
}
|
|
33
41
|
return skill;
|
package/dist/grade.d.ts
CHANGED
|
@@ -7,13 +7,26 @@ export interface JudgePromptInput {
|
|
|
7
7
|
scenario: Scenario;
|
|
8
8
|
transcript: string;
|
|
9
9
|
}
|
|
10
|
+
/** The heading runSeeded writes above the staged diff. Gating on this exact string is what keeps the guidance honest — see below. */
|
|
11
|
+
export declare const STAGED_DIFF_HEADING = "=== STAGED DIFF ===";
|
|
10
12
|
/** Build the LLM-judge prompt for one transcript (ported from the old grade.sh). */
|
|
11
13
|
export declare function buildJudgePrompt(input: JudgePromptInput): string;
|
|
12
14
|
export interface ParsedVerdict {
|
|
13
15
|
verdict: Verdict;
|
|
14
16
|
reason: string;
|
|
15
17
|
}
|
|
16
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Parse a judge's raw output into a verdict + reason.
|
|
20
|
+
*
|
|
21
|
+
* Judges sometimes emit MORE than one verdict block (a first pass, then a restated
|
|
22
|
+
* conclusion). Every block is read, never just the first:
|
|
23
|
+
* - all blocks agree → that verdict, with the reason from the LAST block (the
|
|
24
|
+
* judge's final word) in full
|
|
25
|
+
* - blocks disagree → JUDGE-AMBIGUOUS, which counts as a non-pass and carries both
|
|
26
|
+
* verdicts in the reason so a rejudge can be queued. Silently taking either one
|
|
27
|
+
* would be inventing a grade the judge did not give.
|
|
28
|
+
* Unparseable → ERROR.
|
|
29
|
+
*/
|
|
17
30
|
export declare function parseVerdict(out: string): ParsedVerdict;
|
|
18
31
|
/**
|
|
19
32
|
* Judge-≠-subject de-confound guard. True when the judge resembles the model
|
package/dist/grade.js
CHANGED
|
@@ -1,8 +1,33 @@
|
|
|
1
1
|
import { createWorkspace } from "./workspace.js";
|
|
2
|
+
/** The heading runSeeded writes above the staged diff. Gating on this exact string is what keeps the guidance honest — see below. */
|
|
3
|
+
export const STAGED_DIFF_HEADING = "=== STAGED DIFF ===";
|
|
4
|
+
/**
|
|
5
|
+
* Addendum pointing the judge at the code, added only when the code is actually there.
|
|
6
|
+
*
|
|
7
|
+
* A seeded transcript ends with the staged diff, and without this the judge
|
|
8
|
+
* weighs the model's prose about its work equally with the work itself — which
|
|
9
|
+
* is how six reps that all passed the objective gates split PASS/FAIL purely on
|
|
10
|
+
* whether the model wrote "rejects overdrafts" or "subtracts amount".
|
|
11
|
+
*
|
|
12
|
+
* Gated on the transcript CONTAINING the diff section, not on `scenario.mode`.
|
|
13
|
+
* The first sentence is a factual claim about the transcript, and every seeded
|
|
14
|
+
* transcript saved before this feature existed lacks that section — so keying
|
|
15
|
+
* off the mode would tell the judge its primary evidence is at the end of a
|
|
16
|
+
* transcript that has none, while also telling it the gate lines prove nothing.
|
|
17
|
+
* That is a sweep of FAILs justified by absent evidence, and it would land
|
|
18
|
+
* squarely on `grade`, the command AGENTS.md rule 4 recommends as the *cheap*
|
|
19
|
+
* de-confounding step before re-running. Inline scenarios have no diff either,
|
|
20
|
+
* so their prompt stays byte-identical and every published inline verdict
|
|
21
|
+
* remains comparable.
|
|
22
|
+
*/
|
|
23
|
+
const SEEDED_DIFF_GUIDANCE = `
|
|
24
|
+
This transcript ends with a "=== STAGED DIFF ===" section: the actual code the assistant wrote, as \`git diff --cached\`. It is the primary evidence. Grade what the diff shows the code DOES, not what the assistant's prose claims it does — a confident description of behavior the diff does not implement is a FAIL, and behavior the diff plainly implements passes even if the assistant described it poorly or not at all. The "=== SEEDED GATES ===" lines above it are keyword and test-run checks only; they do not establish that the required behavior exists. If the diff is marked truncated, judge only what you can see and never infer that cut-off code is missing.
|
|
25
|
+
`;
|
|
2
26
|
/** Build the LLM-judge prompt for one transcript (ported from the old grade.sh). */
|
|
3
27
|
export function buildJudgePrompt(input) {
|
|
4
28
|
const { skill, persona, scenario, transcript } = input;
|
|
5
29
|
const numbered = scenario.checklist.map((c, i) => `${i + 1}. ${c}`).join("\n");
|
|
30
|
+
const diffGuidance = scenario.mode === "seeded" && transcript.includes(STAGED_DIFF_HEADING) ? SEEDED_DIFF_GUIDANCE : "";
|
|
6
31
|
return `You are grading ONE response from an AI assistant using a "${skill}" skill — ${persona} Judge it ONLY against the checklist below — do not add requirements beyond it.
|
|
7
32
|
|
|
8
33
|
CHECKLIST (every numbered item must hold for a PASS):
|
|
@@ -10,23 +35,44 @@ ${numbered}
|
|
|
10
35
|
|
|
11
36
|
TRANSCRIPT (the assistant is the model under test):
|
|
12
37
|
${transcript}
|
|
13
|
-
|
|
38
|
+
${diffGuidance}
|
|
14
39
|
Grade each checklist item PASS or FAIL with a <=12-word justification quoting the transcript. Be skeptical: if an item is not clearly satisfied, mark it FAIL. Then output exactly these two lines:
|
|
15
40
|
VERDICT: PASS (only if EVERY item passed) — or — VERDICT: FAIL
|
|
16
41
|
REASON: <15 words or fewer>`;
|
|
17
42
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
43
|
+
// Both anchor to the start of a line and REQUIRE the colon. Without those anchors the
|
|
44
|
+
// reason pattern matched any word containing "reason" — git-ops GLM C1 stored
|
|
45
|
+
// "able given no repo present.", a fragment of "Reasonable" in the judge's prose, which
|
|
46
|
+
// then read as a FAIL-verdict-with-passing-reason misfire that never happened.
|
|
47
|
+
const VERDICT_RE = /^\s*\**\s*VERDICT\**\s*:\s*\**\s*(PASS|FAIL)/gim;
|
|
48
|
+
const REASON_RE = /^\s*\**\s*REASON\**\s*:\s*\**\s*(.*)$/gim;
|
|
49
|
+
/**
|
|
50
|
+
* Parse a judge's raw output into a verdict + reason.
|
|
51
|
+
*
|
|
52
|
+
* Judges sometimes emit MORE than one verdict block (a first pass, then a restated
|
|
53
|
+
* conclusion). Every block is read, never just the first:
|
|
54
|
+
* - all blocks agree → that verdict, with the reason from the LAST block (the
|
|
55
|
+
* judge's final word) in full
|
|
56
|
+
* - blocks disagree → JUDGE-AMBIGUOUS, which counts as a non-pass and carries both
|
|
57
|
+
* verdicts in the reason so a rejudge can be queued. Silently taking either one
|
|
58
|
+
* would be inventing a grade the judge did not give.
|
|
59
|
+
* Unparseable → ERROR.
|
|
60
|
+
*/
|
|
21
61
|
export function parseVerdict(out) {
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
62
|
+
const verdicts = [...out.matchAll(VERDICT_RE)].map((m) => m[1].toUpperCase());
|
|
63
|
+
if (verdicts.length === 0) {
|
|
24
64
|
return { verdict: "ERROR", reason: "judge produced no parseable verdict" };
|
|
25
65
|
}
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
66
|
+
const reasons = [...out.matchAll(REASON_RE)].map((m) => m[1].trim());
|
|
67
|
+
const reason = reasons.length > 0 ? reasons[reasons.length - 1] : "";
|
|
68
|
+
const unique = [...new Set(verdicts)];
|
|
69
|
+
if (unique.length > 1) {
|
|
70
|
+
return {
|
|
71
|
+
verdict: "JUDGE-AMBIGUOUS",
|
|
72
|
+
reason: `judge emitted conflicting verdicts (${verdicts.join(", ")}) — needs rejudge; last reason: ${reason}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { verdict: unique[0], reason };
|
|
30
76
|
}
|
|
31
77
|
/**
|
|
32
78
|
* Judge-≠-subject de-confound guard. True when the judge resembles the model
|
|
@@ -51,13 +97,30 @@ const ITEM_RE = /^\s*\d+[.)]\s*\**\s*(PASS|FAIL)\b/gim;
|
|
|
51
97
|
export function detectMisfire(raw, verdict) {
|
|
52
98
|
if (verdict === "ERROR")
|
|
53
99
|
return false;
|
|
100
|
+
// Conflicting verdicts are suspect by construction — there is no consistent grade.
|
|
101
|
+
if (verdict === "JUDGE-AMBIGUOUS")
|
|
102
|
+
return true;
|
|
54
103
|
const items = [...raw.matchAll(ITEM_RE)].map((m) => m[1].toUpperCase() === "PASS");
|
|
55
|
-
if (items.length === 0)
|
|
104
|
+
if (items.length === 0) {
|
|
105
|
+
// No item lines to cross-check, so fall back to the verdict-vs-reason shape: a FAIL
|
|
106
|
+
// whose reason says everything passed is the misfire class from REVIEW-FINDINGS
|
|
107
|
+
// finding 2. Deliberately narrow — an earlier version of this tripwire fired on
|
|
108
|
+
// terse genuine FAILs, so it requires an explicitly total claim ("all items ...
|
|
109
|
+
// pass", "every item ... satisfied") and no negation anywhere in the reason.
|
|
110
|
+
if (verdict === "FAIL") {
|
|
111
|
+
const reason = (raw.match(REASON_LINE_RE)?.[1] ?? "").trim();
|
|
112
|
+
const totalPass = /\b(all|every)\b[^.]*\b(pass(es|ed)?|satisf(y|ies|ied)|hold(s)?|met)\b/i.test(reason);
|
|
113
|
+
const negated = /\b(not|no|n't|fails?|failed|missing|except|but|however)\b/i.test(reason);
|
|
114
|
+
return totalPass && !negated;
|
|
115
|
+
}
|
|
56
116
|
return false; // fail-open
|
|
117
|
+
}
|
|
57
118
|
const andItems = items.every((ok) => ok);
|
|
58
119
|
const verdictBool = verdict === "PASS";
|
|
59
120
|
return verdictBool !== andItems;
|
|
60
121
|
}
|
|
122
|
+
// Non-global twin of REASON_RE: matchAll needs /g, a single .match() must not have it.
|
|
123
|
+
const REASON_LINE_RE = /^\s*\**\s*REASON\**\s*:\s*\**\s*(.*)$/im;
|
|
61
124
|
/** Drive the judge for one transcript and parse the result. */
|
|
62
125
|
export async function gradeTranscript(adapter, judge, prompt, cwd) {
|
|
63
126
|
const raw = await adapter.judge({ model: judge, prompt, cwd });
|
package/dist/index.d.ts
CHANGED
|
@@ -8,10 +8,15 @@ export * from "./journal.js";
|
|
|
8
8
|
export * from "./scheduler.js";
|
|
9
9
|
export * from "./reps.js";
|
|
10
10
|
export * from "./regrade.js";
|
|
11
|
+
export * from "./rescore.js";
|
|
11
12
|
export * from "./workspace.js";
|
|
12
13
|
export * from "./seeded.js";
|
|
13
14
|
export * from "./report.js";
|
|
14
15
|
export * from "./trends.js";
|
|
15
16
|
export * from "./lint.js";
|
|
17
|
+
export * from "./sources.js";
|
|
18
|
+
export * from "./lift.js";
|
|
16
19
|
export * from "./adapters/types.js";
|
|
17
20
|
export * from "./util/exec.js";
|
|
21
|
+
export * from "./util/env.js";
|
|
22
|
+
export * from "./scaffold.js";
|
package/dist/index.js
CHANGED
|
@@ -8,11 +8,16 @@ export * from "./journal.js";
|
|
|
8
8
|
export * from "./scheduler.js";
|
|
9
9
|
export * from "./reps.js";
|
|
10
10
|
export * from "./regrade.js";
|
|
11
|
+
export * from "./rescore.js";
|
|
11
12
|
export * from "./workspace.js";
|
|
12
13
|
export * from "./seeded.js";
|
|
13
14
|
export * from "./report.js";
|
|
14
15
|
export * from "./trends.js";
|
|
15
16
|
export * from "./lint.js";
|
|
17
|
+
export * from "./sources.js";
|
|
18
|
+
export * from "./lift.js";
|
|
16
19
|
export * from "./adapters/types.js";
|
|
17
20
|
export * from "./util/exec.js";
|
|
21
|
+
export * from "./util/env.js";
|
|
22
|
+
export * from "./scaffold.js";
|
|
18
23
|
//# sourceMappingURL=index.js.map
|
package/dist/journal.d.ts
CHANGED
|
@@ -54,6 +54,20 @@ export type JournalEvent = {
|
|
|
54
54
|
id: string;
|
|
55
55
|
reason: string;
|
|
56
56
|
rep?: number;
|
|
57
|
+
} | {
|
|
58
|
+
event: "empty-response-retry";
|
|
59
|
+
ts: string;
|
|
60
|
+
id: string;
|
|
61
|
+
attempt: number;
|
|
62
|
+
rep?: number;
|
|
63
|
+
} | {
|
|
64
|
+
event: "rescore";
|
|
65
|
+
ts: string;
|
|
66
|
+
changed: string[];
|
|
67
|
+
passed: number;
|
|
68
|
+
total: number;
|
|
69
|
+
pct: number;
|
|
70
|
+
ship: boolean;
|
|
57
71
|
} | {
|
|
58
72
|
event: "score";
|
|
59
73
|
ts: string;
|
package/dist/lift.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type ResultsFile } from "./results.js";
|
|
2
|
+
import type { Verdict } from "./score.js";
|
|
3
|
+
/**
|
|
4
|
+
* What a skill did to one scenario, red (skill off) vs green (skill active).
|
|
5
|
+
*
|
|
6
|
+
* `inconclusive` is load-bearing: it is what stops lift from becoming a number
|
|
7
|
+
* that only ever goes up. See `classify`.
|
|
8
|
+
*/
|
|
9
|
+
export type LiftClass = "gained" | "regressed" | "kept" | "both-fail" | "inconclusive";
|
|
10
|
+
export interface LiftCell {
|
|
11
|
+
red: Verdict;
|
|
12
|
+
/**
|
|
13
|
+
* Whether the red side's verdict was an unresolved misfire. Carried (not just
|
|
14
|
+
* folded into `class`) because the review UI recomputes the class live as the
|
|
15
|
+
* author overrides green verdicts — it needs the red evidence, not a frozen
|
|
16
|
+
* conclusion. See assets/report.grade.js's liftClass.
|
|
17
|
+
*/
|
|
18
|
+
redSuspect: boolean;
|
|
19
|
+
green: Verdict;
|
|
20
|
+
class: LiftClass;
|
|
21
|
+
}
|
|
22
|
+
export interface Lift {
|
|
23
|
+
tag: string;
|
|
24
|
+
model: string;
|
|
25
|
+
redTimestamp: string;
|
|
26
|
+
greenTimestamp: string;
|
|
27
|
+
/** Scenario ids present in both runs — the only ones a lift can speak to. */
|
|
28
|
+
compared: number;
|
|
29
|
+
gained: number;
|
|
30
|
+
regressed: number;
|
|
31
|
+
kept: number;
|
|
32
|
+
bothFail: number;
|
|
33
|
+
inconclusive: number;
|
|
34
|
+
redPassed: number;
|
|
35
|
+
greenPassed: number;
|
|
36
|
+
delta: number;
|
|
37
|
+
/** Ids the green run covered that the red baseline did not (and vice versa). */
|
|
38
|
+
greenOnly: string[];
|
|
39
|
+
redOnly: string[];
|
|
40
|
+
/** True when either side was an `--only` run, so coverage is a subset by construction. */
|
|
41
|
+
partial: boolean;
|
|
42
|
+
cells: Record<string, LiftCell>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Compare a red (baseline, skill off) run against a green (skill active) run of
|
|
46
|
+
* the same model: the "does this skill actually do anything?" measurement.
|
|
47
|
+
*
|
|
48
|
+
* Both sides go through `effectiveVerdicts`, so an author override is what
|
|
49
|
+
* counts and an override resolves a misfire — the same rule scoring uses. Only
|
|
50
|
+
* the intersection of scenario ids is compared; a lift cannot speak to a
|
|
51
|
+
* scenario one side never ran.
|
|
52
|
+
*/
|
|
53
|
+
export declare function computeLift(red: ResultsFile, green: ResultsFile): Lift;
|
|
54
|
+
/** One line for a human: what the skill did, and what it cost. */
|
|
55
|
+
export declare function liftHeadline(lift: Lift): string;
|
|
56
|
+
/**
|
|
57
|
+
* Per model-tag under <skillDir>/tests/results/, pair the most recent red run
|
|
58
|
+
* with the most recent green run and compute the lift.
|
|
59
|
+
*
|
|
60
|
+
* Deliberately derived on read rather than persisted into results.yaml: a lift
|
|
61
|
+
* is a fact about a *pair* of runs, so caching it inside one run's file would go
|
|
62
|
+
* stale the moment a new baseline lands — the stale-scorecard failure mode
|
|
63
|
+
* `source_hashes` exists to prevent. Deriving also means lift works
|
|
64
|
+
* retroactively on results already committed by 0.1.x/0.2.0 users.
|
|
65
|
+
*
|
|
66
|
+
* A tag with no red baseline is omitted entirely rather than reported as a zero
|
|
67
|
+
* lift: "not measured" and "measured no effect" are different claims.
|
|
68
|
+
*/
|
|
69
|
+
export declare function collectLift(skillDir: string): Lift[];
|
package/dist/lift.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readResults, effectiveVerdicts } from "./results.js";
|
|
4
|
+
/** A verdict that carries real evidence about the task, rather than about the harness or the judge. */
|
|
5
|
+
function conclusive(verdict, suspect) {
|
|
6
|
+
// ERROR is a harness failure (timeout, empty reply) — it says nothing about
|
|
7
|
+
// whether the agent could do the task. JUDGE-AMBIGUOUS and an unresolved
|
|
8
|
+
// misfire are judge failures. Treating any of them as a FAIL would make
|
|
9
|
+
// "red FAIL -> green PASS" fire on infrastructure noise, and lift would
|
|
10
|
+
// measure flakiness while looking like skill value.
|
|
11
|
+
return !suspect && verdict !== "ERROR" && verdict !== "JUDGE-AMBIGUOUS";
|
|
12
|
+
}
|
|
13
|
+
function classify(red, green) {
|
|
14
|
+
if (!conclusive(red.verdict, red.suspect) || !conclusive(green.verdict, green.suspect))
|
|
15
|
+
return "inconclusive";
|
|
16
|
+
const redPass = red.verdict === "PASS";
|
|
17
|
+
const greenPass = green.verdict === "PASS";
|
|
18
|
+
if (redPass && greenPass)
|
|
19
|
+
return "kept";
|
|
20
|
+
if (!redPass && greenPass)
|
|
21
|
+
return "gained";
|
|
22
|
+
if (redPass && !greenPass)
|
|
23
|
+
return "regressed";
|
|
24
|
+
return "both-fail";
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Compare a red (baseline, skill off) run against a green (skill active) run of
|
|
28
|
+
* the same model: the "does this skill actually do anything?" measurement.
|
|
29
|
+
*
|
|
30
|
+
* Both sides go through `effectiveVerdicts`, so an author override is what
|
|
31
|
+
* counts and an override resolves a misfire — the same rule scoring uses. Only
|
|
32
|
+
* the intersection of scenario ids is compared; a lift cannot speak to a
|
|
33
|
+
* scenario one side never ran.
|
|
34
|
+
*/
|
|
35
|
+
export function computeLift(red, green) {
|
|
36
|
+
const redV = new Map(effectiveVerdicts(red.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
|
|
37
|
+
const greenV = new Map(effectiveVerdicts(green.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
|
|
38
|
+
const cells = {};
|
|
39
|
+
const counts = { gained: 0, regressed: 0, kept: 0, "both-fail": 0, inconclusive: 0 };
|
|
40
|
+
let redPassed = 0;
|
|
41
|
+
let greenPassed = 0;
|
|
42
|
+
// Green order drives display order (it is the run the author is looking at),
|
|
43
|
+
// restricted to ids the red baseline also covered.
|
|
44
|
+
for (const [id, g] of greenV) {
|
|
45
|
+
const r = redV.get(id);
|
|
46
|
+
if (!r)
|
|
47
|
+
continue;
|
|
48
|
+
const cls = classify(r, g);
|
|
49
|
+
cells[id] = { red: r.verdict, redSuspect: r.suspect, green: g.verdict, class: cls };
|
|
50
|
+
counts[cls]++;
|
|
51
|
+
if (cls !== "inconclusive") {
|
|
52
|
+
if (r.verdict === "PASS")
|
|
53
|
+
redPassed++;
|
|
54
|
+
if (g.verdict === "PASS")
|
|
55
|
+
greenPassed++;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
tag: "",
|
|
60
|
+
model: green.model,
|
|
61
|
+
redTimestamp: red.timestamp,
|
|
62
|
+
greenTimestamp: green.timestamp,
|
|
63
|
+
compared: Object.keys(cells).length,
|
|
64
|
+
gained: counts.gained,
|
|
65
|
+
regressed: counts.regressed,
|
|
66
|
+
kept: counts.kept,
|
|
67
|
+
bothFail: counts["both-fail"],
|
|
68
|
+
inconclusive: counts.inconclusive,
|
|
69
|
+
redPassed,
|
|
70
|
+
greenPassed,
|
|
71
|
+
delta: greenPassed - redPassed,
|
|
72
|
+
greenOnly: [...greenV.keys()].filter((id) => !redV.has(id)),
|
|
73
|
+
redOnly: [...redV.keys()].filter((id) => !greenV.has(id)),
|
|
74
|
+
partial: Boolean(red.partial || green.partial),
|
|
75
|
+
cells,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** One line for a human: what the skill did, and what it cost. */
|
|
79
|
+
export function liftHeadline(lift) {
|
|
80
|
+
if (lift.compared === 0)
|
|
81
|
+
return "no shared scenarios to compare";
|
|
82
|
+
// Everything inconclusive is NOT "no effect" — it is no measurement. Saying
|
|
83
|
+
// "no measured effect" here would be the same not-measured/measured-no-effect
|
|
84
|
+
// conflation this module refuses to make when a red baseline is missing.
|
|
85
|
+
const conclusive = lift.compared - lift.inconclusive;
|
|
86
|
+
if (conclusive === 0) {
|
|
87
|
+
return `nothing conclusive to compare (${lift.inconclusive} inconclusive — fix the harness/judge, then re-run)`;
|
|
88
|
+
}
|
|
89
|
+
const segments = [];
|
|
90
|
+
if (lift.gained === 0 && lift.regressed === 0) {
|
|
91
|
+
segments.push(lift.kept > 0
|
|
92
|
+
? `no measured effect (${lift.kept} passed without the skill too)`
|
|
93
|
+
: "no measured effect");
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const sign = lift.delta > 0 ? `+${lift.delta}` : String(lift.delta);
|
|
97
|
+
segments.push(`${sign} net (${lift.gained} gained, ${lift.regressed} regressed)`);
|
|
98
|
+
}
|
|
99
|
+
if (lift.inconclusive > 0)
|
|
100
|
+
segments.push(`${lift.inconclusive} inconclusive`);
|
|
101
|
+
if (lift.partial)
|
|
102
|
+
segments.push("partial run");
|
|
103
|
+
return segments.join(" · ");
|
|
104
|
+
}
|
|
105
|
+
/** A directory that exists right now; false (never throws) if it vanished concurrently. */
|
|
106
|
+
function isDir(p) {
|
|
107
|
+
try {
|
|
108
|
+
return statSync(p).isDirectory();
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Per model-tag under <skillDir>/tests/results/, pair the most recent red run
|
|
116
|
+
* with the most recent green run and compute the lift.
|
|
117
|
+
*
|
|
118
|
+
* Deliberately derived on read rather than persisted into results.yaml: a lift
|
|
119
|
+
* is a fact about a *pair* of runs, so caching it inside one run's file would go
|
|
120
|
+
* stale the moment a new baseline lands — the stale-scorecard failure mode
|
|
121
|
+
* `source_hashes` exists to prevent. Deriving also means lift works
|
|
122
|
+
* retroactively on results already committed by 0.1.x/0.2.0 users.
|
|
123
|
+
*
|
|
124
|
+
* A tag with no red baseline is omitted entirely rather than reported as a zero
|
|
125
|
+
* lift: "not measured" and "measured no effect" are different claims.
|
|
126
|
+
*/
|
|
127
|
+
export function collectLift(skillDir) {
|
|
128
|
+
const resultsRoot = join(skillDir, "tests", "results");
|
|
129
|
+
if (!existsSync(resultsRoot))
|
|
130
|
+
return [];
|
|
131
|
+
const lifts = [];
|
|
132
|
+
for (const tag of readdirSync(resultsRoot).filter((n) => isDir(join(resultsRoot, n))).sort()) {
|
|
133
|
+
const tagDir = join(resultsRoot, tag);
|
|
134
|
+
const runDirs = readdirSync(tagDir)
|
|
135
|
+
.map((n) => join(tagDir, n))
|
|
136
|
+
.filter((p) => isDir(p) && existsSync(join(p, "results.yaml")))
|
|
137
|
+
.sort(); // timestamp-slug names ⇒ chronological ascending
|
|
138
|
+
// Mode is only knowable after reading results.yaml, so every run in the tag
|
|
139
|
+
// is read; last-wins gives the most recent of each mode.
|
|
140
|
+
let red;
|
|
141
|
+
let green;
|
|
142
|
+
for (const rd of runDirs) {
|
|
143
|
+
let r;
|
|
144
|
+
try {
|
|
145
|
+
r = readResults(rd);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
// A corrupt/truncated results.yaml must not take down the whole view.
|
|
149
|
+
console.warn(`skill-harness lift: skipping unreadable run ${rd}: ${e instanceof Error ? e.message : e}`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (r.mode === "red")
|
|
153
|
+
red = r;
|
|
154
|
+
else if (r.mode === "green")
|
|
155
|
+
green = r;
|
|
156
|
+
}
|
|
157
|
+
if (!red || !green)
|
|
158
|
+
continue;
|
|
159
|
+
lifts.push({ ...computeLift(red, green), tag });
|
|
160
|
+
}
|
|
161
|
+
return lifts;
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=lift.js.map
|
package/dist/lint.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "consistency" | "lint-error";
|
|
1
|
+
export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "fixture-marker" | "consistency" | "stale" | "lint-error";
|
|
2
2
|
export interface LintFinding {
|
|
3
3
|
readonly skill: string;
|
|
4
4
|
readonly scenario?: string;
|