@skill-harness/core 0.30.0 → 0.31.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/authoring-preflight.d.ts +9 -1
- package/dist/authoring-preflight.js +57 -4
- package/dist/authoring.js +11 -2
- package/dist/calibration.d.ts +57 -0
- package/dist/calibration.js +200 -60
- package/dist/canary.d.ts +28 -26
- package/dist/canary.js +109 -92
- package/dist/diagnose.d.ts +2 -0
- package/dist/diagnose.js +12 -3
- package/dist/grade.js +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/journal.d.ts +3 -0
- package/dist/lint.js +2 -2
- package/dist/regate.js +1 -0
- package/dist/regrade.d.ts +3 -1
- package/dist/regrade.js +90 -73
- package/dist/rescore.js +1 -0
- package/dist/resource-monitor.d.ts +3 -0
- package/dist/resource-monitor.js +72 -0
- package/dist/results.d.ts +9 -4
- package/dist/results.js +4 -0
- package/dist/run.d.ts +2 -2
- package/dist/run.js +128 -108
- package/dist/saved-run.d.ts +15 -0
- package/dist/saved-run.js +114 -0
- package/dist/scheduler.d.ts +3 -5
- package/dist/scheduler.js +18 -6
- package/dist/sources.js +13 -34
- package/dist/util/exec.d.ts +5 -0
- package/dist/util/exec.js +16 -32
- package/dist/util/fixture-inventory.d.ts +19 -0
- package/dist/util/fixture-inventory.js +68 -0
- package/dist/util/process.d.ts +78 -0
- package/dist/util/process.js +394 -0
- package/dist/workspace.js +13 -29
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Spec } from "./spec.js";
|
|
2
2
|
export interface AuthoringFinding {
|
|
3
3
|
scenario: string;
|
|
4
|
-
code: "INPUT_DISCOVERY" | "READ_COUNT" | "PATH_PORTABILITY" | "COMMAND_SPELLING" | "TEST_SYNTAX" | "CITATION_FORMAT" | "UNAVAILABLE_FORBIDDEN_TOOL" | "UNAVAILABLE_REQUIRED_TOOL";
|
|
4
|
+
code: "INPUT_DISCOVERY" | "READ_COUNT" | "PATH_PORTABILITY" | "COMMAND_SPELLING" | "TEST_SYNTAX" | "CITATION_FORMAT" | "UNAVAILABLE_FORBIDDEN_TOOL" | "UNAVAILABLE_REQUIRED_TOOL" | "JUDGE_REFERENCE_GAP" | "MISSING_COVERAGE_MAP";
|
|
5
5
|
severity?: "error";
|
|
6
6
|
message: string;
|
|
7
7
|
}
|
|
@@ -9,6 +9,14 @@ export interface AuthoringFinding {
|
|
|
9
9
|
export declare function inspectEvalAuthoring(spec: Spec, profile?: {
|
|
10
10
|
tools?: readonly string[];
|
|
11
11
|
fixtureFiles?: (scenario: Spec["scenarios"][number]) => readonly string[];
|
|
12
|
+
/** Explicitly selected fixture/reference text only. No host search or automatic judge disclosure. */
|
|
13
|
+
fixtureText?: (scenario: Spec["scenarios"][number], file: string) => string | null;
|
|
14
|
+
referenceText?: (file: string) => string | null;
|
|
12
15
|
}): AuthoringFinding[];
|
|
13
16
|
/** Names only, never file contents. Do not follow symlinks or inspect private host directories. */
|
|
14
17
|
export declare function fixtureInventory(root: string): string[];
|
|
18
|
+
/** Optional text inventory for offline lint. Bounded; never expands the judge's input. */
|
|
19
|
+
export declare function authoringEvidenceProfile(specDir: string): {
|
|
20
|
+
fixtureText: (s: Spec["scenarios"][number], file: string) => string | null;
|
|
21
|
+
referenceText: (file: string) => string | null;
|
|
22
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readdirSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative, isAbsolute, resolve } from "node:path";
|
|
3
3
|
import { MARKERS } from "./workspace.js";
|
|
4
4
|
/** Review hints only. A valid exact-format contract may intentionally trigger one. */
|
|
5
5
|
export function inspectEvalAuthoring(spec, profile = {}) {
|
|
@@ -7,6 +7,21 @@ export function inspectEvalAuthoring(spec, profile = {}) {
|
|
|
7
7
|
for (const s of spec.scenarios) {
|
|
8
8
|
const tools = profile.tools ?? s.tools;
|
|
9
9
|
const add = (code, message) => findings.push({ scenario: s.id, code, message });
|
|
10
|
+
if (!s.covers?.length)
|
|
11
|
+
add("MISSING_COVERAGE_MAP", "No instruction-section coverage is declared. Map the behavior this scenario can detect; a map is not proof of coverage.");
|
|
12
|
+
if (s.judgeEvidence === "response" && profile.fixtureText && profile.referenceText) {
|
|
13
|
+
const references = (s.judgeReferences ?? []).map(profile.referenceText).filter((text) => text !== null);
|
|
14
|
+
const normalize = (text) => text.replace(/\r\n/g, "\n").trim();
|
|
15
|
+
const visible = [...references, s.judgeContext ?? ""].map(normalize);
|
|
16
|
+
for (const file of profile.fixtureFiles?.(s) ?? []) {
|
|
17
|
+
if (!/\.(?:md|txt|json|ya?ml|csv)$/i.test(file))
|
|
18
|
+
continue;
|
|
19
|
+
const source = profile.fixtureText(s, file);
|
|
20
|
+
if (source !== null && normalize(source) && !visible.some(ref => ref.includes(normalize(source)))) {
|
|
21
|
+
add("JUDGE_REFERENCE_GAP", `Fixture ${file} is not present verbatim in judge references/context. Verify whether its claims are needed to judge valid answers, including brand, goals and conflicting facts. Deliberately private sources may remain excluded; document the reason. This is an inventory warning, not a semantic verdict.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
10
25
|
for (const call of s.traceAssert?.require_calls ?? []) {
|
|
11
26
|
const pattern = call.args?.path?.matches;
|
|
12
27
|
if (tools && !tools.includes(call.tool)) {
|
|
@@ -41,13 +56,18 @@ export function inspectEvalAuthoring(spec, profile = {}) {
|
|
|
41
56
|
/** Names only, never file contents. Do not follow symlinks or inspect private host directories. */
|
|
42
57
|
export function fixtureInventory(root) {
|
|
43
58
|
const files = [];
|
|
44
|
-
|
|
59
|
+
let entries = 0;
|
|
60
|
+
const visit = (directory, prefix, depth = 0) => {
|
|
61
|
+
if (depth > 32)
|
|
62
|
+
throw new Error("Fixture inventory exceeds 32 directory levels; select a smaller fixture");
|
|
45
63
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
64
|
+
if (++entries > 4096)
|
|
65
|
+
throw new Error("Fixture inventory exceeds 4096 entries; select a smaller fixture");
|
|
46
66
|
if (entry.name === ".git" || entry.isSymbolicLink())
|
|
47
67
|
continue;
|
|
48
68
|
const name = prefix + entry.name;
|
|
49
69
|
if (entry.isDirectory())
|
|
50
|
-
visit(join(directory, entry.name), name + "/");
|
|
70
|
+
visit(join(directory, entry.name), name + "/", depth + 1);
|
|
51
71
|
else if (entry.isFile())
|
|
52
72
|
files.push(MARKERS.some(marker => name.startsWith(marker + "/")) ? name.slice(name.indexOf("/") + 1) : name);
|
|
53
73
|
}
|
|
@@ -55,4 +75,37 @@ export function fixtureInventory(root) {
|
|
|
55
75
|
visit(root, "");
|
|
56
76
|
return [...new Set(files)].sort();
|
|
57
77
|
}
|
|
78
|
+
/** Optional text inventory for offline lint. Bounded; never expands the judge's input. */
|
|
79
|
+
export function authoringEvidenceProfile(specDir) {
|
|
80
|
+
let remaining = 2 * 1024 * 1024;
|
|
81
|
+
const cache = new Map();
|
|
82
|
+
const text = (file) => {
|
|
83
|
+
const path = resolve(specDir, file);
|
|
84
|
+
if (cache.has(path))
|
|
85
|
+
return cache.get(path);
|
|
86
|
+
let value = null;
|
|
87
|
+
try {
|
|
88
|
+
const rel = relative(realpathSync(specDir), realpathSync(path));
|
|
89
|
+
const stat = statSync(path), size = stat.size;
|
|
90
|
+
if (!stat.isFile() || isAbsolute(rel) || rel === ".." || rel.startsWith("../") || rel.startsWith("..\\") || size > 65536 || size > remaining)
|
|
91
|
+
return null;
|
|
92
|
+
remaining -= size;
|
|
93
|
+
value = new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path));
|
|
94
|
+
}
|
|
95
|
+
catch { /* Unreadable/binary source is not invented as empty evidence. */ }
|
|
96
|
+
cache.set(path, value);
|
|
97
|
+
return value;
|
|
98
|
+
};
|
|
99
|
+
const fixture = (s) => typeof s.workspace === "object" ? s.workspace.fixture : s.fixture;
|
|
100
|
+
return {
|
|
101
|
+
fixtureText: (s, file) => {
|
|
102
|
+
const root = fixture(s);
|
|
103
|
+
if (!root)
|
|
104
|
+
return null;
|
|
105
|
+
// Overlay order is the same as workspace creation: later pending content wins.
|
|
106
|
+
return text(join(root, "_uncommitted", file)) ?? text(join(root, "_staged", file)) ?? text(join(root, file));
|
|
107
|
+
},
|
|
108
|
+
referenceText: text,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
58
111
|
//# sourceMappingURL=authoring-preflight.js.map
|
package/dist/authoring.js
CHANGED
|
@@ -300,7 +300,15 @@ function draftIssues(state, draft) {
|
|
|
300
300
|
issues.push(`${scenario.id} support file is missing: ${path}`);
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
|
-
for (const finding of inspectEvalAuthoring(spec, {
|
|
303
|
+
for (const finding of inspectEvalAuthoring(spec, {
|
|
304
|
+
referenceText: path => lookup("evals", path),
|
|
305
|
+
fixtureText: (scenario, path) => {
|
|
306
|
+
const fixture = typeof scenario.workspace === "object" ? scenario.workspace.fixture : scenario.fixture;
|
|
307
|
+
if (!fixture)
|
|
308
|
+
return null;
|
|
309
|
+
const prefix = fixture.replace(/\/$/, "");
|
|
310
|
+
return lookup("evals", `${prefix}/_uncommitted/${path}`) ?? lookup("evals", `${prefix}/_staged/${path}`) ?? lookup("evals", `${prefix}/${path}`);
|
|
311
|
+
}, fixtureFiles: scenario => {
|
|
304
312
|
const fixture = typeof scenario.workspace === "object" ? scenario.workspace.fixture : scenario.fixture;
|
|
305
313
|
if (!fixture)
|
|
306
314
|
return [];
|
|
@@ -310,7 +318,8 @@ function draftIssues(state, draft) {
|
|
|
310
318
|
const actual = resolve(locations(state).evals, fixture);
|
|
311
319
|
noLinks(actual);
|
|
312
320
|
return [...new Set([...staged, ...(existsSync(actual) && lstatSync(actual).isDirectory() ? fixtureInventory(actual) : [])])];
|
|
313
|
-
}
|
|
321
|
+
}
|
|
322
|
+
})) {
|
|
314
323
|
(finding.severity === "error" ? issues : warnings).push(`${finding.scenario}: ${finding.message}`);
|
|
315
324
|
}
|
|
316
325
|
if (state.answers.placement === "separate-repo" && existsSync(join(state.skillDir, "tests", "specification.yaml"))) {
|
package/dist/calibration.d.ts
CHANGED
|
@@ -37,7 +37,11 @@ export interface LoadedCalibration {
|
|
|
37
37
|
spec: Spec;
|
|
38
38
|
controls: Array<BoundControl & {
|
|
39
39
|
prompt: string;
|
|
40
|
+
executionEvidence: "verified" | "unknown";
|
|
40
41
|
}>;
|
|
42
|
+
/** Calibration judges supplied answers; even a verified trace is not a gate evaluation. */
|
|
43
|
+
calibrationScope: "answer-judge";
|
|
44
|
+
executionGates: "not-evaluated";
|
|
41
45
|
ready: boolean;
|
|
42
46
|
issues: string[];
|
|
43
47
|
}
|
|
@@ -49,6 +53,43 @@ export declare function prepareCalibration(options: {
|
|
|
49
53
|
}): LoadedCalibration;
|
|
50
54
|
/** Offline validation only. No Pi, provider access, or result rewrite. */
|
|
51
55
|
export declare function loadCalibration(manifestPath: string): LoadedCalibration;
|
|
56
|
+
export interface CalibrationReuseSource {
|
|
57
|
+
directory: string;
|
|
58
|
+
manifestSha256: string;
|
|
59
|
+
artifacts: {
|
|
60
|
+
path: string;
|
|
61
|
+
sha256: string;
|
|
62
|
+
}[];
|
|
63
|
+
}
|
|
64
|
+
export interface CalibrationSelection {
|
|
65
|
+
only?: string[];
|
|
66
|
+
reuse?: string[];
|
|
67
|
+
}
|
|
68
|
+
export interface CalibrationPlan {
|
|
69
|
+
schema: 1;
|
|
70
|
+
purpose: "judge-calibration-plan";
|
|
71
|
+
manifestSha256: string;
|
|
72
|
+
judge: ModelRef;
|
|
73
|
+
calibrationScope: "answer-judge";
|
|
74
|
+
executionGates: "not-evaluated";
|
|
75
|
+
availableCases: number;
|
|
76
|
+
cases: number;
|
|
77
|
+
partial: boolean;
|
|
78
|
+
ready: boolean;
|
|
79
|
+
issues: string[];
|
|
80
|
+
notes: string[];
|
|
81
|
+
judgeCallsPlanned: number;
|
|
82
|
+
reused: number;
|
|
83
|
+
controls: {
|
|
84
|
+
id: string;
|
|
85
|
+
action: "judge" | "reuse";
|
|
86
|
+
source?: CalibrationReuseSource;
|
|
87
|
+
}[];
|
|
88
|
+
}
|
|
89
|
+
/** Preview eligible historical judgments. Reuse is not a fresh stability measurement. */
|
|
90
|
+
export declare function planCalibration(loaded: LoadedCalibration, options: CalibrationSelection & {
|
|
91
|
+
judge: ModelRef;
|
|
92
|
+
}): CalibrationPlan;
|
|
52
93
|
export interface CalibrationSummary {
|
|
53
94
|
schema: 1;
|
|
54
95
|
purpose: "judge-calibration";
|
|
@@ -60,6 +101,20 @@ export interface CalibrationSummary {
|
|
|
60
101
|
matched: number;
|
|
61
102
|
judgeCalls: number;
|
|
62
103
|
status: "matched" | "mismatch" | "error";
|
|
104
|
+
calibrationScope: "answer-judge";
|
|
105
|
+
executionGates: "not-evaluated";
|
|
106
|
+
availableCases: number;
|
|
107
|
+
partial: boolean;
|
|
108
|
+
disagreements: {
|
|
109
|
+
id: string;
|
|
110
|
+
expected: "PASS" | "FAIL";
|
|
111
|
+
observed: "PASS" | "FAIL";
|
|
112
|
+
}[];
|
|
113
|
+
reused: {
|
|
114
|
+
id: string;
|
|
115
|
+
source: CalibrationReuseSource;
|
|
116
|
+
}[];
|
|
117
|
+
/** Legacy direction aliases for label disagreements, not adjudicated judge errors. */
|
|
63
118
|
falsePasses: string[];
|
|
64
119
|
falseFailures: string[];
|
|
65
120
|
errors: string[];
|
|
@@ -72,6 +127,8 @@ export declare function runCalibration(loaded: LoadedCalibration, options: {
|
|
|
72
127
|
judge: ModelRef;
|
|
73
128
|
output: string;
|
|
74
129
|
allowMeteredJudge?: boolean;
|
|
130
|
+
only?: string[];
|
|
131
|
+
reuse?: string[];
|
|
75
132
|
onProgress?: (progress: {
|
|
76
133
|
id: string;
|
|
77
134
|
index: number;
|
package/dist/calibration.js
CHANGED
|
@@ -2,11 +2,13 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { loadSpec } from "./spec.js";
|
|
5
|
-
import { prepareJudgePrompt, judgeInWorkspace } from "./grade.js";
|
|
5
|
+
import { prepareJudgePrompt, judgeInWorkspace, parseStructuredJudgeReply } from "./grade.js";
|
|
6
6
|
import { loadExecutionFacts, transcriptResponses, unavailableExecution } from "./judge-evidence.js";
|
|
7
7
|
import { assertJudgeAllowed } from "./judge-policy.js";
|
|
8
8
|
import { rubricDigest } from "./sources.js";
|
|
9
9
|
import { resolveJudgeReferences } from "./judge-references.js";
|
|
10
|
+
import { withResourceMonitoring } from "./resource-monitor.js";
|
|
11
|
+
import { isProcessControlError, throwIfProcessAborted } from "./util/process.js";
|
|
10
12
|
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
11
13
|
const HASH = /^[a-f0-9]{64}$/;
|
|
12
14
|
const ID = /^[A-Za-z][A-Za-z0-9_-]{0,79}$/;
|
|
@@ -88,11 +90,12 @@ function validateManifest(manifest, manifestPath, manifestSha256) {
|
|
|
88
90
|
if (execution.status !== "verified" || execution.coverage !== "complete")
|
|
89
91
|
throw new Error(`${control.id}: execution evidence ERROR: ${execution.reason}`);
|
|
90
92
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const prompt = prepareJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript: control.transcript, execution, references }, specDir)
|
|
95
|
-
|
|
93
|
+
const scope = control.kind === "saved"
|
|
94
|
+
? "This is a selected saved answer. Only the separately verified execution facts establish observed execution."
|
|
95
|
+
: "This is an authored answer control. Any supplied code, staged diff, test output or gate trailer is synthetic control material, not evidence that the subject executed it.";
|
|
96
|
+
const prompt = prepareJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript: control.transcript, execution, references }, specDir) +
|
|
97
|
+
`\nCALIBRATION SCOPE (trusted harness metadata):\n${scope} Assess the supplied response/code against the semantic checklist and references. Tool use, execution and unchanged-file claims remain unverified unless supported by the separate execution facts. This run does not evaluate objective execution gates, even when a trace is verified. Keep the required JSON response format.\n`;
|
|
98
|
+
return { ...control, prompt, executionEvidence: execution.status === "verified" ? "verified" : "unknown" };
|
|
96
99
|
});
|
|
97
100
|
for (const control of controls.filter(c => c.kind === "equivalent")) {
|
|
98
101
|
const original = controls.find(c => c.id === control.equivalentTo);
|
|
@@ -105,7 +108,8 @@ function validateManifest(manifest, manifestPath, manifestSha256) {
|
|
|
105
108
|
issues.push("Add a deliberately incorrect negative control");
|
|
106
109
|
if (!controls.some(c => c.expected === "PASS" && c.kind === "equivalent"))
|
|
107
110
|
issues.push("Add an equivalent correct answer to detect wording overfit");
|
|
108
|
-
return { manifestPath: resolve(manifestPath), manifestSha256, specDir, spec, controls,
|
|
111
|
+
return { manifestPath: resolve(manifestPath), manifestSha256, specDir, spec, controls,
|
|
112
|
+
calibrationScope: "answer-judge", executionGates: "not-evaluated", ready: !issues.length, issues };
|
|
109
113
|
}
|
|
110
114
|
/** Deterministic authoring helper. Hashes are produced by code, never requested from a model. */
|
|
111
115
|
export function prepareCalibration(options) {
|
|
@@ -144,69 +148,205 @@ export function loadCalibration(manifestPath) {
|
|
|
144
148
|
throw new Error("Calibration manifest exceeds 4 MiB");
|
|
145
149
|
return validateManifest(JSON.parse(bytes.toString("utf8")), manifestPath, hash(bytes));
|
|
146
150
|
}
|
|
147
|
-
/**
|
|
148
|
-
|
|
151
|
+
/** Explicitly selected prior evidence only: no directory discovery and no model calls. */
|
|
152
|
+
function calibrationPlan(loaded, options) {
|
|
149
153
|
const current = loadCalibration(loaded.manifestPath);
|
|
150
154
|
if (current.manifestSha256 !== loaded.manifestSha256)
|
|
151
155
|
throw new Error("Calibration changed after validation");
|
|
152
|
-
if (!
|
|
153
|
-
|
|
156
|
+
if (options.only && (!options.only.length || new Set(options.only).size !== options.only.length ||
|
|
157
|
+
options.only.some(id => !current.controls.some(control => control.id === id))))
|
|
158
|
+
throw new Error("Select distinct existing calibration control IDs");
|
|
159
|
+
if ((options.reuse?.length ?? 0) > 20)
|
|
160
|
+
throw new Error("Select at most 20 prior calibration directories");
|
|
161
|
+
const selected = current.controls.filter(control => !options.only || options.only.includes(control.id));
|
|
162
|
+
const plan = {
|
|
163
|
+
schema: 1, purpose: "judge-calibration-plan", manifestSha256: current.manifestSha256, judge: options.judge,
|
|
164
|
+
calibrationScope: "answer-judge", executionGates: "not-evaluated", availableCases: current.controls.length,
|
|
165
|
+
cases: selected.length, partial: selected.length !== current.controls.length, ready: current.ready,
|
|
166
|
+
issues: [...current.issues], notes: [], judgeCallsPlanned: selected.length, reused: 0,
|
|
167
|
+
controls: selected.map(control => ({ id: control.id, action: "judge" })),
|
|
168
|
+
};
|
|
169
|
+
const retained = new Map();
|
|
170
|
+
// ModelRef currently has no sampling/settings fields. Unknown future settings must not silently reuse.
|
|
171
|
+
const sameJudge = (value) => value?.provider === options.judge.provider && value?.model === options.judge.model &&
|
|
172
|
+
Object.keys(value).sort().join(",") === "model,provider" && Object.keys(options.judge).sort().join(",") === "model,provider";
|
|
173
|
+
for (const directory of [...new Set(options.reuse ?? [])].map(path => resolve(path))) {
|
|
174
|
+
const evidence = [];
|
|
175
|
+
const files = new Map();
|
|
176
|
+
const read = (path, limit = 2_097_152) => {
|
|
177
|
+
const bytes = readFileSync(selectedFile(directory, path, limit));
|
|
178
|
+
evidence.push({ path, sha256: hash(bytes) });
|
|
179
|
+
files.set(path, bytes);
|
|
180
|
+
return JSON.parse(bytes.toString("utf8"));
|
|
181
|
+
};
|
|
182
|
+
const inputs = read("inputs.json", 33_554_432), summary = read("summary.json");
|
|
183
|
+
if (summary.schema !== 1 || summary.purpose !== "judge-calibration" || !HASH.test(summary.manifestSha256) ||
|
|
184
|
+
summary.manifestSha256 !== inputs.manifestSha256 || !Array.isArray(inputs.controls) || inputs.controls.length > 100 ||
|
|
185
|
+
!["matched", "mismatch", "error"].includes(summary.status) ||
|
|
186
|
+
!Array.isArray(summary.errors) || !Array.isArray(summary.unrun))
|
|
187
|
+
throw new Error(`Invalid prior calibration evidence: ${directory}`);
|
|
188
|
+
if (!sameJudge(inputs.judge) || !sameJudge(summary.judge)) {
|
|
189
|
+
plan.notes.push(`${directory}: different judge; no results reused`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const common = evidence.slice();
|
|
193
|
+
for (const control of selected) {
|
|
194
|
+
const prior = inputs.controls.find((candidate) => candidate.id === control.id);
|
|
195
|
+
if (!prior || summary.errors.includes(control.id) || summary.unrun.includes(control.id))
|
|
196
|
+
continue;
|
|
197
|
+
if (prior.scenarioId !== control.scenarioId || prior.expected !== control.expected || prior.prompt !== control.prompt ||
|
|
198
|
+
prior.transcript !== control.transcript || prior.transcriptSha256 !== control.transcriptSha256 ||
|
|
199
|
+
prior.rubricSha256 !== control.rubricSha256 || prior.review?.status !== "accepted" || !prior.review.by?.trim())
|
|
200
|
+
continue;
|
|
201
|
+
evidence.length = 0;
|
|
202
|
+
evidence.push(...common);
|
|
203
|
+
files.clear();
|
|
204
|
+
const request = read(`${control.id}.request.json`), result = read(`${control.id}.result.json`);
|
|
205
|
+
if (request.prompt !== control.prompt || request.sha256 !== hash(control.prompt) || result.expected !== control.expected ||
|
|
206
|
+
result.judgeFormat !== "json" || typeof result.raw !== "string" || !Array.isArray(result.rawReplies) ||
|
|
207
|
+
result.rawReplies.length < 1 || result.rawReplies.length > 2 || !Array.isArray(result.prompts) ||
|
|
208
|
+
result.prompts.length !== result.rawReplies.length || result.prompts[0] !== control.prompt ||
|
|
209
|
+
result.raw !== result.rawReplies.at(-1))
|
|
210
|
+
throw new Error(`Invalid prior control evidence: ${control.id} in ${directory}`);
|
|
211
|
+
for (let index = 0; index < result.rawReplies.length; index++) {
|
|
212
|
+
const sent = read(`${control.id}.attempt-${index + 1}.request.json`);
|
|
213
|
+
const reply = read(`${control.id}.attempt-${index + 1}.reply.json`);
|
|
214
|
+
if (typeof result.rawReplies[index] !== "string" || typeof result.prompts[index] !== "string" ||
|
|
215
|
+
sent.prompt !== result.prompts[index] || sent.sha256 !== hash(sent.prompt) ||
|
|
216
|
+
reply.reply !== result.rawReplies[index] || reply.sha256 !== hash(reply.reply))
|
|
217
|
+
throw new Error(`Prior request/reply changed: ${control.id} in ${directory}`);
|
|
218
|
+
}
|
|
219
|
+
const scenario = current.spec.scenarios.find(scenario => scenario.id === control.scenarioId);
|
|
220
|
+
if (result.prompts.length === 2) {
|
|
221
|
+
let validationError;
|
|
222
|
+
try {
|
|
223
|
+
parseStructuredJudgeReply(result.rawReplies[0], scenario.checklist.length);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
validationError = error.message;
|
|
227
|
+
}
|
|
228
|
+
// Bind the validation-guided retry as well as the initial request; arbitrary follow-ups are not equivalent.
|
|
229
|
+
const retry = `${control.prompt}\n\nYour reply was not valid: ${JSON.stringify(validationError)}; reply with only the JSON object.`;
|
|
230
|
+
if (validationError === undefined || /^\[judge error:/i.test(result.rawReplies[0].trim()) || result.prompts[1] !== retry) {
|
|
231
|
+
throw new Error(`Prior retry prompt is not equivalent: ${control.id} in ${directory}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const parsed = parseStructuredJudgeReply(result.raw, scenario.checklist.length);
|
|
235
|
+
const consistent = parsed.verdict === "PASS" ? parsed.criteria.every(vote => vote.verdict === "PASS") : parsed.criteria.some(vote => vote.verdict === "FAIL");
|
|
236
|
+
if (result.suspect || !consistent || parsed.verdict !== result.verdict || parsed.reason !== result.reason ||
|
|
237
|
+
JSON.stringify(parsed.criteria) !== JSON.stringify(result.criteria))
|
|
238
|
+
throw new Error(`Invalid prior judgment: ${control.id} in ${directory}`);
|
|
239
|
+
if (retained.has(control.id) && retained.get(control.id).result.verdict !== result.verdict) {
|
|
240
|
+
plan.issues.push(`${control.id}: prior judgments disagree; inspect them or select a fresh run explicitly`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (!retained.has(control.id)) {
|
|
244
|
+
// Freeze the exact selected records now; later writes cannot substitute changed source bytes.
|
|
245
|
+
retained.set(control.id, { result, files: new Map(files) });
|
|
246
|
+
const row = plan.controls.find(row => row.id === control.id);
|
|
247
|
+
row.action = "reuse";
|
|
248
|
+
row.source = { directory, manifestSha256: summary.manifestSha256, artifacts: evidence.slice() };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
plan.reused = retained.size;
|
|
253
|
+
plan.judgeCallsPlanned -= retained.size;
|
|
254
|
+
plan.ready = plan.issues.length === 0;
|
|
255
|
+
return { current, selected, plan, retained };
|
|
256
|
+
}
|
|
257
|
+
/** Preview eligible historical judgments. Reuse is not a fresh stability measurement. */
|
|
258
|
+
export function planCalibration(loaded, options) {
|
|
259
|
+
return calibrationPlan(loaded, options).plan;
|
|
260
|
+
}
|
|
261
|
+
/** Explicit Pi-backed judging; semantic mismatches continue, infrastructure errors stop the remaining calls. */
|
|
262
|
+
export async function runCalibration(loaded, options) {
|
|
263
|
+
const { current, selected, plan, retained } = calibrationPlan(loaded, options);
|
|
264
|
+
if (!plan.ready)
|
|
265
|
+
throw new Error(`Calibration is not ready: ${plan.issues.join("; ")}`);
|
|
154
266
|
if (options.adapter.name !== "pi")
|
|
155
267
|
throw new Error("Calibration requires Pi; direct provider adapters are unsupported");
|
|
156
268
|
assertJudgeAllowed(options.judge, { source: "calibration judge", allowMetered: options.allowMeteredJudge });
|
|
157
|
-
if (!await options.adapter.available())
|
|
269
|
+
if (plan.judgeCallsPlanned && !await options.adapter.available())
|
|
158
270
|
throw new Error("Pi is unavailable; no judge calls made");
|
|
159
271
|
const output = resolve(options.output);
|
|
160
272
|
mkdirSync(output, { mode: 0o700 }); // exclusive new directory, including when a previous run was interrupted
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
let attempt = 0;
|
|
173
|
-
const observedAdapter = {
|
|
174
|
-
name: "pi", available: () => options.adapter.available(), run: request => options.adapter.run(request),
|
|
175
|
-
judge: async (request) => {
|
|
176
|
-
const number = ++attempt;
|
|
177
|
-
save(`${control.id}.attempt-${number}.request.json`, { prompt: request.prompt, sha256: hash(request.prompt) });
|
|
178
|
-
summary.judgeCalls++;
|
|
179
|
-
const reply = await options.adapter.judge(request);
|
|
180
|
-
save(`${control.id}.attempt-${number}.reply.json`, { reply, sha256: hash(reply) });
|
|
181
|
-
return reply;
|
|
182
|
-
},
|
|
273
|
+
return withResourceMonitoring(output, async () => {
|
|
274
|
+
throwIfProcessAborted();
|
|
275
|
+
const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
276
|
+
save("inputs.json", { manifestSha256: current.manifestSha256, judge: options.judge, controls: selected });
|
|
277
|
+
save("plan.json", plan);
|
|
278
|
+
const summary = {
|
|
279
|
+
schema: 1, purpose: "judge-calibration", modelQualityScore: false, manifestSha256: current.manifestSha256,
|
|
280
|
+
judge: options.judge, cases: selected.length, completed: 0, matched: 0, judgeCalls: 0,
|
|
281
|
+
calibrationScope: "answer-judge", executionGates: "not-evaluated", availableCases: current.controls.length,
|
|
282
|
+
partial: plan.partial, disagreements: [], reused: [],
|
|
283
|
+
status: "matched", falsePasses: [], falseFailures: [], errors: [], unrun: [], output,
|
|
183
284
|
};
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
285
|
+
for (const [index, control] of selected.entries()) {
|
|
286
|
+
throwIfProcessAborted();
|
|
287
|
+
const scenario = current.spec.scenarios.find(s => s.id === control.scenarioId);
|
|
288
|
+
let attempt = 0;
|
|
289
|
+
const observedAdapter = {
|
|
290
|
+
name: "pi", available: () => options.adapter.available(), run: request => options.adapter.run(request),
|
|
291
|
+
judge: async (request) => {
|
|
292
|
+
throwIfProcessAborted();
|
|
293
|
+
const number = ++attempt;
|
|
294
|
+
save(`${control.id}.attempt-${number}.request.json`, { prompt: request.prompt, sha256: hash(request.prompt) });
|
|
295
|
+
summary.judgeCalls++;
|
|
296
|
+
const reply = await options.adapter.judge(request);
|
|
297
|
+
save(`${control.id}.attempt-${number}.reply.json`, { reply, sha256: hash(reply) });
|
|
298
|
+
throwIfProcessAborted();
|
|
299
|
+
return reply;
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
let result;
|
|
303
|
+
try {
|
|
304
|
+
const prior = retained.get(control.id);
|
|
305
|
+
if (prior) {
|
|
306
|
+
const source = plan.controls.find(row => row.id === control.id).source;
|
|
307
|
+
result = prior.result;
|
|
308
|
+
// Historical records remain byte-identical and self-contained, never counted as fresh calls.
|
|
309
|
+
for (const [name, bytes] of prior.files)
|
|
310
|
+
writeFileSync(join(output, name), bytes, { flag: "wx", mode: 0o600 });
|
|
311
|
+
save(`${control.id}.reuse.json`, source);
|
|
312
|
+
summary.reused.push({ id: control.id, source });
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
save(`${control.id}.request.json`, { prompt: control.prompt, sha256: hash(control.prompt) });
|
|
316
|
+
options.onProgress?.({ id: control.id, index, total: selected.length });
|
|
317
|
+
result = await judgeInWorkspace(observedAdapter, options.judge, control.prompt, current.specDir, scenario.checklist.length);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (isProcessControlError(error))
|
|
322
|
+
throw error;
|
|
323
|
+
throwIfProcessAborted();
|
|
324
|
+
save(`${control.id}.error.json`, { status: "ERROR", message: String(error) });
|
|
325
|
+
summary.errors.push(control.id);
|
|
326
|
+
summary.unrun = selected.slice(index + 1).map(c => c.id);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
if (!retained.has(control.id))
|
|
330
|
+
save(`${control.id}.result.json`, { expected: control.expected, ...result });
|
|
331
|
+
if ((result.verdict !== "PASS" && result.verdict !== "FAIL") || result.suspect) {
|
|
332
|
+
summary.errors.push(control.id);
|
|
333
|
+
summary.unrun = selected.slice(index + 1).map(c => c.id);
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
summary.completed++;
|
|
337
|
+
if (result.verdict === control.expected)
|
|
338
|
+
summary.matched++;
|
|
339
|
+
else {
|
|
340
|
+
summary.disagreements.push({ id: control.id, expected: control.expected, observed: result.verdict });
|
|
341
|
+
if (result.verdict === "PASS")
|
|
342
|
+
summary.falsePasses.push(control.id);
|
|
343
|
+
else
|
|
344
|
+
summary.falseFailures.push(control.id);
|
|
345
|
+
}
|
|
187
346
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
break;
|
|
193
|
-
}
|
|
194
|
-
save(`${control.id}.result.json`, { expected: control.expected, ...result });
|
|
195
|
-
if ((result.verdict !== "PASS" && result.verdict !== "FAIL") || result.suspect) {
|
|
196
|
-
summary.errors.push(control.id);
|
|
197
|
-
summary.unrun = current.controls.slice(index + 1).map(c => c.id);
|
|
198
|
-
break;
|
|
199
|
-
}
|
|
200
|
-
summary.completed++;
|
|
201
|
-
if (result.verdict === control.expected)
|
|
202
|
-
summary.matched++;
|
|
203
|
-
else if (result.verdict === "PASS")
|
|
204
|
-
summary.falsePasses.push(control.id);
|
|
205
|
-
else
|
|
206
|
-
summary.falseFailures.push(control.id);
|
|
207
|
-
}
|
|
208
|
-
summary.status = summary.errors.length ? "error" : summary.matched === summary.cases ? "matched" : "mismatch";
|
|
209
|
-
save("summary.json", summary);
|
|
210
|
-
return summary;
|
|
347
|
+
summary.status = summary.errors.length ? "error" : summary.matched === summary.cases ? "matched" : "mismatch";
|
|
348
|
+
save("summary.json", summary);
|
|
349
|
+
return summary;
|
|
350
|
+
});
|
|
211
351
|
}
|
|
212
352
|
//# sourceMappingURL=calibration.js.map
|
package/dist/canary.d.ts
CHANGED
|
@@ -1,44 +1,46 @@
|
|
|
1
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
|
-
*/
|
|
2
|
+
/** A body-only quote target. Matching it is an observation, not proof of loading. */
|
|
15
3
|
export declare function deliveryAnchor(skillMd: string): string | null;
|
|
4
|
+
export interface CanaryObservations {
|
|
5
|
+
/** Local readability does not establish what Pi advertised to the subject. */
|
|
6
|
+
localSkillAvailable: boolean;
|
|
7
|
+
loadingRequested: boolean;
|
|
8
|
+
/** Successful completion of a read of the exact selected SKILL.md, if observable. */
|
|
9
|
+
successfulSkillRead: "observed" | "not-observed" | "unobserved";
|
|
10
|
+
/** Only the final assistant response is checked, never tool output or prompts. */
|
|
11
|
+
returnedAnchor: boolean | null;
|
|
12
|
+
/** The probe explicitly requests loading, so it cannot measure autonomous selection. */
|
|
13
|
+
autonomousSelection: "untested";
|
|
14
|
+
executionError?: string;
|
|
15
|
+
}
|
|
16
16
|
export interface CanaryResult {
|
|
17
|
-
/** `
|
|
17
|
+
/** `fail` is an inconclusive delivery check, not evidence that the skill is unavailable. */
|
|
18
18
|
status: "pass" | "fail" | "skipped";
|
|
19
|
-
/** What the probe looked for (null when skipped). */
|
|
20
19
|
anchor: string | null;
|
|
21
|
-
/** Why it was skipped, or what the model said instead (trimmed) — for the log and the journal. */
|
|
22
20
|
detail: string;
|
|
21
|
+
observations: CanaryObservations;
|
|
22
|
+
probeCalls: 0 | 1;
|
|
23
23
|
}
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Ask Pi to load the named skill through its advertised discovery mechanism.
|
|
26
|
+
* Neither the expected quote nor a fallback file path is disclosed: a supplied
|
|
27
|
+
* path would let the probe bypass missing skill discovery entirely.
|
|
28
|
+
*/
|
|
29
|
+
export declare function canaryPrompt(skillName: string, _anchor?: string): string;
|
|
25
30
|
export interface CanaryOptions {
|
|
26
31
|
adapter: HarnessAdapter;
|
|
27
32
|
model: ModelRef;
|
|
28
33
|
skillDir: string;
|
|
29
34
|
skillName: string;
|
|
30
|
-
/** Neutral cwd, same as a scenario gets
|
|
35
|
+
/** Neutral cwd, same as a scenario gets. */
|
|
31
36
|
cwd: string;
|
|
32
37
|
}
|
|
33
38
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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.
|
|
39
|
+
* One optional subject invocation, with read-only tools, before a green wave.
|
|
40
|
+
* A successful structured probe observes an instructed read and returned content;
|
|
41
|
+
* it does not establish autonomous skill selection in any later scenario. A
|
|
42
|
+
* failed quote is inconclusive, never proof that every later scenario is naked.
|
|
41
43
|
*/
|
|
42
44
|
export declare function runDeliveryCanary(opts: CanaryOptions): Promise<CanaryResult>;
|
|
43
|
-
/**
|
|
45
|
+
/** Stop the requested wave without converting an inconclusive probe into a claim. */
|
|
44
46
|
export declare function canaryFailure(skillName: string, result: CanaryResult, cliVersion: string | null): string;
|