@openpond/evals 0.11.8 → 0.11.10

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/LEARNING.md CHANGED
@@ -359,3 +359,38 @@ unique eligible, unconsumed training attempts across current policies. It
359
359
  deduplicates shared sources and excludes held-out family reservations. Paused
360
360
  policies retain pending tasks. An invalid policy yields a null count and explicit
361
361
  issues rather than a misleading zero. Source credentials cannot read the queue.
362
+
363
+ ## Independent acceptance groups
364
+
365
+ `createAcceptanceGroupManifest` pins a training Job, exact baseline/candidate
366
+ artifacts, an acceptance plan and bounded concurrency. Each durable check declares
367
+ its role and score unit. Required quality/retention gates remain separate;
368
+ diagnostic checks cannot authorize acceptance. Pin the plan before training
369
+ admission, then create the group when the exact candidate exists. A missing
370
+ required group must block acceptance.
371
+
372
+ `createAcceptanceGroupSnapshot` verifies plan/manifest/snapshot integrity and
373
+ attempt identity. `assertAcceptanceGroupUpdate` enforces sequential revisions,
374
+ append-only retries, immutable terminal evidence, legal lifecycle transitions,
375
+ concurrency and dispatch budgets including outstanding reservations and prior
376
+ attempt costs. New attempts enter queued; an explicitly reused baseline may enter
377
+ completed. A terminal attempt requires cleanup, settled accounting and no remaining
378
+ reservation. Deliberate reruns create a new immutable group.
379
+
380
+ `assertAcceptanceBaselineReuse` accepts only fresh, exact, same-team measurements
381
+ matching the artifact, check, qualification reference and execution configuration.
382
+ It rejects recursively reused measurements. The owner must resolve the retained
383
+ source group and current qualification/revocation/access state; hashes alone are
384
+ not authorization or proof of execution.
385
+
386
+ `acceptanceGroupVerdict` preserves per-check results and blocks acceptance until
387
+ the group is complete, all required attempts have comparable measurements, cleanup
388
+ and accounting are settled, and recorded spend stays within all budgets. Failed
389
+ attempt costs count even if a later retry passes. It never averages unrelated
390
+ metrics into one quality score.
391
+
392
+ These contracts do not dispatch work or implement a database. Hosts must serialize
393
+ revision changes and budget reservations, resolve authoritative execution/grading
394
+ receipts, isolate holdouts from training, bind the exact candidate at runtime,
395
+ verify cleanup, and enforce the verdict inside the candidate-decision transaction.
396
+ No customer serving is activated by an acceptance group.
package/RELEASING.md CHANGED
@@ -20,3 +20,19 @@ Version `0.4.0` removes the retired Harness root barrel and
20
20
  `./harness-improvements` / `./harness-workspaces` compatibility subpaths.
21
21
  Consumers must import those public contracts from `@openpond/harness`; this is
22
22
  an intentional breaking package-boundary correction.
