@openpond/evals 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CONTRACT.md +13 -1
  2. package/README.md +26 -2
  3. package/conformance/telemetry/v1/invalid-batch.json +17 -0
  4. package/conformance/telemetry/v1/valid-batch.json +58 -0
  5. package/dist/index.js +5 -0
  6. package/dist/learned-preference.js +334 -0
  7. package/dist/preferences.js +77 -11
  8. package/dist/telemetry/index.js +4 -0
  9. package/dist/telemetry-analysis.js +183 -0
  10. package/dist/telemetry-bundle.js +60 -0
  11. package/dist/telemetry-catalog.js +50 -0
  12. package/dist/telemetry.js +112 -0
  13. package/dist/types/index.d.ts +5 -0
  14. package/dist/types/index.d.ts.map +1 -1
  15. package/dist/types/learned-preference.d.ts +282 -0
  16. package/dist/types/learned-preference.d.ts.map +1 -0
  17. package/dist/types/preferences.d.ts +52 -4
  18. package/dist/types/preferences.d.ts.map +1 -1
  19. package/dist/types/telemetry/index.d.ts +5 -0
  20. package/dist/types/telemetry/index.d.ts.map +1 -0
  21. package/dist/types/telemetry-analysis.d.ts +116 -0
  22. package/dist/types/telemetry-analysis.d.ts.map +1 -0
  23. package/dist/types/telemetry-bundle.d.ts +309 -0
  24. package/dist/types/telemetry-bundle.d.ts.map +1 -0
  25. package/dist/types/telemetry-catalog.d.ts +269 -0
  26. package/dist/types/telemetry-catalog.d.ts.map +1 -0
  27. package/dist/types/telemetry.d.ts +266 -0
  28. package/dist/types/telemetry.d.ts.map +1 -0
  29. package/package.json +12 -2
  30. package/schemas/telemetry/v1/evidence-completeness.schema.json +82 -0
  31. package/schemas/telemetry/v1/evidence-reference.schema.json +41 -0
  32. package/schemas/telemetry/v1/metric-definition.schema.json +96 -0
  33. package/schemas/telemetry/v1/metric-observation.schema.json +182 -0
  34. package/schemas/telemetry/v1/run-metric-summary.schema.json +98 -0
  35. package/schemas/telemetry/v1/run-telemetry-batch.schema.json +405 -0
  36. package/schemas/telemetry/v1/run-telemetry-event.schema.json +201 -0
  37. package/schemas/telemetry/v1/telemetry-cohort.schema.json +100 -0
  38. package/schemas/telemetry/v1/telemetry-export-bundle.schema.json +655 -0
package/CONTRACT.md CHANGED
@@ -4,7 +4,8 @@
4
4
  model-driven refinement/review policy, improvements, traces, tools, model
5
5
  identities, and shared hashing.
6
6
  `@openpond/evals` owns Tasksets, graders, evaluation runs and receipts,
7
- execution orchestration, conformance fixtures, and Work-evidence eligibility.
7
+ execution orchestration, conformance fixtures, Work-evidence eligibility, and
8
+ portable Run telemetry/metric semantics.
8
9
  Evals depends on Harness for exact release identities but does not re-export
9
10
  Harness APIs; Harness never depends on Evals. Host applications own persistence, provider sessions, secret
10
11
  leases, connected-app authorization, model streaming, artifact bytes, and
@@ -26,6 +27,8 @@ runtime processes.
26
27
  | user feedback on Work output | `WorkFeedbackReceipt` | Append a new receipt bound to the evidence receipt and, when selected, the exact content-addressed output-revision descriptor. Corrections are separate artifacts and never mutate prior receipts. |
27
28
  | bounded cross-Work review | `HarnessEvaluationReviewReceipt` | Select only currently authorized immutable evidence, advance one watermark, group one stable claim, route to the smallest correct layer, and name the next authority without performing downstream effects. |
28
29
  | model-improvement qualification | `ModelImprovementQualificationReceipt` | Bind the originating review, exact Harness, Taskset, real baseline Evaluation, Model, Environment/tool/permission/policy hashes, Verifier, source policies, privacy, budget, and non-frozen signal. Weak or confounded evidence emits `no_training`; training and activation remain host effects. |
30
+ | trainer/runtime telemetry | `RunTelemetryEvent` + `MetricObservation` | Emit compact ordered events and observations with exact Run lineage, bounded attributes/dimensions, source authority, visibility, and stable idempotency identity. Tenant identity, provider credentials, storage, retention, billing, and durable indexes remain host projections. |
31
+ | local/hosted Run investigation export | `TelemetryExportBundle` | Export metric definitions, ordered evidence, bounded references, completeness, and a content hash. Apply visibility and redaction before crossing authority boundaries; never include raw privileged trace bytes. |
29
32
 
30
33
  ## Compatibility policy
31
34
 
@@ -48,6 +51,15 @@ runtime processes.
48
51
  immutable objects and rejects lifecycle, tool, grader-interface, or required
