@openpond/evals 0.11.9 → 0.11.11

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 (46) hide show
  1. package/LEARNING.md +35 -0
  2. package/dist/experiments.js +169 -0
  3. package/dist/index.js +1 -0
  4. package/dist/learning/acceptance-group.js +199 -0
  5. package/dist/learning/acceptance-plan.js +7 -1
  6. package/dist/learning/authoring.js +2 -1
  7. package/dist/learning/index.js +1 -0
  8. package/dist/learning/reward-authoring.js +3 -3
  9. package/dist/rewards.js +5 -0
  10. package/dist/types/experiments.d.ts +426 -0
  11. package/dist/types/experiments.d.ts.map +1 -0
  12. package/dist/types/index.d.ts +1 -0
  13. package/dist/types/index.d.ts.map +1 -1
  14. package/dist/types/learning/acceptance-group.d.ts +540 -0
  15. package/dist/types/learning/acceptance-group.d.ts.map +1 -0
  16. package/dist/types/learning/acceptance-plan.d.ts +39 -0
  17. package/dist/types/learning/acceptance-plan.d.ts.map +1 -1
  18. package/dist/types/learning/admission.d.ts +1 -0
  19. package/dist/types/learning/admission.d.ts.map +1 -1
  20. package/dist/types/learning/authoring-service.d.ts +1 -0
  21. package/dist/types/learning/authoring-service.d.ts.map +1 -1
  22. package/dist/types/learning/authoring.d.ts +4 -0
  23. package/dist/types/learning/authoring.d.ts.map +1 -1
  24. package/dist/types/learning/contracts.d.ts +4 -3
  25. package/dist/types/learning/contracts.d.ts.map +1 -1
  26. package/dist/types/learning/index.d.ts +1 -0
  27. package/dist/types/learning/index.d.ts.map +1 -1
  28. package/dist/types/learning/iteration-candidate-decision.d.ts +1 -1
  29. package/dist/types/learning/operations.d.ts +6 -0
  30. package/dist/types/learning/operations.d.ts.map +1 -1
  31. package/dist/types/learning/repository.d.ts +4 -2
  32. package/dist/types/learning/repository.d.ts.map +1 -1
  33. package/dist/types/learning/reward-authoring.d.ts.map +1 -1
  34. package/dist/types/learning/reward-calibration.d.ts +1 -0
  35. package/dist/types/learning/reward-calibration.d.ts.map +1 -1
  36. package/dist/types/learning/transport.d.ts +8 -3
  37. package/dist/types/learning/transport.d.ts.map +1 -1
  38. package/dist/types/rewards.d.ts +4 -0
  39. package/dist/types/rewards.d.ts.map +1 -1
  40. package/package.json +5 -1
  41. package/schemas/learning/v1/acceptance-group-manifest.schema.json +261 -0
  42. package/schemas/learning/v1/acceptance-group-snapshot.schema.json +585 -0
  43. package/schemas/learning/v1/acceptance-plan.schema.json +205 -0
  44. package/schemas/learning/v1/command-request.schema.json +248 -235
  45. package/schemas/learning/v1/draft.schema.json +4 -0
  46. package/schemas/learning/v1/reward.schema.json +4 -0
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.
@@ -0,0 +1,169 @@
1
+ import { z } from "zod";
2
+ import { ImmutableArtifactRefSchema, ImmutableReleaseRefSchema, ReleaseHashSchema, ReleaseIdSchema, ReleaseTimestampSchema, assertContentHash, contentHash, } from "@openpond/harness";
3
+ import { ProfileEvaluationRunSourceSchema } from "./profile-evaluations.js";
4
+ import { FeedbackKeySchema } from "./rewards.js";
5
+ const MoneySchema = z.number().finite().nonnegative();
6
+ export const ExperimentTargetSchema = z.discriminatedUnion("kind", [
7
+ z.object({
8
+ kind: z.literal("model"),
9
+ modelId: ReleaseIdSchema,
10
+ configurationHash: ReleaseHashSchema,
11
+ }).strict(),
12
+ z.object({
13
+ kind: z.literal("harness"),
14
+ source: ProfileEvaluationRunSourceSchema,
15
+ }).strict(),
16
+ ]);
17
+ export const ExperimentEvaluatorSchema = z.object({
18
+ release: ImmutableReleaseRefSchema.extend({ revision: z.number().int().positive() }).strict(),
19
+ feedbackKey: FeedbackKeySchema,
20
+ output: z.enum(["boolean", "score", "category"]),
21
+ /** Category labels are meaningful only for category output. */
22
+ categories: z.array(z.string().trim().min(1).max(120)).max(50),
23
+ }).strict().superRefine((value, context) => {
24
+ if ((value.output === "category") !== (value.categories.length > 0))
25
+ context.addIssue({ code: "custom", path: ["categories"], message: "Only category feedback declares categories." });
26
+ if (new Set(value.categories).size !== value.categories.length)
27
+ context.addIssue({ code: "custom", path: ["categories"], message: "Feedback categories must be unique." });
28
+ });
29
+ export const ExperimentCaseIdentitySchema = z.object({
30
+ caseId: ReleaseIdSchema,
31
+ seed: z.string().trim().min(1).max(500),
32
+ fixtureId: ReleaseIdSchema.nullable(),
33
+ }).strict();
34
+ export function experimentCaseKey(identity) {
35
+ const value = ExperimentCaseIdentitySchema.parse(identity);
36
+ return JSON.stringify([value.caseId, value.seed, value.fixtureId]);
37
+ }
38
+ export const ExperimentManifestContentSchema = z.object({
39
+ schemaVersion: z.literal("openpond.experimentManifest.v1"),
40
+ id: ReleaseIdSchema,
41
+ name: z.string().trim().min(1).max(500),
42
+ teamId: ReleaseIdSchema,
43
+ operationId: ReleaseIdSchema,
44
+ maximumCostUsd: MoneySchema,
45
+ dataset: ImmutableReleaseRefSchema.extend({ revision: z.number().int().positive() }).strict(),
46
+ target: ExperimentTargetSchema,
47
+ evaluators: z.array(ExperimentEvaluatorSchema).max(100),
48
+ population: z.array(ExperimentCaseIdentitySchema).min(1).max(10_000),
49
+ createdAt: ReleaseTimestampSchema,
50
+ }).strict().superRefine((value, context) => {
51
+ if (new Set(value.population.map(experimentCaseKey)).size !== value.population.length)
52
+ context.addIssue({ code: "custom", path: ["population"], message: "Experiment cases must be unique." });
53
+ if (new Set(value.evaluators.map((item) => item.feedbackKey)).size !== value.evaluators.length)
54
+ context.addIssue({ code: "custom", path: ["evaluators"], message: "Experiment feedback keys must be unique." });
55
+ });
56
+ export const ExperimentManifestSchema = ExperimentManifestContentSchema.extend({ contentHash: ReleaseHashSchema }).strict();
57
+ export const ExperimentFeedbackSchema = z.object({
58
+ feedbackKey: FeedbackKeySchema,
59
+ evaluator: ExperimentEvaluatorSchema.shape.release,
60
+ status: z.enum(["scored", "unavailable", "failed", "pending"]),
61
+ value: z.union([z.boolean(), z.number().finite(), z.string().max(120)]).nullable(),
62
+ reasoning: z.string().max(20_000).nullable(),
63
+ evidenceRefs: z.array(ImmutableArtifactRefSchema).max(1_000),
64
+ }).strict();
65
+ export const ExperimentUsageSchema = z.object({
66
+ inputTokens: z.number().int().nonnegative().nullable(),
67
+ outputTokens: z.number().int().nonnegative().nullable(),
68
+ totalTokens: z.number().int().nonnegative().nullable(),
69
+ costUsd: MoneySchema.nullable(),
70
+ latencyMs: z.number().finite().nonnegative().nullable(),
71
+ }).strict().superRefine((usage, context) => {
72
+ if (usage.inputTokens !== null && usage.outputTokens !== null && usage.totalTokens !== null
73
+ && usage.totalTokens < usage.inputTokens + usage.outputTokens)
74
+ context.addIssue({ code: "custom", path: ["totalTokens"], message: "Total tokens cannot be less than input plus output." });
75
+ });
76
+ export const ExperimentCaseResultSchema = z.object({
77
+ identity: ExperimentCaseIdentitySchema,
78
+ status: z.enum(["completed", "failed", "cancelled", "unavailable"]),
79
+ output: z.unknown().nullable(),
80
+ error: z.object({ code: ReleaseIdSchema, message: z.string().max(2_000) }).strict().nullable(),
81
+ feedback: z.array(ExperimentFeedbackSchema).max(100),
82
+ usage: ExperimentUsageSchema,
83
+ traceRef: ImmutableArtifactRefSchema.nullable(),
84
+ startedAt: ReleaseTimestampSchema.nullable(),
85
+ completedAt: ReleaseTimestampSchema.nullable(),
86
+ }).strict();
87
+ export const ExperimentResultContentSchema = z.object({
88
+ schemaVersion: z.literal("openpond.experimentResult.v1"),
89
+ manifest: ImmutableReleaseRefSchema,
90
+ status: z.enum(["completed", "failed", "cancelled"]),
91
+ cases: z.array(ExperimentCaseResultSchema).min(1).max(10_000),
92
+ completedAt: ReleaseTimestampSchema,
93
+ }).strict();
94
+ export const ExperimentResultSchema = ExperimentResultContentSchema.extend({ contentHash: ReleaseHashSchema }).strict();
95
+ export function createExperimentManifest(input) {
96
+ const value = ExperimentManifestContentSchema.parse(input);
97
+ return ExperimentManifestSchema.parse({ ...value, contentHash: contentHash(value) });
98
+ }
99
+ export function createExperimentResult(input, manifest) {
100
+ ExperimentManifestSchema.parse(manifest);
101
+ assertContentHash(manifest, "Experiment manifest");
102
+ const value = ExperimentResultContentSchema.parse(input);
103
+ if (value.manifest.id !== manifest.id || value.manifest.contentHash !== manifest.contentHash)
104
+ throw new Error("Experiment result differs from its manifest.");
105
+ const expected = new Set(manifest.population.map(experimentCaseKey));
106
+ const seen = new Set();
107
+ const evaluators = new Map(manifest.evaluators.map((item) => [item.feedbackKey, item]));
108
+ for (const result of value.cases) {
109
+ const key = experimentCaseKey(result.identity);
110
+ if (!expected.has(key) || seen.has(key))
111
+ throw new Error("Experiment result has an unexpected or repeated case.");
112
+ seen.add(key);
113
+ const feedbackKeys = new Set();
114
+ for (const feedback of result.feedback) {
115
+ const evaluator = evaluators.get(feedback.feedbackKey);
116
+ if (!evaluator || feedbackKeys.has(feedback.feedbackKey)
117
+ || feedback.evaluator.id !== evaluator.release.id
118
+ || feedback.evaluator.contentHash !== evaluator.release.contentHash
119
+ || feedback.evaluator.revision !== evaluator.release.revision)
120
+ throw new Error("Experiment feedback differs from its pinned evaluator.");
121
+ feedbackKeys.add(feedback.feedbackKey);
122
+ if (feedback.status === "scored") {
123
+ if (feedback.value === null || typeof feedback.value !== { boolean: "boolean", score: "number", category: "string" }[evaluator.output]
124
+ || (evaluator.output === "category" && !evaluator.categories.includes(feedback.value))
125
+ || (evaluator.output === "score" && (feedback.value < 0 || feedback.value > 1)))
126
+ throw new Error("Experiment feedback value violates its output contract.");
127
+ }
128
+ else if (feedback.value !== null)
129
+ throw new Error("Unscored experiment feedback cannot claim a value.");
130
+ }
131
+ }
132
+ if (seen.size !== expected.size)
133
+ throw new Error("Experiment result omits admitted cases.");
134
+ return ExperimentResultSchema.parse({ ...value, contentHash: contentHash(value) });
135
+ }
136
+ /** A comparison reads retained results; it never executes or regrades a case. */
137
+ export function compareExperiments(baseline, candidate) {
138
+ for (const pair of [baseline, candidate]) {
139
+ const { contentHash: hash, ...value } = ExperimentResultSchema.parse(pair.result);
140
+ if (contentHash(value) !== hash)
141
+ throw new Error("Experiment result integrity failed.");
142
+ createExperimentResult(value, pair.manifest);
143
+ }
144
+ const reasons = [];
145
+ if (baseline.manifest.teamId !== candidate.manifest.teamId)
146
+ reasons.push("different_workspace");
147
+ if (baseline.manifest.dataset.id !== candidate.manifest.dataset.id
148
+ || baseline.manifest.dataset.contentHash !== candidate.manifest.dataset.contentHash
149
+ || baseline.manifest.dataset.revision !== candidate.manifest.dataset.revision)
150
+ reasons.push("different_dataset_version");
151
+ if (baseline.manifest.target.kind !== candidate.manifest.target.kind)
152
+ reasons.push("different_target_kind");
153
+ if (contentHash(baseline.manifest.evaluators) !== contentHash(candidate.manifest.evaluators))
154
+ reasons.push("different_evaluators");
155
+ const left = new Map(baseline.result.cases.map((item) => [experimentCaseKey(item.identity), item]));
156
+ const right = new Map(candidate.result.cases.map((item) => [experimentCaseKey(item.identity), item]));
157
+ const keys = [...new Set([...left.keys(), ...right.keys()])].sort();
158
+ if (left.size !== right.size || keys.some((key) => !left.has(key) || !right.has(key)))
159
+ reasons.push("different_population");
160
+ return {
161
+ comparable: reasons.length === 0,
162
+ reasons,
163
+ cases: keys.map((key) => ({
164
+ identity: (left.get(key) ?? right.get(key)).identity,
165
+ baseline: left.get(key) ?? null,
166
+ candidate: right.get(key) ?? null,
167
+ })),
168
+ };
169
+ }
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export * from "./artifact-verification.js";
4
4
  export * from "./evidence/index.js";
