@openpond/evals 0.11.10 → 0.11.12

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 (34) hide show
  1. package/CONTRACT.md +20 -0
  2. package/dist/experiments.js +243 -0
  3. package/dist/index.js +1 -0
  4. package/dist/learning/authoring.js +2 -1
  5. package/dist/learning/reward-authoring.js +3 -3
  6. package/dist/rewards.js +5 -0
  7. package/dist/types/experiments.d.ts +642 -0
  8. package/dist/types/experiments.d.ts.map +1 -0
  9. package/dist/types/index.d.ts +1 -0
  10. package/dist/types/index.d.ts.map +1 -1
  11. package/dist/types/learning/admission.d.ts +1 -0
  12. package/dist/types/learning/admission.d.ts.map +1 -1
  13. package/dist/types/learning/authoring-service.d.ts +1 -0
  14. package/dist/types/learning/authoring-service.d.ts.map +1 -1
  15. package/dist/types/learning/authoring.d.ts +4 -0
  16. package/dist/types/learning/authoring.d.ts.map +1 -1
  17. package/dist/types/learning/contracts.d.ts +4 -3
  18. package/dist/types/learning/contracts.d.ts.map +1 -1
  19. package/dist/types/learning/iteration-candidate-decision.d.ts +1 -1
  20. package/dist/types/learning/operations.d.ts +6 -0
  21. package/dist/types/learning/operations.d.ts.map +1 -1
  22. package/dist/types/learning/repository.d.ts +5 -3
  23. package/dist/types/learning/repository.d.ts.map +1 -1
  24. package/dist/types/learning/reward-authoring.d.ts.map +1 -1
  25. package/dist/types/learning/reward-calibration.d.ts +1 -0
  26. package/dist/types/learning/reward-calibration.d.ts.map +1 -1
  27. package/dist/types/learning/transport.d.ts +10 -5
  28. package/dist/types/learning/transport.d.ts.map +1 -1
  29. package/dist/types/rewards.d.ts +4 -0
  30. package/dist/types/rewards.d.ts.map +1 -1
  31. package/package.json +5 -1
  32. package/schemas/learning/v1/command-request.schema.json +248 -235
  33. package/schemas/learning/v1/draft.schema.json +4 -0
  34. package/schemas/learning/v1/reward.schema.json +4 -0
package/CONTRACT.md CHANGED
@@ -162,3 +162,23 @@ Node subprocess for hosts without Node worker APIs. It strips authored TypeScrip
162
162
  inside its deadline, evaluates the verified source in the same deterministic
163
163
  isolate, and exits before success, failure or cancellation settles. Importing
164
164
  the metrics entrypoint does not require Node's type-stripping API on the host.