49
52
  Environment-tool drift before issuing the receipt.
50
53
  - The initial support target is Node.js ESM on Node 22.14 through Node 24.
54
+ - Telemetry schema literals are shared across `@openpond/evals/telemetry` and
55
+ the `openpond-evals` Python distribution. Generated JSON Schemas and positive
56
+ and negative fixtures are the cross-language conformance authority.
57
+ - Telemetry producer sequence is monotonic within a Run. Receivers deduplicate
58
+ exact retries, accept late delivery, and reject conflicting reuse of an
59
+ idempotency key or `(runId, sequence)` pair.
60
+ - Core metrics reject unknown dimensions. Taskset- or Environment-specific
61
+ extensions require an explicit `MetricDefinition`; arbitrary metric names or
62
+ unbounded labels are not portable telemetry.
51
63
  - Portable paths are relative and at most 2,000 characters. Individual assets
52
64
  are at most 250 MB. Tasksets, traces, and evidence arrays have schema-level
53
65
  upper bounds.
package/README.md CHANGED
@@ -3,7 +3,8 @@
3
3
  Portable evaluation and benchmark contracts plus pure helpers for Tasksets,
4
4
  graders, run manifests, attempt and evaluation receipts, paired benchmark
5
5
  comparisons, execution adapters, conformance fixtures, Work-evidence
6
- eligibility, and no-training/SFT/preference/RL qualification receipts. The package depends on
6
+ eligibility, no-training/SFT/preference/RL qualification receipts, and portable
7
+ training telemetry. The package depends on
7
8
  [`@openpond/harness`](../harness/README.md) for exact Harness identities but
8
9
  does not re-export Harness APIs. Applications import the two packages directly,
9
10
  which keeps refinement and evaluation authority visibly separate.