5
5
  export * from "./execution-contracts.js";
6
6
  export * from "./execution-receipts.js";
7
+ export * from "./experiments.js";
7
8
  export * from "./graders.js";
8
9
  export * from "./harness.js";
9
10
  export * from "./learned-preference.js";
@@ -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
+ }
@@ -4,11 +4,17 @@ const Id = z.string().trim().min(1).max(240);
4
4
  const Hash = z.string().regex(/^[a-f0-9]{64}$/);
5
5
  export const AcceptanceCheckSchema = z.object({
6
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(),
7
10
  dataset: ImmutableReleaseRefSchema, evaluator: ImmutableReleaseRefSchema,
8
11
  executionHash: Hash, populationHash: Hash, metric: Id, direction: z.enum(["higher", "lower"]),
9
12
  minimumCoverage: z.number().positive().max(1), threshold: z.number().finite(),
10
13
  maximumRegression: z.number().finite().nonnegative(), maximumSpendUsd: z.number().finite().positive(),
11
- }).strict();
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
+ });
12
18
  export const AcceptancePlanContentSchema = z.object({
13
19
  schemaVersion: z.literal("openpond.acceptancePlan.v1"), id: Id, revision: z.number().int().positive(),
14
20
  checks: z.array(AcceptanceCheckSchema).min(1).max(100),
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { ReleaseHashSchema, ReleaseIdSchema, ReleaseTimestampSchema } from "@openpond/harness";
3
- import { RewardBindingSourceSchema, RewardReleaseRefSchema } from "../rewards.js";
3
+ import { FeedbackKeySchema, RewardBindingSourceSchema, RewardReleaseRefSchema } from "../rewards.js";
4
4
  import { LearningRevisionRefSchema } from "./contracts.js";
5
5
  /** Strings intentionally preserve incomplete JSON/code until explicit publication. */
6
6
  export const RewardFixtureAuthoringFieldsSchema = z.object({
@@ -14,6 +14,7 @@ export const RewardFixtureAuthoringFieldsSchema = z.object({
14
14
  }).strict();
15
15
  export const RewardAuthoringFieldsSchema = z.object({
16
16
  name: z.string().max(500), description: z.string().max(10_000),
17
+ feedbackKey: FeedbackKeySchema.optional(),
17
18
  kind: z.enum(["custom_verifier", "state", "content", "schema", "artifact", "runtime_event", "model_judge", "learned_model", "human"]),
18
19
  fields: z.string(), outputField: z.string(), expectedField: z.string(), expectedValue: z.string(),
19
20
  schema: z.string(), reference: z.string(), events: z.string(), code: z.string(), exportName: z.string(), timeout: z.string(),
@@ -33,3 +33,4 @@ export * from "./nightly-schedule.js";
33
33
  export * from "./policy-inspection.js";
34
34
  export * from "./task-queue-inspection.js";
35
35
  export * from "./acceptance-plan.js";
36
+ export * from "./acceptance-group.js";
@@ -1,4 +1,4 @@
1
- import { RewardReleaseSchema, createRewardRelease } from "../rewards.js";
1
+ import { RewardReleaseSchema, createRewardRelease, feedbackKeyForReward } from "../rewards.js";
2
2
  import { assertBoundedTaskJson } from "../task-schema.js";
3
3
  import { RewardAuthoringFieldsSchema, RewardFixtureAuthoringFieldsSchema } from "./authoring.js";
4
4
  import { createLearningTextAsset, verifyLearningTextAsset } from "./assets.js";
@@ -64,7 +64,7 @@ export function compileRewardAuthoring(input) {
64
64
  const assets = [...(asset ? [asset] : []), ...(fixtureAsset ? [fixtureAsset] : [])];
65
65
  const reward = createRewardRelease({
66
66
  schemaVersion: "openpond.rewardRelease.v1", id: input.id, revision: (base?.revision ?? 0) + 1,
67
- name: fields.name, description: fields.description, implementation,
67
+ name: fields.name, description: fields.description, feedbackKey: fields.feedbackKey ?? feedbackKeyForReward({ id: input.id }), implementation,
68
68
  rawScore: fields.kind === "learned_model" ? { minimum: authoringNumber(fields.minimum, "Minimum score"), maximum: authoringNumber(fields.maximum, "Maximum score") } : { minimum: 0, maximum: 1 },
69
69
  assets: assets.map(value => value.asset), ...(fixtureAsset ? { fixtureSetRef: fixtureAsset.asset } : {}),
70
70
  ...(calibrationCheckRef ? { calibrationCheckRef } : {}),
@@ -84,7 +84,7 @@ export function rewardAuthoringFields(reward, sourceAsset, fixtureAsset = null)
84
84
  const fixtures = reward?.fixtureSetRef && fixtureAsset ? RewardFixtureAuthoringFieldsSchema.array().max(50).parse(JSON.parse(verifyLearningTextAsset(fixtureAsset, reward.fixtureSetRef))) : [];
85
85
  const config = implementation && "config" in implementation ? implementation.config : {};
86
86
  return {
87
- name: reward?.name ?? "", description: reward?.description ?? "", kind: implementation?.kind ?? "state",
87
+ name: reward?.name ?? "", description: reward?.description ?? "", ...(reward?.feedbackKey ? { feedbackKey: reward.feedbackKey } : {}), kind: implementation?.kind ?? "state",
88
88
  fields: strings(config.fields).join(", ") || "answer", outputField: text(config.outputField) || "text", expectedField: text(config.expectedField) || "text", expectedValue: text(config.expectedValue),
89
89
  schema: JSON.stringify(config.jsonSchema ?? { type: "object", properties: { answer: { type: "string" } }, required: ["answer"] }, null, 2),
90
90
  reference: text(config.refIncludes), events: strings(config.requiredEvents).join(", "),
package/dist/rewards.js CHANGED
@@ -5,6 +5,10 @@ import { gradeEvidence, GraderEvidenceSchema } from "./graders.js";
5
5
  import { CustomVerifierGraderSpecSchema, DeterministicGraderSpecSchema, GraderSpecSchema, HumanGraderSpecSchema, ModelJudgeGraderSpecSchema } from "./tasksets.js";
6
6
  import { assertBoundedTaskJson, validateTaskSchema } from "./task-schema.js";
7
7
  const bindingFields = { id: true, version: true, weight: true, hardGate: true, rewardEligible: true, privileged: true };
8
+ export const FeedbackKeySchema = z.string().regex(/^[a-z][a-z0-9_]{0,79}$/);
9
+ export function feedbackKeyForReward(reward) {
10
+ return reward.feedbackKey ?? `reward_${contentHash(reward.id).slice(0, 24)}`;
11
+ }
8
12
  export const RewardImplementationSchema = z.union([
9
13
  DeterministicGraderSpecSchema.omit(bindingFields),
10
14
  ModelJudgeGraderSpecSchema.omit(bindingFields),
@@ -18,6 +22,7 @@ export const RewardReleaseContentSchema = z.object({
18
22
  revision: z.number().int().positive(),
19
23
  name: z.string().trim().min(1).max(500),
20
24
  description: z.string().max(10_000),
25
+ feedbackKey: FeedbackKeySchema.optional(),
21
26
  implementation: RewardImplementationSchema,
22
27
  rawScore: z.object({ minimum: z.number().finite(), maximum: z.number().finite() }).strict(),
23
28
  assets: z.array(ImmutableAssetRefSchema).max(1_000),