@openpond/evals 0.10.0 → 0.10.2

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 (56) hide show
  1. package/CONTRACT.md +6 -0
  2. package/LEARNING.md +70 -0
  3. package/dist/javascript-isolate-process.js +2 -2
  4. package/dist/learning/batch-service.js +44 -0
  5. package/dist/learning/contracts.js +1 -1
  6. package/dist/learning/index.js +3 -0
  7. package/dist/learning/iteration-dispatch-commands.js +31 -0
  8. package/dist/learning/iteration-dispatch-contracts.js +73 -0
  9. package/dist/learning/iteration-dispatch-worker.js +178 -0
  10. package/dist/learning/iteration-eligibility.js +76 -0
  11. package/dist/learning/iteration-reservation-cancellation.js +31 -0
  12. package/dist/learning/iteration-reservation-contracts.js +57 -0
  13. package/dist/learning/iteration-reservation-service.js +106 -0
  14. package/dist/learning/operations.js +6 -1
  15. package/dist/learning/repository.js +4 -0
  16. package/dist/learning/service.js +19 -43
  17. package/dist/metrics-node.js +20 -6
  18. package/dist/types/javascript-isolate-process.d.ts +3 -1
  19. package/dist/types/javascript-isolate-process.d.ts.map +1 -1
  20. package/dist/types/learning/batch-service.d.ts +8 -0
  21. package/dist/types/learning/batch-service.d.ts.map +1 -0
  22. package/dist/types/learning/contracts.d.ts +6 -0
  23. package/dist/types/learning/contracts.d.ts.map +1 -1
  24. package/dist/types/learning/index.d.ts +3 -0
  25. package/dist/types/learning/index.d.ts.map +1 -1
  26. package/dist/types/learning/iteration-dispatch-commands.d.ts +8 -0
  27. package/dist/types/learning/iteration-dispatch-commands.d.ts.map +1 -0
  28. package/dist/types/learning/iteration-dispatch-contracts.d.ts +93 -0
  29. package/dist/types/learning/iteration-dispatch-contracts.d.ts.map +1 -0
  30. package/dist/types/learning/iteration-dispatch-worker.d.ts +35 -0
  31. package/dist/types/learning/iteration-dispatch-worker.d.ts.map +1 -0
  32. package/dist/types/learning/iteration-eligibility.d.ts +18 -0
  33. package/dist/types/learning/iteration-eligibility.d.ts.map +1 -0
  34. package/dist/types/learning/iteration-reservation-cancellation.d.ts +8 -0
  35. package/dist/types/learning/iteration-reservation-cancellation.d.ts.map +1 -0
  36. package/dist/types/learning/iteration-reservation-contracts.d.ts +90 -0
  37. package/dist/types/learning/iteration-reservation-contracts.d.ts.map +1 -0
  38. package/dist/types/learning/iteration-reservation-service.d.ts +9 -0
  39. package/dist/types/learning/iteration-reservation-service.d.ts.map +1 -0
  40. package/dist/types/learning/operations.d.ts +64 -0
  41. package/dist/types/learning/operations.d.ts.map +1 -1
  42. package/dist/types/learning/repository.d.ts +121 -0
  43. package/dist/types/learning/repository.d.ts.map +1 -1
  44. package/dist/types/learning/service.d.ts.map +1 -1
  45. package/dist/types/learning/transport.d.ts +151 -0
  46. package/dist/types/learning/transport.d.ts.map +1 -1
  47. package/dist/types/metrics-node.d.ts +4 -0
  48. package/dist/types/metrics-node.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/schemas/learning/v1/chain.schema.json +72 -0
  51. package/schemas/learning/v1/command-request.schema.json +374 -233
  52. package/schemas/learning/v1/consumption.schema.json +77 -0
  53. package/schemas/learning/v1/dispatch.schema.json +338 -0
  54. package/schemas/learning/v1/iteration.schema.json +3 -1
  55. package/schemas/learning/v1/read-request.schema.json +4 -0
  56. package/schemas/learning/v1/reservation.schema.json +201 -0
package/CONTRACT.md CHANGED
@@ -143,3 +143,9 @@ and excludes missing rewards. An empty scorable population remains null.
143
143
  `aggregateTasksetRunInWorker` from `@openpond/evals/metrics/node` verifies custom
144
144
  module bytes and uses the existing bounded isolated worker. A fixture metric
145
145
  does not qualify a model for learning or acceptance.
146
+
147
+ `aggregateTasksetRunInProcess` and `executeTasksetMetricInProcess` use an owned
148
+ Node subprocess for hosts without Node worker APIs. It strips authored TypeScript
149
+ inside its deadline, evaluates the verified source in the same deterministic
150
+ isolate, and exits before success, failure or cancellation settles. Importing
151
+ the metrics entrypoint does not require Node's type-stripping API on the host.
package/LEARNING.md CHANGED
@@ -148,3 +148,73 @@ Policy and iteration contracts distinguish training parent, optional teacher and
148
148
  upstream trigger. The contracts themselves do not launch training, schedule jobs,
149
149
  qualify a model, accept a candidate or promote a serving endpoint. Hosts must
150
150
  implement those transitions and record actual execution/evaluation receipts.