165
+
166
+ ## Portable Experiment comparison
167
+
168
+ `verifyExperimentEvidence` verifies the manifest hash, result hash, complete
169
+ case population and exact evaluator bindings. Historical runs without a whole
170
+ execution ceiling report `maximumCostUsd: null`. Dataset-authored graders retain
171
+ their actual version string and implementation hash; reusable Rewards retain
172
+ their numeric release revision. Authored fixture checks use a fixture target.
173
+
174
+ Hosts can pin package, runtime and metric-policy hashes in `execution`. Shared
175
+ comparison requires equal dataset version, population, evaluators and execution
176
+ contract. Harness comparison also requires the same component, evaluation
177
+ definition and environment; candidate source/Harness revisions can differ.
178
+
179
+ `compareExperiments` aligns cases and reports per-feedback eligibility, exclusion
180
+ reasons and paired means/deltas. Only matching completed cases with scored
181
+ numeric or Boolean feedback contribute. Failed, cancelled, absent or ungraded
182
+ cases never become zero-valued measurements. Categorical feedback remains
183
+ inspectable without a numeric delta. This operation reads retained evidence and
184
+ has no execution, grading or billing authority.
@@ -0,0 +1,243 @@
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
+ z.object({ kind: z.literal("fixture"), configurationHash: ReleaseHashSchema }).strict(),
17
+ ]);
18
+ export const ExperimentEvaluatorSchema = z.object({
19
+ // A reusable Reward has a numeric revision; a dataset-authored GraderSpec
20
+ // has its exact version string and implementation hash. Neither is invented.
21
+ release: ImmutableReleaseRefSchema.extend({ revision: z.union([z.number().int().positive(), z.string().trim().min(1).max(200)]) }).strict(),
22
+ feedbackKey: FeedbackKeySchema,
23
+ /** Binds field mappings and admitted scorer settings without changing the Reward's release hash. */
24
+ configurationHash: ReleaseHashSchema.optional(),
25
+ output: z.enum(["boolean", "score", "category"]),
26
+ /** Category labels are meaningful only for category output. */
27
+ categories: z.array(z.string().trim().min(1).max(120)).max(50),
28
+ }).strict().superRefine((value, context) => {
29
+ if ((value.output === "category") !== (value.categories.length > 0))
30
+ context.addIssue({ code: "custom", path: ["categories"], message: "Only category feedback declares categories." });
31
+ if (new Set(value.categories).size !== value.categories.length)
32
+ context.addIssue({ code: "custom", path: ["categories"], message: "Feedback categories must be unique." });
33
+ });
34
+ export const ExperimentCaseIdentitySchema = z.object({
35
+ caseId: ReleaseIdSchema,
36
+ seed: z.string().trim().min(1).max(500),
37
+ fixtureId: ReleaseIdSchema.nullable(),
38
+ }).strict();
39
+ export function experimentCaseKey(identity) {
40
+ const value = ExperimentCaseIdentitySchema.parse(identity);
41
+ return JSON.stringify([value.caseId, value.seed, value.fixtureId]);
42
+ }
43
+ export const ExperimentManifestContentSchema = z.object({
44
+ schemaVersion: z.literal("openpond.experimentManifest.v1"),
45
+ id: ReleaseIdSchema,
46
+ name: z.string().trim().min(1).max(500),
47
+ teamId: ReleaseIdSchema,
48
+ operationId: ReleaseIdSchema,
49
+ /** Historical imported runs may have no admitted whole-run ceiling. */
50
+ maximumCostUsd: MoneySchema.nullable(),
51
+ dataset: ImmutableReleaseRefSchema.extend({ revision: z.number().int().positive() }).strict(),
52
+ target: ExperimentTargetSchema,
53
+ execution: z.object({ packageHash: ReleaseHashSchema, runtimeTargetHash: ReleaseHashSchema, metricPolicyHash: ReleaseHashSchema }).strict().optional(),
54
+ lineage: z.object({
55
+ definition: ImmutableReleaseRefSchema.extend({ revision: z.number().int().positive() }).strict().nullable(),
56
+ execution: ImmutableReleaseRefSchema,
57
+ scoringPassId: ReleaseIdSchema.nullable(),
58
+ }).strict().optional(),
59
+ evaluators: z.array(ExperimentEvaluatorSchema).max(100),
60
+ population: z.array(ExperimentCaseIdentitySchema).min(1).max(10_000),
61
+ createdAt: ReleaseTimestampSchema,
62
+ }).strict().superRefine((value, context) => {
63
+ if (value.lineage && value.id !== (value.lineage.scoringPassId ?? value.lineage.execution.id))
64
+ context.addIssue({ code: "custom", path: ["lineage"], message: "Experiment identity must match its execution or independent scoring pass." });
65
+ if (new Set(value.population.map(experimentCaseKey)).size !== value.population.length)
66
+ context.addIssue({ code: "custom", path: ["population"], message: "Experiment cases must be unique." });
67
+ if (new Set(value.evaluators.map((item) => item.feedbackKey)).size !== value.evaluators.length)
68
+ context.addIssue({ code: "custom", path: ["evaluators"], message: "Experiment feedback keys must be unique." });
69
+ });
70
+ export const ExperimentManifestSchema = ExperimentManifestContentSchema.extend({ contentHash: ReleaseHashSchema }).strict();
71
+ export const ExperimentFeedbackSchema = z.object({
72
+ feedbackKey: FeedbackKeySchema,
73
+ evaluator: ExperimentEvaluatorSchema.shape.release,
74
+ status: z.enum(["scored", "unavailable", "failed", "pending"]),
75
+ value: z.union([z.boolean(), z.number().finite(), z.string().max(120)]).nullable(),
76
+ passed: z.boolean().nullable().optional(),
77
+ reasoning: z.string().max(20_000).nullable(),
78
+ evidenceRefs: z.array(ImmutableArtifactRefSchema).max(1_000),
79
+ }).strict();
80
+ export const ExperimentUsageSchema = z.object({
81
+ inputTokens: z.number().int().nonnegative().nullable(),
82
+ outputTokens: z.number().int().nonnegative().nullable(),
83
+ totalTokens: z.number().int().nonnegative().nullable(),
84
+ costUsd: MoneySchema.nullable(),
85
+ latencyMs: z.number().finite().nonnegative().nullable(),
86
+ }).strict().superRefine((usage, context) => {
87
+ if (usage.inputTokens !== null && usage.outputTokens !== null && usage.totalTokens !== null
88
+ && usage.totalTokens < usage.inputTokens + usage.outputTokens)
89
+ context.addIssue({ code: "custom", path: ["totalTokens"], message: "Total tokens cannot be less than input plus output." });
90
+ });
91
+ export const ExperimentCaseResultSchema = z.object({
92
+ identity: ExperimentCaseIdentitySchema,
93
+ status: z.enum(["completed", "failed", "cancelled", "unavailable"]),
94
+ output: z.unknown().nullable(),
95
+ error: z.object({ code: ReleaseIdSchema, message: z.string().max(2_000) }).strict().nullable(),
96
+ feedback: z.array(ExperimentFeedbackSchema).max(100),
97
+ usage: ExperimentUsageSchema,
98
+ traceRef: ImmutableArtifactRefSchema.nullable(),
99
+ startedAt: ReleaseTimestampSchema.nullable(),
100
+ completedAt: ReleaseTimestampSchema.nullable(),
101
+ }).strict();
102
+ export const ExperimentResultContentSchema = z.object({
103
+ schemaVersion: z.literal("openpond.experimentResult.v1"),
104
+ manifest: ImmutableReleaseRefSchema,
105
+ status: z.enum(["completed", "failed", "cancelled"]),
106
+ cases: z.array(ExperimentCaseResultSchema).min(1).max(10_000),
107
+ completedAt: ReleaseTimestampSchema,
108
+ }).strict();
109
+ export const ExperimentResultSchema = ExperimentResultContentSchema.extend({ contentHash: ReleaseHashSchema }).strict();
110
+ export function createExperimentManifest(input) {
111
+ const value = ExperimentManifestContentSchema.parse(input);
112
+ return ExperimentManifestSchema.parse({ ...value, contentHash: contentHash(value) });
113
+ }
114
+ export function createExperimentResult(input, manifest) {
115
+ ExperimentManifestSchema.parse(manifest);
116
+ assertContentHash(manifest, "Experiment manifest");
117
+ const value = ExperimentResultContentSchema.parse(input);
118
+ if (value.manifest.id !== manifest.id || value.manifest.contentHash !== manifest.contentHash)
119
+ throw new Error("Experiment result differs from its manifest.");
120
+ const expected = new Set(manifest.population.map(experimentCaseKey));
121
+ const seen = new Set();
122
+ const evaluators = new Map(manifest.evaluators.map((item) => [item.feedbackKey, item]));
123
+ for (const result of value.cases) {
124
+ const key = experimentCaseKey(result.identity);
125
+ if (!expected.has(key) || seen.has(key))
126
+ throw new Error("Experiment result has an unexpected or repeated case.");
127
+ seen.add(key);
128
+ const feedbackKeys = new Set();
129
+ for (const feedback of result.feedback) {
130
+ const evaluator = evaluators.get(feedback.feedbackKey);
131
+ if (!evaluator || feedbackKeys.has(feedback.feedbackKey)
132
+ || feedback.evaluator.id !== evaluator.release.id
133
+ || feedback.evaluator.contentHash !== evaluator.release.contentHash
134
+ || feedback.evaluator.revision !== evaluator.release.revision)
135
+ throw new Error("Experiment feedback differs from its pinned evaluator.");
136
+ feedbackKeys.add(feedback.feedbackKey);
137
+ if (feedback.status === "scored") {
138
+ if (feedback.value === null || typeof feedback.value !== { boolean: "boolean", score: "number", category: "string" }[evaluator.output]
139
+ || (evaluator.output === "category" && !evaluator.categories.includes(feedback.value))
140
+ || (evaluator.output === "score" && (feedback.value < 0 || feedback.value > 1)))
141
+ throw new Error("Experiment feedback value violates its output contract.");
142
+ }
143
+ else if (feedback.value !== null || feedback.passed !== null && feedback.passed !== undefined)
144
+ throw new Error("Unscored experiment feedback cannot claim a value or pass decision.");
145
+ }
146
+ }
147
+ if (seen.size !== expected.size)
148
+ throw new Error("Experiment result omits admitted cases.");
149
+ return ExperimentResultSchema.parse({ ...value, contentHash: contentHash(value) });
150
+ }
151
+ export function verifyExperimentEvidence(input) {
152
+ const manifest = ExperimentManifestSchema.parse(input.manifest);
153
+ const result = ExperimentResultSchema.parse(input.result);
154
+ const { contentHash: hash, ...content } = result;
155
+ if (contentHash(content) !== hash)
156
+ throw new Error("Experiment result integrity failed.");
157
+ createExperimentResult(content, manifest);
158
+ return { manifest, result };
159
+ }
160
+ /** A comparison reads retained results; it never executes or regrades a case. */
161
+ export function compareExperiments(baseline, candidate) {
162
+ for (const pair of [baseline, candidate]) {
163
+ verifyExperimentEvidence(pair);
164
+ }
165
+ const reasons = [];
166
+ if (baseline.manifest.teamId !== candidate.manifest.teamId)
167
+ reasons.push("different_workspace");
168
+ if (baseline.manifest.dataset.id !== candidate.manifest.dataset.id
169
+ || baseline.manifest.dataset.contentHash !== candidate.manifest.dataset.contentHash
170
+ || baseline.manifest.dataset.revision !== candidate.manifest.dataset.revision)
171
+ reasons.push("different_dataset_version");
172
+ if (baseline.manifest.target.kind !== candidate.manifest.target.kind)
173
+ reasons.push("different_target_kind");
174
+ if (contentHash(baseline.manifest.execution ?? null) !== contentHash(candidate.manifest.execution ?? null))
175
+ reasons.push("different_execution_contract");
176
+ if (baseline.manifest.target.kind === "harness" && candidate.manifest.target.kind === "harness") {
177
+ const a = baseline.manifest.target.source;
178
+ const b = candidate.manifest.target.source;
179
+ if (a.profileId !== b.profileId || contentHash(a.target) !== contentHash(b.target))
180
+ reasons.push("different_component");
181
+ if (a.environmentHash !== b.environmentHash)
182
+ reasons.push("different_environment");
183
+ if (a.definitionId !== b.definitionId || a.definitionHash !== b.definitionHash)
184
+ reasons.push("different_evaluation_definition");
185
+ }
186
+ const orderedEvaluators = (manifest) => [...manifest.evaluators].sort((a, b) => a.feedbackKey.localeCompare(b.feedbackKey));
187
+ if (contentHash(orderedEvaluators(baseline.manifest)) !== contentHash(orderedEvaluators(candidate.manifest)))
188
+ reasons.push("different_evaluators");
189
+ const left = new Map(baseline.result.cases.map((item) => [experimentCaseKey(item.identity), item]));
190
+ const right = new Map(candidate.result.cases.map((item) => [experimentCaseKey(item.identity), item]));
191
+ const keys = [...new Set([...left.keys(), ...right.keys()])].sort();
192
+ if (left.size !== right.size || keys.some((key) => !left.has(key) || !right.has(key)))
193
+ reasons.push("different_population");
194
+ const comparable = reasons.length === 0;
195
+ const cases = keys.map((key) => {
196
+ const baselineCase = left.get(key) ?? null;
197
+ const candidateCase = right.get(key) ?? null;
198
+ const baselineFeedback = new Map(baselineCase?.feedback.map(item => [item.feedbackKey, item]) ?? []);
199
+ const candidateFeedback = new Map(candidateCase?.feedback.map(item => [item.feedbackKey, item]) ?? []);
200
+ return {
201
+ identity: (baselineCase ?? candidateCase).identity,
202
+ baseline: baselineCase,
203
+ candidate: candidateCase,
204
+ feedback: baseline.manifest.evaluators.map(evaluator => {
205
+ const a = baselineFeedback.get(evaluator.feedbackKey);
206
+ const b = candidateFeedback.get(evaluator.feedbackKey);
207
+ const reason = !comparable ? "incompatible_experiments"
208
+ : !baselineCase || !candidateCase ? "missing_case"
209
+ : baselineCase.status !== "completed" || candidateCase.status !== "completed" || baselineCase.error || candidateCase.error ? "case_not_completed"
210
+ : !a || !b || a.status !== "scored" || b.status !== "scored" ? "feedback_unavailable"
211
+ : evaluator.output === "category" ? "categorical_feedback" : null;
212
+ const numeric = (item) => typeof item?.value === "boolean" ? Number(item.value)
213
+ : typeof item?.value === "number" ? item.value : null;
214
+ const av = reason ? null : numeric(a);
215
+ const bv = reason ? null : numeric(b);
216
+ const pass = (item) => reason ? null : typeof item?.value === "boolean" ? item.value : item?.passed ?? null;
217
+ return { feedbackKey: evaluator.feedbackKey, eligible: reason === null, reason,
218
+ baseline: av, candidate: bv, delta: av === null || bv === null ? null : bv - av,
219
+ baselinePassed: pass(a), candidatePassed: pass(b) };
220
+ }),
221
+ };
222
+ });
223
+ const metrics = baseline.manifest.evaluators.map((evaluator, index) => {
224
+ const eligible = cases.map(item => item.feedback[index]).filter(value => value.eligible);
225
+ const mean = (field) => eligible.length
226
+ ? eligible.reduce((sum, value) => sum + value[field], 0) / eligible.length : null;
227
+ const a = mean("baseline");
228
+ const b = mean("candidate");
229
+ const passRate = (field) => eligible.length && eligible.every(value => value[field] !== null)
230
+ ? eligible.reduce((sum, value) => sum + Number(value[field]), 0) / eligible.length : null;
231
+ const ap = passRate("baselinePassed");
232
+ const bp = passRate("candidatePassed");
233
+ return { feedbackKey: evaluator.feedbackKey, eligibleCount: eligible.length, excludedCount: cases.length - eligible.length,
234
+ baseline: a, candidate: b, delta: a === null || b === null ? null : b - a,
235
+ baselinePassRate: ap, candidatePassRate: bp, passRateDelta: ap === null || bp === null ? null : bp - ap };
236
+ });
237
+ return {
238
+ comparable,
239
+ reasons,
240
+ cases,
241
+ metrics,
242
+ };
243
+ }
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";
@@ -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(),
@@ -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),