@skill-harness/core 0.29.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/README.md +16 -2
- package/dist/adapters/types.d.ts +2 -0
- package/dist/authoring-preflight.d.ts +22 -0
- package/dist/authoring-preflight.js +111 -0
- package/dist/authoring.d.ts +96 -0
- package/dist/authoring.js +451 -0
- package/dist/calibration.d.ts +138 -0
- package/dist/calibration.js +352 -0
- package/dist/canary.d.ts +28 -26
- package/dist/canary.js +109 -92
- package/dist/command-cost.d.ts +1 -1
- package/dist/command-cost.js +2 -0
- package/dist/diagnose.d.ts +17 -0
- package/dist/diagnose.js +70 -0
- package/dist/execution-trace.d.ts +2 -1
- package/dist/execution-trace.js +4 -3
- package/dist/grade.d.ts +8 -0
- package/dist/grade.js +37 -8
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/journal.d.ts +3 -0
- package/dist/judge-evidence.d.ts +40 -0
- package/dist/judge-evidence.js +120 -0
- package/dist/judge-references.d.ts +8 -0
- package/dist/judge-references.js +34 -0
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +22 -0
- package/dist/regate.js +21 -3
- package/dist/regrade.d.ts +10 -1
- package/dist/regrade.js +146 -75
- 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 +16 -4
- package/dist/results.js +10 -0
- package/dist/run.d.ts +2 -2
- package/dist/run.js +147 -116
- 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/seeded.js +1 -0
- package/dist/sources.d.ts +4 -1
- package/dist/sources.js +44 -40
- package/dist/spec.d.ts +8 -0
- package/dist/spec.js +27 -0
- package/dist/trace-gates.d.ts +2 -0
- package/dist/trace-gates.js +13 -3
- 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
- package/schemas/results-v2.schema.json +10 -0
- package/schemas/results-v3.schema.json +10 -0
- package/schemas/specification-v1.schema.json +21 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { loadSpec } from "./spec.js";
|
|
5
|
+
import { prepareJudgePrompt, judgeInWorkspace, parseStructuredJudgeReply } from "./grade.js";
|
|
6
|
+
import { loadExecutionFacts, transcriptResponses, unavailableExecution } from "./judge-evidence.js";
|
|
7
|
+
import { assertJudgeAllowed } from "./judge-policy.js";
|
|
8
|
+
import { rubricDigest } from "./sources.js";
|
|
9
|
+
import { resolveJudgeReferences } from "./judge-references.js";
|
|
10
|
+
import { withResourceMonitoring } from "./resource-monitor.js";
|
|
11
|
+
import { isProcessControlError, throwIfProcessAborted } from "./util/process.js";
|
|
12
|
+
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
13
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
14
|
+
const ID = /^[A-Za-z][A-Za-z0-9_-]{0,79}$/;
|
|
15
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
16
|
+
/** Selected artifacts stay beneath the manifest directory, including after symlink resolution. */
|
|
17
|
+
function selectedFile(root, path, limit = 2_097_152) {
|
|
18
|
+
if (!path || /[\\:\x00-\x1f]/.test(path) || path.startsWith("/") || path.split("/").some(part => !part || part === "." || part === ".."))
|
|
19
|
+
throw new Error(`Unsafe calibration path: ${path}`);
|
|
20
|
+
const abs = realpathSync(resolve(root, path));
|
|
21
|
+
const rel = relative(realpathSync(root), abs);
|
|
22
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith("../") || rel.startsWith("..\\"))
|
|
23
|
+
throw new Error(`Calibration path escapes its directory: ${path}`);
|
|
24
|
+
const info = statSync(abs);
|
|
25
|
+
if (!info.isFile() || info.size > limit)
|
|
26
|
+
throw new Error(`Calibration artifact is not a bounded regular file: ${path}`);
|
|
27
|
+
return abs;
|
|
28
|
+
}
|
|
29
|
+
function validateManifest(manifest, manifestPath, manifestSha256) {
|
|
30
|
+
if (!object(manifest) || manifest.schema !== 1 || manifest.purpose !== "judge-calibration" ||
|
|
31
|
+
typeof manifest.specFile !== "string" || !HASH.test(manifest.specSha256) || !Array.isArray(manifest.controls) ||
|
|
32
|
+
manifest.controls.length < 1 || manifest.controls.length > 100)
|
|
33
|
+
throw new Error("Invalid judge calibration manifest");
|
|
34
|
+
const specDir = dirname(resolve(manifestPath));
|
|
35
|
+
const specPath = selectedFile(specDir, manifest.specFile);
|
|
36
|
+
if (hash(readFileSync(specPath)) !== manifest.specSha256)
|
|
37
|
+
throw new Error("Calibration specification changed; prepare and review controls against the new specification");
|
|
38
|
+
const spec = loadSpec(specPath);
|
|
39
|
+
const ids = new Set();
|
|
40
|
+
const issues = [];
|
|
41
|
+
const controls = manifest.controls.map(control => {
|
|
42
|
+
if (!object(control) || typeof control.id !== "string" || !ID.test(control.id) || ids.has(control.id) ||
|
|
43
|
+
!["PASS", "FAIL"].includes(control.expected) || !["authored", "saved", "equivalent"].includes(control.kind) ||
|
|
44
|
+
typeof control.transcript !== "string" || !control.transcript.trim() || Buffer.byteLength(control.transcript) > 262_144 ||
|
|
45
|
+
typeof control.rationale !== "string" || !control.rationale.trim() || !object(control.review) ||
|
|
46
|
+
!["proposed", "accepted"].includes(control.review.status) ||
|
|
47
|
+
!HASH.test(control.transcriptSha256) || hash(control.transcript) !== control.transcriptSha256 || !HASH.test(control.rubricSha256)) {
|
|
48
|
+
throw new Error(`Invalid calibration control: ${String(control?.id)}`);
|
|
49
|
+
}
|
|
50
|
+
ids.add(control.id);
|
|
51
|
+
const scenario = spec.scenarios.find(s => s.id === control.scenarioId);
|
|
52
|
+
if (!scenario)
|
|
53
|
+
throw new Error(`Unknown calibration scenario: ${control.scenarioId}`);
|
|
54
|
+
const references = resolveJudgeReferences(scenario, specDir);
|
|
55
|
+
if (rubricDigest(scenario, specDir, references) !== control.rubricSha256)
|
|
56
|
+
throw new Error(`Calibration reference/rubric changed: ${control.id}`);
|
|
57
|
+
if (control.review.status !== "accepted" || typeof control.review.by !== "string" || !control.review.by.trim())
|
|
58
|
+
issues.push(`${control.id}: expected label needs author review`);
|
|
59
|
+
if (control.kind === "saved" && (typeof control.provenance !== "string" || !control.provenance.trim()))
|
|
60
|
+
throw new Error(`${control.id}: saved evidence needs provenance`);
|
|
61
|
+
if (control.kind === "saved" || control.sourceTranscript !== undefined) {
|
|
62
|
+
if (typeof control.sourceTranscript !== "string" || !control.sourceTranscript || !HASH.test(control.sourceTranscriptSha256 ?? ""))
|
|
63
|
+
throw new Error(`${control.id}: saved controls need a bound original transcript; use authored for constructed answers`);
|
|
64
|
+
const original = readFileSync(selectedFile(specDir, control.sourceTranscript));
|
|
65
|
+
if (hash(original) !== control.sourceTranscriptSha256 || !original.equals(Buffer.from(control.transcript)))
|
|
66
|
+
throw new Error(`${control.id}: original saved transcript changed or does not match the control`);
|
|
67
|
+
}
|
|
68
|
+
if (control.kind === "equivalent" && (typeof control.equivalentTo !== "string" || !ID.test(control.equivalentTo)))
|
|
69
|
+
throw new Error(`${control.id}: equivalent control needs equivalentTo`);
|
|
70
|
+
const responses = transcriptResponses(control.transcript, scenario);
|
|
71
|
+
if (responses === null || responses.length !== scenario.turns.length)
|
|
72
|
+
throw new Error(`${control.id}: retain the complete production-shaped transcript, not an answer-only reconstruction`);
|
|
73
|
+
const requests = control.transcript.split(/^>>> USER(?: \(turn \d+\/\d+\))?:[ \t]*$/m).slice(1)
|
|
74
|
+
.map(section => section.split(/^<<< ASSISTANT:[ \t]*$/m)[0].trim());
|
|
75
|
+
if (requests.length !== scenario.turns.length || requests.some((request, index) => request !== scenario.turns[index].trim()))
|
|
76
|
+
throw new Error(`${control.id}: transcript user turns do not match the scenario`);
|
|
77
|
+
if (scenario.mode === "seeded" && (!control.transcript.includes("=== SEEDED GATES ===") || !control.transcript.includes("=== STAGED DIFF ==="))) {
|
|
78
|
+
throw new Error(`${control.id}: seeded calibration must retain gates and staged-diff framing`);
|
|
79
|
+
}
|
|
80
|
+
let execution = unavailableExecution("This control has no bound execution trace; evaluate its supplied response/reference only.");
|
|
81
|
+
if (control.trace !== undefined) {
|
|
82
|
+
const trace = control.trace;
|
|
83
|
+
if (!object(trace) || typeof trace.path !== "string" || !HASH.test(trace.fileSha256) || !HASH.test(trace.sha256) || !object(trace.identity) ||
|
|
84
|
+
!["red", "green", "force"].includes(trace.identity.mode) || !Number.isSafeInteger(trace.identity.repetition) || trace.identity.repetition < 0)
|
|
85
|
+
throw new Error(`${control.id}: invalid trace binding`);
|
|
86
|
+
const path = selectedFile(specDir, trace.path);
|
|
87
|
+
if (hash(readFileSync(path)) !== trace.fileSha256)
|
|
88
|
+
throw new Error(`${control.id}: trace bytes changed`);
|
|
89
|
+
execution = loadExecutionFacts(path, { ...trace.identity, scenarioId: scenario.id, turns: scenario.turns.length, responses }, trace.sha256);
|
|
90
|
+
if (execution.status !== "verified" || execution.coverage !== "complete")
|
|
91
|
+
throw new Error(`${control.id}: execution evidence ERROR: ${execution.reason}`);
|
|
92
|
+
}
|
|
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" };
|
|
99
|
+
});
|
|
100
|
+
for (const control of controls.filter(c => c.kind === "equivalent")) {
|
|
101
|
+
const original = controls.find(c => c.id === control.equivalentTo);
|
|
102
|
+
if (!original || original.id === control.id || original.scenarioId !== control.scenarioId || original.expected !== control.expected || original.transcript === control.transcript)
|
|
103
|
+
throw new Error(`${control.id}: equivalent contrast must target a distinct same-scenario, same-label control`);
|
|
104
|
+
}
|
|
105
|
+
if (!controls.some(c => c.expected === "PASS"))
|
|
106
|
+
issues.push("Add a supported positive control");
|
|
107
|
+
if (!controls.some(c => c.expected === "FAIL"))
|
|
108
|
+
issues.push("Add a deliberately incorrect negative control");
|
|
109
|
+
if (!controls.some(c => c.expected === "PASS" && c.kind === "equivalent"))
|
|
110
|
+
issues.push("Add an equivalent correct answer to detect wording overfit");
|
|
111
|
+
return { manifestPath: resolve(manifestPath), manifestSha256, specDir, spec, controls,
|
|
112
|
+
calibrationScope: "answer-judge", executionGates: "not-evaluated", ready: !issues.length, issues };
|
|
113
|
+
}
|
|
114
|
+
/** Deterministic authoring helper. Hashes are produced by code, never requested from a model. */
|
|
115
|
+
export function prepareCalibration(options) {
|
|
116
|
+
const specPath = resolve(options.specPath);
|
|
117
|
+
const specDir = dirname(specPath);
|
|
118
|
+
const manifestPath = resolve(options.manifestPath ?? join(specDir, "calibration.json"));
|
|
119
|
+
if (dirname(manifestPath) !== specDir)
|
|
120
|
+
throw new Error("Keep calibration.json beside its specification; selected evidence stays below that directory");
|
|
121
|
+
const spec = loadSpec(specPath);
|
|
122
|
+
const manifest = {
|
|
123
|
+
schema: 1, purpose: "judge-calibration", specFile: basename(specPath), specSha256: hash(readFileSync(specPath)),
|
|
124
|
+
controls: options.controls.map(control => {
|
|
125
|
+
const scenario = spec.scenarios.find(s => s.id === control.scenarioId);
|
|
126
|
+
if (!scenario)
|
|
127
|
+
throw new Error(`Unknown calibration scenario: ${control.scenarioId}`);
|
|
128
|
+
const { id, scenarioId, expected, kind, transcript, rationale, equivalentTo, review, provenance, sourceTranscript, trace } = control;
|
|
129
|
+
return { id, scenarioId, expected, kind, transcript, rationale, equivalentTo, review, provenance, sourceTranscript, trace,
|
|
130
|
+
...(sourceTranscript ? { sourceTranscriptSha256: hash(readFileSync(selectedFile(specDir, sourceTranscript))) } : {}),
|
|
131
|
+
transcriptSha256: hash(transcript), rubricSha256: rubricDigest(scenario, specDir) };
|
|
132
|
+
}),
|
|
133
|
+
};
|
|
134
|
+
const bytes = JSON.stringify(manifest, null, 2) + "\n";
|
|
135
|
+
if (Buffer.byteLength(bytes) > 4_194_304)
|
|
136
|
+
throw new Error("Calibration manifest exceeds 4 MiB; select fewer controls");
|
|
137
|
+
const loaded = validateManifest(manifest, manifestPath, hash(bytes));
|
|
138
|
+
writeFileSync(manifestPath, bytes, { flag: "wx", mode: 0o600 });
|
|
139
|
+
return loaded;
|
|
140
|
+
}
|
|
141
|
+
/** Offline validation only. No Pi, provider access, or result rewrite. */
|
|
142
|
+
export function loadCalibration(manifestPath) {
|
|
143
|
+
const info = statSync(manifestPath);
|
|
144
|
+
if (!info.isFile() || info.size > 4_194_304)
|
|
145
|
+
throw new Error("Calibration manifest must be a regular file of at most 4 MiB");
|
|
146
|
+
const bytes = readFileSync(manifestPath);
|
|
147
|
+
if (bytes.length > 4_194_304)
|
|
148
|
+
throw new Error("Calibration manifest exceeds 4 MiB");
|
|
149
|
+
return validateManifest(JSON.parse(bytes.toString("utf8")), manifestPath, hash(bytes));
|
|
150
|
+
}
|
|
151
|
+
/** Explicitly selected prior evidence only: no directory discovery and no model calls. */
|
|
152
|
+
function calibrationPlan(loaded, options) {
|
|
153
|
+
const current = loadCalibration(loaded.manifestPath);
|
|
154
|
+
if (current.manifestSha256 !== loaded.manifestSha256)
|
|
155
|
+
throw new Error("Calibration changed after validation");
|
|
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("; ")}`);
|
|
266
|
+
if (options.adapter.name !== "pi")
|
|
267
|
+
throw new Error("Calibration requires Pi; direct provider adapters are unsupported");
|
|
268
|
+
assertJudgeAllowed(options.judge, { source: "calibration judge", allowMetered: options.allowMeteredJudge });
|
|
269
|
+
if (plan.judgeCallsPlanned && !await options.adapter.available())
|
|
270
|
+
throw new Error("Pi is unavailable; no judge calls made");
|
|
271
|
+
const output = resolve(options.output);
|
|
272
|
+
mkdirSync(output, { mode: 0o700 }); // exclusive new directory, including when a previous run was interrupted
|
|
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,
|
|
284
|
+
};
|
|
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
|
+
}
|
|
346
|
+
}
|
|
347
|
+
summary.status = summary.errors.length ? "error" : summary.matched === summary.cases ? "matched" : "mismatch";
|
|
348
|
+
save("summary.json", summary);
|
|
349
|
+
return summary;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
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;
|