151
+
152
+ `reserve_iteration` is the shared reviewer-authorized reservation operation for
153
+ manual and scheduled triggers. It pins the current enabled policy, checks the
154
+ Model's active chain and cooldown, discovers approved training evidence across
155
+ all source pages, and atomically seals a batch, records consumption, reserves
156
+ spend and creates the iteration. It does not submit a training Job. The current
157
+ approved-batch methods are SFT, GRPO and PPO with human admission; qualified
158
+ automatic admission and automatic acceptance/serving are rejected until their
159
+ execution paths are implemented.
160
+
161
+ Manual triggers carry a stable `identity`; scheduled triggers carry a concrete
162
+ `scheduledAt`. Equivalent timestamp offsets denote the same fire. A manual
163
+ trigger can use a policy with a saved schedule without editing that policy.
164
+ Retries, including another reviewer or a restarted host, resolve the same fire
165
+ and dispatch identity. New events cannot bypass an active chain by editing the
166
+ policy. Waiting for data/review records a zero-budget result and consumes no
167
+ examples; a later event discovers newly approved evidence.
168
+
169
+ The `chain`, `consumption` and `reservation` resources are stored through the same
170
+ atomic repository interface. Consumption keys include the Model chain and exact
171
+ evidence revision; decision identity is retained alongside it. Reapproval or a
172
+ policy edit does not make consumed evidence new. Corrections create new evidence
173
+ revisions. Numeric source watermarks remain empty because existing evidence has
174
+ no ordered ingress sequence; hashed resource pagination is never a consumption
175
+ watermark. Outstanding reserved spend carries across midnight and is counted
176
+ alongside spend settled in the current UTC day.
177
+
178
+ `cancel_iteration_reservation` releases only an undispatched reservation and
179
+ requires its current revision. Consumption history remains intact. Once an
180
+ execution owner claims the iteration, it must persist an executing status before
181
+ making a network submission; local reservation cancellation then refuses to
182
+ release its budget. Submitted-job cancellation and cost settlement belong to the
183
+ execution reconciler and require authoritative provider state.
184
+
185
+ ### Durable iteration dispatch
186
+
187
+ `createLearningIterationWorker` claims one reserved iteration with a persisted,
188
+ expiring lease and generation fence. It resolves the pinned policy and batch,
189
+ asks a host executor to prepare a compute-free submission, and persists that
190
+ exact protocol payload and hash before any provider submission. The preparation
191
+ state is `dispatching`; only a provider observation establishes `training` or
192
+ `evaluating`. A paused policy stops new submissions while existing executions
193
+ continue to be observed.
194
+
195
+ The executor must deduplicate `submit` by `dispatch.id`, verify observation
196
+ identity and provider receipts, and use `reconcile` to recover an existing Job
197
+ after a lost reply. An unreachable owner is an error, never proof of absence.
198
+ Submission payloads contain immutable inputs, not credentials or temporary URLs.
199
+ All provider calls occur outside repository transactions and have bounded
200
+ request timeouts. Expired lease owners cannot write results over a newer claim.
201
+
202
+ `cancel_iteration` persists cancellation intent and preserves a live lease.
203
+ Before submission starts, cancellation can settle at zero spend locally. Once
204
+ submission may have begun, the executor's cancellation must fence late submits
205
+ under the same dispatch identity and return authoritative terminal cleanup,
206
+ including when the original submission reply was lost. Merely aborting an HTTP
207
+ request is insufficient. Reserved spend is retained through uncertainty and
208
+ released only when terminal spend and cleanup are recorded atomically. Executors
209
+ must enforce the configured bounds; settlement records actual spend without
210
+ silently clipping a provider overrun.
211
+
212
+ Consecutive transport/preparation failures exhaust the policy retry limit into
213
+ a blocked dispatch without releasing its budget or consuming another batch.
214
+ Reviewer-only `retry_iteration_dispatch` resumes that same dispatch; cancellation
215
+ also remains available. A successful execution with an evaluated adapter becomes
216
+ `candidate_ready`, retaining the active chain for explicit candidate review.
217
+ A successful execution without an adapter becomes `completed_without_candidate`;
218
+ it does not fabricate a version or an improvement. Provider adapters, timers,
219
+ candidate-decision reconciliation and product controls must be connected by the
220
+ host; this shared worker alone does not enable hosted scheduling.
@@ -6,10 +6,10 @@ import { assertBoundedTaskJson } from "./task-schema.js";
6
6
  export async function executeJavaScriptIsolateInProcess(input) {
7
7
  input.signal?.throwIfAborted();
8
8
  validateJavaScriptIsolateInput(input);
9
- const { signal, ...data } = input;
9
+ const { signal, stripTypeScript, ...data } = input;
10
10
  const error = (suffix) => new Error(`${input.errorPrefix}_${suffix}`);
11
11
  // The authored source remains JSON data passed to QuickJS, never host code.
12
- const program = `globalThis.__openpondIsolateInput = JSON.parse(${JSON.stringify(JSON.stringify(data))});\n${javascriptIsolateProcessSource}`;
12
+ const program = `globalThis.__openpondIsolateInput = JSON.parse(${JSON.stringify(JSON.stringify(data))});\n${stripTypeScript ? 'globalThis.__openpondIsolateInput.source = require("node:module").stripTypeScriptTypes(globalThis.__openpondIsolateInput.source, { mode: "strip" });\n' : ""}${javascriptIsolateProcessSource}`;
13
13
  return new Promise((resolve, reject) => {
14
14
  const child = spawn("node", ["--max-old-space-size=64", "--input-type=commonjs", "-"], {
15
15
  stdio: ["pipe", "pipe", "pipe"],
@@ -0,0 +1,44 @@
1
+ import { resolveBoundRewards } from "../rewards.js";
2
+ import { compileTaskBatch, sealTaskBatch, taskFamilyReservations } from "./admission.js";
3
+ import { learningRef, sameLearningRef } from "./contracts.js";
4
+ import { LearningDomainError } from "./errors.js";
5
+ import { requireLearningRelease, requireLearningResource } from "./repository.js";
6
+ export async function sealLearningBatch(transaction, input, actorId, timestamp) {
7
+ const definition = await requireLearningRelease(transaction, "definition", input.taskDefinition);
8
+ const binding = await requireLearningRelease(transaction, "binding", definition.rewardBinding);
9
+ const rewards = await Promise.all(binding.sources.map((source) => requireLearningRelease(transaction, "reward", source.reward)));
10
+ resolveBoundRewards(binding, rewards);
11
+ const evidence = await Promise.all(input.evidence.map((ref) => requireCurrentLearningEvidence(transaction, ref)));
12
+ const decisions = await Promise.all(input.decisions.map(async (ref) => {
13
+ const decision = await requireLearningRelease(transaction, "decision", ref);
14
+ const current = await requireLearningResource(transaction, "decision", ref.id);
15
+ if (!sameLearningRef(learningRef(current), ref))
16
+ throw new LearningDomainError("task_admission_revision_stale", 409);
17
+ return decision;
18
+ }));
19
+ const priorSplits = [];
20
+ for (const item of evidence) {
21
+ for (const reservation of taskFamilyReservations(item, definition)) {
22
+ const split = await transaction.familySplit(reservation.namespace, reservation.kind, reservation.key);
23
+ if (split !== null)
24
+ priorSplits.push({ ...reservation, split: split });
25
+ }
26
+ }
27
+ const batch = sealTaskBatch({ id: input.batchId, definition, binding, rewards, purpose: input.purpose, evidence, decisions, priorSplits, actorId, now: timestamp });
28
+ const release = compileTaskBatch({ batch, definition, binding, rewards, evidence, decisions });
29
+ for (const item of evidence) {
30
+ for (const reservation of taskFamilyReservations(item, definition))
31
+ await transaction.reserveFamilySplit(reservation.namespace, reservation.kind, reservation.key, reservation.split);
32
+ }
33
+ await transaction.put("batch", batch, 0, { parentId: definition.id });
34
+ await transaction.put("package", release, 0, { parentId: batch.id });
35
+ return [pointer("batch", batch), pointer("package", release)];
36
+ }
37
+ export async function requireCurrentLearningEvidence(transaction, ref) {
38
+ const evidence = await requireLearningRelease(transaction, "evidence", ref);
39
+ const current = await requireLearningResource(transaction, "evidence", ref.id);
40
+ if (!sameLearningRef(learningRef(current), ref))
41
+ throw new LearningDomainError("task_evidence_revision_stale", 409);
42
+ return evidence;
43
+ }
44
+ function pointer(kind, resource) { return { kind, id: resource.id, revision: resource.revision }; }
@@ -216,7 +216,7 @@ export const LearningPolicyContentSchema = z.object({
216
216
  context.addIssue({ code: "custom", path: ["limits"], message: "Daily spend must permit the iteration budget." });
217
217
  });
218
218
  export const LearningPolicySchema = LearningPolicyContentSchema.safeExtend({ contentHash: ReleaseHashSchema }).strict();
219
- export const LearningIterationStatusSchema = z.enum(["waiting_for_data", "waiting_for_review", "ready", "training", "evaluating", "candidate_ready", "accepted", "rejected", "paused", "failed", "cancelling", "cancelled"]);
219
+ export const LearningIterationStatusSchema = z.enum(["waiting_for_data", "waiting_for_review", "ready", "dispatching", "training", "evaluating", "candidate_ready", "accepted", "rejected", "paused", "failed", "cancelling", "cancelled", "completed_without_candidate"]);
220
220
  export const LearningIterationSchema = z.object({
221
221
  schemaVersion: z.literal("openpond.learningIteration.v1"),
222
222
  id: ReleaseIdSchema,
@@ -12,3 +12,6 @@ export * from "./reward-authoring.js";
12
12
  export * from "./reward-checks.js";
13
13
  export * from "./reward-check-worker.js";
14
14
  export * from "./reward-fixture-executor.js";
15
+ export * from "./iteration-reservation-contracts.js";
16
+ export * from "./iteration-dispatch-contracts.js";
17
+ export * from "./iteration-dispatch-worker.js";
@@ -0,0 +1,31 @@
1
+ import { LearningIterationSchema } from "./contracts.js";
2
+ import { LearningDomainError } from "./errors.js";
3
+ import { LearningIterationDispatchSchema } from "./iteration-dispatch-contracts.js";
4
+ import { cancelLearningIterationReservation } from "./iteration-reservation-cancellation.js";
5
+ import { LearningConflictError, requireLearningResource } from "./repository.js";
6
+ export async function commandLearningIterationDispatch(tx, input, now) {
7
+ const iteration = await requireLearningResource(tx, "iteration", input.iterationId);
8
+ if (iteration.revision !== input.expectedRevision)
9
+ throw new LearningConflictError("iteration", iteration.id, input.expectedRevision, iteration.revision);
10
+ const dispatch = await tx.get("dispatch", iteration.dispatchId);
11
+ if (input.action === "cancel_iteration" && !dispatch)
12
+ return cancelLearningIterationReservation(tx, { ...input, action: "cancel_iteration_reservation" }, now);
13
+ if (!dispatch)
14
+ throw new LearningDomainError("learning_dispatch_not_started", 409);
15
+ if (dispatch.state === "settled")
16
+ throw new LearningDomainError("learning_execution_already_terminal", 409);
17
+ if (input.action === "retry_iteration_dispatch" && dispatch.state !== "blocked")
18
+ throw new LearningDomainError("learning_dispatch_not_blocked", 409);
19
+ const cancelling = input.action === "cancel_iteration" || Boolean(dispatch.cancelRequestedAt);
20
+ const updated = LearningIterationSchema.parse({ ...iteration, revision: iteration.revision + 1,
21
+ status: cancelling ? "cancelling" : iteration.status, failure: null, updatedAt: now });
22
+ const resumed = LearningIterationDispatchSchema.parse({ ...dispatch, revision: dispatch.revision + 1,
23
+ state: dispatch.execution ? "submitted" : dispatch.submission ? "prepared" : "preparing",
24
+ cancelRequestedAt: cancelling ? dispatch.cancelRequestedAt ?? now : null,
25
+ consecutiveFailures: 0, nextAttemptAt: null, lastError: null, updatedAt: now });
26
+ // Preserve an in-flight lease. Its owner observes cancellation on the next
27
+ // transition, and a replacement worker may claim only after that lease ends.
28
+ await tx.put("dispatch", resumed, dispatch.revision, { parentId: iteration.id, status: resumed.state });
29
+ await tx.put("iteration", updated, iteration.revision, { parentId: dispatch.chainId, status: updated.status });
30
+ return [{ kind: "iteration", id: updated.id, revision: updated.revision }, { kind: "dispatch", id: resumed.id, revision: resumed.revision }];
31
+ }
@@ -0,0 +1,73 @@
1
+ import { z } from "zod";
2
+ import { contentHash, ImmutableReleaseRefSchema, ReleaseIdSchema, ReleaseTimestampSchema } from "@openpond/harness";
3
+ import { LearningJsonObjectSchema, LearningRevisionRefSchema } from "./contracts.js";
4
+ /** The executor validates its protocol payload before returning this snapshot.
5
+ * Credentials and expiring transport URLs belong to the executor, not payload. */
6
+ export const LearningIterationSubmissionSchema = z.object({
7
+ provider: ReleaseIdSchema,
8
+ protocol: ReleaseIdSchema,
9
+ payload: LearningJsonObjectSchema,
10
+ }).strict();
11
+ export const LearningIterationDispatchSchema = z.object({
12
+ schemaVersion: z.literal("openpond.learningIterationDispatch.v1"),
13
+ id: ReleaseIdSchema,
14
+ revision: z.number().int().positive(),
15
+ iterationId: ReleaseIdSchema,
16
+ chainId: ReleaseIdSchema,
17
+ policy: LearningRevisionRefSchema,
18
+ batch: LearningRevisionRefSchema,
19
+ state: z.enum(["preparing", "prepared", "submitted", "settled", "blocked"]),
20
+ submission: LearningIterationSubmissionSchema.nullable(),
21
+ submissionHash: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
22
+ submissionStartedAt: ReleaseTimestampSchema.nullable(),
23
+ execution: ImmutableReleaseRefSchema.nullable(),
24
+ terminalReceipt: ImmutableReleaseRefSchema.nullable(),
25
+ cancelRequestedAt: ReleaseTimestampSchema.nullable(),
26
+ leaseOwner: ReleaseIdSchema.nullable(),
27
+ leaseGeneration: z.number().int().nonnegative(),
28
+ leaseExpiresAt: ReleaseTimestampSchema.nullable(),
29
+ consecutiveFailures: z.number().int().nonnegative(),
30
+ nextAttemptAt: ReleaseTimestampSchema.nullable(),
31
+ lastError: z.string().max(20_000).nullable(),
32
+ createdAt: ReleaseTimestampSchema,
33
+ updatedAt: ReleaseTimestampSchema,
34
+ }).strict().superRefine((value, context) => {
35
+ if (Boolean(value.submission) !== Boolean(value.submissionHash) || (value.submission && contentHash(value.submission) !== value.submissionHash)) {
36
+ context.addIssue({ code: "custom", path: ["submissionHash"], message: "Dispatch submission identity changed." });
37
+ }
38
+ if (value.submissionStartedAt && !value.submission)
39
+ context.addIssue({ code: "custom", path: ["submission"], message: "A started dispatch requires its persisted submission." });
40
+ if (value.execution && !value.submissionStartedAt)
41
+ context.addIssue({ code: "custom", path: ["execution"], message: "An execution requires a started dispatch." });
42
+ if (["prepared", "submitted"].includes(value.state) && !value.submission)
43
+ context.addIssue({ code: "custom", path: ["state"], message: "A prepared dispatch requires its immutable submission." });
44
+ if (value.state === "submitted" && !value.execution)
45
+ context.addIssue({ code: "custom", path: ["execution"], message: "A submitted dispatch requires an observed execution." });
46
+ if (value.state === "settled" && value.submissionStartedAt && !value.terminalReceipt)
47
+ context.addIssue({ code: "custom", path: ["terminalReceipt"], message: "A started dispatch requires terminal receipt evidence before settlement." });
48
+ if (Boolean(value.leaseOwner) !== Boolean(value.leaseExpiresAt))
49
+ context.addIssue({ code: "custom", path: ["leaseOwner"], message: "Dispatch lease owner and expiration must be recorded together." });
50
+ });
51
+ /** Terminal cleanup includes fencing future submission under this dispatch ID.
52
+ * The execution adapter verifies its authoritative provider receipt. */
53
+ export const LearningIterationObservationSchema = z.object({
54
+ scope: ReleaseIdSchema,
55
+ dispatchId: ReleaseIdSchema,
56
+ submissionHash: z.string().regex(/^[a-f0-9]{64}$/),
57
+ execution: ImmutableReleaseRefSchema.nullable(),
58
+ status: z.enum(["running", "evaluating", "succeeded", "failed", "cancelled"]),
59
+ spendUsd: z.number().nonnegative(),
60
+ cleanupComplete: z.boolean(),
61
+ receipt: ImmutableReleaseRefSchema.nullable(),
62
+ evaluation: ImmutableReleaseRefSchema.nullable(),
63
+ candidate: ImmutableReleaseRefSchema.nullable(),
64
+ failure: z.string().max(20_000).nullable(),
65
+ }).strict().superRefine((value, context) => {
66
+ if (["succeeded", "failed", "cancelled"].includes(value.status) && (!value.cleanupComplete || !value.receipt)) {
67
+ context.addIssue({ code: "custom", path: ["receipt"], message: "Terminal execution requires an authoritative cleanup receipt." });
68
+ }
69
+ if (value.status !== "cancelled" && !value.execution)
70
+ context.addIssue({ code: "custom", path: ["execution"], message: "The execution identity is required." });
71
+ if (value.candidate && (value.status !== "succeeded" || !value.evaluation))
72
+ context.addIssue({ code: "custom", path: ["candidate"], message: "A candidate requires successful retained evaluation." });
73
+ });
@@ -0,0 +1,178 @@
1
+ import { contentHash } from "@openpond/harness";
2
+ import { LearningIterationSchema } from "./contracts.js";
3
+ import { LearningDomainError } from "./errors.js";
4
+ import { LearningChainSchema, LearningIterationReservationSchema } from "./iteration-reservation-contracts.js";
5
+ import { LearningIterationDispatchSchema, LearningIterationObservationSchema, LearningIterationSubmissionSchema, } from "./iteration-dispatch-contracts.js";
6
+ import { requireLearningRelease, requireLearningResource } from "./repository.js";
7
+ const closed = new Set(["accepted", "rejected", "failed", "cancelled", "completed_without_candidate"]);
8
+ export function createLearningIterationWorker(repository, executor, options) {
9
+ const now = options.now ?? (() => new Date().toISOString());
10
+ const leaseMs = options.leaseMs ?? 60_000;
11
+ const requestTimeoutMs = options.requestTimeoutMs ?? 45_000;
12
+ if (!Number.isFinite(leaseMs) || !Number.isFinite(requestTimeoutMs) || requestTimeoutMs < 1 || leaseMs <= requestTimeoutMs)
13
+ throw new Error("Iteration lease must exceed its bounded request timeout.");
14
+ async function claim(scope, id) {
15
+ return repository.transaction(scope, async (tx) => {
16
+ const iteration = await requireLearningResource(tx, "iteration", id);
17
+ if (closed.has(iteration.status) || iteration.status === "candidate_ready" || !iteration.batch)
18
+ return null;
19
+ const current = await tx.get("dispatch", iteration.dispatchId);
20
+ const stamp = now();
21
+ if (current?.state === "settled" || (current?.state === "blocked" && !current.cancelRequestedAt)
22
+ || (current?.leaseExpiresAt && Date.parse(current.leaseExpiresAt) > Date.parse(stamp))
23
+ || (current?.nextAttemptAt && Date.parse(current.nextAttemptAt) > Date.parse(stamp)))
24
+ return null;
25
+ const policy = await requireLearningRelease(tx, "policy", iteration.policy);
26
+ if (policy.executionOwner !== options.executionOwner)
27
+ throw new LearningDomainError("learning_execution_owner_mismatch", 409);
28
+ const latestPolicy = await requireLearningResource(tx, "policy", policy.id);
29
+ if (!latestPolicy.enabled && !current?.submissionStartedAt && !current?.cancelRequestedAt)
30
+ return null;
31
+ const batch = await requireLearningRelease(tx, "batch", iteration.batch);
32
+ const reservation = await requireLearningResource(tx, "reservation", id);
33
+ const chain = await requireLearningResource(tx, "chain", reservation.chainId);
34
+ if (chain.activeIterationId !== id || reservation.outcome !== "reserved")
35
+ throw new LearningDomainError("learning_dispatch_reservation_mismatch", 409);
36
+ const dispatch = LearningIterationDispatchSchema.parse({
37
+ schemaVersion: "openpond.learningIterationDispatch.v1", id: iteration.dispatchId,
38
+ iterationId: id, chainId: chain.id, policy: iteration.policy, batch: iteration.batch,
39
+ state: "preparing", submission: null, submissionHash: null, submissionStartedAt: null,
40
+ execution: null, terminalReceipt: null, cancelRequestedAt: null,
41
+ consecutiveFailures: 0, nextAttemptAt: null, lastError: null, createdAt: stamp,
42
+ ...current, revision: (current?.revision ?? 0) + 1,
43
+ leaseOwner: options.workerId, leaseGeneration: (current?.leaseGeneration ?? 0) + 1,
44
+ leaseExpiresAt: new Date(Date.parse(stamp) + leaseMs).toISOString(), updatedAt: stamp,
45
+ });
46
+ if (dispatch.iterationId !== id || contentHash(dispatch.policy) !== contentHash(iteration.policy) || contentHash(dispatch.batch) !== contentHash(iteration.batch))
47
+ throw new LearningDomainError("learning_dispatch_identity_mismatch", 409);
48
+ await tx.put("dispatch", dispatch, current?.revision ?? 0, { parentId: id, status: dispatch.state });
49
+ const running = LearningIterationSchema.parse({ ...iteration, revision: iteration.revision + 1,
50
+ status: iteration.status === "cancelling" ? "cancelling" : current?.submissionStartedAt ? iteration.status : "dispatching", updatedAt: stamp });
51
+ await tx.put("iteration", running, iteration.revision, { parentId: chain.id, status: running.status });
52
+ return { dispatch, iteration: running, policy, batch };
53
+ });
54
+ }
55
+ async function fenced(scope, claimed, update) {
56
+ return repository.transaction(scope, async (tx) => {
57
+ const current = await requireLearningResource(tx, "dispatch", claimed.id);
58
+ if (current.leaseOwner !== options.workerId || current.leaseGeneration !== claimed.leaseGeneration
59
+ || !current.leaseExpiresAt || Date.parse(current.leaseExpiresAt) <= Date.parse(now()) || current.state === "settled")
60
+ return null;
61
+ return update(tx, current, await requireLearningResource(tx, "iteration", current.iterationId));
62
+ });
63
+ }
64
+ async function saveDispatch(tx, current, patch) {
65
+ const updated = LearningIterationDispatchSchema.parse({ ...current, ...patch, revision: current.revision + 1, updatedAt: now() });
66
+ await tx.put("dispatch", updated, current.revision, { parentId: current.iterationId, status: updated.state });
67
+ return updated;
68
+ }
69
+ async function settle(tx, current, iteration, outcome) {
70
+ const terminal = !outcome || ["succeeded", "failed", "cancelled"].includes(outcome.status);
71
+ const status = !outcome ? "cancelled" : outcome.status === "succeeded"
72
+ ? outcome.candidate ? "candidate_ready" : "completed_without_candidate"
73
+ : outcome.status === "running" ? current.cancelRequestedAt ? "cancelling" : "training"
74
+ : outcome.status === "evaluating" ? current.cancelRequestedAt ? "cancelling" : "evaluating" : outcome.status;
75
+ const updated = LearningIterationSchema.parse({
76
+ ...iteration, revision: iteration.revision + 1, status, updatedAt: now(),
77
+ trainingJob: outcome?.execution ?? iteration.trainingJob, evaluationJob: outcome?.evaluation ?? iteration.evaluationJob,
78
+ candidateVersion: outcome?.candidate ?? iteration.candidateVersion, spendUsd: outcome?.spendUsd ?? iteration.spendUsd,
79
+ failure: outcome?.failure ? { code: "learning_execution_failed", message: outcome.failure } : null,
80
+ });
81
+ await tx.put("iteration", updated, iteration.revision, { parentId: current.chainId, status });
82
+ await saveDispatch(tx, current, { state: terminal ? "settled" : "submitted", execution: outcome?.execution ?? current.execution,
83
+ terminalReceipt: outcome?.receipt ?? null, leaseOwner: null, leaseExpiresAt: null,
84
+ nextAttemptAt: terminal ? null : new Date(Date.parse(now()) + 5_000).toISOString(), consecutiveFailures: 0, lastError: null });
85
+ if (terminal) {
86
+ const reservation = await requireLearningResource(tx, "reservation", iteration.id);
87
+ const settled = LearningIterationReservationSchema.parse({ ...reservation, revision: reservation.revision + 1,
88
+ budget: { ...reservation.budget, reservedSpendUsd: 0, settledSpendUsd: updated.spendUsd, settledAt: now() }, updatedAt: now() });
89
+ await tx.put("reservation", settled, reservation.revision, { parentId: current.chainId, status: settled.outcome });
90
+ if (status !== "candidate_ready") {
91
+ const chain = await requireLearningResource(tx, "chain", current.chainId);
92
+ if (chain.activeIterationId === iteration.id)
93
+ await tx.put("chain", LearningChainSchema.parse({ ...chain, revision: chain.revision + 1, activeIterationId: null, updatedAt: now() }), chain.revision, { parentId: chain.modelProjectId });
94
+ }
95
+ }
96
+ return updated;
97
+ }
98
+ async function run(scope, iterationId) {
99
+ const acquired = await claim(scope, iterationId);
100
+ const read = () => repository.transaction(scope, tx => requireLearningResource(tx, "iteration", iterationId));
101
+ if (!acquired)
102
+ return read();
103
+ let dispatch = acquired.dispatch;
104
+ const bounded = async (operation) => {
105
+ const controller = new AbortController();
106
+ let timer;
107
+ const timeout = new Promise((_, reject) => { timer = setTimeout(() => { const error = new Error("Learning dispatch request timed out; reconcile its existing identity."); controller.abort(error); reject(error); }, requestTimeoutMs); });
108
+ try {
109
+ return await Promise.race([operation({ ...structuredClone({ scope, ...acquired, dispatch }), signal: controller.signal }), timeout]);
110
+ }
111
+ finally {
112
+ clearTimeout(timer);
113
+ }
114
+ };
115
+ try {
116
+ if (dispatch.cancelRequestedAt && !dispatch.submissionStartedAt) {
117
+ await fenced(scope, dispatch, (tx, current, iteration) => settle(tx, current, iteration, null));
118
+ return read();
119
+ }
120
+ if (!dispatch.submission) {
121
+ const submission = LearningIterationSubmissionSchema.parse(await bounded(input => executor.prepare(input)));
122
+ const prepared = await fenced(scope, dispatch, (tx, current) => saveDispatch(tx, current, { submission, submissionHash: contentHash(submission), state: "prepared" }));
123
+ if (!prepared)
124
+ return read();
125
+ dispatch = prepared;
126
+ }
127
+ const starting = await fenced(scope, dispatch, async (tx, current, iteration) => {
128
+ if (current.cancelRequestedAt && !current.submissionStartedAt) {
129
+ await settle(tx, current, iteration, null);
130
+ return null;
131
+ }
132
+ const policy = await requireLearningResource(tx, "policy", current.policy.id);
133
+ if (!policy.enabled && !current.submissionStartedAt) {
134
+ await saveDispatch(tx, current, { leaseOwner: null, leaseExpiresAt: null });
135
+ return null;
136
+ }
137
+ return saveDispatch(tx, current, { submissionStartedAt: current.submissionStartedAt ?? now() });
138
+ });
139
+ if (!starting)
140
+ return read();
141
+ dispatch = starting;
142
+ let observed = dispatch.cancelRequestedAt ? await bounded(input => executor.cancel(input)) : await bounded(input => executor.reconcile(input));
143
+ if (!observed) {
144
+ const refreshed = await fenced(scope, dispatch, (tx, current) => saveDispatch(tx, current, {}));
145
+ if (!refreshed)
146
+ return read();
147
+ dispatch = refreshed;
148
+ observed = dispatch.cancelRequestedAt ? await bounded(input => executor.cancel(input)) : await bounded(input => executor.submit(input));
149
+ }
150
+ const outcome = LearningIterationObservationSchema.parse(observed);
151
+ if (outcome.scope !== scope || outcome.dispatchId !== dispatch.id || outcome.submissionHash !== dispatch.submissionHash
152
+ || (dispatch.execution && contentHash(outcome.execution) !== contentHash(dispatch.execution)))
153
+ throw new LearningDomainError("learning_execution_identity_mismatch", 409);
154
+ await fenced(scope, dispatch, async (tx, current, iteration) => {
155
+ if (outcome.submissionHash !== current.submissionHash || (current.execution && contentHash(outcome.execution) !== contentHash(current.execution)))
156
+ throw new LearningDomainError("learning_execution_identity_mismatch", 409);
157
+ if (outcome.spendUsd < iteration.spendUsd)
158
+ throw new LearningDomainError("learning_execution_spend_regressed", 409);
159
+ return settle(tx, current, iteration, outcome);
160
+ });
161
+ }
162
+ catch (error) {
163
+ await fenced(scope, dispatch, async (tx, current, iteration) => {
164
+ const failures = current.consecutiveFailures + 1;
165
+ const blocked = failures > acquired.policy.limits.maxRetries;
166
+ const message = (error instanceof Error ? error.message : "Learning dispatch failed.").slice(0, 20_000);
167
+ await saveDispatch(tx, current, { state: blocked ? "blocked" : current.state, consecutiveFailures: failures,
168
+ lastError: message, leaseOwner: null, leaseExpiresAt: null,
169
+ nextAttemptAt: blocked ? null : new Date(Date.parse(now()) + Math.min(60_000, 1_000 * 2 ** Math.min(failures, 6))).toISOString() });
170
+ const updated = LearningIterationSchema.parse({ ...iteration, revision: iteration.revision + 1, retryCount: iteration.retryCount + 1,
171
+ failure: { code: blocked ? "learning_dispatch_attention_required" : "learning_dispatch_retry_pending", message }, updatedAt: now() });
172
+ await tx.put("iteration", updated, iteration.revision, { parentId: current.chainId, status: updated.status });
173
+ });
174
+ }
175
+ return read();
176
+ }
177
+ return { run };
178
+ }
@@ -0,0 +1,76 @@
1
+ import { contentHash } from "@openpond/harness";
2
+ import { assertAdmissionDecision } from "./admission.js";
3
+ import { learningRef, sameLearningRef } from "./contracts.js";
4
+ import { LearningDomainError } from "./errors.js";
5
+ import { requireLearningRelease, requireLearningResource } from "./repository.js";
6
+ export function learningChainId(modelProjectId) {
7
+ return `chain-${contentHash(modelProjectId)}`;
8
+ }
9
+ export function learningConsumptionId(chainId, evidence) {
10
+ return `consumption-${contentHash([chainId, evidence.id, evidence.revision])}`;
11
+ }
12
+ export async function inspectIterationEligibility(transaction, policy) {
13
+ // These are the methods supported by the approved-batch materializer.
14
+ const purpose = policy.training.method === "sft" ? "supervised_training" : "reward_training";
15
+ if (!["sft", "grpo", "ppo"].includes(policy.training.method)) {
16
+ throw new LearningDomainError("learning_batch_method_unsupported", 422);
17
+ }
18
+ // Publishing a qualification reference is not proof that an automatic decision
19
+ // was produced by a qualified executor. That admission operation is separate.
20
+ if (policy.admission.mode !== "human")
21
+ throw new LearningDomainError("learning_automatic_admission_not_available", 422);
22
+ const definition = await requireLearningRelease(transaction, "definition", policy.taskDefinition);
23
+ if (!sameLearningRef(definition.rewardBinding, policy.rewardBinding))
24
+ throw new LearningDomainError("learning_policy_binding_mismatch", 422);
25
+ const counts = { eligible: 0, awaitingReview: 0, excluded: 0, consumed: 0 };
26
+ const selected = [];
27
+ const chainId = learningChainId(policy.modelProjectId);
28
+ const seen = new Set();
29
+ let backlog = 0;
30
+ for (const sourceRef of policy.sources) {
31
+ const source = await requireLearningRelease(transaction, "source", sourceRef);
32
+ const currentSource = await requireLearningResource(transaction, "source", source.id);
33
+ if (!source.enabled || !currentSource.enabled)
34
+ throw new LearningDomainError("learning_source_disabled", 409);
35
+ if (!sameLearningRef(source.taskDefinition, policy.taskDefinition))
36
+ throw new LearningDomainError("learning_policy_source_definition_mismatch", 422);
37
+ let afterId;
38
+ do {
39
+ const page = await transaction.list("evidence", { parentId: source.id, limit: 100, ...(afterId ? { afterId } : {}) });
40
+ for (const evidence of page.items) {
41
+ if (seen.has(evidence.id))
42
+ continue;
43
+ seen.add(evidence.id);
44
+ if (!policy.sources.some((allowed) => sameLearningRef(evidence.source, allowed)) || !sameLearningRef(evidence.submission.taskDefinition, policy.taskDefinition) || evidence.submission.split !== "train") {
45
+ counts.excluded += 1;
46
+ continue;
47
+ }
48
+ if (await transaction.get("consumption", learningConsumptionId(chainId, evidence))) {
49
+ counts.consumed += 1;
50
+ continue;
51
+ }
52
+ if (++backlog > policy.limits.maxBacklogExamples)
53
+ throw new LearningDomainError("learning_backlog_limit_exceeded", 409);
54
+ const decision = await transaction.get("decision", `decision-${evidence.id}`);
55
+ if (!decision || !sameLearningRef(decision.evidence, learningRef(evidence)) || decision.taskAdmissibility === "pending" || decision.actor.kind !== "human") {
56
+ counts.awaitingReview += 1;
57
+ continue;
58
+ }
59
+ try {
60
+ assertAdmissionDecision({ decision, evidence, definition, purpose });
61
+ }
62
+ catch (error) {
63
+ if (!(error instanceof LearningDomainError))
64
+ throw error;
65
+ counts.excluded += 1;
66
+ continue;
67
+ }
68
+ counts.eligible += 1;
69
+ if (selected.length < policy.limits.maxBatchExamples)
70
+ selected.push({ evidence, decision });
71
+ }
72
+ afterId = page.nextCursor ?? undefined;
73
+ } while (afterId);
74
+ }
75
+ return { counts, selected, purpose };
76
+ }
@@ -0,0 +1,31 @@
1
+ import { LearningIterationSchema } from "./contracts.js";
2
+ import { LearningDomainError } from "./errors.js";
3
+ import { LearningChainSchema, LearningIterationReservationSchema } from "./iteration-reservation-contracts.js";
4
+ import { LearningConflictError, requireLearningResource } from "./repository.js";
5
+ /** Only a reservation that has never been dispatched can release spend locally.
6
+ * Submitted executions require authoritative cancellation and settlement. */
7
+ export async function cancelLearningIterationReservation(transaction, input, now) {
8
+ const iteration = await requireLearningResource(transaction, "iteration", input.iterationId);
9
+ if (iteration.revision !== input.expectedRevision)
10
+ throw new LearningConflictError("iteration", iteration.id, input.expectedRevision, iteration.revision);
11
+ const reservation = await requireLearningResource(transaction, "reservation", iteration.id);
12
+ if (iteration.status === "cancelled")
13
+ return [{ kind: "iteration", id: iteration.id, revision: iteration.revision }];
14
+ if (iteration.trainingJob || iteration.evaluationJob || !["ready", "waiting_for_data", "waiting_for_review"].includes(iteration.status)) {
15
+ throw new LearningDomainError("learning_execution_cancellation_required", 409);
16
+ }
17
+ const updated = LearningIterationSchema.parse({ ...iteration, revision: iteration.revision + 1, status: "cancelled", updatedAt: now });
18
+ const released = LearningIterationReservationSchema.parse({
19
+ ...reservation, revision: reservation.revision + 1,
20
+ budget: { ...reservation.budget, reservedSpendUsd: 0, settledSpendUsd: 0, settledAt: now }, updatedAt: now,
21
+ });
22
+ await transaction.put("iteration", updated, iteration.revision, { parentId: reservation.chainId, status: updated.status });
23
+ await transaction.put("reservation", released, reservation.revision, { parentId: reservation.chainId, status: reservation.outcome });
24
+ const chain = await requireLearningResource(transaction, "chain", reservation.chainId);
25
+ if (chain.activeIterationId === iteration.id) {
26
+ await transaction.put("chain", LearningChainSchema.parse({ ...chain, revision: chain.revision + 1, activeIterationId: null, updatedAt: now }), chain.revision, { parentId: chain.modelProjectId });
27
+ }
28
+ // Consumption is retained. Retry/replay is an explicit operation, never a
29
+ // side effect of cancelling that silently makes the same examples new again.
30
+ return [{ kind: "iteration", id: updated.id, revision: updated.revision }, { kind: "reservation", id: released.id, revision: released.revision }];
31
+ }
@@ -0,0 +1,57 @@
1
+ import { z } from "zod";
2
+ import { ReleaseHashSchema, ReleaseIdSchema, ReleaseTimestampSchema } from "@openpond/harness";
3
+ import { LearningRevisionRefSchema } from "./contracts.js";
4
+ export const LearningIterationTriggerSchema = z.discriminatedUnion("kind", [
5
+ z.object({ kind: z.literal("manual"), identity: ReleaseIdSchema }).strict(),
6
+ z.object({ kind: z.literal("schedule"), scheduledAt: ReleaseTimestampSchema }).strict(),
7
+ ]);
8
+ /** Stable across policy edits: a Model owns one learning chain in its scope. */
9
+ export const LearningChainSchema = z.object({
10
+ schemaVersion: z.literal("openpond.learningChain.v1"),
11
+ id: ReleaseIdSchema,
12
+ revision: z.number().int().positive(),
13
+ modelProjectId: ReleaseIdSchema,
14
+ activeIterationId: ReleaseIdSchema.nullable(),
15
+ latestIterationId: ReleaseIdSchema,
16
+ lastReservedAt: ReleaseTimestampSchema.nullable(),
17
+ updatedAt: ReleaseTimestampSchema,
18
+ }).strict();
19
+ export const LearningEligibilityCountsSchema = z.object({
20
+ eligible: z.number().int().nonnegative(),
21
+ awaitingReview: z.number().int().nonnegative(),
22
+ excluded: z.number().int().nonnegative(),
23
+ consumed: z.number().int().nonnegative(),
24
+ }).strict();
25
+ /** Immutable evidence consumption survives rejection and policy revision changes.
26
+ * A corrected evidence revision is new data; reapproving the same revision isn't. */
27
+ export const LearningConsumptionSchema = z.object({
28
+ schemaVersion: z.literal("openpond.learningConsumption.v1"),
29
+ id: ReleaseIdSchema,
30
+ revision: z.literal(1),
31
+ chainId: ReleaseIdSchema,
32
+ iterationId: ReleaseIdSchema,
33
+ evidence: LearningRevisionRefSchema,
34
+ decision: LearningRevisionRefSchema,
35
+ reservedAt: ReleaseTimestampSchema,
36
+ }).strict();
37
+ /** Dispatch must reconcile this reservation, never reserve a second batch on retry. */
38
+ export const LearningIterationReservationSchema = z.object({
39
+ schemaVersion: z.literal("openpond.learningIterationReservation.v1"),
40
+ id: ReleaseIdSchema,
41
+ revision: z.number().int().positive(),
42
+ chainId: ReleaseIdSchema,
43
+ iterationId: ReleaseIdSchema,
44
+ policy: LearningRevisionRefSchema,
45
+ trigger: LearningIterationTriggerSchema,
46
+ requestHash: ReleaseHashSchema,
47
+ outcome: z.enum(["reserved", "waiting_for_data", "waiting_for_review"]),
48
+ counts: LearningEligibilityCountsSchema,
49
+ budget: z.object({
50
+ maximumSpendUsd: z.number().nonnegative(),
51
+ reservedSpendUsd: z.number().nonnegative(),
52
+ settledSpendUsd: z.number().nonnegative(),
53
+ settledAt: ReleaseTimestampSchema.nullable(),
54
+ }).strict(),
55
+ createdAt: ReleaseTimestampSchema,
56
+ updatedAt: ReleaseTimestampSchema,
57
+ }).strict();