23
+
24
+ ## Version-only CI
25
+
26
+ Release preparation is shared across Evals, Harness, SDK, and Agent SDK. A stable
27
+ version increase in exactly one package manifest selects package checks on the
28
+ PR. All other manifest fields must be semantically identical. On merge, the
29
+ previous master SHA must have a successful latest `ci.yml` push run and `Checks`
30
+ job; missing, failed, pending, or ambiguous proof selects full CI. Publication
31
+ still waits for the new SHA's `Checks`.
32
+
33
+ Lockfile changes conservatively use ordinary CI, as do dependency, source, export,
34
+ workflow, and combined package changes. Pure workspace version bumps normally
35
+ leave the lockfile unchanged. The helper reports the selected path before commit.
36
+ A dry run requires clean, current master and prints the commands without changing
37
+ Git or package versions. Existing trusted publishing, registry verification,
38
+ package tags, and retry behavior are unchanged.
@@ -0,0 +1,199 @@
1
+ import { z } from "zod";
2
+ import { assertContentHash, contentHash, ImmutableReleaseRefSchema } from "@openpond/harness";
3
+ import { AcceptanceMeasurementSchema, AcceptancePlanSchema, evaluateAcceptancePlan } from "./acceptance-plan.js";
4
+ const Id = z.string().trim().min(1).max(240);
5
+ const Hash = z.string().regex(/^[a-f0-9]{64}$/);
6
+ const Timestamp = z.iso.datetime();
7
+ const Money = z.number().finite().nonnegative();
8
+ const terminalAttempts = new Set(["completed", "failed", "cancelled", "budget_exhausted"]);
9
+ const terminalGroups = new Set(["completed", "failed", "cancelled", "budget_exhausted"]);
10
+ const groupTransitions = {
11
+ queued: ["queued", "running", "blocked", "cancelling", "failed", "budget_exhausted"],
12
+ running: ["running", "blocked", "cancelling", "cleaning", "completed", "failed", "budget_exhausted"],
13
+ blocked: ["blocked", "running", "cancelling", "failed", "budget_exhausted"],
14
+ cancelling: ["cancelling", "cleaning", "cancelled", "budget_exhausted"],
15
+ cleaning: ["cleaning", "completed", "failed", "cancelled", "budget_exhausted"],
16
+ };
17
+ const attemptTransitions = {
18
+ queued: ["queued", "dispatching", "failed", "cancelled", "budget_exhausted"],
19
+ dispatching: ["dispatching", "running", "cleaning", "failed", "cancelled", "budget_exhausted"],
20
+ running: ["running", "grading", "cleaning", "failed", "cancelled", "budget_exhausted"],
21
+ grading: ["grading", "cleaning", "completed", "failed", "cancelled", "budget_exhausted"],
22
+ cleaning: ["cleaning", "completed", "failed", "cancelled", "budget_exhausted"],
23
+ };
24
+ /** A group starts only after the exact candidate is available. The host must pin
25
+ * its acceptance plan before training admission so a missing group blocks review. */
26
+ export const AcceptanceGroupManifestContentSchema = z.object({
27
+ schemaVersion: z.literal("openpond.acceptanceGroupManifest.v1"),
28
+ id: Id, teamId: Id, trainingJob: ImmutableReleaseRefSchema,
29
+ baseline: ImmutableReleaseRefSchema, candidate: ImmutableReleaseRefSchema,
30
+ plan: AcceptancePlanSchema, maximumParallelAttempts: z.number().int().min(1).max(8),
31
+ createdAt: Timestamp,
32
+ }).strict().superRefine((manifest, ctx) => {
33
+ if (manifest.plan.checks.some(check => !check.role || !check.unit))
34
+ ctx.addIssue({ code: "custom", message: "Durable acceptance checks require explicit roles and score units." });
35
+ });
36
+ export const AcceptanceGroupManifestSchema = AcceptanceGroupManifestContentSchema.safeExtend({ contentHash: Hash });
37
+ export const AcceptanceAttemptSchema = z.object({
38
+ id: Id, checkId: Id, subject: z.enum(["baseline", "candidate"]),
39
+ ordinal: z.number().int().positive(), dispatchId: Id,
40
+ state: z.enum(["queued", "dispatching", "running", "grading", "cleaning", "completed", "failed", "cancelled", "budget_exhausted"]),
41
+ artifact: ImmutableReleaseRefSchema, checkHash: Hash,
42
+ measurement: AcceptanceMeasurementSchema.nullable(),
43
+ executionReceipt: ImmutableReleaseRefSchema.nullable(),
44
+ reusedFrom: z.object({ group: ImmutableReleaseRefSchema, attemptId: Id }).strict().nullable(),
45
+ spendUsd: Money, reservedUsd: Money,
46
+ cleanupComplete: z.boolean(), accountingComplete: z.boolean(),
47
+ failureCode: Id.nullable(), createdAt: Timestamp, updatedAt: Timestamp,
48
+ }).strict().superRefine((attempt, ctx) => {
49
+ if (Date.parse(attempt.updatedAt) < Date.parse(attempt.createdAt))
50
+ ctx.addIssue({ code: "custom", message: "Attempt timestamps are reversed." });
51
+ if (terminalAttempts.has(attempt.state) && (!attempt.cleanupComplete || !attempt.accountingComplete || attempt.reservedUsd !== 0))
52
+ ctx.addIssue({ code: "custom", message: "Terminal attempts require cleanup and settled accounting." });
53
+ if (attempt.state === "completed" && (attempt.measurement?.status !== "completed" || !attempt.executionReceipt))
54
+ ctx.addIssue({ code: "custom", message: "Completed attempts require retained measurement and execution receipts." });
55
+ if (attempt.reusedFrom && (attempt.subject !== "baseline" || attempt.state !== "completed" || attempt.spendUsd !== 0))
56
+ ctx.addIssue({ code: "custom", message: "Only completed baselines may reuse retained evidence without new spend." });
57
+ });
58
+ export const AcceptanceGroupSnapshotContentSchema = z.object({
59
+ schemaVersion: z.literal("openpond.acceptanceGroupSnapshot.v1"),
60
+ manifest: AcceptanceGroupManifestSchema,
61
+ revision: z.number().int().positive(),
62
+ state: z.enum(["queued", "running", "blocked", "cancelling", "cleaning", "completed", "failed", "cancelled", "budget_exhausted"]),
63
+ attempts: z.array(AcceptanceAttemptSchema).max(2_000),
64
+ failureCode: Id.nullable(), updatedAt: Timestamp,
65
+ }).strict();
66
+ export const AcceptanceGroupSnapshotSchema = AcceptanceGroupSnapshotContentSchema.extend({ contentHash: Hash });
67
+ export function createAcceptanceGroupManifest(raw) {
68
+ const content = AcceptanceGroupManifestContentSchema.parse(raw);
69
+ assertContentHash(content.plan, "Acceptance plan");
70
+ return { ...content, contentHash: contentHash(content) };
71
+ }
72
+ export function createAcceptanceGroupSnapshot(raw) {
73
+ const content = AcceptanceGroupSnapshotContentSchema.parse(raw);
74
+ return assertAcceptanceGroupSnapshot({ ...content, contentHash: contentHash(content) });
75
+ }
76
+ /** Structural integrity does not replace receipt retrieval, resource access,
77
+ * qualification/revocation checks, or authoritative ledger verification. */
78
+ export function assertAcceptanceGroupSnapshot(raw) {
79
+ const snapshot = AcceptanceGroupSnapshotSchema.parse(raw);
80
+ assertContentHash(snapshot, "Acceptance group snapshot");
81
+ assertContentHash(snapshot.manifest, "Acceptance group manifest");
82
+ assertContentHash(snapshot.manifest.plan, "Acceptance plan");
83
+ const checks = new Map(snapshot.manifest.plan.checks.map(check => [check.id, check]));
84
+ const identities = new Set(), dispatches = new Set(), positions = new Set();
85
+ for (const attempt of snapshot.attempts) {
86
+ const check = checks.get(attempt.checkId);
87
+ if (!check || attempt.checkHash !== contentHash(check) || contentHash(attempt.artifact) !== contentHash(snapshot.manifest[attempt.subject]))
88
+ throw new Error("Acceptance attempt does not match its pinned check and artifact.");
89
+ const position = JSON.stringify([attempt.checkId, attempt.subject, attempt.ordinal]);
90
+ if (identities.has(attempt.id) || dispatches.has(attempt.dispatchId) || positions.has(position))
91
+ throw new Error("Acceptance attempts require unique identities, dispatches and positions.");
92
+ identities.add(attempt.id);
93
+ dispatches.add(attempt.dispatchId);
94
+ positions.add(position);
95
+ if (attempt.measurement && (attempt.measurement.checkHash !== attempt.checkHash || contentHash(attempt.measurement.artifact) !== contentHash(attempt.artifact)))
96
+ throw new Error("Acceptance measurement does not match its attempt.");
97
+ if (attempt.reusedFrom && !check.baselineReuse)
98
+ throw new Error("This check does not permit baseline reuse.");
99
+ }
100
+ for (const check of checks.values())
101
+ for (const subject of ["baseline", "candidate"]) {
102
+ const attempts = snapshot.attempts.filter(attempt => attempt.checkId === check.id && attempt.subject === subject).sort((a, b) => a.ordinal - b.ordinal);
103
+ attempts.forEach((attempt, index) => {
104
+ if (attempt.ordinal !== index + 1 || (index > 0 && (!terminalAttempts.has(attempts[index - 1].state) || attempts[index - 1].state === "completed")))
105
+ throw new Error("Retries must follow a settled failed attempt; completed evidence requires a new group to rerun.");
106
+ });
107
+ }
108
+ if (terminalGroups.has(snapshot.state) && snapshot.attempts.some(attempt => !terminalAttempts.has(attempt.state)))
109
+ throw new Error("Terminal groups cannot retain unsettled attempts.");
110
+ if (snapshot.attempts.filter(attempt => !terminalAttempts.has(attempt.state) && attempt.state !== "queued").length > snapshot.manifest.maximumParallelAttempts)
111
+ throw new Error("Acceptance attempts exceed the declared concurrency limit.");
112
+ if (Date.parse(snapshot.updatedAt) < Date.parse(snapshot.manifest.createdAt) || snapshot.attempts.some(attempt => Date.parse(attempt.createdAt) < Date.parse(snapshot.manifest.createdAt) || Date.parse(attempt.updatedAt) > Date.parse(snapshot.updatedAt)))
113
+ throw new Error("Acceptance group timestamps do not contain their attempts.");
114
+ if (snapshot.state === "completed" && snapshot.manifest.plan.checks.some(check => ["baseline", "candidate"].some(subject => !snapshot.attempts.some(attempt => attempt.checkId === check.id && attempt.subject === subject))))
115
+ throw new Error("Completed groups require an attempt for each declared check and subject.");
116
+ return snapshot;
117
+ }
118
+ /** Runtime retries append history. A terminal group is immutable; deliberate
119
+ * reruns create a new manifest and cannot silently replace reviewed evidence. */
120
+ export function assertAcceptanceGroupUpdate(previousRaw, nextRaw) {
121
+ const previous = assertAcceptanceGroupSnapshot(previousRaw), next = assertAcceptanceGroupSnapshot(nextRaw);
122
+ if (previous.manifest.contentHash !== next.manifest.contentHash || next.revision !== previous.revision + 1 || terminalGroups.has(previous.state))
123
+ throw new Error("Acceptance group revision or immutable identity changed.");
124
+ if (!groupTransitions[previous.state]?.includes(next.state))
125
+ throw new Error("Invalid acceptance group transition.");
126
+ if (["cancelling", "cleaning"].includes(previous.state) && next.attempts.length !== previous.attempts.length)
127
+ throw new Error("Stopping groups cannot dispatch additional attempts.");
128
+ if (Date.parse(next.updatedAt) < Date.parse(previous.updatedAt))
129
+ throw new Error("Acceptance group time moved backwards.");
130
+ const priorIds = new Set(previous.attempts.map(attempt => attempt.id));
131
+ for (const added of next.attempts.filter(attempt => !priorIds.has(attempt.id))) {
132
+ if (added.state !== "queued" && !(added.state === "completed" && added.reusedFrom))
133
+ throw new Error("New attempts must enter the queue or retain an explicitly reused baseline.");
134
+ if (Date.parse(added.createdAt) < Date.parse(previous.updatedAt))
135
+ throw new Error("New attempts cannot predate the preceding group revision.");
136
+ }
137
+ for (const before of previous.attempts) {
138
+ const after = next.attempts.find(attempt => attempt.id === before.id);
139
+ if (!after || after.checkId !== before.checkId || after.subject !== before.subject || after.ordinal !== before.ordinal || after.dispatchId !== before.dispatchId || after.createdAt !== before.createdAt || after.checkHash !== before.checkHash || contentHash(after.reusedFrom) !== contentHash(before.reusedFrom) || contentHash(after.artifact) !== contentHash(before.artifact))
140
+ throw new Error("Acceptance attempt identity or history changed.");
141
+ if (after.spendUsd < before.spendUsd || Date.parse(after.updatedAt) < Date.parse(before.updatedAt) || (terminalAttempts.has(before.state) && contentHash(before) !== contentHash(after)))
142
+ throw new Error("Settled acceptance evidence and spend cannot be rewritten.");
143
+ if (!terminalAttempts.has(before.state) && !attemptTransitions[before.state]?.includes(after.state))
144
+ throw new Error("Invalid acceptance attempt transition.");
145
+ }
146
+ const admitsWork = next.attempts.some(after => after.state === "dispatching" && previous.attempts.find(before => before.id === after.id)?.state === "queued");
147
+ if (admitsWork) {
148
+ const committed = (attempt) => attempt.spendUsd + attempt.reservedUsd;
149
+ if (next.attempts.reduce((sum, attempt) => sum + committed(attempt), 0) > next.manifest.plan.maximumSpendUsd + 1e-9 ||
150
+ next.manifest.plan.checks.some(check => next.attempts.filter(attempt => attempt.checkId === check.id).reduce((sum, attempt) => sum + committed(attempt), 0) > check.maximumSpendUsd + 1e-9))
151
+ throw new Error("Dispatch exceeds the pinned acceptance budget, including outstanding reservations and retry spend.");
152
+ }
153
+ return next;
154
+ }
155
+ /** Hosts additionally resolve the qualification reference and check current
156
+ * revocation/access. This verifier rejects stale or differently configured
157
+ * cached measurements without requiring the source candidate to have passed. */
158
+ export function assertAcceptanceBaselineReuse(input) {
159
+ const source = assertAcceptanceGroupSnapshot(input.sourceGroup);
160
+ const target = AcceptanceGroupManifestSchema.parse(input.targetManifest);
161
+ assertContentHash(target, "Acceptance group manifest");
162
+ assertContentHash(target.plan, "Acceptance plan");
163
+ const check = target.plan.checks.find(value => value.id === input.checkId);
164
+ const attempt = source.attempts.find(value => value.id === input.attemptId);
165
+ const now = Date.parse(Timestamp.parse(input.now));
166
+ if (!check?.baselineReuse || !attempt || !terminalGroups.has(source.state) || source.manifest.id === target.id || source.manifest.teamId !== target.teamId || attempt.state !== "completed" || !attempt.measurement || attempt.reusedFrom || !attempt.cleanupComplete || !attempt.accountingComplete)
167
+ throw new Error("This retained attempt is not eligible for baseline reuse.");
168
+ if (attempt.checkId !== check.id || attempt.checkHash !== contentHash(check) || contentHash(attempt.artifact) !== contentHash(target.baseline) || now < Date.parse(attempt.updatedAt) || now - Date.parse(attempt.updatedAt) > check.baselineReuse.maximumAgeSeconds * 1_000)
169
+ throw new Error("Baseline reuse requires the exact artifact, check, qualification and freshness window.");
170
+ const measurement = attempt.measurement;
171
+ if (measurement.status !== "completed" || measurement.score === null || measurement.scoredCount / measurement.expectedCount < check.minimumCoverage || measurement.populationHash !== check.populationHash || measurement.executionHash !== check.executionHash || contentHash(measurement.evaluator) !== contentHash(check.evaluator) || measurement.metric !== check.metric)
172
+ throw new Error("Baseline reuse requires complete compatible retained measurements.");
173
+ return attempt;
174
+ }
175
+ export function acceptanceGroupVerdict(raw) {
176
+ const snapshot = assertAcceptanceGroupSnapshot(raw);
177
+ const { manifest } = snapshot;
178
+ const latest = (checkId, subject) => snapshot.attempts
179
+ .filter(attempt => attempt.checkId === checkId && attempt.subject === subject)
180
+ .sort((a, b) => b.ordinal - a.ordinal)[0];
181
+ const measurements = manifest.plan.checks.map(check => ({
182
+ checkId: check.id,
183
+ baseline: latest(check.id, "baseline")?.measurement ?? null,
184
+ candidate: latest(check.id, "candidate")?.measurement ?? null,
185
+ }));
186
+ const verdict = evaluateAcceptancePlan({ plan: manifest.plan, baseline: manifest.baseline, candidate: manifest.candidate, measurements });
187
+ const reasons = [];
188
+ if (snapshot.state !== "completed")
189
+ reasons.push("group_not_completed");
190
+ if (snapshot.attempts.some(attempt => !attempt.cleanupComplete || !attempt.accountingComplete || attempt.reservedUsd !== 0))
191
+ reasons.push("cleanup_or_accounting_incomplete");
192
+ const spendUsd = snapshot.attempts.reduce((sum, attempt) => sum + attempt.spendUsd, 0);
193
+ if (spendUsd > manifest.plan.maximumSpendUsd + 1e-9 || manifest.plan.checks.some(check => snapshot.attempts.filter(attempt => attempt.checkId === check.id).reduce((sum, attempt) => sum + attempt.spendUsd, 0) > check.maximumSpendUsd + 1e-9))
194
+ reasons.push("acceptance_budget_exceeded");
195
+ for (const check of manifest.plan.checks.filter(check => check.required))
196
+ if (["baseline", "candidate"].some(subject => latest(check.id, subject)?.state !== "completed"))
197
+ reasons.push("required_attempt_incomplete");
198
+ return { ...verdict, passed: verdict.passed && reasons.length === 0, reasons: [...new Set(reasons)], spendUsd };
199
+ }
@@ -0,0 +1,90 @@
1
+ import { z } from "zod";
2
+ import { assertContentHash, contentHash, ImmutableReleaseRefSchema } from "@openpond/harness";
3
+ const Id = z.string().trim().min(1).max(240);
4
+ const Hash = z.string().regex(/^[a-f0-9]{64}$/);
5
+ export const AcceptanceCheckSchema = z.object({
6
+ id: Id, name: z.string().trim().min(1).max(200), required: z.boolean(),
7
+ role: z.enum(["quality", "retention", "diagnostic"]).optional(),
8
+ unit: z.string().trim().min(1).max(80).optional(),
9
+ baselineReuse: z.object({ maximumAgeSeconds: z.number().int().positive().max(31_536_000), qualification: ImmutableReleaseRefSchema }).strict().optional(),
10
+ dataset: ImmutableReleaseRefSchema, evaluator: ImmutableReleaseRefSchema,
11
+ executionHash: Hash, populationHash: Hash, metric: Id, direction: z.enum(["higher", "lower"]),
12
+ minimumCoverage: z.number().positive().max(1), threshold: z.number().finite(),
13
+ maximumRegression: z.number().finite().nonnegative(), maximumSpendUsd: z.number().finite().positive(),
14
+ }).strict().superRefine((check, ctx) => {
15
+ if (check.role === "diagnostic" && check.required)
16
+ ctx.addIssue({ code: "custom", message: "Diagnostic checks cannot be required acceptance gates." });
17
+ });
18
+ export const AcceptancePlanContentSchema = z.object({
19
+ schemaVersion: z.literal("openpond.acceptancePlan.v1"), id: Id, revision: z.number().int().positive(),
20
+ checks: z.array(AcceptanceCheckSchema).min(1).max(100),
21
+ maximumSpendUsd: z.number().finite().positive(),
22
+ }).strict().superRefine((value, ctx) => {
23
+ if (new Set(value.checks.map(c => c.id)).size !== value.checks.length)
24
+ ctx.addIssue({ code: "custom", message: "Acceptance check IDs must be unique." });
25
+ if (!value.checks.some(c => c.required))
26
+ ctx.addIssue({ code: "custom", message: "An acceptance plan needs at least one required check." });
27
+ if (value.checks.reduce((total, c) => total + c.maximumSpendUsd, 0) > value.maximumSpendUsd + 1e-9)
28
+ ctx.addIssue({ code: "custom", message: "Check budgets exceed the total plan budget." });
29
+ });
30
+ export const AcceptancePlanSchema = AcceptancePlanContentSchema.safeExtend({ contentHash: Hash });
31
+ export function createAcceptancePlan(input) {
32
+ const value = AcceptancePlanContentSchema.parse(input);
33
+ return { ...value, contentHash: contentHash(value) };
34
+ }
35
+ export const AcceptanceMeasurementSchema = z.object({
36
+ runId: Id, artifact: ImmutableReleaseRefSchema, checkHash: Hash,
37
+ populationHash: Hash, scoredPopulationHash: Hash, executionHash: Hash, evaluator: ImmutableReleaseRefSchema,
38
+ metric: Id, expectedCount: z.number().int().positive(), scoredCount: z.number().int().nonnegative(),
39
+ score: z.number().finite().nullable(), status: z.enum(["pending", "running", "completed", "failed", "cancelled"]),
40
+ evidenceHash: Hash,
41
+ }).strict().superRefine((value, ctx) => {
42
+ if (value.scoredCount > value.expectedCount)
43
+ ctx.addIssue({ code: "custom", message: "Scored count exceeds declared population." });
44
+ if ((value.scoredCount === 0) !== (value.score === null))
45
+ ctx.addIssue({ code: "custom", message: "Scores require a nonempty scored population." });
46
+ });
47
+ /** Evaluates verified retained measurements, never browser-supplied scores.
48
+ * Hosts must load and verify the referenced receipts and authorize their scope. */
49
+ export function evaluateAcceptancePlan(input) {
50
+ const plan = AcceptancePlanSchema.parse(input.plan);
51
+ assertContentHash(plan, "Acceptance plan");
52
+ const baseline = ImmutableReleaseRefSchema.parse(input.baseline);
53
+ const candidate = ImmutableReleaseRefSchema.parse(input.candidate);
54
+ if (new Set(input.measurements.map(m => m.checkId)).size !== input.measurements.length || input.measurements.some(m => !plan.checks.some(c => c.id === m.checkId)))
55
+ throw new Error("Acceptance evidence has duplicate or unknown checks.");
56
+ const checks = plan.checks.map(check => {
57
+ const pair = input.measurements.find(m => m.checkId === check.id);
58
+ const reasons = [];
59
+ const expectedHash = contentHash(check);
60
+ const values = [pair?.baseline ?? null, pair?.candidate ?? null].map((raw, index) => {
61
+ if (!raw) {
62
+ reasons.push(index ? "candidate_missing" : "baseline_missing");
63
+ return null;
64
+ }
65
+ const value = AcceptanceMeasurementSchema.parse(raw);
66
+ if (contentHash(value.artifact) !== contentHash(index ? candidate : baseline))
67
+ reasons.push("artifact_mismatch");
68
+ if (value.checkHash !== expectedHash || value.populationHash !== check.populationHash || value.executionHash !== check.executionHash || contentHash(value.evaluator) !== contentHash(check.evaluator) || value.metric !== check.metric)
69
+ reasons.push("configuration_mismatch");
70
+ if (value.status !== "completed")
71
+ reasons.push(`execution_${value.status}`);
72
+ if (value.score === null || value.scoredCount / value.expectedCount < check.minimumCoverage)
73
+ reasons.push("coverage_incomplete");
74
+ return value;
75
+ });
76
+ if (values[0] && values[1] && (values[0].expectedCount !== values[1].expectedCount || values[0].scoredCount !== values[1].scoredCount || values[0].scoredPopulationHash !== values[1].scoredPopulationHash))
77
+ reasons.push("population_count_mismatch");
78
+ if (reasons.length)
79
+ return { id: check.id, required: check.required, state: "blocked", reasons: [...new Set(reasons)], delta: null };
80
+ const before = values[0].score;
81
+ const after = values[1].score;
82
+ const delta = (after - before) * (check.direction === "higher" ? 1 : -1);
83
+ if (check.direction === "higher" ? after < check.threshold : after > check.threshold)
84
+ reasons.push("threshold_failed");
85
+ if (delta < -check.maximumRegression)
86
+ reasons.push("regression_failed");
87
+ return { id: check.id, required: check.required, state: reasons.length ? "failed" : "passed", reasons, delta };
88
+ });
89
+ return { passed: checks.filter(c => c.required).every(c => c.state === "passed"), checks };
90
+ }
@@ -32,3 +32,5 @@ export * from "./schedule-worker.js";
32
32
  export * from "./nightly-schedule.js";
33
33
  export * from "./policy-inspection.js";
34
34
  export * from "./task-queue-inspection.js";
35
+ export * from "./acceptance-plan.js";
36
+ export * from "./acceptance-group.js";