@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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -2
  3. package/container/Containerfile +24 -0
  4. package/container/README.md +29 -0
  5. package/dist/adapters/opencode/events.d.ts +90 -0
  6. package/dist/adapters/opencode/events.js +213 -0
  7. package/dist/adapters/opencode/runtime.d.ts +87 -0
  8. package/dist/adapters/opencode/runtime.js +136 -0
  9. package/dist/adapters/podman/container.d.ts +62 -0
  10. package/dist/adapters/podman/container.js +119 -0
  11. package/dist/adapters/podman/image.d.ts +50 -0
  12. package/dist/adapters/podman/image.js +124 -0
  13. package/dist/adapters/process.d.ts +38 -0
  14. package/dist/adapters/process.js +71 -0
  15. package/dist/campaign/assess.d.ts +45 -0
  16. package/dist/campaign/assess.js +208 -0
  17. package/dist/campaign/close.d.ts +17 -0
  18. package/dist/campaign/close.js +112 -0
  19. package/dist/campaign/evidence-guard.d.ts +24 -0
  20. package/dist/campaign/evidence-guard.js +49 -0
  21. package/dist/campaign/freeze.d.ts +94 -0
  22. package/dist/campaign/freeze.js +297 -0
  23. package/dist/campaign/inventory.d.ts +62 -0
  24. package/dist/campaign/inventory.js +192 -0
  25. package/dist/campaign/judge.d.ts +209 -0
  26. package/dist/campaign/judge.js +1285 -0
  27. package/dist/campaign/ledger.d.ts +64 -0
  28. package/dist/campaign/ledger.js +149 -0
  29. package/dist/campaign/prepare.d.ts +129 -0
  30. package/dist/campaign/prepare.js +483 -0
  31. package/dist/campaign/primary.d.ts +96 -0
  32. package/dist/campaign/primary.js +189 -0
  33. package/dist/campaign/run.d.ts +151 -0
  34. package/dist/campaign/run.js +562 -0
  35. package/dist/campaign/status.d.ts +76 -0
  36. package/dist/campaign/status.js +237 -0
  37. package/dist/cli/otc.d.ts +51 -0
  38. package/dist/cli/otc.js +435 -0
  39. package/dist/contracts/canonical.d.ts +21 -0
  40. package/dist/contracts/canonical.js +44 -0
  41. package/dist/contracts/types.d.ts +261 -0
  42. package/dist/contracts/types.js +9 -0
  43. package/dist/contracts/validate.d.ts +72 -0
  44. package/dist/contracts/validate.js +1021 -0
  45. package/dist/contracts/version.d.ts +44 -0
  46. package/dist/contracts/version.js +147 -0
  47. package/dist/evaluators/docs/analysis/accounting.d.ts +140 -0
  48. package/dist/evaluators/docs/analysis/accounting.js +237 -0
  49. package/dist/evaluators/docs/analysis/blinding.d.ts +40 -0
  50. package/dist/evaluators/docs/analysis/blinding.js +80 -0
  51. package/dist/evaluators/docs/analysis/bounds.d.ts +40 -0
  52. package/dist/evaluators/docs/analysis/bounds.js +31 -0
  53. package/dist/evaluators/docs/analysis/budget.d.ts +137 -0
  54. package/dist/evaluators/docs/analysis/budget.js +305 -0
  55. package/dist/evaluators/docs/analysis/closure.d.ts +175 -0
  56. package/dist/evaluators/docs/analysis/closure.js +251 -0
  57. package/dist/evaluators/docs/analysis/decision.d.ts +59 -0
  58. package/dist/evaluators/docs/analysis/decision.js +161 -0
  59. package/dist/evaluators/docs/analysis/freeze.d.ts +201 -0
  60. package/dist/evaluators/docs/analysis/freeze.js +395 -0
  61. package/dist/evaluators/docs/analysis/guardrails.d.ts +51 -0
  62. package/dist/evaluators/docs/analysis/guardrails.js +194 -0
  63. package/dist/evaluators/docs/analysis/journal.d.ts +150 -0
  64. package/dist/evaluators/docs/analysis/journal.js +475 -0
  65. package/dist/evaluators/docs/analysis/ledger.d.ts +78 -0
  66. package/dist/evaluators/docs/analysis/ledger.js +145 -0
  67. package/dist/evaluators/docs/analysis/measures.d.ts +302 -0
  68. package/dist/evaluators/docs/analysis/measures.js +1069 -0
  69. package/dist/evaluators/docs/analysis/model.d.ts +236 -0
  70. package/dist/evaluators/docs/analysis/model.js +740 -0
  71. package/dist/evaluators/docs/analysis/pairing.d.ts +68 -0
  72. package/dist/evaluators/docs/analysis/pairing.js +95 -0
  73. package/dist/evaluators/docs/analysis/records.d.ts +229 -0
  74. package/dist/evaluators/docs/analysis/records.js +935 -0
  75. package/dist/evaluators/docs/analysis/recovery.d.ts +225 -0
  76. package/dist/evaluators/docs/analysis/recovery.js +507 -0
  77. package/dist/evaluators/docs/analysis/render.d.ts +235 -0
  78. package/dist/evaluators/docs/analysis/render.js +566 -0
  79. package/dist/evaluators/docs/analysis/replay.d.ts +15 -0
  80. package/dist/evaluators/docs/analysis/replay.js +1272 -0
  81. package/dist/evaluators/docs/analysis/screening/inventory.d.ts +136 -0
  82. package/dist/evaluators/docs/analysis/screening/inventory.js +307 -0
  83. package/dist/evaluators/docs/analysis/screening/noteVersion.d.ts +15 -0
  84. package/dist/evaluators/docs/analysis/screening/noteVersion.js +46 -0
  85. package/dist/evaluators/docs/analysis/screening/reconcile.d.ts +38 -0
  86. package/dist/evaluators/docs/analysis/screening/reconcile.js +40 -0
  87. package/dist/evaluators/docs/analysis/screening/rules.d.ts +163 -0
  88. package/dist/evaluators/docs/analysis/screening/rules.js +330 -0
  89. package/dist/evaluators/docs/analysis/screening/scan.d.ts +72 -0
  90. package/dist/evaluators/docs/analysis/screening/scan.js +362 -0
  91. package/dist/evaluators/docs/analysis/selector.d.ts +68 -0
  92. package/dist/evaluators/docs/analysis/selector.js +123 -0
  93. package/dist/evaluators/docs/assess.d.ts +129 -0
  94. package/dist/evaluators/docs/assess.js +433 -0
  95. package/dist/evaluators/docs/campaign.d.ts +133 -0
  96. package/dist/evaluators/docs/campaign.js +222 -0
  97. package/dist/evaluators/docs/index.d.ts +25 -0
  98. package/dist/evaluators/docs/index.js +25 -0
  99. package/dist/evaluators/docs/journal.d.ts +150 -0
  100. package/dist/evaluators/docs/journal.js +475 -0
  101. package/dist/evaluators/docs/scoring.d.ts +367 -0
  102. package/dist/evaluators/docs/scoring.js +662 -0
  103. package/dist/evaluators/skills/assess.d.ts +94 -0
  104. package/dist/evaluators/skills/assess.js +207 -0
  105. package/dist/index.d.ts +46 -0
  106. package/dist/index.js +29 -0
  107. package/dist/legacy/prod014.d.ts +86 -0
  108. package/dist/legacy/prod014.js +209 -0
  109. package/dist/reporting/report.d.ts +79 -0
  110. package/dist/reporting/report.js +242 -0
  111. package/docs/architecture.md +106 -0
  112. package/docs/capability-matrix.md +84 -0
  113. package/docs/offline-rehearsal.md +75 -0
  114. package/docs/parity-record.md +81 -0
  115. package/docs/pr-description.md +103 -0
  116. package/docs/release-0.1.0.md +52 -0
  117. package/docs/releasing.md +82 -0
  118. package/docs/schema.md +97 -0
  119. package/examples/skills/README.md +13 -0
  120. package/examples/skills/campaign.json +73 -0
  121. package/examples/skills/skills/otel-helper/SKILL.md +10 -0
  122. package/examples/skills/suite.json +29 -0
  123. package/package.json +45 -5
