@markstanden/opencode-test-container 0.0.0-stage → 0.1.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.
Files changed (121) 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/schema.md +97 -0
  117. package/examples/skills/README.md +13 -0
  118. package/examples/skills/campaign.json +73 -0
  119. package/examples/skills/skills/otel-helper/SKILL.md +10 -0
  120. package/examples/skills/suite.json +29 -0
  121. package/package.json +45 -5
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Campaign accounting and closure, derived from durable evidence.
3
+ *
4
+ * The report never trusts a hard-coded checkpoint. Counts come from the
5
+ * frozen schedule, the launch claims, the recorded trial directories and each
6
+ * attempt's own token/duration record. A stopped partial campaign closes with
7
+ * the real scope it ran, and a closed campaign cannot be reopened by either
8
+ * the runner or the recovery path.
9
+ */
10
+ /** An empty token accumulator. */
11
+ function emptyTokens() {
12
+ return { input: null, output: null, reasoning: null, cacheRead: null };
13
+ }
14
+ /**
15
+ * Add one recorded field. Once an observation is unknown the total stays
16
+ * unknown: a later known value never restarts the subtotal at zero.
17
+ */
18
+ function addField(account, field, value) {
19
+ if (value === null) {
20
+ account.unknown.set(field, true);
21
+ }
22
+ else {
23
+ const total = account[field];
24
+ account[field] =
25
+ total === null || account.unknown.get(field) === true
26
+ ? value
27
+ : total + value;
28
+ }
29
+ if (account.unknown.get(field) === true)
30
+ account[field] = null;
31
+ }
32
+ /** Add one attempt's tokens into an accumulator. */
33
+ function addTokens(account, tokens) {
34
+ const fields = ["input", "output", "reasoning", "cacheRead"];
35
+ if (tokens === null) {
36
+ for (const field of fields)
37
+ account.unknownTokens.add(field);
38
+ }
39
+ else {
40
+ for (const field of fields) {
41
+ const value = tokens[field];
42
+ if (value === null) {
43
+ account.unknownTokens.add(field);
44
+ }
45
+ else {
46
+ account.tokens[field] = (account.tokens[field] ?? 0) + value;
47
+ }
48
+ }
49
+ }
50
+ for (const field of account.unknownTokens)
51
+ account.tokens[field] = null;
52
+ }
53
+ /** Token coverage counters. */
54
+ function emptyCoverage() {
55
+ return { input: 0, output: 0, reasoning: 0, cacheRead: 0 };
56
+ }
57
+ /**
58
+ * Charge a claimed-but-unspawned attempt explicitly. A reservation consumes
59
+ * budget and appears as an unresolved attempt, never as a launch.
60
+ * @param claims The durable claims.
61
+ * @returns The count of claims that never spawned.
62
+ */
63
+ export function unresolvedReservations(claims) {
64
+ return (claims ?? []).filter((claim) => claim.state === "reserved");
65
+ }
66
+ /** The numeric ordinal of an `attempt-NNN` id, or 0 when malformed. */
67
+ function attemptOrdinal(attemptId) {
68
+ const match = /^attempt-(\d{3})$/.exec(attemptId);
69
+ return match === null ? 0 : Number(match[1]);
70
+ }
71
+ /**
72
+ * Derive the campaign accounts from durable observations.
73
+ * @param input The schedule, slot observations, claims and ceilings.
74
+ * @returns The closure report.
75
+ */
76
+ export function deriveClosure(input) {
77
+ const trial = emptyAccount();
78
+ const judge = {
79
+ ...emptyAccount(),
80
+ usableJudgements: 0,
81
+ unusableJudgements: 0,
82
+ };
83
+ const observed = input.observations.filter(isObserved);
84
+ const judgeAttempts = observed.flatMap((slot) => slot.judgeAttempts);
85
+ for (const slot of observed) {
86
+ addTrial(trial, slot);
87
+ for (const attempt of slot.judgeAttempts)
88
+ addJudge(judge, attempt);
89
+ }
90
+ const problems = [
91
+ ...budgetProblems(input, trial.attempts, judge.attempts),
92
+ ...observationProblems(observed),
93
+ ];
94
+ const recordedTrials = observed.filter((slot) => slot.recorded).length;
95
+ problems.push(...closureProblems(input.closed, observed.length, recordedTrials));
96
+ return {
97
+ state: input.closed ? "closed" : "open",
98
+ scheduledSlots: input.scheduledSlots,
99
+ observedSlots: observed.length,
100
+ unrunSlots: unrunOf(input.scheduledSlots, observed),
101
+ trialLaunches: trial.attempts,
102
+ recordedTrials,
103
+ trial,
104
+ judge: {
105
+ ...judge,
106
+ initialAttempts: judgeAttempts.filter((attempt) => attemptOrdinal(attempt.attemptId) === 1).length,
107
+ recoveryAttempts: judgeAttempts.filter((attempt) => attemptOrdinal(attempt.attemptId) > 1).length,
108
+ },
109
+ stoppedAt: [...observed].at(-1)?.stamp ?? null,
110
+ problems,
111
+ };
112
+ }
113
+ /** An empty model accumulator. */
114
+ function emptyAccount() {
115
+ return {
116
+ attempts: 0,
117
+ inferred: 0,
118
+ steps: null,
119
+ tokens: emptyTokens(),
120
+ tokenCoverage: emptyCoverage(),
121
+ durationMs: null,
122
+ unknown: new Map(),
123
+ unknownTokens: new Set(),
124
+ };
125
+ }
126
+ /** Whether one slot has any evidence at all. */
127
+ function isObserved(slot) {
128
+ return slot.launched || slot.recorded || slot.judgeAttempts.length > 0;
129
+ }
130
+ /** Add one slot's trial evidence. */
131
+ function addTrial(account, slot) {
132
+ if (!slot.launched)
133
+ return;
134
+ account.attempts += 1;
135
+ if (slot.trialSteps !== null && slot.trialSteps > 0)
136
+ account.inferred += 1;
137
+ addField(account, "steps", slot.trialSteps);
138
+ addTokens(account, slot.trialTokens);
139
+ addField(account, "durationMs", slot.trialDurationMs);
140
+ addCoverage(account, slot.trialTokens);
141
+ }
142
+ /** Add one judge attempt's evidence. */
143
+ function addJudge(account, attempt) {
144
+ if (attempt.spawned)
145
+ account.attempts += 1;
146
+ if (attempt.reachedInference)
147
+ account.inferred += 1;
148
+ addField(account, "steps", attempt.steps);
149
+ addTokens(account, attempt.tokens);
150
+ addField(account, "durationMs", attempt.durationMs);
151
+ addCoverage(account, attempt.tokens);
152
+ if (attempt.status === "judged")
153
+ account.usableJudgements += 1;
154
+ else
155
+ account.unusableJudgements += 1;
156
+ }
157
+ /** Count which token fields an observation reported. */
158
+ function addCoverage(account, tokens) {
159
+ if (tokens === null)
160
+ return;
161
+ for (const field of [
162
+ "input",
163
+ "output",
164
+ "reasoning",
165
+ "cacheRead",
166
+ ]) {
167
+ if (tokens[field] !== null)
168
+ account.tokenCoverage[field] += 1;
169
+ }
170
+ }
171
+ /** The slot numbers with no evidence. */
172
+ function unrunOf(scheduledSlots, observed) {
173
+ const observedNumbers = new Set(observed.map((slot) => slot.slot));
174
+ return Array.from({ length: scheduledSlots }, (_, index) => index + 1).filter((number) => !observedNumbers.has(number));
175
+ }
176
+ /** Ceiling and reservation disagreements. */
177
+ function budgetProblems(input, trialLaunches, judgeAttempts) {
178
+ const problems = [];
179
+ if (trialLaunches > input.ceilings.trial) {
180
+ problems.push(`trial launches ${String(trialLaunches)} exceed the ceiling ${String(input.ceilings.trial)}`);
181
+ }
182
+ if (judgeAttempts > input.ceilings.judge) {
183
+ problems.push(`judge attempts ${String(judgeAttempts)} exceed the ceiling ${String(input.ceilings.judge)}`);
184
+ }
185
+ const unresolvedTrials = unresolvedReservations(input.trialClaims);
186
+ if (unresolvedTrials.length > 0) {
187
+ problems.push(`${String(unresolvedTrials.length)} durable trial reservation(s) never spawned and stay unresolved`);
188
+ }
189
+ const unresolvedJudges = unresolvedReservations(input.judgeClaims);
190
+ if (unresolvedJudges.length > 0) {
191
+ problems.push(`${String(unresolvedJudges.length)} durable judge reservation(s) never spawned and stay unresolved`);
192
+ }
193
+ return problems;
194
+ }
195
+ /** Per-slot disagreements the accounts expose. */
196
+ function observationProblems(observed) {
197
+ const problems = [];
198
+ for (const slot of observed) {
199
+ if (slot.launched && !slot.recorded) {
200
+ problems.push(`${slot.stamp}: launched without a recorded result`);
201
+ }
202
+ for (const attempt of slot.judgeAttempts) {
203
+ if (attempt.spawned && attempt.steps === null) {
204
+ problems.push(`${slot.stamp}/${attempt.attemptId}: spawned without a readable trail`);
205
+ }
206
+ }
207
+ }
208
+ return problems;
209
+ }
210
+ /** A closed campaign with an observed but unrecorded slot. */
211
+ function closureProblems(closed, observed, recorded) {
212
+ return closed && recorded < observed
213
+ ? [
214
+ `campaign is closed with ${String(observed - recorded)} observed slot(s) lacking a recorded result`,
215
+ ]
216
+ : [];
217
+ }
218
+ /**
219
+ * Decide whether a campaign may close now, and with what scope.
220
+ *
221
+ * A campaign with no evidence is refused rather than closed empty. A stopped
222
+ * partial campaign closes with the slots that actually ran; the schedule's
223
+ * unrun slots are preserved as scope, not invented results.
224
+ * @param report The derived closure report.
225
+ * @returns The close event, or every reason closure is refused.
226
+ */
227
+ export function closeCampaign(report) {
228
+ if (report.state === "closed") {
229
+ return {
230
+ ok: false,
231
+ problems: ["campaign is already closed; reopening is refused"],
232
+ };
233
+ }
234
+ if (report.observedSlots === 0) {
235
+ return {
236
+ ok: false,
237
+ problems: ["no campaign evidence exists to close"],
238
+ };
239
+ }
240
+ if (report.problems.length > 0) {
241
+ return { ok: false, problems: report.problems };
242
+ }
243
+ return {
244
+ ok: true,
245
+ event: {
246
+ event: "campaign-closed",
247
+ stoppedAt: report.stoppedAt,
248
+ scope: report.observedSlots,
249
+ },
250
+ };
251
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Decision evaluation for the analysis framework.
3
+ *
4
+ * The frozen selection rule is evaluated as three separate limbs — assessment
5
+ * coverage, the utility threshold and the non-compensable guardrails — and one
6
+ * overall outcome. Missing rules report "not defined" instead of inventing
7
+ * defaults; an unknown limb can prevent qualification without proving that
8
+ * the candidate failed the performance threshold.
9
+ */
10
+ import type { AccountingCounts } from "./accounting.ts";
11
+ import { type Interval } from "./bounds.ts";
12
+ import type { GuardrailArmSummary } from "./guardrails.ts";
13
+ import type { SelectionSpec } from "./model.ts";
14
+ /** The coverage limb. */
15
+ export interface CoverageLimb {
16
+ /** Whether the required coverage is complete. */
17
+ status: "pass" | "fail" | "not-applicable" | "not-defined";
18
+ /** The observed documented/planned coverage, rendered for reports. */
19
+ detail: string;
20
+ }
21
+ /** The utility threshold limb. */
22
+ export interface ThresholdLimb {
23
+ /** Whether the candidate's lead meets the threshold. */
24
+ status: "pass" | "fail" | "inconclusive" | "not-defined";
25
+ /** Candidate minus control over the assessed populations, or null. */
26
+ lead: number | null;
27
+ /** The full-population candidate-minus-control interval, when available. */
28
+ interval: Interval | null;
29
+ /** The threshold comparison, rendered for reports. */
30
+ detail: string;
31
+ }
32
+ /** The guardrail limb for the candidate arm. */
33
+ export interface GuardrailLimb {
34
+ /** Whether a non-compensable breach is established or unresolved. */
35
+ status: "clean" | "breached" | "unresolved" | "not-defined";
36
+ /** The breached or unresolved guardrail ids, rendered for reports. */
37
+ detail: string;
38
+ }
39
+ /** The decision result. */
40
+ export interface DecisionResult {
41
+ /** The overall outcome under the declared rule. */
42
+ overall: "qualified" | "not-qualified" | "inconclusive" | "not-defined";
43
+ /** The coverage limb. */
44
+ coverage: CoverageLimb;
45
+ /** The threshold limb. */
46
+ threshold: ThresholdLimb;
47
+ /** The guardrail limb. */
48
+ guardrails: GuardrailLimb;
49
+ /** Human-readable reasons for the non-passing limbs. */
50
+ reasons: string[];
51
+ }
52
+ /**
53
+ * Evaluate the declared decision rule for a candidate against its control.
54
+ * @param selection The frozen selection rule, or undefined when none was frozen.
55
+ * @param arms The accounting counts keyed by arm id.
56
+ * @param guardrails The candidate arm's guardrail summaries.
57
+ * @returns The three limbs and the overall outcome.
58
+ */
59
+ export declare function evaluateDecision(selection: SelectionSpec | undefined, arms: Readonly<Record<string, AccountingCounts>>, guardrails: readonly GuardrailArmSummary[]): DecisionResult;
@@ -0,0 +1,161 @@
1
+ import { differenceInterval, intervalFromCounts, } from "./bounds.js";
2
+ /**
3
+ * Build the result for a campaign without a usable declared rule.
4
+ * @param detail Why no rule could be evaluated.
5
+ * @returns The not-defined decision.
6
+ */
7
+ function notDefined(detail) {
8
+ return {
9
+ overall: "not-defined",
10
+ coverage: { status: "not-defined", detail },
11
+ threshold: {
12
+ status: "not-defined",
13
+ lead: null,
14
+ interval: null,
15
+ detail,
16
+ },
17
+ guardrails: { status: "not-defined", detail },
18
+ reasons: [detail],
19
+ };
20
+ }
21
+ /**
22
+ * Evaluate the coverage limb against the declared rule.
23
+ * @param selection The declared selection rule.
24
+ * @param control The control arm counts.
25
+ * @param candidate The candidate arm counts.
26
+ * @returns The coverage limb.
27
+ */
28
+ function coverageLimb(selection, control, candidate) {
29
+ if (!selection.requireCompleteCoverage) {
30
+ return {
31
+ status: "not-applicable",
32
+ detail: "the rule does not require complete coverage",
33
+ };
34
+ }
35
+ const complete = control.documented === control.planned &&
36
+ candidate.documented === candidate.planned;
37
+ return {
38
+ status: complete ? "pass" : "fail",
39
+ detail: `assessment coverage: control ${control.documented}/${control.planned}, candidate ${candidate.documented}/${candidate.planned}`,
40
+ };
41
+ }
42
+ /**
43
+ * Evaluate the utility threshold limb against the full-population interval.
44
+ * The rule is defined on the full design, so a threshold passes only when
45
+ * every missing-data completion clears it and fails only when none can; a
46
+ * crossing interval is inconclusive. The assessed lead stays descriptive.
47
+ * @param selection The declared selection rule.
48
+ * @param control The control arm counts.
49
+ * @param candidate The candidate arm counts.
50
+ * @returns The threshold limb.
51
+ */
52
+ function thresholdLimb(selection, control, candidate) {
53
+ const delta = differenceInterval(intervalFromCounts(candidate), intervalFromCounts(control));
54
+ if (control.assessed === 0 || candidate.assessed === 0) {
55
+ return {
56
+ status: "inconclusive",
57
+ lead: null,
58
+ interval: delta,
59
+ detail: "no fully scored trials to compare",
60
+ };
61
+ }
62
+ const lead = candidate.utilityTotal - control.utilityTotal;
63
+ const status = delta.low >= selection.threshold
64
+ ? "pass"
65
+ : delta.high < selection.threshold
66
+ ? "fail"
67
+ : "inconclusive";
68
+ return {
69
+ status,
70
+ lead,
71
+ interval: delta,
72
+ detail: `lead ${lead === null ? "unknown" : String(lead)}; full-population interval ${delta.low}..${delta.high} vs threshold ${selection.threshold}`,
73
+ };
74
+ }
75
+ /**
76
+ * Evaluate the guardrail limb over the candidate arm's summaries.
77
+ * @param guardrails The candidate arm's guardrail summaries.
78
+ * @returns The guardrail limb.
79
+ */
80
+ function guardrailLimb(guardrails) {
81
+ if (guardrails.length === 0) {
82
+ return {
83
+ status: "not-defined",
84
+ detail: "the campaign declared no guardrails",
85
+ };
86
+ }
87
+ const breached = guardrails
88
+ .filter((summary) => summary.status === "breached")
89
+ .map((summary) => summary.guardrailId);
90
+ if (breached.length > 0) {
91
+ return {
92
+ status: "breached",
93
+ detail: `breached: ${breached.join(", ")}`,
94
+ };
95
+ }
96
+ const unresolved = guardrails
97
+ .filter((summary) => summary.status === "unresolved")
98
+ .map((summary) => summary.guardrailId);
99
+ if (unresolved.length > 0) {
100
+ return {
101
+ status: "unresolved",
102
+ detail: `unresolved: ${unresolved.join(", ")}`,
103
+ };
104
+ }
105
+ return { status: "clean", detail: "no established or unresolved breach" };
106
+ }
107
+ /**
108
+ * Combine the limbs into the overall outcome.
109
+ * @param coverage The coverage limb.
110
+ * @param threshold The threshold limb.
111
+ * @param guardrails The guardrail limb.
112
+ * @returns The overall outcome.
113
+ */
114
+ function overallOf(coverage, threshold, guardrails) {
115
+ if (coverage.status === "fail")
116
+ return "inconclusive";
117
+ if (guardrails.status === "breached")
118
+ return "not-qualified";
119
+ if (guardrails.status === "unresolved")
120
+ return "inconclusive";
121
+ if (threshold.status === "inconclusive")
122
+ return "inconclusive";
123
+ if (threshold.status === "fail")
124
+ return "not-qualified";
125
+ return "qualified";
126
+ }
127
+ /**
128
+ * Evaluate the declared decision rule for a candidate against its control.
129
+ * @param selection The frozen selection rule, or undefined when none was frozen.
130
+ * @param arms The accounting counts keyed by arm id.
131
+ * @param guardrails The candidate arm's guardrail summaries.
132
+ * @returns The three limbs and the overall outcome.
133
+ */
134
+ export function evaluateDecision(selection, arms, guardrails) {
135
+ if (selection === undefined) {
136
+ return notDefined("the campaign froze no selection rule");
137
+ }
138
+ const control = arms[selection.controlArm];
139
+ const candidate = arms[selection.candidateArm];
140
+ if (control === undefined || candidate === undefined) {
141
+ return notDefined(`selection names an arm with no accounting: ${selection.controlArm}/${selection.candidateArm}`);
142
+ }
143
+ const coverage = coverageLimb(selection, control, candidate);
144
+ const threshold = thresholdLimb(selection, control, candidate);
145
+ const guardrail = guardrailLimb(guardrails);
146
+ const reasons = [];
147
+ if (coverage.status === "fail")
148
+ reasons.push(coverage.detail);
149
+ if (threshold.status === "fail")
150
+ reasons.push(threshold.detail);
151
+ if (guardrail.status === "breached" || guardrail.status === "unresolved") {
152
+ reasons.push(guardrail.detail);
153
+ }
154
+ return {
155
+ overall: overallOf(coverage, threshold, guardrail),
156
+ coverage,
157
+ threshold,
158
+ guardrails: guardrail,
159
+ reasons,
160
+ };
161
+ }
@@ -0,0 +1,201 @@
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
+ /** One immutable input and its SHA-256. */
12
+ export interface FrozenFile {
13
+ /** The root-relative identity. */
14
+ path: string;
15
+ /** The SHA-256 of its bytes. */
16
+ sha256: string;
17
+ }
18
+ /** One directory inventory: every file under it, path-sorted. */
19
+ export interface FrozenInventory {
20
+ /** The root-relative directory identity. */
21
+ path: string;
22
+ /** Every file beneath it. */
23
+ files: FrozenFile[];
24
+ }
25
+ /** The current freeze record format. */
26
+ export declare const FREEZE_SCHEMA_VERSION = 2;
27
+ /** One arm's frozen identity. */
28
+ export interface FrozenArm {
29
+ /** The arm id. */
30
+ id: string;
31
+ /** The arm worktree's commit. */
32
+ commit: string;
33
+ /** The arm worktree's tree. */
34
+ tree: string;
35
+ /** Whether the worktree was clean at freeze time. */
36
+ clean: boolean;
37
+ /** The registered native description's character count. */
38
+ descriptionChars: number;
39
+ /** The registered native description's SHA-256. */
40
+ descriptionHash: string;
41
+ /** The delivered description's character count. */
42
+ deliveredChars: number;
43
+ /** The delivered description's SHA-256. */
44
+ deliveredHash: string;
45
+ /** The annotation payload's character count. */
46
+ annotationChars: number;
47
+ /** The annotation payload's SHA-256. */
48
+ annotationHash: string;
49
+ }
50
+ /** The frozen record. */
51
+ export interface FreezeRecord {
52
+ /** The record format; absent means a legacy, read-only freeze. */
53
+ schemaVersion: number;
54
+ /** The campaign id. */
55
+ campaign: string;
56
+ /** The source revision the campaign was materialised from. */
57
+ sourceRevision: string;
58
+ /** The source tree. */
59
+ sourceTree: string;
60
+ /** The image identity actually used, if known before launch. */
61
+ imageId: string;
62
+ /** The container CLI identity. */
63
+ containerCli: string;
64
+ /** The measurement declaration hash. */
65
+ measurementSha256: string;
66
+ /** The arms, in id order. */
67
+ arms: FrozenArm[];
68
+ /** Every immutable single-file input, path-sorted. */
69
+ files: FrozenFile[];
70
+ /** Every immutable input directory and its complete file inventory. */
71
+ inventories: FrozenInventory[];
72
+ /** The stopping rules, verbatim. */
73
+ stoppingRules: string[];
74
+ /** The trial and judge ceilings. */
75
+ ceilings: {
76
+ trial: number;
77
+ judge: number;
78
+ };
79
+ }
80
+ /** The freeze outcome. */
81
+ export type FreezeOutcome = {
82
+ ok: true;
83
+ record: FreezeRecord;
84
+ } | {
85
+ ok: false;
86
+ problems: string[];
87
+ };
88
+ /** The inputs a freeze decision reads. */
89
+ export interface FreezeInput {
90
+ /** The campaign id. */
91
+ campaign: string;
92
+ /** The source revision. */
93
+ sourceRevision: string;
94
+ /** The source tree. */
95
+ sourceTree: string;
96
+ /** The image identity. */
97
+ imageId: string | null;
98
+ /** The container CLI identity. */
99
+ containerCli: string | null;
100
+ /** The measurement declaration hash. */
101
+ measurementSha256: string | null;
102
+ /** The arms' registered and delivered descriptions. */
103
+ arms: readonly FrozenArm[];
104
+ /** Every immutable input. */
105
+ files: readonly FrozenFile[];
106
+ /** Every immutable input directory. */
107
+ inventories: readonly FrozenInventory[];
108
+ /** The stopping rules. */
109
+ stoppingRules: readonly string[];
110
+ /** The trial and judge ceilings. */
111
+ ceilings: {
112
+ trial: number;
113
+ judge: number;
114
+ } | null;
115
+ /** Whether any trial or judge evidence already exists. */
116
+ started: boolean;
117
+ /** Whether a freeze file already exists. */
118
+ frozen: boolean;
119
+ }
120
+ /**
121
+ * SHA-256 of exact text bytes.
122
+ * @param text The text.
123
+ * @returns The lowercase hex digest.
124
+ */
125
+ export declare function sha256Text(text: string): string;
126
+ /** One served tool description. */
127
+ export interface ServedDescription {
128
+ /** The description text. */
129
+ description: string;
130
+ /** Its UTF-16 code-point count, matching the registration measurement. */
131
+ chars: number;
132
+ /** The SHA-256 of its UTF-8 bytes. */
133
+ sha256: string;
134
+ }
135
+ /**
136
+ * Extract the served description of one tool from a provider request body.
137
+ *
138
+ * The delivery probe saves the raw request body, so this is the actual payload
139
+ * the provider received — never a hand-extracted copy. A body without the tool
140
+ * is a problem, not an empty description.
141
+ * @param body The parsed request body.
142
+ * @param toolName The registered tool name.
143
+ * @returns The served description, or a problem.
144
+ */
145
+ export declare function deliveredToolDescription(body: unknown, toolName?: string): {
146
+ delivered?: ServedDescription;
147
+ problem?: string;
148
+ };
149
+ /**
150
+ * Decide whether the experiment may be frozen.
151
+ *
152
+ * Refusals happen before anything is written: a started experiment, an
153
+ * existing freeze, a dirty arm, a missing or contradictory delivery, an absent
154
+ * input or a ceiling all block. Inputs are deduplicated by path and sorted, so
155
+ * two runs hash the same immutable set in the same order.
156
+ * @param input Everything the decision reads.
157
+ * @returns The record, or every problem.
158
+ */
159
+ export declare function freezeExperiment(input: FreezeInput): FreezeOutcome;
160
+ /**
161
+ * Write a verified freeze exclusively. An existing file refuses without
162
+ * touching its bytes.
163
+ * @param path The freeze file path.
164
+ * @param record The verified record.
165
+ * @returns Whether the write landed.
166
+ */
167
+ export declare function writeFreeze(path: string, record: FreezeRecord): {
168
+ ok: true;
169
+ } | {
170
+ ok: false;
171
+ reason: string;
172
+ };
173
+ /** The observed state a verification compares against the freeze. */
174
+ export interface FreezeObservation {
175
+ /** The current immutable single-file inputs. */
176
+ files: readonly FrozenFile[];
177
+ /** The current inventory for every declared directory. */
178
+ inventories: readonly FrozenInventory[];
179
+ /** The image identity actually executed. */
180
+ imageId: string;
181
+ /** The container CLI actually used. */
182
+ containerCli: string;
183
+ /** The source revision in use. */
184
+ sourceRevision: string;
185
+ }
186
+ /**
187
+ * Why a freeze cannot authorise new spend, or null when it can.
188
+ * @param record The recorded freeze, or undefined when absent.
189
+ * @returns The refusal, or null for a modern complete record.
190
+ */
191
+ export declare function spendFreezeProblem(record: FreezeRecord | null | undefined): string | null;
192
+ /**
193
+ * Compare observed inputs with the frozen record, including directory
194
+ * inventories in both directions so an added input is caught too.
195
+ * @param record The frozen record.
196
+ * @param observation What is present now.
197
+ * @returns Every mismatch; empty means the premise still holds.
198
+ */
199
+ export declare function verifyFreeze(record: FreezeRecord, observation: FreezeObservation): string[];
200
+ /** Whether a path already holds a freeze. */
201
+ export declare function freezeExists(path: string): boolean;