@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.
- package/LEARNING.md +35 -0
- package/dist/experiments.js +169 -0
- package/dist/index.js +1 -0
- package/dist/learning/acceptance-group.js +199 -0
- package/dist/learning/acceptance-plan.js +7 -1
- package/dist/learning/authoring.js +2 -1
- package/dist/learning/index.js +1 -0
- package/dist/learning/reward-authoring.js +3 -3
- package/dist/rewards.js +5 -0
- package/dist/types/experiments.d.ts +426 -0
- package/dist/types/experiments.d.ts.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/learning/acceptance-group.d.ts +540 -0
- package/dist/types/learning/acceptance-group.d.ts.map +1 -0
- package/dist/types/learning/acceptance-plan.d.ts +39 -0
- package/dist/types/learning/acceptance-plan.d.ts.map +1 -1
- package/dist/types/learning/admission.d.ts +1 -0
- package/dist/types/learning/admission.d.ts.map +1 -1
- package/dist/types/learning/authoring-service.d.ts +1 -0
- package/dist/types/learning/authoring-service.d.ts.map +1 -1
- package/dist/types/learning/authoring.d.ts +4 -0
- package/dist/types/learning/authoring.d.ts.map +1 -1
- package/dist/types/learning/contracts.d.ts +4 -3
- package/dist/types/learning/contracts.d.ts.map +1 -1
- package/dist/types/learning/index.d.ts +1 -0
- package/dist/types/learning/index.d.ts.map +1 -1
- package/dist/types/learning/iteration-candidate-decision.d.ts +1 -1
- package/dist/types/learning/operations.d.ts +6 -0
- package/dist/types/learning/operations.d.ts.map +1 -1
- package/dist/types/learning/repository.d.ts +4 -2
- package/dist/types/learning/repository.d.ts.map +1 -1
- package/dist/types/learning/reward-authoring.d.ts.map +1 -1
- package/dist/types/learning/reward-calibration.d.ts +1 -0
- package/dist/types/learning/reward-calibration.d.ts.map +1 -1
- package/dist/types/learning/transport.d.ts +8 -3
- package/dist/types/learning/transport.d.ts.map +1 -1
- package/dist/types/rewards.d.ts +4 -0
- package/dist/types/rewards.d.ts.map +1 -1
- package/package.json +5 -1
- package/schemas/learning/v1/acceptance-group-manifest.schema.json +261 -0
- package/schemas/learning/v1/acceptance-group-snapshot.schema.json +585 -0
- package/schemas/learning/v1/acceptance-plan.schema.json +205 -0
- package/schemas/learning/v1/command-request.schema.json +248 -235
- package/schemas/learning/v1/draft.schema.json +4 -0
- 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(),
|
package/dist/learning/index.js
CHANGED
|
@@ -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),
|