@@ -0,0 +1,740 @@
1
+ /**
2
+ * Versioned campaign manifest for the trial analysis framework.
3
+ *
4
+ * A manifest describes a campaign as data — identity, arms, scenarios, the
5
+ * frozen schedule with declared pair identities, the evidence layout, the
6
+ * recorded assessment-layer selection policy and the frozen measurement
7
+ * policy — so a future agent can run the standard analyser without writing
8
+ * another calculator. Validation is pure and total: it collects problems
9
+ * instead of throwing, so a CLI can report every defect in one pass.
10
+ *
11
+ * Manifests are portable: evidence locations are layout ids and paths relative
12
+ * to a runtime evidence root. Absolute paths and traversal segments are
13
+ * refused here, never resolved at parse time. Nothing in this module reads
14
+ * the filesystem or calls a model.
15
+ */
16
+ import { parseRecoveryPolicy } from "./recovery.js";
17
+ /** The only manifest schema version this framework parses. */
18
+ export const MANIFEST_SCHEMA_VERSION = 1;
19
+ /**
20
+ * Evidence layout adapters the loader knows how to read. The ledger-shaped
21
+ * `rr-ledger-v1` layout is deliberately absent until an adapter exists: a
22
+ * manifest declaring an unreadable layout is refused, never accepted and
23
+ * silently read as ac-v1.
24
+ */
25
+ export const EVIDENCE_LAYOUTS = ["ac-v1"];
26
+ /** Recorded policies for choosing among multiple judgement layers. */
27
+ export const SELECTION_POLICIES = [
28
+ "resolution-then-judgement",
29
+ "judgement-only",
30
+ ];
31
+ /** Guardrail kinds the evaluator implements. */
32
+ export const GUARDRAIL_KINDS = ["mutation-policy", "destructive-loss"];
33
+ /** How a slot's observation relates to the frozen schedule. */
34
+ export const DISPOSITIONS = [
35
+ "first-attempt",
36
+ "replacement",
37
+ "excluded",
38
+ ];
39
+ /** The five weight keys, in reporting order. */
40
+ const WEIGHT_KEYS = [
41
+ "useful",
42
+ "transient",
43
+ "duplicate",
44
+ "misleading",
45
+ "taskFailure",
46
+ ];
47
+ /** Whether a value is a plain JSON object. */
48
+ function isPlainObject(value) {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value);
50
+ }
51
+ /** Whether a value is a string with non-whitespace content. */
52
+ function isNonblankString(value) {
53
+ return typeof value === "string" && value.trim() !== "";
54
+ }
55
+ /** Whether a value is a finite number (JSON cannot carry NaN or Infinity). */
56
+ function isFiniteNumber(value) {
57
+ return typeof value === "number" && Number.isFinite(value);
58
+ }
59
+ /** Whether a value is an array made only of strings. */
60
+ function isStringArray(value) {
61
+ return (Array.isArray(value) && value.every((one) => typeof one === "string"));
62
+ }
63
+ /**
64
+ * Check that every regex source in a list compiles.
65
+ * @param label The dotted field path, used in problem messages.
66
+ * @param sources The regex source strings to compile.
67
+ * @returns One problem per source that does not compile.
68
+ */
69
+ function regexProblems(label, sources) {
70
+ const problems = [];
71
+ for (const source of sources) {
72
+ try {
73
+ new RegExp(source);
74
+ }
75
+ catch {
76
+ problems.push(`${label}: regex does not compile: ${source}`);
77
+ }
78
+ }
79
+ return problems;
80
+ }
81
+ /**
82
+ * Validate one optional field holding a nonempty array of regex sources.
83
+ * @param label The dotted field path of the containing object.
84
+ * @param fieldName The field name holding the array.
85
+ * @param value The containing object.
86
+ * @returns Whether the field is present with a nonempty array, and its problems.
87
+ */
88
+ function regexSourceField(label, fieldName, value) {
89
+ const sources = value[fieldName];
90
+ if (sources === undefined)
91
+ return { nonempty: false, problems: [] };
92
+ if (!isStringArray(sources) || sources.length === 0) {
93
+ return {
94
+ nonempty: false,
95
+ problems: [
96
+ `${label}.${fieldName} must be a nonempty array of regex source strings when present`,
97
+ ],
98
+ };
99
+ }
100
+ return {
101
+ nonempty: true,
102
+ problems: regexProblems(`${label}.${fieldName}`, sources),
103
+ };
104
+ }
105
+ /**
106
+ * Check that an evidence-relative path is portable: nonblank, relative,
107
+ * no traversal segments.
108
+ * @param label The dotted field path, used in problem messages.
109
+ * @param path The path as written in the manifest.
110
+ * @returns Problems; empty means portable.
111
+ */
112
+ function relativePathProblems(label, path) {
113
+ const problems = [];
114
+ if (path.trim() === "")
115
+ problems.push(`${label}: path is blank`);
116
+ if (path.startsWith("/")) {
117
+ problems.push(`${label}: absolute path is not portable: ${path}`);
118
+ }
119
+ if (path.split("/").includes("..")) {
120
+ problems.push(`${label}: traversal segment is not portable: ${path}`);
121
+ }
122
+ return problems;
123
+ }
124
+ /**
125
+ * Collect the declared entity ids from an array-shaped field.
126
+ * @param entries The array field (arms or scenarios).
127
+ * @returns The nonblank id strings; an invalid array yields none.
128
+ */
129
+ function declaredIds(entries) {
130
+ if (!Array.isArray(entries))
131
+ return [];
132
+ return entries
133
+ .filter(isPlainObject)
134
+ .map((entry) => entry.id)
135
+ .filter(isNonblankString);
136
+ }
137
+ /**
138
+ * Validate the campaign identity section.
139
+ * @param value The raw campaign field.
140
+ * @returns Problems; empty means valid.
141
+ */
142
+ function campaignProblems(value) {
143
+ if (!isPlainObject(value))
144
+ return ["campaign must be an object"];
145
+ const problems = [];
146
+ if (!isNonblankString(value.id)) {
147
+ problems.push("campaign.id must be a nonblank string");
148
+ }
149
+ if (value.question !== undefined && !isNonblankString(value.question)) {
150
+ problems.push("campaign.question must be a nonblank string when present");
151
+ }
152
+ return problems;
153
+ }
154
+ /**
155
+ * Validate the arms section: a nonempty array with unique nonblank ids.
156
+ * @param value The raw arms field.
157
+ * @returns Problems; empty means valid.
158
+ */
159
+ function armsProblems(value) {
160
+ if (!Array.isArray(value) || value.length === 0) {
161
+ return ["arms must be a nonempty array"];
162
+ }
163
+ const problems = [];
164
+ const seen = new Set();
165
+ value.forEach((arm, index) => {
166
+ if (!isPlainObject(arm)) {
167
+ problems.push(`arms[${index}] must be an object`);
168
+ return;
169
+ }
170
+ if (!isNonblankString(arm.id)) {
171
+ problems.push(`arms[${index}].id must be a nonblank string`);
172
+ }
173
+ else if (seen.has(arm.id)) {
174
+ problems.push(`duplicate arm id ${JSON.stringify(arm.id)}`);
175
+ }
176
+ else {
177
+ seen.add(arm.id);
178
+ }
179
+ if (arm.label !== undefined && !isNonblankString(arm.label)) {
180
+ problems.push(`arms[${index}].label must be a nonblank string when present`);
181
+ }
182
+ });
183
+ return problems;
184
+ }
185
+ /**
186
+ * Validate one configured preservation fact.
187
+ * @param label The dotted field path of the fact.
188
+ * @param value The raw fact object.
189
+ * @param seen Fact ids already declared in this inventory.
190
+ * @returns Problems; empty means valid.
191
+ */
192
+ function inventoryFactProblems(label, value, seen) {
193
+ if (!isPlainObject(value))
194
+ return [`${label} must be an object`];
195
+ const problems = [];
196
+ if (!isNonblankString(value.id)) {
197
+ problems.push(`${label}.id must be a nonblank string`);
198
+ }
199
+ else if (seen.has(value.id)) {
200
+ problems.push(`${label}: duplicate inventory fact id ${JSON.stringify(value.id)}`);
201
+ }
202
+ else {
203
+ seen.add(value.id);
204
+ }
205
+ if (!isNonblankString(value.label)) {
206
+ problems.push(`${label}.label must be a nonblank string`);
207
+ }
208
+ const mentions = regexSourceField(label, "mentionPatterns", value);
209
+ const obsolete = regexSourceField(label, "obsoleteCurrentPatterns", value);
210
+ if (!mentions.nonempty && !obsolete.nonempty) {
211
+ problems.push(`${label} (${String(value.id)}) needs nonempty mentionPatterns or obsoleteCurrentPatterns`);
212
+ }
213
+ problems.push(...mentions.problems, ...obsolete.problems);
214
+ return problems;
215
+ }
216
+ /**
217
+ * Validate one scenario's preservation inventory.
218
+ * @param label The dotted field path of the inventory.
219
+ * @param value The raw inventory object.
220
+ * @returns Problems; empty means valid.
221
+ */
222
+ function inventoryProblems(label, value) {
223
+ if (!isPlainObject(value))
224
+ return [`${label} must be an object`];
225
+ if (!Array.isArray(value.facts) || value.facts.length === 0) {
226
+ return [`${label}.facts must be a nonempty array`];
227
+ }
228
+ const problems = [];
229
+ const seen = new Set();
230
+ value.facts.forEach((fact, index) => {
231
+ problems.push(...inventoryFactProblems(`${label}.facts[${index}]`, fact, seen));
232
+ });
233
+ if (!isStringArray(value.negationCues) || value.negationCues.length === 0) {
234
+ problems.push(`${label}.negationCues must be a nonempty array of regex source strings`);
235
+ }
236
+ else {
237
+ problems.push(...regexProblems(`${label}.negationCues`, value.negationCues));
238
+ }
239
+ return problems;
240
+ }
241
+ /**
242
+ * Validate one optional mutation policy.
243
+ * @param label The dotted field path of the policy.
244
+ * @param value The raw policy object.
245
+ * @returns Problems; empty means valid.
246
+ */
247
+ function mutationPolicyProblems(label, value) {
248
+ if (!isPlainObject(value)) {
249
+ return [`${label} must be an object when present`];
250
+ }
251
+ const problems = [];
252
+ if (!isStringArray(value.allowed) || value.allowed.length === 0) {
253
+ problems.push(`${label}.allowed must be a nonempty array of path globs`);
254
+ }
255
+ if (value.visibleConstraints !== undefined &&
256
+ (!isStringArray(value.visibleConstraints) ||
257
+ value.visibleConstraints.length === 0)) {
258
+ problems.push(`${label}.visibleConstraints must be a nonempty array of strings when present`);
259
+ }
260
+ return problems;
261
+ }
262
+ /**
263
+ * Validate the scenarios section: unique ids and well-formed inventories.
264
+ * @param value The raw scenarios field.
265
+ * @returns Problems; empty means valid.
266
+ */
267
+ function scenariosProblems(value) {
268
+ if (!Array.isArray(value) || value.length === 0) {
269
+ return ["scenarios must be a nonempty array"];
270
+ }
271
+ const problems = [];
272
+ const seen = new Set();
273
+ value.forEach((scenario, index) => {
274
+ if (!isPlainObject(scenario)) {
275
+ problems.push(`scenarios[${index}] must be an object`);
276
+ return;
277
+ }
278
+ if (!isNonblankString(scenario.id)) {
279
+ problems.push(`scenarios[${index}].id must be a nonblank string`);
280
+ }
281
+ else if (seen.has(scenario.id)) {
282
+ problems.push(`duplicate scenario id ${JSON.stringify(scenario.id)}`);
283
+ }
284
+ else {
285
+ seen.add(scenario.id);
286
+ }
287
+ if (scenario.kind !== undefined && !isNonblankString(scenario.kind)) {
288
+ problems.push(`scenarios[${index}].kind must be a nonblank string when present`);
289
+ }
290
+ if (scenario.preservationInventory !== undefined) {
291
+ problems.push(...inventoryProblems(`scenarios[${index}].preservationInventory`, scenario.preservationInventory));
292
+ }
293
+ if (scenario.mutationPolicy !== undefined) {
294
+ problems.push(...mutationPolicyProblems(`scenarios[${index}].mutationPolicy`, scenario.mutationPolicy));
295
+ }
296
+ });
297
+ return problems;
298
+ }
299
+ /**
300
+ * Validate one slot's identity fields: positive slot number and unique stamp.
301
+ * @param label The dotted field path of the slot.
302
+ * @param entry The raw slot object.
303
+ * @param slotNumbers Slot numbers seen so far.
304
+ * @param stamps Stamps seen so far.
305
+ * @returns Problems; empty means the identity fields are valid.
306
+ */
307
+ function slotIdentityProblems(label, entry, slotNumbers, stamps) {
308
+ const problems = [];
309
+ if (typeof entry.slot !== "number" ||
310
+ !Number.isInteger(entry.slot) ||
311
+ entry.slot < 1) {
312
+ problems.push(`${label}.slot must be a positive integer`);
313
+ }
314
+ else if (slotNumbers.has(entry.slot)) {
315
+ problems.push(`duplicate slot number ${String(entry.slot)}`);
316
+ }
317
+ else {
318
+ slotNumbers.add(entry.slot);
319
+ }
320
+ if (!isNonblankString(entry.stamp)) {
321
+ problems.push(`${label}.stamp must be a nonblank string`);
322
+ }
323
+ else if (stamps.has(entry.stamp)) {
324
+ problems.push(`duplicate stamp ${JSON.stringify(entry.stamp)}`);
325
+ }
326
+ else {
327
+ stamps.add(entry.stamp);
328
+ }
329
+ return problems;
330
+ }
331
+ /**
332
+ * Validate one slot's membership fields against the declared entities.
333
+ * @param label The dotted field path of the slot.
334
+ * @param entry The raw slot object.
335
+ * @param armIds Declared arm ids.
336
+ * @param scenarioIds Declared scenario ids.
337
+ * @returns Problems; empty means membership is valid.
338
+ */
339
+ function slotMembershipProblems(label, entry, armIds, scenarioIds) {
340
+ const problems = [];
341
+ if (!isNonblankString(entry.scenario)) {
342
+ problems.push(`${label}.scenario must be a nonblank string`);
343
+ }
344
+ else if (!scenarioIds.includes(entry.scenario)) {
345
+ problems.push(`${label}.scenario ${JSON.stringify(entry.scenario)} is an undeclared scenario`);
346
+ }
347
+ if (!isNonblankString(entry.arm)) {
348
+ problems.push(`${label}.arm must be a nonblank string`);
349
+ }
350
+ else if (!armIds.includes(entry.arm)) {
351
+ problems.push(`${label}.arm ${JSON.stringify(entry.arm)} is an undeclared arm`);
352
+ }
353
+ if (typeof entry.repetition !== "number" ||
354
+ !Number.isInteger(entry.repetition) ||
355
+ entry.repetition < 0) {
356
+ problems.push(`${label}.repetition must be an integer >= 0`);
357
+ }
358
+ if (entry.pairId !== undefined && !isNonblankString(entry.pairId)) {
359
+ problems.push(`${label}.pairId must be a nonblank string when present`);
360
+ }
361
+ return problems;
362
+ }
363
+ /**
364
+ * Validate one slot's optional disposition.
365
+ * @param label The dotted field path of the slot.
366
+ * @param entry The raw slot object.
367
+ * @returns Problems; empty means valid.
368
+ */
369
+ function slotDispositionProblems(label, entry) {
370
+ if (entry.disposition !== undefined &&
371
+ !DISPOSITIONS.includes(String(entry.disposition))) {
372
+ return [
373
+ `${label}.disposition must be one of ${DISPOSITIONS.join("|")} when present`,
374
+ ];
375
+ }
376
+ return [];
377
+ }
378
+ /**
379
+ * Validate one slot.
380
+ * @param label The dotted field path of the slot.
381
+ * @param entry The raw slot value.
382
+ * @param armIds Declared arm ids.
383
+ * @param scenarioIds Declared scenario ids.
384
+ * @param slotNumbers Slot numbers seen so far.
385
+ * @param stamps Stamps seen so far.
386
+ * @returns Problems; empty means valid.
387
+ */
388
+ function slotProblems(label, entry, armIds, scenarioIds, slotNumbers, stamps) {
389
+ if (!isPlainObject(entry))
390
+ return [`${label} must be an object`];
391
+ return [
392
+ ...slotIdentityProblems(label, entry, slotNumbers, stamps),
393
+ ...slotMembershipProblems(label, entry, armIds, scenarioIds),
394
+ ...slotDispositionProblems(label, entry),
395
+ ];
396
+ }
397
+ /**
398
+ * Validate one declared pair: exactly two slots, distinct arms, one scenario
399
+ * and repetition. Pairing is by declared identity, never array position, so
400
+ * the check reads only the declared fields.
401
+ * @param pairId The declared pair id.
402
+ * @param group The slots carrying that pair id.
403
+ * @returns Problems; empty means the pair is coherent.
404
+ */
405
+ function pairGroupProblems(pairId, group) {
406
+ const label = `pair ${pairId}`;
407
+ if (group.length !== 2) {
408
+ return [`${label} must hold exactly 2 slots, found ${group.length}`];
409
+ }
410
+ const fields = (name) => group.map((slot) => (isPlainObject(slot) ? slot[name] : undefined));
411
+ const arms = fields("arm");
412
+ if (arms[0] === arms[1]) {
413
+ return [
414
+ `${label} must pair distinct arms, found ${JSON.stringify(arms[0])} twice`,
415
+ ];
416
+ }
417
+ if (fields("scenario")[0] !== fields("scenario")[1]) {
418
+ return [
419
+ `${label} must pair one scenario, found ${JSON.stringify(fields("scenario"))}`,
420
+ ];
421
+ }
422
+ if (fields("repetition")[0] !== fields("repetition")[1]) {
423
+ return [
424
+ `${label} must pair one repetition, found ${JSON.stringify(fields("repetition"))}`,
425
+ ];
426
+ }
427
+ return [];
428
+ }
429
+ /**
430
+ * Validate pair membership across the schedule.
431
+ * @param slots The raw slot values.
432
+ * @returns Problems; empty means every declared pair is coherent.
433
+ */
434
+ function pairProblems(slots) {
435
+ const groups = new Map();
436
+ for (const entry of slots) {
437
+ if (!isPlainObject(entry))
438
+ continue;
439
+ const pairId = entry.pairId;
440
+ if (!isNonblankString(pairId))
441
+ continue;
442
+ const group = groups.get(pairId) ?? [];
443
+ group.push(entry);
444
+ groups.set(pairId, group);
445
+ }
446
+ const problems = [];
447
+ for (const [pairId, group] of groups) {
448
+ problems.push(...pairGroupProblems(pairId, group));
449
+ }
450
+ return problems;
451
+ }
452
+ /**
453
+ * Validate the schedule section, including pair coherence.
454
+ * @param value The raw schedule field.
455
+ * @param armIds Declared arm ids.
456
+ * @param scenarioIds Declared scenario ids.
457
+ * @returns Problems; empty means valid.
458
+ */
459
+ function scheduleProblems(value, armIds, scenarioIds) {
460
+ if (!isPlainObject(value))
461
+ return ["schedule must be an object"];
462
+ if (!Array.isArray(value.slots))
463
+ return ["schedule.slots must be an array"];
464
+ const problems = [];
465
+ const slotNumbers = new Set();
466
+ const stamps = new Set();
467
+ value.slots.forEach((entry, index) => {
468
+ problems.push(...slotProblems(`schedule.slots[${index}]`, entry, armIds, scenarioIds, slotNumbers, stamps));
469
+ });
470
+ problems.push(...pairProblems(value.slots));
471
+ return problems;
472
+ }
473
+ /**
474
+ * Validate the evidence section: a known layout, a relative root per arm,
475
+ * and any layout-required inputs.
476
+ * @param value The raw evidence field.
477
+ * @param armIds Declared arm ids.
478
+ * @returns Problems; empty means valid.
479
+ */
480
+ function evidenceProblems(value, armIds) {
481
+ if (!isPlainObject(value))
482
+ return ["evidence must be an object"];
483
+ const problems = [];
484
+ if (!isNonblankString(value.layout) ||
485
+ !EVIDENCE_LAYOUTS.includes(value.layout)) {
486
+ problems.push(`evidence.layout must be one of ${EVIDENCE_LAYOUTS.join("|")}`);
487
+ }
488
+ const roots = value.resultRoots;
489
+ if (!isPlainObject(roots)) {
490
+ problems.push("evidence.resultRoots must be an object keyed by arm id");
491
+ return problems;
492
+ }
493
+ for (const arm of armIds) {
494
+ const root = roots[arm];
495
+ if (!isNonblankString(root)) {
496
+ problems.push(`evidence.resultRoots.${arm} must be a nonblank relative path`);
497
+ }
498
+ else {
499
+ problems.push(...relativePathProblems(`evidence.resultRoots.${arm}`, root));
500
+ }
501
+ }
502
+ for (const key of Object.keys(roots)) {
503
+ if (!armIds.includes(key)) {
504
+ problems.push(`evidence.resultRoots.${key} is not a declared arm`);
505
+ }
506
+ }
507
+ problems.push(...inputsProblems(value.inputs));
508
+ return problems;
509
+ }
510
+ /**
511
+ * Validate the optional named inputs record. Named inputs are portable,
512
+ * relative evidence paths; an input this analyser does not read produces no
513
+ * effect rather than being silently treated as present.
514
+ * @param inputs The raw inputs field.
515
+ * @returns Problems; empty means valid.
516
+ */
517
+ function inputsProblems(inputs) {
518
+ if (inputs === undefined)
519
+ return [];
520
+ if (!isPlainObject(inputs)) {
521
+ return ["evidence.inputs must be an object keyed by input name"];
522
+ }
523
+ const problems = [];
524
+ for (const [name, path] of Object.entries(inputs)) {
525
+ if (!isNonblankString(path)) {
526
+ problems.push(`evidence.inputs.${name} must be a nonblank relative path`);
527
+ }
528
+ else {
529
+ problems.push(...relativePathProblems(`evidence.inputs.${name}`, path));
530
+ }
531
+ }
532
+ return problems;
533
+ }
534
+ /**
535
+ * Validate the assessment section: the selection policy must be a known id.
536
+ * @param value The raw assessment field.
537
+ * @returns Problems; empty means valid.
538
+ */
539
+ function assessmentProblems(value) {
540
+ if (!isPlainObject(value))
541
+ return ["assessment must be an object"];
542
+ if (!isNonblankString(value.selectionPolicy) ||
543
+ !SELECTION_POLICIES.includes(value.selectionPolicy)) {
544
+ return [
545
+ `assessment.selectionPolicy must be one of ${SELECTION_POLICIES.join("|")}`,
546
+ ];
547
+ }
548
+ if ("attemptId" in value &&
549
+ (typeof value.attemptId !== "string" ||
550
+ !/^attempt-\d{3}$/.test(value.attemptId))) {
551
+ return ['assessment.attemptId must match "attempt-NNN"'];
552
+ }
553
+ if ("recovery" in value && value.recovery !== undefined) {
554
+ const parsed = parseRecoveryPolicy(value.recovery);
555
+ if (!parsed.ok) {
556
+ return parsed.problems.map((problem) => `assessment.recovery: ${problem}`);
557
+ }
558
+ }
559
+ return [];
560
+ }
561
+ /**
562
+ * Validate the utility weights: all five keys, each a finite number.
563
+ * @param value The raw weights field.
564
+ * @returns Problems; empty means valid.
565
+ */
566
+ function weightsProblems(value) {
567
+ if (!isPlainObject(value))
568
+ return ["policy.weights must be an object"];
569
+ const problems = [];
570
+ for (const key of WEIGHT_KEYS) {
571
+ if (!isFiniteNumber(value[key])) {
572
+ problems.push(`policy.weights.${key} must be a finite number`);
573
+ }
574
+ }
575
+ return problems;
576
+ }
577
+ /**
578
+ * Validate the guardrails section: unique ids and known kinds.
579
+ * @param value The raw guardrails field.
580
+ * @returns Problems; empty means valid.
581
+ */
582
+ function guardrailsProblems(value) {
583
+ if (!Array.isArray(value) || value.length === 0) {
584
+ return ["policy.guardrails must be a nonempty array"];
585
+ }
586
+ const problems = [];
587
+ const seen = new Set();
588
+ value.forEach((guardrail, index) => {
589
+ if (!isPlainObject(guardrail)) {
590
+ problems.push(`policy.guardrails[${index}] must be an object`);
591
+ return;
592
+ }
593
+ const id = guardrail.id;
594
+ if (!isNonblankString(id)) {
595
+ problems.push(`policy.guardrails[${index}].id must be a nonblank string`);
596
+ }
597
+ else if (seen.has(id)) {
598
+ problems.push(`duplicate guardrail id ${JSON.stringify(id)}`);
599
+ }
600
+ else {
601
+ seen.add(id);
602
+ }
603
+ if (!isNonblankString(guardrail.kind) ||
604
+ !GUARDRAIL_KINDS.includes(guardrail.kind)) {
605
+ problems.push(`policy.guardrails[${index}].kind must be a known guardrail kind (${GUARDRAIL_KINDS.join("|")})`);
606
+ }
607
+ if (!isNonblankString(guardrail.requirement)) {
608
+ problems.push(`policy.guardrails[${index}].requirement must be a nonblank string`);
609
+ }
610
+ });
611
+ return problems;
612
+ }
613
+ /**
614
+ * Validate the optional selection rule.
615
+ * @param value The raw selection field.
616
+ * @param armIds Declared arm ids.
617
+ * @returns Problems; empty means valid.
618
+ */
619
+ function selectionProblems(value, armIds) {
620
+ if (value === undefined)
621
+ return [];
622
+ if (!isPlainObject(value)) {
623
+ return ["policy.selection must be an object when present"];
624
+ }
625
+ const problems = [];
626
+ for (const side of ["controlArm", "candidateArm"]) {
627
+ const arm = value[side];
628
+ if (!isNonblankString(arm) || !armIds.includes(arm)) {
629
+ problems.push(`policy.selection.${side} must be a declared arm id`);
630
+ }
631
+ }
632
+ if (value.controlArm !== undefined &&
633
+ value.controlArm === value.candidateArm) {
634
+ problems.push("policy.selection.controlArm and candidateArm must be distinct arms");
635
+ }
636
+ if (!isFiniteNumber(value.threshold)) {
637
+ problems.push("policy.selection.threshold must be a finite number");
638
+ }
639
+ if (typeof value.requireCompleteCoverage !== "boolean") {
640
+ problems.push("policy.selection.requireCompleteCoverage must be a boolean");
641
+ }
642
+ return problems;
643
+ }
644
+ /**
645
+ * Validate the optional sensitivity grid.
646
+ * @param value The raw sensitivity field.
647
+ * @returns Problems; empty means valid.
648
+ */
649
+ function sensitivityProblems(value) {
650
+ if (value === undefined)
651
+ return [];
652
+ if (!isPlainObject(value)) {
653
+ return ["policy.sensitivity must be an object when present"];
654
+ }
655
+ const problems = [];
656
+ for (const field of ["usefulWeights", "transientPenalties"]) {
657
+ const grid = value[field];
658
+ if (!Array.isArray(grid) || grid.length === 0) {
659
+ problems.push(`policy.sensitivity.${field} must be a nonempty array`);
660
+ }
661
+ else if (!grid.every(isFiniteNumber)) {
662
+ problems.push(`policy.sensitivity.${field} must hold finite numbers`);
663
+ }
664
+ }
665
+ return problems;
666
+ }
667
+ /**
668
+ * Validate the optional screening configuration.
669
+ * @param value The raw screening field.
670
+ * @returns Problems; empty means valid.
671
+ */
672
+ function screeningProblems(value) {
673
+ if (value === undefined)
674
+ return [];
675
+ if (!isPlainObject(value)) {
676
+ return ["policy.screening must be an object when present"];
677
+ }
678
+ const problems = [];
679
+ if (!isNonblankString(value.rulesVersion)) {
680
+ problems.push("policy.screening.rulesVersion must be a nonblank string");
681
+ }
682
+ for (const field of [
683
+ "transientWordingPatterns",
684
+ "durableCounterexamplePatterns",
685
+ ]) {
686
+ problems.push(...regexSourceField("policy.screening", field, value).problems);
687
+ }
688
+ return problems;
689
+ }
690
+ /**
691
+ * Validate the policy section.
692
+ * @param value The raw policy field.
693
+ * @param armIds Declared arm ids.
694
+ * @returns Problems; empty means valid.
695
+ */
696
+ function policyProblems(value, armIds) {
697
+ if (!isPlainObject(value))
698
+ return ["policy must be an object"];
699
+ const problems = [];
700
+ if (!isNonblankString(value.policyId)) {
701
+ problems.push("policy.policyId must be a nonblank string");
702
+ }
703
+ problems.push(...weightsProblems(value.weights), ...guardrailsProblems(value.guardrails), ...selectionProblems(value.selection, armIds), ...sensitivityProblems(value.sensitivity), ...screeningProblems(value.screening));
704
+ return problems;
705
+ }
706
+ /**
707
+ * Parse and validate one manifest value.
708
+ *
709
+ * The parse is total: junk input yields problems instead of an exception.
710
+ * The returned value is cast only after every field has been validated, so
711
+ * the cast carries the guarantee the validators establish.
712
+ * @param value The raw manifest value (as parsed from JSON).
713
+ * @returns The validated manifest, or every problem found.
714
+ */
715
+ export function parseManifest(value) {
716
+ if (!isPlainObject(value)) {
717
+ return { problems: ["manifest must be a JSON object"] };
718
+ }
719
+ if (value.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
720
+ return {
721
+ problems: [
722
+ `manifest.schemaVersion must be ${MANIFEST_SCHEMA_VERSION}, got ${JSON.stringify(value.schemaVersion)}`,
723
+ ],
724
+ };
725
+ }
726
+ const armIds = declaredIds(value.arms);
727
+ const scenarioIds = declaredIds(value.scenarios);
728
+ const problems = [
729
+ ...campaignProblems(value.campaign),
730
+ ...armsProblems(value.arms),
731
+ ...scenariosProblems(value.scenarios),
732
+ ...scheduleProblems(value.schedule, armIds, scenarioIds),
733
+ ...evidenceProblems(value.evidence, armIds),
734
+ ...assessmentProblems(value.assessment),
735
+ ...policyProblems(value.policy, armIds),
736
+ ];
737
+ if (problems.length > 0)
738
+ return { problems };
739
+ return { value: value, problems: [] };
740
+ }