@@ -34,7 +35,7 @@ import {
34
35
  ```
35
36
 
36
37
  Subpath exports are available at `/harness`, `/tasksets`, `/benchmarks`, `/graders`, `/runs`,
37
- `/conformance`, `/evidence`, `/preferences`, `/review`, and
38
+ `/conformance`, `/evidence`, `/telemetry`, `/preferences`, `/review`, and
38
39
  `/model-improvement-qualification`. The package is an evaluation protocol library,
39
40
  not a hosted client. It does not execute OpenPond Desktop or Sandbox sessions,
40
41
  resolve credentials, or persist artifacts.
@@ -122,6 +123,29 @@ semantics. Infrastructure failures must remain reward-ineligible.
122
123
  Package semver and schema literals are independent. See [CONTRACT.md](./CONTRACT.md)
123
124
  for compatibility aliases, migration rules, size limits, and the field map.
124
125
 
126
+ ## Training telemetry
127
+
128
+ The `@openpond/evals/telemetry` subpath defines the cross-runtime Run event,
129
+ metric, cohort, evidence-completeness, and export-bundle protocol. It includes a
130
+ bounded core metric catalog, deterministic builders, duplicate and late-delivery
131
+ merge semantics, chart-ready aggregation, cohort filtering, and
132
+ visibility-aware export helpers. The sibling `openpond-evals` Python
133
+ distribution implements the producer-facing models, builders, and asynchronous
134
+ buffered emitter used by GPU workers and external trainers; both languages
135
+ validate the same fixtures and schema literals.
136
+
137
+ Telemetry records what an admitted Run did; it does not configure the Taskset
138
+ or execute training. Core observations are accepted only when their metric ID
139
+ and bounded dimensions match the catalog. Custom metrics require an explicit
140
+ versioned definition. Events carry immutable Run/Model/Harness/Taskset lineage,
141
+ producer sequence, source authority, and evidence visibility.
142
+
143
+ Portable export bundles contain definitions, events, observations, bounded
144
+ evidence references, completeness state, and a content hash. Raw trace bytes,
145
+ credentials, provider handles, tenant identity, billing policy, and hosted
146
+ indexes remain host-owned. The package contains no trainer, optimizer,
147
+ provisioner, persistence client, or diagnostic agent.
148
+
125
149
  ## Release preparation
126
150
 
127
151
  ```bash
@@ -0,0 +1,17 @@
1
+ {
2
+ "schemaVersion": "openpond.runTelemetryBatch.v1",
3
+ "events": [
4
+ {
5
+ "schemaVersion": "openpond.runTelemetryEvent.v1",
6
+ "eventId": "event-invalid",
7
+ "sequence": -1,
8
+ "occurredAt": "not-a-timestamp",
9
+ "source": "unknown_source",
10
+ "type": "optimizer_step_completed",
11
+ "visibility": "team_visible",
12
+ "lineage": {},
13
+ "attributes": {}
14
+ }
15
+ ],
16
+ "observations": []
17
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "schemaVersion": "openpond.runTelemetryBatch.v1",
3
+ "events": [
4
+ {
5
+ "schemaVersion": "openpond.runTelemetryEvent.v1",
6
+ "eventId": "event-optimizer-step-1",
7
+ "sequence": 1,
8
+ "occurredAt": "2026-08-25T20:00:00.000Z",
9
+ "source": "optimizer",
10
+ "type": "optimizer_step_completed",
11
+ "visibility": "team_visible",
12
+ "lineage": {
13
+ "modelProjectId": "project-1",
14
+ "runId": "run-1",
15
+ "modelVersionId": "version-1",
16
+ "harnessReleaseHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
17
+ "tasksetReleaseHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
18
+ "environmentReleaseHash": null,
19
+ "checkpointId": null,
20
+ "step": 1,
21
+ "rolloutGroupId": "group-1",
22
+ "attemptId": null,
23
+ "scenarioId": "scenario-1"
24
+ },
25
+ "attributes": {
26
+ "learningRate": 0.00001,
27
+ "runner": "openpond_direct_grpo_v1"
28
+ }
29
+ }
30
+ ],
31
+ "observations": [
32
+ {
33
+ "schemaVersion": "openpond.metricObservation.v1",
34
+ "observationId": "observation-learning-rate-1",
35
+ "metricId": "optimizer.learning_rate",
36
+ "eventId": "event-optimizer-step-1",
37
+ "sequence": 2,
38
+ "observedAt": "2026-08-25T20:00:00.000Z",
39
+ "value": 0.00001,
40
+ "lineage": {
41
+ "modelProjectId": "project-1",
42
+ "runId": "run-1",
43
+ "modelVersionId": "version-1",
44
+ "harnessReleaseHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
45
+ "tasksetReleaseHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
46
+ "environmentReleaseHash": null,
47
+ "checkpointId": null,
48
+ "step": 1,
49
+ "rolloutGroupId": "group-1",
50
+ "attemptId": null,
51
+ "scenarioId": "scenario-1"
52
+ },
53
+ "dimensions": {
54
+ "split": "train"
55
+ }
56
+ }
57
+ ]
58
+ }
package/dist/index.js CHANGED
@@ -6,9 +6,14 @@ export * from "./execution-contracts.js";
6
6
  export * from "./execution-receipts.js";
7
7
  export * from "./graders.js";
8
8
  export * from "./harness.js";
9
+ export * from "./learned-preference.js";
9
10
  export * from "./runs.js";
10
11
  export * from "./model-improvement-qualification.js";
11
12
  export * from "./preferences.js";
12
13
  export * from "./review-conformance.js";
13
14
  export * from "./rollouts.js";
15
+ export * from "./telemetry.js";
16
+ export * from "./telemetry-catalog.js";
17
+ export * from "./telemetry-analysis.js";
18
+ export * from "./telemetry-bundle.js";
14
19
  export * from "./tasksets.js";
@@ -0,0 +1,334 @@
1
+ import { z } from "zod";
2
+ import { ImmutableReleaseRefSchema, MetadataSchema, ReleaseHashSchema, ReleaseIdSchema, ReleaseTimestampSchema, contentHash, } from "@openpond/harness";
3
+ import { verifyComparisonAssignment, verifyPreferenceReceipt, } from "./preferences.js";
4
+ const PreferenceDatasetPartitionSchema = z.enum([
5
+ "reward_train",
6
+ "reward_validation",
7
+ "reward_qualification",
8
+ ]);
9
+ export const PreferenceEvidenceAuthoritySchema = z.enum([
10
+ "human",
11
+ "synthetic_fixture",
12
+ ]);
13
+ export const RewardModelQualificationKindSchema = z.enum([
14
+ "synthetic_smoke",
15
+ "human_heldout",
16
+ ]);
17
+ const PreferenceDatasetGroupSchema = z.object({
18
+ id: ReleaseIdSchema,
19
+ assignmentRef: ImmutableReleaseRefSchema,
20
+ scenarioRef: ImmutableReleaseRefSchema,
21
+ scenarioSplit: z.enum(["train", "validation"]),
22
+ preferenceResultRef: ImmutableReleaseRefSchema,
23
+ receiptRefs: z.array(ImmutableReleaseRefSchema).min(1).max(100),
24
+ attemptRefs: z.array(ImmutableReleaseRefSchema).min(2).max(4),
25
+ artifactManifestRefs: z.array(ImmutableReleaseRefSchema).min(2).max(4),
26
+ orderedBuckets: z.array(z.array(ReleaseIdSchema).min(1).max(4)).max(4),
27
+ rejectAll: z.boolean(),
28
+ partition: PreferenceDatasetPartitionSchema,
29
+ metadata: MetadataSchema,
30
+ }).strict().superRefine((group, context) => {
31
+ const attemptIds = group.attemptRefs.map((attempt) => attempt.id);
32
+ if (new Set(attemptIds).size !== attemptIds.length) {
33
+ context.addIssue({
34
+ code: "custom",
35
+ path: ["attemptRefs"],
36
+ message: "Preference dataset group Attempt refs must be unique.",
37
+ });
38
+ }
39
+ if (group.artifactManifestRefs.length !== group.attemptRefs.length) {
40
+ context.addIssue({
41
+ code: "custom",
42
+ path: ["artifactManifestRefs"],
43
+ message: "Every preference dataset Attempt requires one Artifact Manifest ref.",
44
+ });
45
+ }
46
+ const bucketIds = group.orderedBuckets.flat();
47
+ if (group.rejectAll && bucketIds.length > 0) {
48
+ context.addIssue({
49
+ code: "custom",
50
+ path: ["orderedBuckets"],
51
+ message: "A reject-all preference dataset group cannot contain ordered buckets.",
52
+ });
53
+ }
54
+ if (!group.rejectAll
55
+ && (bucketIds.length !== attemptIds.length
56
+ || new Set(bucketIds).size !== bucketIds.length
57
+ || bucketIds.some((attemptId) => !attemptIds.includes(attemptId)))) {
58
+ context.addIssue({
59
+ code: "custom",
60
+ path: ["orderedBuckets"],
61
+ message: "Ordered buckets must contain every group Attempt exactly once.",
62
+ });
63
+ }
64
+ });
65
+ const PreferenceDatasetPairSchema = z.object({
66
+ groupId: ReleaseIdSchema,
67
+ preferredAttemptRef: ImmutableReleaseRefSchema,
68
+ dispreferredAttemptRef: ImmutableReleaseRefSchema,
69
+ relation: z.enum(["preferred", "tie"]),
70
+ }).strict().superRefine((pair, context) => {
71
+ if (pair.preferredAttemptRef.id === pair.dispreferredAttemptRef.id
72
+ && pair.preferredAttemptRef.contentHash === pair.dispreferredAttemptRef.contentHash) {
73
+ context.addIssue({
74
+ code: "custom",
75
+ message: "A derived preference pair must reference two different Attempts.",
76
+ });
77
+ }
78
+ });
79
+ export const PreferenceDatasetReleaseContentSchema = z.object({
80
+ schemaVersion: z.literal("openpond.preferenceDatasetRelease.v1"),
81
+ id: ReleaseIdSchema,
82
+ revision: z.number().int().positive(),
83
+ tasksetRelease: ImmutableReleaseRefSchema,
84
+ comparisonRelease: ImmutableReleaseRefSchema,
85
+ authority: PreferenceEvidenceAuthoritySchema,
86
+ qualificationEligibility: z.enum(["smoke_only", "human_heldout"]),
87
+ fixtureRelease: ImmutableReleaseRefSchema.nullable(),
88
+ groups: z.array(PreferenceDatasetGroupSchema).min(1).max(100_000),
89
+ derivedPairs: z.array(PreferenceDatasetPairSchema).max(1_000_000),
90
+ createdAt: ReleaseTimestampSchema,
91
+ metadata: MetadataSchema,
92
+ }).strict().superRefine((release, context) => {
93
+ if (release.authority === "synthetic_fixture"
94
+ && (release.qualificationEligibility !== "smoke_only"
95
+ || release.fixtureRelease === null)) {
96
+ context.addIssue({
97
+ code: "custom",
98
+ path: ["qualificationEligibility"],
99
+ message: "Synthetic preference datasets require a fixture release and are smoke-only.",
100
+ });
101
+ }
102
+ if (release.authority === "human"
103
+ && (release.qualificationEligibility !== "human_heldout"
104
+ || release.fixtureRelease !== null)) {
105
+ context.addIssue({
106
+ code: "custom",
107
+ path: ["authority"],
108
+ message: "Human preference datasets must be human-heldout eligible and cannot bind a fixture release.",
109
+ });
110
+ }
111
+ const groupIds = release.groups.map((group) => group.id);
112
+ if (new Set(groupIds).size !== groupIds.length) {
113
+ context.addIssue({
114
+ code: "custom",
115
+ path: ["groups"],
116
+ message: "Preference dataset group IDs must be unique.",
117
+ });
118
+ }
119
+ const groups = new Map(release.groups.map((group) => [group.id, group]));
120
+ for (const [index, pair] of release.derivedPairs.entries()) {
121
+ const group = groups.get(pair.groupId);
122
+ if (!group) {
123
+ context.addIssue({
124
+ code: "custom",
125
+ path: ["derivedPairs", index, "groupId"],
126
+ message: "Derived preference pairs must reference a group in the same release.",
127
+ });
128
+ continue;
129
+ }
130
+ const attemptIds = new Set(group.attemptRefs.map((attempt) => attempt.id));
131
+ if (!attemptIds.has(pair.preferredAttemptRef.id)
132
+ || !attemptIds.has(pair.dispreferredAttemptRef.id)) {
133
+ context.addIssue({
134
+ code: "custom",
135
+ path: ["derivedPairs", index],
136
+ message: "Derived preference pairs must reference Attempts in their source group.",
137
+ });
138
+ }
139
+ }
140
+ });
141
+ export const PreferenceDatasetReleaseSchema = PreferenceDatasetReleaseContentSchema
142
+ .extend({ contentHash: ReleaseHashSchema })
143
+ .strict();
144
+ const RewardModelQualificationMetricsSchema = z.object({
145
+ sampleCount: z.number().int().positive(),
146
+ finiteScoreRate: z.number().min(0).max(1),
147
+ scoreVariance: z.number().nonnegative(),
148
+ checkpointReloadPassed: z.boolean(),
149
+ processorCompatibilityPassed: z.boolean(),
150
+ invalidAttemptExclusionPassed: z.boolean(),
151
+ orderedPairAccuracy: z.number().min(0).max(1).nullable(),
152
+ bucketAccuracy: z.number().min(0).max(1).nullable(),
153
+ tieAgreement: z.number().min(0).max(1).nullable(),
154
+ }).strict();
155
+ export const RewardModelQualificationReportContentSchema = z.object({
156
+ schemaVersion: z.literal("openpond.rewardModelQualificationReport.v1"),
157
+ id: ReleaseIdSchema,
158
+ kind: RewardModelQualificationKindSchema,
159
+ rewardModelVersion: ImmutableReleaseRefSchema,
160
+ preferenceDatasetRelease: ImmutableReleaseRefSchema,
161
+ tasksetRelease: ImmutableReleaseRefSchema,
162
+ processorRelease: ImmutableReleaseRefSchema,
163
+ metrics: RewardModelQualificationMetricsSchema,
164
+ passed: z.boolean(),
165
+ productionRewardEligible: z.boolean(),
166
+ createdAt: ReleaseTimestampSchema,
167
+ metadata: MetadataSchema,
168
+ }).strict().superRefine((report, context) => {
169
+ if (report.kind === "synthetic_smoke" && report.productionRewardEligible) {
170
+ context.addIssue({
171
+ code: "custom",
172
+ path: ["productionRewardEligible"],
173
+ message: "Synthetic-smoke Reward Model qualification can never grant production reward eligibility.",
174
+ });
175
+ }
176
+ if (report.kind === "human_heldout"
177
+ && report.productionRewardEligible !== report.passed) {
178
+ context.addIssue({
179
+ code: "custom",
180
+ path: ["productionRewardEligible"],
181
+ message: "Human-heldout reward eligibility must match the frozen qualification outcome.",
182
+ });
183
+ }
184
+ });
185
+ export const RewardModelQualificationReportSchema = RewardModelQualificationReportContentSchema
186
+ .extend({ contentHash: ReleaseHashSchema })
187
+ .strict();
188
+ export function createPreferenceDatasetRelease(input) {
189
+ const content = PreferenceDatasetReleaseContentSchema.parse(input);
190
+ return PreferenceDatasetReleaseSchema.parse({
191
+ ...content,
192
+ contentHash: contentHash(content),
193
+ });
194
+ }
195
+ export function materializePreferenceDatasetRelease(input) {
196
+ if (input.groups.length === 0) {
197
+ throw new Error("Preference dataset materialization requires at least one reviewed group.");
198
+ }
199
+ const tasksetRef = releaseRef(input.tasksetRelease);
200
+ const comparisonRef = releaseRef(input.comparisonRelease);
201
+ const fixtureRefs = new Map();
202
+ const groups = input.groups.map(({ assignment, receipt, partition }) => {
203
+ if (!verifyComparisonAssignment(assignment)) {
204
+ throw new Error("Preference dataset assignment failed immutable verification.");
205
+ }
206
+ if (!verifyPreferenceReceipt(receipt)) {
207
+ throw new Error("Preference dataset receipt failed immutable verification.");
208
+ }
209
+ if (!sameReleaseRef(assignment.lineage.tasksetRelease, tasksetRef)) {
210
+ throw new Error("Preference dataset assignment belongs to a different Taskset release.");
211
+ }
212
+ if (!sameReleaseRef(assignment.comparisonRelease, comparisonRef)) {
213
+ throw new Error("Preference dataset assignment belongs to a different comparison release.");
214
+ }
215
+ if (!sameReleaseRef(receipt.assignmentRef, releaseRef(assignment))) {
216
+ throw new Error("Preference dataset receipt belongs to a different assignment.");
217
+ }
218
+ if (input.authority === "synthetic_fixture") {
219
+ if (receipt.reviewer.kind !== "fixture") {
220
+ throw new Error("Synthetic preference datasets accept only fixture receipts.");
221
+ }
222
+ fixtureRefs.set(receipt.reviewer.fixtureRelease.contentHash, receipt.reviewer.fixtureRelease);
223
+ }
224
+ else if (receipt.reviewer.kind !== "human") {
225
+ throw new Error("Human preference datasets accept only human receipts.");
226
+ }
227
+ const scenario = input.tasksetRelease.tasks.find((task) => task.id === assignment.taskRef.id);
228
+ if (!scenario || (scenario.split !== "train" && scenario.split !== "validation")) {
229
+ throw new Error("Preference model datasets require train or validation scenarios.");
230
+ }
231
+ if (partition === "reward_train" && scenario.split !== "train") {
232
+ throw new Error("Reward training groups must use train scenarios.");
233
+ }
234
+ if (partition !== "reward_train" && scenario.split !== "validation") {
235
+ throw new Error("Reward validation and qualification groups must use validation scenarios.");
236
+ }
237
+ return {
238
+ id: `preference-group:${assignment.id}`,
239
+ assignmentRef: releaseRef(assignment),
240
+ scenarioRef: assignment.taskRef,
241
+ scenarioSplit: scenario.split,
242
+ preferenceResultRef: releaseRef(receipt),
243
+ receiptRefs: [releaseRef(receipt)],
244
+ attemptRefs: assignment.candidates.map((candidate) => candidate.attemptRef),
245
+ artifactManifestRefs: assignment.candidates.map((candidate) => candidate.artifactManifestRef),
246
+ orderedBuckets: receipt.order,
247
+ rejectAll: receipt.rejectAll,
248
+ partition,
249
+ metadata: { purpose: assignment.purpose, ...assignment.metadata },
250
+ };
251
+ });
252
+ if (input.authority === "synthetic_fixture" && fixtureRefs.size !== 1) {
253
+ throw new Error("Synthetic preference dataset groups must share one immutable fixture release.");
254
+ }
255
+ const fixtureRelease = input.authority === "synthetic_fixture"
256
+ ? [...fixtureRefs.values()][0]
257
+ : null;
258
+ return createPreferenceDatasetRelease({
259
+ schemaVersion: "openpond.preferenceDatasetRelease.v1",
260
+ id: input.id,
261
+ revision: input.revision,
262
+ tasksetRelease: tasksetRef,
263
+ comparisonRelease: comparisonRef,
264
+ authority: input.authority,
265
+ qualificationEligibility: input.authority === "synthetic_fixture" ? "smoke_only" : "human_heldout",
266
+ fixtureRelease,
267
+ groups,
268
+ derivedPairs: groups.flatMap(derivePairs),
269
+ createdAt: input.createdAt,
270
+ metadata: input.metadata ?? {},
271
+ });
272
+ }
273
+ export function verifyPreferenceDatasetRelease(value) {
274
+ return verifyHashed(value, PreferenceDatasetReleaseContentSchema, PreferenceDatasetReleaseSchema);
275
+ }
276
+ export function createRewardModelQualificationReport(input) {
277
+ const content = RewardModelQualificationReportContentSchema.parse(input);
278
+ return RewardModelQualificationReportSchema.parse({
279
+ ...content,
280
+ contentHash: contentHash(content),
281
+ });
282
+ }
283
+ export function verifyRewardModelQualificationReport(value) {
284
+ return verifyHashed(value, RewardModelQualificationReportContentSchema, RewardModelQualificationReportSchema);
285
+ }
286
+ function verifyHashed(value, contentSchema, fullSchema) {
287
+ const parsed = fullSchema.safeParse(value);
288
+ if (!parsed.success || !parsed.data)
289
+ return false;
290
+ const { contentHash: actual, ...content } = parsed.data;
291
+ try {
292
+ return contentHash(contentSchema.parse(content)) === actual;
293
+ }
294
+ catch {
295
+ return false;
296
+ }
297
+ }
298
+ function derivePairs(group) {
299
+ if (group.rejectAll)
300
+ return [];
301
+ const attemptById = new Map(group.attemptRefs.map((attempt) => [attempt.id, attempt]));
302
+ const pairs = [];
303
+ for (const [bucketIndex, bucket] of group.orderedBuckets.entries()) {
304
+ for (let left = 0; left < bucket.length; left += 1) {
305
+ for (let right = left + 1; right < bucket.length; right += 1) {
306
+ pairs.push({
307
+ groupId: group.id,
308
+ preferredAttemptRef: attemptById.get(bucket[left]),
309
+ dispreferredAttemptRef: attemptById.get(bucket[right]),
310
+ relation: "tie",
311
+ });
312
+ }
313
+ }
314
+ for (const lowerBucket of group.orderedBuckets.slice(bucketIndex + 1)) {
315
+ for (const preferredId of bucket) {
316
+ for (const dispreferredId of lowerBucket) {
317
+ pairs.push({
318
+ groupId: group.id,
319
+ preferredAttemptRef: attemptById.get(preferredId),
320
+ dispreferredAttemptRef: attemptById.get(dispreferredId),
321
+ relation: "preferred",
322
+ });
323
+ }
324
+ }
325
+ }
326
+ }
327
+ return pairs;
328
+ }
329
+ function releaseRef(value) {
330
+ return { id: value.id, contentHash: value.contentHash };
331
+ }
332
+ function sameReleaseRef(left, right) {
333
+ return left.id === right.id && left.contentHash === right.contentHash;
334
+ }
@@ -13,6 +13,11 @@ export const PreferenceReviewerKindSchema = z.enum([
13
13
  "human",
14
14
  "model",
15
15
  "deterministic",
16
+ "fixture",
17
+ ]);
18
+ export const PreferenceRewardScopeSchema = z.enum([
19
+ "production",
20
+ "synthetic_smoke",
16
21
  ]);
17
22
  export const PreferenceRendererSchema = z.enum([
18
23
  "markdown",
@@ -111,7 +116,7 @@ const ComparisonCandidateSchema = z.object({
111
116
  attemptRef: ImmutableReleaseRefSchema,
112
117
  runManifestRef: ImmutableReleaseRefSchema,
113
118
  artifactManifestRef: ImmutableReleaseRefSchema,
114
- visibleArtifactIds: z.array(ReleaseIdSchema).min(1).max(100_000),
119
+ visibleArtifactIds: z.array(ReleaseIdSchema).max(100_000),
115
120
  }).strict().superRefine((candidate, context) => {
116
121
  if (new Set(candidate.visibleArtifactIds).size !== candidate.visibleArtifactIds.length) {
117
122
  context.addIssue({
@@ -158,14 +163,24 @@ export const ComparisonAssignmentSchema = ComparisonAssignmentBaseSchema
158
163
  }
159
164
  });
160
165
  const PreferenceOrderSchema = z.array(z.array(ReleaseIdSchema).min(1).max(4)).max(4);
166
+ const PreferenceReviewerSchema = z.discriminatedUnion("kind", [
167
+ z.object({
168
+ kind: z.enum(["human", "model", "deterministic"]),
169
+ releaseRef: ImmutableReleaseRefSchema,
170
+ }).strict(),
171
+ z.object({
172
+ kind: z.literal("fixture"),
173
+ releaseRef: ImmutableReleaseRefSchema,
174
+ fixtureRelease: ImmutableReleaseRefSchema,
175
+ labelSource: z.literal("synthetic"),
176
+ qualificationEligibility: z.literal("smoke_only"),
177
+ }).strict(),
178
+ ]);
161
179
  const PreferenceReceiptBaseSchema = z.object({
162
180
  schemaVersion: z.literal("openpond.preferenceReceipt.v1"),
163
181
  id: ReleaseIdSchema,
164
182
  assignmentRef: ImmutableReleaseRefSchema,
165
- reviewer: z.object({
166
- kind: PreferenceReviewerKindSchema,
167
- releaseRef: ImmutableReleaseRefSchema,
168
- }).strict(),
183
+ reviewer: PreferenceReviewerSchema,
169
184
  order: PreferenceOrderSchema,
170
185
  rejectAll: z.boolean(),
171
186
  criterionScores: z.record(ReleaseIdSchema, z.record(ReleaseIdSchema, z.number().finite().min(0).max(1))),
@@ -244,7 +259,8 @@ export function createComparisonAssignment(input) {
244
259
  if (input.candidates.length !== release.candidateCount) {
245
260
  throw new Error("Comparison assignment candidate count does not match the immutable comparison release.");
246
261
  }
247
- const normalized = input.candidates.map((candidate) => validateComparisonCandidate(candidate, taskset));
262
+ const requiresVisibleArtifact = release.presentation.parts.some((part) => part.source === "artifact");
263
+ const normalized = input.candidates.map((candidate) => validateComparisonCandidate(candidate, taskset, requiresVisibleArtifact));
248
264
  const first = normalized[0];
249
265
  for (const candidate of normalized.slice(1)) {
250
266
  if (candidate.attempt.taskId !== first.attempt.taskId) {
@@ -331,6 +347,45 @@ export function createPreferenceReceipt(input) {
331
347
  validateCriterionScores(content.criterionScores, assignment, release);
332
348
  return PreferenceReceiptSchema.parse({ ...content, contentHash: contentHash(content) });
333
349
  }
350
+ export function createSyntheticFixturePreferenceReceipt(input) {
351
+ const candidateIds = input.assignment.candidates.map((candidate) => candidate.attemptRef.id);
352
+ const ratingIds = Object.keys(input.ratings);
353
+ if (ratingIds.length !== candidateIds.length
354
+ || candidateIds.some((candidateId) => !input.ratings[candidateId])
355
+ || ratingIds.some((candidateId) => !candidateIds.includes(candidateId))) {
356
+ throw new Error("Synthetic fixture ratings must label every assignment candidate exactly once.");
357
+ }
358
+ const orderedRatings = ["love", "like", "reject"];
359
+ const rejectAll = candidateIds.every((candidateId) => input.ratings[candidateId] === "reject");
360
+ const order = rejectAll ? [] : orderedRatings
361
+ .map((rating) => candidateIds.filter((candidateId) => input.ratings[candidateId] === rating))
362
+ .filter((bucket) => bucket.length > 0);
363
+ const score = { love: 1, like: 0.5, reject: 0 };
364
+ return createPreferenceReceipt({
365
+ id: input.id,
366
+ assignment: input.assignment,
367
+ comparisonRelease: input.comparisonRelease,
368
+ reviewer: {
369
+ kind: "fixture",
370
+ releaseRef: input.labelerRelease,
371
+ fixtureRelease: input.fixtureRelease,
372
+ labelSource: "synthetic",
373
+ qualificationEligibility: "smoke_only",
374
+ },
375
+ order,
376
+ rejectAll,
377
+ criterionScores: Object.fromEntries(candidateIds.map((candidateId) => [
378
+ candidateId,
379
+ Object.fromEntries(input.comparisonRelease.criteria.map((criterion) => [
380
+ criterion.id,
381
+ score[input.ratings[candidateId]],
382
+ ])),
383
+ ])),
384
+ startedAt: input.startedAt,
385
+ completedAt: input.completedAt,
386
+ metadata: input.metadata ?? {},
387
+ });
388
+ }
334
389
  export function verifyPreferenceReceipt(value) {
335
390
  return verifyHashed(value, PreferenceReceiptContentSchema, PreferenceReceiptSchema);
336
391
  }
@@ -436,12 +491,16 @@ export function createPreferenceRewardComponents(input) {
436
491
  ? input.result
437
492
  : null;
438
493
  const automatedReceipt = modelReceipt?.reviewer.kind === "model";
439
- const calibrated = !automatedReceipt || isCalibratedAutomatedReviewer(modelReceipt, release, input.calibrationReport ?? null);
494
+ const fixtureReceipt = modelReceipt?.reviewer.kind === "fixture";
495
+ const rewardScope = PreferenceRewardScopeSchema.parse(input.rewardScope ?? "production");
496
+ const admittedReviewer = fixtureReceipt
497
+ ? rewardScope === "synthetic_smoke"
498
+ : !automatedReceipt || isCalibratedAutomatedReviewer(modelReceipt, release, input.calibrationReport ?? null);
440
499
  return Object.fromEntries(assignment.candidates.map((candidate) => {
441
500
  const candidateEligibility = eligible.get(candidate.attemptRef.id);
442
501
  const canScore = candidateEligibility?.eligible ?? true;
443
502
  const score = scores[candidate.attemptRef.id];
444
- const component = canScore && calibrated
503
+ const component = canScore && admittedReviewer
445
504
  ? RewardComponentReceiptSchema.parse({
446
505
  verifierId: release.rewardProjection.verifierId,
447
506
  verifierVersion: release.rewardProjection.verifierVersion,
@@ -475,7 +534,11 @@ export function createPreferenceRewardComponents(input) {
475
534
  rewardEligible: false,
476
535
  rewardContribution: null,
477
536
  failureOwner: candidateEligibility?.failureOwner ?? "verifier",
478
- feedback: [canScore ? "Automated preference reviewer is uncalibrated or inconsistent." : "Candidate is not eligible for comparative preference scoring."],
537
+ feedback: [canScore
538
+ ? fixtureReceipt
539
+ ? "Synthetic fixture preference evidence is admitted only to synthetic-smoke reward scope."
540
+ : "Automated preference reviewer is uncalibrated or inconsistent."
541
+ : "Candidate is not eligible for comparative preference scoring."],
479
542
  visibleEvidenceRefs: [],
480
543
  privilegedEvidenceRefs: [],
481
544
  metadata: {
@@ -487,7 +550,7 @@ export function createPreferenceRewardComponents(input) {
487
550
  return [candidate.attemptRef.id, component];
488
551
  }));
489
552
  }
490
- function validateComparisonCandidate(input, taskset) {
553
+ function validateComparisonCandidate(input, taskset, requiresVisibleArtifact) {
491
554
  const attempt = AttemptReceiptSchema.parse(input.attempt);
492
555
  if (!verifyAttemptReceipt(attempt))
493
556
  throw new Error("Comparison candidate Attempt Receipt has an invalid content hash.");
@@ -514,7 +577,10 @@ function validateComparisonCandidate(input, taskset) {
514
577
  const available = new Set(artifactManifest.entries
515
578
  .filter((entry) => entry.status === "collected" && entry.artifact !== null)
516
579
  .map((entry) => entry.artifact.id));
517
- if (!visibleArtifactIds.length || visibleArtifactIds.some((id) => !available.has(id))) {
580
+ if (requiresVisibleArtifact && !visibleArtifactIds.length) {
581
+ throw new Error("Comparison presentation requires each candidate to expose a reviewable artifact.");
582
+ }
583
+ if (visibleArtifactIds.some((id) => !available.has(id))) {
518
584
  throw new Error("Comparison candidate exposes an artifact that is missing, uncollected, or unreviewable.");
519
585
  }
520
586
  if (visible.size !== visibleArtifactIds.length)
@@ -0,0 +1,4 @@
1
+ export * from "../telemetry.js";
2
+ export * from "../telemetry-catalog.js";
3
+ export * from "../telemetry-analysis.js";
4
+ export * from "../telemetry-bundle.js";