@markstanden/opencode-test-container 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +263 -2
- package/container/Containerfile +24 -0
- package/container/README.md +29 -0
- package/dist/adapters/opencode/events.d.ts +90 -0
- package/dist/adapters/opencode/events.js +213 -0
- package/dist/adapters/opencode/runtime.d.ts +87 -0
- package/dist/adapters/opencode/runtime.js +136 -0
- package/dist/adapters/podman/container.d.ts +62 -0
- package/dist/adapters/podman/container.js +119 -0
- package/dist/adapters/podman/image.d.ts +50 -0
- package/dist/adapters/podman/image.js +124 -0
- package/dist/adapters/process.d.ts +38 -0
- package/dist/adapters/process.js +71 -0
- package/dist/campaign/assess.d.ts +45 -0
- package/dist/campaign/assess.js +208 -0
- package/dist/campaign/close.d.ts +17 -0
- package/dist/campaign/close.js +112 -0
- package/dist/campaign/evidence-guard.d.ts +24 -0
- package/dist/campaign/evidence-guard.js +49 -0
- package/dist/campaign/freeze.d.ts +94 -0
- package/dist/campaign/freeze.js +297 -0
- package/dist/campaign/inventory.d.ts +62 -0
- package/dist/campaign/inventory.js +192 -0
- package/dist/campaign/judge.d.ts +209 -0
- package/dist/campaign/judge.js +1285 -0
- package/dist/campaign/ledger.d.ts +64 -0
- package/dist/campaign/ledger.js +149 -0
- package/dist/campaign/prepare.d.ts +129 -0
- package/dist/campaign/prepare.js +483 -0
- package/dist/campaign/primary.d.ts +96 -0
- package/dist/campaign/primary.js +189 -0
- package/dist/campaign/run.d.ts +151 -0
- package/dist/campaign/run.js +562 -0
- package/dist/campaign/status.d.ts +76 -0
- package/dist/campaign/status.js +237 -0
- package/dist/cli/otc.d.ts +51 -0
- package/dist/cli/otc.js +435 -0
- package/dist/contracts/canonical.d.ts +21 -0
- package/dist/contracts/canonical.js +44 -0
- package/dist/contracts/types.d.ts +261 -0
- package/dist/contracts/types.js +9 -0
- package/dist/contracts/validate.d.ts +72 -0
- package/dist/contracts/validate.js +1021 -0
- package/dist/contracts/version.d.ts +44 -0
- package/dist/contracts/version.js +147 -0
- package/dist/evaluators/docs/analysis/accounting.d.ts +140 -0
- package/dist/evaluators/docs/analysis/accounting.js +237 -0
- package/dist/evaluators/docs/analysis/blinding.d.ts +40 -0
- package/dist/evaluators/docs/analysis/blinding.js +80 -0
- package/dist/evaluators/docs/analysis/bounds.d.ts +40 -0
- package/dist/evaluators/docs/analysis/bounds.js +31 -0
- package/dist/evaluators/docs/analysis/budget.d.ts +137 -0
- package/dist/evaluators/docs/analysis/budget.js +305 -0
- package/dist/evaluators/docs/analysis/closure.d.ts +175 -0
- package/dist/evaluators/docs/analysis/closure.js +251 -0
- package/dist/evaluators/docs/analysis/decision.d.ts +59 -0
- package/dist/evaluators/docs/analysis/decision.js +161 -0
- package/dist/evaluators/docs/analysis/freeze.d.ts +201 -0
- package/dist/evaluators/docs/analysis/freeze.js +395 -0
- package/dist/evaluators/docs/analysis/guardrails.d.ts +51 -0
- package/dist/evaluators/docs/analysis/guardrails.js +194 -0
- package/dist/evaluators/docs/analysis/journal.d.ts +150 -0
- package/dist/evaluators/docs/analysis/journal.js +475 -0
- package/dist/evaluators/docs/analysis/ledger.d.ts +78 -0
- package/dist/evaluators/docs/analysis/ledger.js +145 -0
- package/dist/evaluators/docs/analysis/measures.d.ts +302 -0
- package/dist/evaluators/docs/analysis/measures.js +1069 -0
- package/dist/evaluators/docs/analysis/model.d.ts +236 -0
- package/dist/evaluators/docs/analysis/model.js +740 -0
- package/dist/evaluators/docs/analysis/pairing.d.ts +68 -0
- package/dist/evaluators/docs/analysis/pairing.js +95 -0
- package/dist/evaluators/docs/analysis/records.d.ts +229 -0
- package/dist/evaluators/docs/analysis/records.js +935 -0
- package/dist/evaluators/docs/analysis/recovery.d.ts +225 -0
- package/dist/evaluators/docs/analysis/recovery.js +507 -0
- package/dist/evaluators/docs/analysis/render.d.ts +235 -0
- package/dist/evaluators/docs/analysis/render.js +566 -0
- package/dist/evaluators/docs/analysis/replay.d.ts +15 -0
- package/dist/evaluators/docs/analysis/replay.js +1272 -0
- package/dist/evaluators/docs/analysis/screening/inventory.d.ts +136 -0
- package/dist/evaluators/docs/analysis/screening/inventory.js +307 -0
- package/dist/evaluators/docs/analysis/screening/noteVersion.d.ts +15 -0
- package/dist/evaluators/docs/analysis/screening/noteVersion.js +46 -0
- package/dist/evaluators/docs/analysis/screening/reconcile.d.ts +38 -0
- package/dist/evaluators/docs/analysis/screening/reconcile.js +40 -0
- package/dist/evaluators/docs/analysis/screening/rules.d.ts +163 -0
- package/dist/evaluators/docs/analysis/screening/rules.js +330 -0
- package/dist/evaluators/docs/analysis/screening/scan.d.ts +72 -0
- package/dist/evaluators/docs/analysis/screening/scan.js +362 -0
- package/dist/evaluators/docs/analysis/selector.d.ts +68 -0
- package/dist/evaluators/docs/analysis/selector.js +123 -0
- package/dist/evaluators/docs/assess.d.ts +129 -0
- package/dist/evaluators/docs/assess.js +433 -0
- package/dist/evaluators/docs/campaign.d.ts +133 -0
- package/dist/evaluators/docs/campaign.js +222 -0
- package/dist/evaluators/docs/index.d.ts +25 -0
- package/dist/evaluators/docs/index.js +25 -0
- package/dist/evaluators/docs/journal.d.ts +150 -0
- package/dist/evaluators/docs/journal.js +475 -0
- package/dist/evaluators/docs/scoring.d.ts +367 -0
- package/dist/evaluators/docs/scoring.js +662 -0
- package/dist/evaluators/skills/assess.d.ts +94 -0
- package/dist/evaluators/skills/assess.js +207 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +29 -0
- package/dist/legacy/prod014.d.ts +86 -0
- package/dist/legacy/prod014.js +209 -0
- package/dist/reporting/report.d.ts +79 -0
- package/dist/reporting/report.js +242 -0
- package/docs/architecture.md +106 -0
- package/docs/capability-matrix.md +84 -0
- package/docs/offline-rehearsal.md +75 -0
- package/docs/parity-record.md +81 -0
- package/docs/pr-description.md +103 -0
- package/docs/release-0.1.0.md +52 -0
- package/docs/releasing.md +82 -0
- package/docs/schema.md +97 -0
- package/examples/skills/README.md +13 -0
- package/examples/skills/campaign.json +73 -0
- package/examples/skills/skills/otel-helper/SKILL.md +10 -0
- package/examples/skills/suite.json +29 -0
- package/package.json +45 -5
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The experiment freeze: every immutable input, bound before the first launch.
|
|
3
|
+
*
|
|
4
|
+
* A freeze is exclusive. It is written once, with `wx`, after checking that no
|
|
5
|
+
* trial, judge attempt or result exists yet, that both arm trees are clean, and
|
|
6
|
+
* that each arm actually served the description its registration measured.
|
|
7
|
+
* Verification later compares the same hashes, so a modified rubric, fixture,
|
|
8
|
+
* script, spec or swapped description invalidates the premise instead of
|
|
9
|
+
* silently changing the experiment.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
13
|
+
/** The current freeze record format. */
|
|
14
|
+
export const FREEZE_SCHEMA_VERSION = 2;
|
|
15
|
+
/**
|
|
16
|
+
* SHA-256 of exact text bytes.
|
|
17
|
+
* @param text The text.
|
|
18
|
+
* @returns The lowercase hex digest.
|
|
19
|
+
*/
|
|
20
|
+
export function sha256Text(text) {
|
|
21
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Extract the served description of one tool from a provider request body.
|
|
25
|
+
*
|
|
26
|
+
* The delivery probe saves the raw request body, so this is the actual payload
|
|
27
|
+
* the provider received — never a hand-extracted copy. A body without the tool
|
|
28
|
+
* is a problem, not an empty description.
|
|
29
|
+
* @param body The parsed request body.
|
|
30
|
+
* @param toolName The registered tool name.
|
|
31
|
+
* @returns The served description, or a problem.
|
|
32
|
+
*/
|
|
33
|
+
export function deliveredToolDescription(body, toolName = "docs") {
|
|
34
|
+
if (typeof body !== "object" || body === null) {
|
|
35
|
+
return { problem: "delivery request body is not an object" };
|
|
36
|
+
}
|
|
37
|
+
const tools = body.tools;
|
|
38
|
+
if (!Array.isArray(tools)) {
|
|
39
|
+
return { problem: "delivery request body has no tools array" };
|
|
40
|
+
}
|
|
41
|
+
for (const entry of tools) {
|
|
42
|
+
const served = servedDescription(entry, toolName);
|
|
43
|
+
if (served !== undefined)
|
|
44
|
+
return served;
|
|
45
|
+
}
|
|
46
|
+
return { problem: `delivery request body does not serve tool ${toolName}` };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Read one served tool's description.
|
|
50
|
+
* @param entry The request body's tool entry.
|
|
51
|
+
* @param toolName The registered tool name.
|
|
52
|
+
* @returns The served description, a problem for a malformed match, or
|
|
53
|
+
* undefined when the entry is not the named tool.
|
|
54
|
+
*/
|
|
55
|
+
function servedDescription(entry, toolName) {
|
|
56
|
+
if (typeof entry !== "object" || entry === null)
|
|
57
|
+
return undefined;
|
|
58
|
+
const fn = entry.function;
|
|
59
|
+
if (typeof fn !== "object" || fn === null)
|
|
60
|
+
return undefined;
|
|
61
|
+
if (fn.name !== toolName)
|
|
62
|
+
return undefined;
|
|
63
|
+
const description = fn.description;
|
|
64
|
+
if (typeof description !== "string" || description.trim() === "") {
|
|
65
|
+
return {
|
|
66
|
+
problem: `served tool ${toolName} has no nonblank description`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
delivered: {
|
|
71
|
+
description,
|
|
72
|
+
chars: [...description].length,
|
|
73
|
+
sha256: sha256Text(description),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Check one arm's registration against what the provider was served.
|
|
79
|
+
* @param arm The arm identity.
|
|
80
|
+
* @returns Problems; empty means registration and delivery agree.
|
|
81
|
+
*/
|
|
82
|
+
function armProblems(arm) {
|
|
83
|
+
const problems = [];
|
|
84
|
+
if (!arm.clean)
|
|
85
|
+
problems.push(`arm ${arm.id} worktree is dirty`);
|
|
86
|
+
if (arm.descriptionHash !== arm.deliveredHash) {
|
|
87
|
+
problems.push(`arm ${arm.id} delivered description differs from the registered one`);
|
|
88
|
+
}
|
|
89
|
+
if (arm.descriptionChars !== arm.deliveredChars) {
|
|
90
|
+
problems.push(`arm ${arm.id} delivered description length differs from the registered one`);
|
|
91
|
+
}
|
|
92
|
+
return problems;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Decide whether the experiment may be frozen.
|
|
96
|
+
*
|
|
97
|
+
* Refusals happen before anything is written: a started experiment, an
|
|
98
|
+
* existing freeze, a dirty arm, a missing or contradictory delivery, an absent
|
|
99
|
+
* input or a ceiling all block. Inputs are deduplicated by path and sorted, so
|
|
100
|
+
* two runs hash the same immutable set in the same order.
|
|
101
|
+
* @param input Everything the decision reads.
|
|
102
|
+
* @returns The record, or every problem.
|
|
103
|
+
*/
|
|
104
|
+
export function freezeExperiment(input) {
|
|
105
|
+
const problems = [
|
|
106
|
+
...stateProblems(input),
|
|
107
|
+
...armSetProblems(input.arms),
|
|
108
|
+
...identityProblems(input),
|
|
109
|
+
...inputFileProblems(input.files),
|
|
110
|
+
...inventoryProblems(input.inventories),
|
|
111
|
+
];
|
|
112
|
+
if (problems.length > 0)
|
|
113
|
+
return { ok: false, problems };
|
|
114
|
+
const byPath = new Map();
|
|
115
|
+
for (const file of input.files)
|
|
116
|
+
byPath.set(file.path, file);
|
|
117
|
+
return {
|
|
118
|
+
ok: true,
|
|
119
|
+
record: {
|
|
120
|
+
schemaVersion: FREEZE_SCHEMA_VERSION,
|
|
121
|
+
campaign: input.campaign,
|
|
122
|
+
sourceRevision: input.sourceRevision,
|
|
123
|
+
sourceTree: input.sourceTree,
|
|
124
|
+
imageId: input.imageId,
|
|
125
|
+
containerCli: input.containerCli,
|
|
126
|
+
measurementSha256: input.measurementSha256,
|
|
127
|
+
arms: [...input.arms].sort((left, right) => left.id.localeCompare(right.id)),
|
|
128
|
+
files: [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path)),
|
|
129
|
+
inventories: [...input.inventories]
|
|
130
|
+
.map((inventory) => ({
|
|
131
|
+
path: inventory.path,
|
|
132
|
+
files: [...inventory.files].sort((left, right) => left.path.localeCompare(right.path)),
|
|
133
|
+
}))
|
|
134
|
+
.sort((left, right) => left.path.localeCompare(right.path)),
|
|
135
|
+
stoppingRules: [...input.stoppingRules],
|
|
136
|
+
ceilings: input.ceilings,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The inventory preconditions.
|
|
142
|
+
* @param inventories The declared directory inventories.
|
|
143
|
+
* @returns Problems; empty means each inventory is usable.
|
|
144
|
+
*/
|
|
145
|
+
function inventoryProblems(inventories) {
|
|
146
|
+
const problems = [];
|
|
147
|
+
for (const inventory of inventories) {
|
|
148
|
+
if (inventory.path.trim() === "") {
|
|
149
|
+
problems.push("every inventory needs a directory path");
|
|
150
|
+
}
|
|
151
|
+
if (inventory.files.length === 0) {
|
|
152
|
+
problems.push(`inventory ${inventory.path} is empty`);
|
|
153
|
+
}
|
|
154
|
+
for (const file of inventory.files) {
|
|
155
|
+
if (!file.path.startsWith(`${inventory.path}/`)) {
|
|
156
|
+
problems.push(`inventory file ${file.path} is outside ${inventory.path}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return problems;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The start/existing-freeze preconditions.
|
|
164
|
+
* @param input The freeze input.
|
|
165
|
+
* @returns Problems; empty means the experiment has not begun.
|
|
166
|
+
*/
|
|
167
|
+
function stateProblems(input) {
|
|
168
|
+
const problems = [];
|
|
169
|
+
if (input.started) {
|
|
170
|
+
problems.push("refusing to freeze after a launch, judge attempt or recorded result exists");
|
|
171
|
+
}
|
|
172
|
+
if (input.frozen) {
|
|
173
|
+
problems.push("refusing to overwrite an existing freeze");
|
|
174
|
+
}
|
|
175
|
+
return problems;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The arm-set preconditions: at least two distinct arms, clean, delivered as
|
|
179
|
+
* registered, and not serving identical descriptions.
|
|
180
|
+
* @param arms The arms.
|
|
181
|
+
* @returns Problems; empty means the arm set is coherent.
|
|
182
|
+
*/
|
|
183
|
+
function armSetProblems(arms) {
|
|
184
|
+
const problems = [];
|
|
185
|
+
if (arms.length < 2)
|
|
186
|
+
problems.push("a comparison needs at least two arms");
|
|
187
|
+
const ids = arms.map((arm) => arm.id).sort();
|
|
188
|
+
if (new Set(ids).size !== ids.length) {
|
|
189
|
+
problems.push("arm ids must be distinct");
|
|
190
|
+
}
|
|
191
|
+
for (const arm of arms)
|
|
192
|
+
problems.push(...armProblems(arm));
|
|
193
|
+
const sorted = [...arms].sort((left, right) => left.id.localeCompare(right.id));
|
|
194
|
+
const first = sorted[0];
|
|
195
|
+
const second = sorted[1];
|
|
196
|
+
if (first !== undefined &&
|
|
197
|
+
second !== undefined &&
|
|
198
|
+
first.descriptionHash === second.descriptionHash) {
|
|
199
|
+
problems.push("the arms serve an identical description");
|
|
200
|
+
}
|
|
201
|
+
return problems;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The run-identity preconditions.
|
|
205
|
+
* @param input The freeze input.
|
|
206
|
+
* @returns Problems; empty means identity and rules are recorded.
|
|
207
|
+
*/
|
|
208
|
+
function identityProblems(input) {
|
|
209
|
+
return [
|
|
210
|
+
...requiredIdentities(input),
|
|
211
|
+
...ruleProblems(input.ceilings, input.stoppingRules),
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The image, CLI and measurement identity checks.
|
|
216
|
+
* @param input The freeze input.
|
|
217
|
+
* @returns Problems; empty means those identities are recorded.
|
|
218
|
+
*/
|
|
219
|
+
function requiredIdentities(input) {
|
|
220
|
+
const problems = [];
|
|
221
|
+
if (input.imageId === null || input.imageId.trim() === "") {
|
|
222
|
+
problems.push("no container image identity was observed");
|
|
223
|
+
}
|
|
224
|
+
if (input.containerCli === null || input.containerCli.trim() === "") {
|
|
225
|
+
problems.push("no container CLI identity was observed");
|
|
226
|
+
}
|
|
227
|
+
if (input.measurementSha256 === null ||
|
|
228
|
+
input.measurementSha256.trim() === "") {
|
|
229
|
+
problems.push("no measurement declaration was frozen");
|
|
230
|
+
}
|
|
231
|
+
return problems;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The ceiling and stopping-rule checks.
|
|
235
|
+
* @param ceilings The declared ceilings, when present.
|
|
236
|
+
* @param stoppingRules The declared stopping rules.
|
|
237
|
+
* @returns Problems; empty means the rules are recorded.
|
|
238
|
+
*/
|
|
239
|
+
function ruleProblems(ceilings, stoppingRules) {
|
|
240
|
+
const problems = [];
|
|
241
|
+
if (ceilings === null || ceilings.trial < 1 || ceilings.judge < 1) {
|
|
242
|
+
problems.push("the trial and judge ceilings must both be positive");
|
|
243
|
+
}
|
|
244
|
+
if (stoppingRules.length === 0) {
|
|
245
|
+
problems.push("the stopping rules must be recorded");
|
|
246
|
+
}
|
|
247
|
+
return problems;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The immutable-input preconditions.
|
|
251
|
+
* @param files The hashed inputs.
|
|
252
|
+
* @returns Problems; empty means every input is identified.
|
|
253
|
+
*/
|
|
254
|
+
function inputFileProblems(files) {
|
|
255
|
+
const problems = [];
|
|
256
|
+
if (files.length === 0)
|
|
257
|
+
problems.push("no immutable inputs were hashed");
|
|
258
|
+
for (const file of files) {
|
|
259
|
+
if (file.path.trim() === "" || file.sha256.trim() === "") {
|
|
260
|
+
problems.push("every immutable input needs a path and a hash");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return problems;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Write a verified freeze exclusively. An existing file refuses without
|
|
267
|
+
* touching its bytes.
|
|
268
|
+
* @param path The freeze file path.
|
|
269
|
+
* @param record The verified record.
|
|
270
|
+
* @returns Whether the write landed.
|
|
271
|
+
*/
|
|
272
|
+
export function writeFreeze(path, record) {
|
|
273
|
+
try {
|
|
274
|
+
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, {
|
|
275
|
+
encoding: "utf8",
|
|
276
|
+
flag: "wx",
|
|
277
|
+
});
|
|
278
|
+
return { ok: true };
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
reason: error.code === "EEXIST"
|
|
284
|
+
? "refusing to overwrite an existing freeze"
|
|
285
|
+
: `cannot write freeze: ${String(error)}`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Why a freeze cannot authorise new spend, or null when it can.
|
|
291
|
+
* @param record The recorded freeze, or undefined when absent.
|
|
292
|
+
* @returns The refusal, or null for a modern complete record.
|
|
293
|
+
*/
|
|
294
|
+
export function spendFreezeProblem(record) {
|
|
295
|
+
if (record === null || record === undefined) {
|
|
296
|
+
return "no freeze record; new spend requires a validated freeze";
|
|
297
|
+
}
|
|
298
|
+
if (record.schemaVersion !== FREEZE_SCHEMA_VERSION) {
|
|
299
|
+
return `freeze format ${String(record.schemaVersion)} is legacy and read-only; re-freeze before spending`;
|
|
300
|
+
}
|
|
301
|
+
return (inventoryRefusal(record) ??
|
|
302
|
+
ceilingRefusal(record.ceilings) ??
|
|
303
|
+
imageRefusal(record.imageId));
|
|
304
|
+
}
|
|
305
|
+
/** The inventory requirement, or null. */
|
|
306
|
+
function inventoryRefusal(record) {
|
|
307
|
+
return Array.isArray(record.inventories) && record.inventories.length > 0
|
|
308
|
+
? null
|
|
309
|
+
: "the freeze carries no input inventory; it cannot detect added inputs";
|
|
310
|
+
}
|
|
311
|
+
/** The ceiling requirement, or null. */
|
|
312
|
+
function ceilingRefusal(ceilings) {
|
|
313
|
+
if (ceilings === null)
|
|
314
|
+
return "the freeze carries no valid trial and judge ceilings";
|
|
315
|
+
const valid = Number.isInteger(ceilings.trial) &&
|
|
316
|
+
Number.isInteger(ceilings.judge) &&
|
|
317
|
+
ceilings.trial > 0 &&
|
|
318
|
+
ceilings.judge > 0;
|
|
319
|
+
return valid
|
|
320
|
+
? null
|
|
321
|
+
: "the freeze carries no valid trial and judge ceilings";
|
|
322
|
+
}
|
|
323
|
+
/** The image requirement, or null. */
|
|
324
|
+
function imageRefusal(imageId) {
|
|
325
|
+
return imageId.trim() === ""
|
|
326
|
+
? "the freeze carries no container image identity"
|
|
327
|
+
: null;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Compare observed inputs with the frozen record, including directory
|
|
331
|
+
* inventories in both directions so an added input is caught too.
|
|
332
|
+
* @param record The frozen record.
|
|
333
|
+
* @param observation What is present now.
|
|
334
|
+
* @returns Every mismatch; empty means the premise still holds.
|
|
335
|
+
*/
|
|
336
|
+
export function verifyFreeze(record, observation) {
|
|
337
|
+
const problems = [];
|
|
338
|
+
if (observation.sourceRevision !== record.sourceRevision) {
|
|
339
|
+
problems.push(`source revision changed: ${record.sourceRevision} to ${observation.sourceRevision}`);
|
|
340
|
+
}
|
|
341
|
+
if (observation.imageId !== record.imageId) {
|
|
342
|
+
problems.push(`container image changed: ${record.imageId} to ${observation.imageId}`);
|
|
343
|
+
}
|
|
344
|
+
if (observation.containerCli !== record.containerCli) {
|
|
345
|
+
problems.push(`container CLI changed: ${record.containerCli} to ${observation.containerCli}`);
|
|
346
|
+
}
|
|
347
|
+
const frozen = new Map(record.files.map((file) => [file.path, file.sha256]));
|
|
348
|
+
for (const file of observation.files) {
|
|
349
|
+
if (frozen.get(file.path) !== file.sha256) {
|
|
350
|
+
problems.push(`immutable input changed: ${file.path}`);
|
|
351
|
+
}
|
|
352
|
+
frozen.delete(file.path);
|
|
353
|
+
}
|
|
354
|
+
for (const path of [...frozen.keys()].sort()) {
|
|
355
|
+
problems.push(`immutable input missing: ${path}`);
|
|
356
|
+
}
|
|
357
|
+
problems.push(...inventoryProblemsOf(record.inventories, observation.inventories));
|
|
358
|
+
return problems;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Compare every declared directory inventory with what is present.
|
|
362
|
+
* @param frozen The recorded inventories.
|
|
363
|
+
* @param observed The inventories read now.
|
|
364
|
+
* @returns Added, changed and missing file problems.
|
|
365
|
+
*/
|
|
366
|
+
function inventoryProblemsOf(frozen, observed) {
|
|
367
|
+
const problems = [];
|
|
368
|
+
const byPath = new Map(observed.map((entry) => [entry.path, entry]));
|
|
369
|
+
for (const inventory of frozen) {
|
|
370
|
+
const current = byPath.get(inventory.path);
|
|
371
|
+
if (current === undefined) {
|
|
372
|
+
problems.push(`inventory missing: ${inventory.path}`);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
byPath.delete(inventory.path);
|
|
376
|
+
const expected = new Map(inventory.files.map((file) => [file.path, file.sha256]));
|
|
377
|
+
for (const file of current.files) {
|
|
378
|
+
if (expected.get(file.path) !== file.sha256) {
|
|
379
|
+
problems.push(`immutable input changed: ${file.path}`);
|
|
380
|
+
}
|
|
381
|
+
expected.delete(file.path);
|
|
382
|
+
}
|
|
383
|
+
for (const path of [...expected.keys()].sort()) {
|
|
384
|
+
problems.push(`immutable input missing: ${path}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
for (const path of [...byPath.keys()].sort()) {
|
|
388
|
+
problems.push(`unexpected inventory: ${path}`);
|
|
389
|
+
}
|
|
390
|
+
return problems;
|
|
391
|
+
}
|
|
392
|
+
/** Whether a path already holds a freeze. */
|
|
393
|
+
export function freezeExists(path) {
|
|
394
|
+
return existsSync(path);
|
|
395
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-compensable guardrail evaluation.
|
|
3
|
+
*
|
|
4
|
+
* Each declared guardrail is evaluated per trial into pass, fail, unknown or
|
|
5
|
+
* not-applicable with its evidence, then summarised per arm. A missing
|
|
6
|
+
* policy record stays unknown, never a false pass; and a textual gate reason
|
|
7
|
+
* can raise an unresolved guardrail but can never establish semantic loss on
|
|
8
|
+
* its own.
|
|
9
|
+
*/
|
|
10
|
+
import type { GuardrailKind, GuardrailSpec, ScenarioIdentity } from "./model.ts";
|
|
11
|
+
import type { TrialRecord } from "./records.ts";
|
|
12
|
+
/** One guardrail's state for one trial. */
|
|
13
|
+
export type GuardrailStatus = "pass" | "fail" | "unknown" | "not-applicable";
|
|
14
|
+
/** One trial's guardrail finding with its evidence. */
|
|
15
|
+
export interface GuardrailTrialFinding {
|
|
16
|
+
/** The evaluated status. */
|
|
17
|
+
status: GuardrailStatus;
|
|
18
|
+
/** Evidence lines supporting a failure or an unresolved state. */
|
|
19
|
+
evidence: string[];
|
|
20
|
+
}
|
|
21
|
+
/** One guardrail's summary for an arm or population. */
|
|
22
|
+
export interface GuardrailArmSummary {
|
|
23
|
+
/** The declared guardrail id. */
|
|
24
|
+
guardrailId: string;
|
|
25
|
+
/** The declared guardrail kind. */
|
|
26
|
+
kind: GuardrailKind;
|
|
27
|
+
/** The declared requirement, quoted verbatim. */
|
|
28
|
+
requirement: string;
|
|
29
|
+
/** The population status: breached, unresolved, clean or not applicable. */
|
|
30
|
+
status: "clean" | "breached" | "unresolved" | "not-applicable";
|
|
31
|
+
/** Per-status trial counts. */
|
|
32
|
+
coverage: Record<GuardrailStatus, number>;
|
|
33
|
+
/** Stamp-prefixed evidence lines for failures and unresolved trials. */
|
|
34
|
+
evidence: string[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Evaluate one declared guardrail for one trial.
|
|
38
|
+
* @param guardrail The declared guardrail.
|
|
39
|
+
* @param record The loaded trial record.
|
|
40
|
+
* @param scenario The trial's declared scenario, when known.
|
|
41
|
+
* @returns The trial finding.
|
|
42
|
+
*/
|
|
43
|
+
export declare function evaluateGuardrail(guardrail: GuardrailSpec, record: TrialRecord, scenario: ScenarioIdentity | undefined): GuardrailTrialFinding;
|
|
44
|
+
/**
|
|
45
|
+
* Summarise every declared guardrail over one population.
|
|
46
|
+
* @param guardrails The declared guardrails.
|
|
47
|
+
* @param scenarios The declared scenarios, for inventory lookup.
|
|
48
|
+
* @param trials The loaded trial records in the population.
|
|
49
|
+
* @returns One summary per guardrail, in declared order.
|
|
50
|
+
*/
|
|
51
|
+
export declare function summariseGuardrails(guardrails: readonly GuardrailSpec[], scenarios: readonly ScenarioIdentity[], trials: readonly TrialRecord[]): GuardrailArmSummary[];
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/** Wording that marks a gate block as touching a protected path. */
|
|
2
|
+
const PRESERVATION_BLOCK = /\bpreserv/i;
|
|
3
|
+
/** States that keep a still-valid fact alive. */
|
|
4
|
+
const RETAINED_STATES = ["retained", "corrected"];
|
|
5
|
+
/**
|
|
6
|
+
* Evaluate a mutation-policy guardrail from the recorded run metadata.
|
|
7
|
+
* @param record The loaded trial record.
|
|
8
|
+
* @returns Pass with no violations, fail with the recorded violations, or unknown.
|
|
9
|
+
*/
|
|
10
|
+
function mutationFinding(record) {
|
|
11
|
+
if (record.run.state !== "available") {
|
|
12
|
+
return {
|
|
13
|
+
status: "unknown",
|
|
14
|
+
evidence: ["no recorded run metadata for the mutation policy"],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const violations = record.run.value.policyViolations;
|
|
18
|
+
if (!Array.isArray(violations)) {
|
|
19
|
+
return {
|
|
20
|
+
status: "unknown",
|
|
21
|
+
evidence: ["no recorded policyViolations field (limited evidence)"],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (violations.length === 0)
|
|
25
|
+
return { status: "pass", evidence: [] };
|
|
26
|
+
return {
|
|
27
|
+
status: "fail",
|
|
28
|
+
evidence: violations.map((one) => `outside policy: ${String(one)}`),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Evaluate one inventory fact's observation into a guardrail outcome.
|
|
33
|
+
* @param fact The configured fact.
|
|
34
|
+
* @param entry The loaded observation, when present.
|
|
35
|
+
* @returns The outcome and an evidence line for failures and unknowns.
|
|
36
|
+
*/
|
|
37
|
+
function factOutcome(fact, entry) {
|
|
38
|
+
if (entry === undefined || entry.state === "missing") {
|
|
39
|
+
return {
|
|
40
|
+
status: "unknown",
|
|
41
|
+
line: `${fact.id}: no evidence-backed assessment`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (entry.state !== "available") {
|
|
45
|
+
return {
|
|
46
|
+
status: "unknown",
|
|
47
|
+
line: `${fact.id}: unreadable assessment (${entry.state})`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const { state, quote } = entry.value;
|
|
51
|
+
const obsolete = fact.obsoleteCurrentPatterns !== undefined;
|
|
52
|
+
if (obsolete) {
|
|
53
|
+
if (state === "removed")
|
|
54
|
+
return { status: "pass", line: "" };
|
|
55
|
+
if (state === "retained") {
|
|
56
|
+
return {
|
|
57
|
+
status: "fail",
|
|
58
|
+
line: `${fact.id}: obsolete claim retained: ${quote}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
if (RETAINED_STATES.includes(state))
|
|
64
|
+
return { status: "pass", line: "" };
|
|
65
|
+
if (state === "lost") {
|
|
66
|
+
return {
|
|
67
|
+
status: "fail",
|
|
68
|
+
line: `${fact.id}: valid fact lost: ${quote}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
status: "unknown",
|
|
74
|
+
line: `${fact.id}: unrecognised preservation state ${JSON.stringify(state)}`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Evaluate a preservation inventory against the recorded observations.
|
|
79
|
+
* A failure dominates an unknown; an unknown dominates a pass.
|
|
80
|
+
* @param inventory The configured inventory.
|
|
81
|
+
* @param observed The per-fact observations.
|
|
82
|
+
* @returns The aggregate finding.
|
|
83
|
+
*/
|
|
84
|
+
function inventoryFinding(inventory, observed) {
|
|
85
|
+
const failures = [];
|
|
86
|
+
const unknowns = [];
|
|
87
|
+
for (const fact of inventory.facts) {
|
|
88
|
+
const outcome = factOutcome(fact, observed[fact.id]);
|
|
89
|
+
if (outcome.status === "fail")
|
|
90
|
+
failures.push(outcome.line);
|
|
91
|
+
else if (outcome.status === "unknown")
|
|
92
|
+
unknowns.push(outcome.line);
|
|
93
|
+
}
|
|
94
|
+
if (failures.length > 0)
|
|
95
|
+
return { status: "fail", evidence: failures };
|
|
96
|
+
if (unknowns.length > 0)
|
|
97
|
+
return { status: "unknown", evidence: unknowns };
|
|
98
|
+
return { status: "pass", evidence: [] };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Evaluate a destructive-loss guardrail for one trial.
|
|
102
|
+
*
|
|
103
|
+
* With a configured inventory the semantic assessment decides. Without one,
|
|
104
|
+
* the guardrail is not applicable — unless a gate block names a preserved
|
|
105
|
+
* path, which raises an unresolved state: the text cannot prove loss, and an
|
|
106
|
+
* authorised rewrite cannot prove safety.
|
|
107
|
+
* @param record The loaded trial record.
|
|
108
|
+
* @param scenario The trial's declared scenario.
|
|
109
|
+
* @returns The finding.
|
|
110
|
+
*/
|
|
111
|
+
function lossFinding(record, scenario) {
|
|
112
|
+
const inventory = scenario?.preservationInventory;
|
|
113
|
+
if (inventory === undefined) {
|
|
114
|
+
const gate = record.gate;
|
|
115
|
+
const blocked = gate.state === "available" &&
|
|
116
|
+
gate.value.status === "blocked" &&
|
|
117
|
+
gate.value.reason !== null &&
|
|
118
|
+
PRESERVATION_BLOCK.test(gate.value.reason);
|
|
119
|
+
return blocked && gate.state === "available"
|
|
120
|
+
? {
|
|
121
|
+
status: "unknown",
|
|
122
|
+
evidence: [gate.value.reason ?? "preserve gate block"],
|
|
123
|
+
}
|
|
124
|
+
: { status: "not-applicable", evidence: [] };
|
|
125
|
+
}
|
|
126
|
+
const observed = record.assessment.preservation;
|
|
127
|
+
if (observed.state !== "available") {
|
|
128
|
+
return {
|
|
129
|
+
status: "unknown",
|
|
130
|
+
evidence: ["no preservation assessment recorded"],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return inventoryFinding(inventory, observed.value);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Evaluate one declared guardrail for one trial.
|
|
137
|
+
* @param guardrail The declared guardrail.
|
|
138
|
+
* @param record The loaded trial record.
|
|
139
|
+
* @param scenario The trial's declared scenario, when known.
|
|
140
|
+
* @returns The trial finding.
|
|
141
|
+
*/
|
|
142
|
+
export function evaluateGuardrail(guardrail, record, scenario) {
|
|
143
|
+
if (guardrail.kind === "mutation-policy")
|
|
144
|
+
return mutationFinding(record);
|
|
145
|
+
return lossFinding(record, scenario);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Derive a population's guardrail status from its coverage counts.
|
|
149
|
+
* @param coverage The per-status counts.
|
|
150
|
+
* @returns Breached on any failure, unresolved on any unknown, else clean.
|
|
151
|
+
*/
|
|
152
|
+
function populationStatus(coverage) {
|
|
153
|
+
if (coverage.fail > 0)
|
|
154
|
+
return "breached";
|
|
155
|
+
if (coverage.unknown > 0)
|
|
156
|
+
return "unresolved";
|
|
157
|
+
if (coverage.pass > 0)
|
|
158
|
+
return "clean";
|
|
159
|
+
return "not-applicable";
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Summarise every declared guardrail over one population.
|
|
163
|
+
* @param guardrails The declared guardrails.
|
|
164
|
+
* @param scenarios The declared scenarios, for inventory lookup.
|
|
165
|
+
* @param trials The loaded trial records in the population.
|
|
166
|
+
* @returns One summary per guardrail, in declared order.
|
|
167
|
+
*/
|
|
168
|
+
export function summariseGuardrails(guardrails, scenarios, trials) {
|
|
169
|
+
const scenarioById = new Map(scenarios.map((scenario) => [scenario.id, scenario]));
|
|
170
|
+
return guardrails.map((guardrail) => {
|
|
171
|
+
const coverage = {
|
|
172
|
+
pass: 0,
|
|
173
|
+
fail: 0,
|
|
174
|
+
unknown: 0,
|
|
175
|
+
"not-applicable": 0,
|
|
176
|
+
};
|
|
177
|
+
const evidence = [];
|
|
178
|
+
for (const trial of trials) {
|
|
179
|
+
const finding = evaluateGuardrail(guardrail, trial, scenarioById.get(trial.identity.scenario));
|
|
180
|
+
coverage[finding.status] += 1;
|
|
181
|
+
if (finding.status === "fail" || finding.status === "unknown") {
|
|
182
|
+
evidence.push(...finding.evidence.map((line) => `${trial.identity.stamp}: ${line}`));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
guardrailId: guardrail.id,
|
|
187
|
+
kind: guardrail.kind,
|
|
188
|
+
requirement: guardrail.requirement,
|
|
189
|
+
status: populationStatus(coverage),
|
|
190
|
+
coverage,
|
|
191
|
+
evidence,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|