@openpond/evals 0.10.1 → 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.
- package/LEARNING.md +70 -0
- package/dist/learning/batch-service.js +44 -0
- package/dist/learning/contracts.js +1 -1
- package/dist/learning/index.js +3 -0
- package/dist/learning/iteration-dispatch-commands.js +31 -0
- package/dist/learning/iteration-dispatch-contracts.js +73 -0
- package/dist/learning/iteration-dispatch-worker.js +178 -0
- package/dist/learning/iteration-eligibility.js +76 -0
- package/dist/learning/iteration-reservation-cancellation.js +31 -0
- package/dist/learning/iteration-reservation-contracts.js +57 -0
- package/dist/learning/iteration-reservation-service.js +106 -0
- package/dist/learning/operations.js +6 -1
- package/dist/learning/repository.js +4 -0
- package/dist/learning/service.js +19 -43
- package/dist/types/learning/batch-service.d.ts +8 -0
- package/dist/types/learning/batch-service.d.ts.map +1 -0
- package/dist/types/learning/contracts.d.ts +6 -0
- package/dist/types/learning/contracts.d.ts.map +1 -1
- package/dist/types/learning/index.d.ts +3 -0
- package/dist/types/learning/index.d.ts.map +1 -1
- package/dist/types/learning/iteration-dispatch-commands.d.ts +8 -0
- package/dist/types/learning/iteration-dispatch-commands.d.ts.map +1 -0
- package/dist/types/learning/iteration-dispatch-contracts.d.ts +93 -0
- package/dist/types/learning/iteration-dispatch-contracts.d.ts.map +1 -0
- package/dist/types/learning/iteration-dispatch-worker.d.ts +35 -0
- package/dist/types/learning/iteration-dispatch-worker.d.ts.map +1 -0
- package/dist/types/learning/iteration-eligibility.d.ts +18 -0
- package/dist/types/learning/iteration-eligibility.d.ts.map +1 -0
- package/dist/types/learning/iteration-reservation-cancellation.d.ts +8 -0
- package/dist/types/learning/iteration-reservation-cancellation.d.ts.map +1 -0
- package/dist/types/learning/iteration-reservation-contracts.d.ts +90 -0
- package/dist/types/learning/iteration-reservation-contracts.d.ts.map +1 -0
- package/dist/types/learning/iteration-reservation-service.d.ts +9 -0
- package/dist/types/learning/iteration-reservation-service.d.ts.map +1 -0
- package/dist/types/learning/operations.d.ts +64 -0
- package/dist/types/learning/operations.d.ts.map +1 -1
- package/dist/types/learning/repository.d.ts +121 -0
- package/dist/types/learning/repository.d.ts.map +1 -1
- package/dist/types/learning/service.d.ts.map +1 -1
- package/dist/types/learning/transport.d.ts +151 -0
- package/dist/types/learning/transport.d.ts.map +1 -1
- package/package.json +1 -1
- package/schemas/learning/v1/chain.schema.json +72 -0
- package/schemas/learning/v1/command-request.schema.json +374 -233
- package/schemas/learning/v1/consumption.schema.json +77 -0
- package/schemas/learning/v1/dispatch.schema.json +338 -0
- package/schemas/learning/v1/iteration.schema.json +3 -1
- package/schemas/learning/v1/read-request.schema.json +4 -0
- package/schemas/learning/v1/reservation.schema.json +201 -0
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.
|
|
@@ -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,
|
package/dist/learning/index.js
CHANGED
|
@@ -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();
|