@openpond/evals 0.10.3 → 0.10.5

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/LEARNING.md +43 -0
  2. package/dist/learning/batch-service.js +12 -7
  3. package/dist/learning/index.js +4 -0
  4. package/dist/learning/iteration-budget.js +17 -0
  5. package/dist/learning/iteration-reservation-service.js +3 -13
  6. package/dist/learning/policy-inspection.js +82 -0
  7. package/dist/learning/repository.js +2 -0
  8. package/dist/learning/schedule-contracts.js +45 -0
  9. package/dist/learning/schedule-service.js +31 -0
  10. package/dist/learning/schedule-worker.js +96 -0
  11. package/dist/learning/service.js +13 -0
  12. package/dist/learning/transport.js +1 -0
  13. package/dist/types/learning/batch-service.d.ts +310 -0
  14. package/dist/types/learning/batch-service.d.ts.map +1 -1
  15. package/dist/types/learning/index.d.ts +4 -0
  16. package/dist/types/learning/index.d.ts.map +1 -1
  17. package/dist/types/learning/iteration-budget.d.ts +8 -0
  18. package/dist/types/learning/iteration-budget.d.ts.map +1 -0
  19. package/dist/types/learning/iteration-reservation-service.d.ts.map +1 -1
  20. package/dist/types/learning/policy-inspection.d.ts +47 -0
  21. package/dist/types/learning/policy-inspection.d.ts.map +1 -0
  22. package/dist/types/learning/repository.d.ts +66 -1
  23. package/dist/types/learning/repository.d.ts.map +1 -1
  24. package/dist/types/learning/schedule-contracts.d.ts +69 -0
  25. package/dist/types/learning/schedule-contracts.d.ts.map +1 -0
  26. package/dist/types/learning/schedule-service.d.ts +30 -0
  27. package/dist/types/learning/schedule-service.d.ts.map +1 -0
  28. package/dist/types/learning/schedule-worker.d.ts +12 -0
  29. package/dist/types/learning/schedule-worker.d.ts.map +1 -0
  30. package/dist/types/learning/service.d.ts +39 -0
  31. package/dist/types/learning/service.d.ts.map +1 -1
  32. package/dist/types/learning/transport.d.ts +75 -2
  33. package/dist/types/learning/transport.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/schemas/learning/v1/policy-inspection.schema.json +243 -0
  36. package/schemas/learning/v1/read-request.schema.json +49 -23
  37. package/schemas/learning/v1/schedule.schema.json +188 -0
  38. package/schemas/learning/v1/schedule_fire.schema.json +163 -0
package/LEARNING.md CHANGED
@@ -231,3 +231,46 @@ cleanup receipt, retains the decision reference on the iteration, and records
231
231
  owns it. Duplicate observations are idempotent, older decision revisions cannot
232
232
  overwrite newer ones, and contradictory observations at the same revision fail.
233
233
  This operation does not change the training parent or activate serving.
234
+
235
+ ### Policy readiness reads
236
+
237
+ `client.inspectPolicy({ id, revision, contentHash })` sends the read payload
238
+ `{ "action": "inspect_policy", "scope": "<profile-or-team>", "policy": { "id": "...", "revision": 1, "contentHash": "..." } }`.
239
+ The authenticated owner reads a scope-serialized snapshot of the current policy's
240
+ eligible, awaiting-review, excluded and consumed counts, chain identity, cooldown
241
+ and daily budget. It uses the same eligibility, complete batch validation and
242
+ budget accounting as manual or scheduled reservation. A stale policy reference is
243
+ rejected; source-only credentials cannot read it.
244
+
245
+ `canReserve` describes shared reservation readiness at `inspectedAt`. The actual
246
+ command rechecks current state and execution-owner preparation still validates
247
+ its training configuration. `blockers` explains unmet constraints. `counts: null`
248
+ means inspection could not count valid sources or supported admission, not that
249
+ the backlog is empty. Reads never write an iteration, timer fire, family
250
+ reservation, batch, consumption or operation record and never launch training.
251
+
252
+ ### Durable learning schedules
253
+
254
+ Policy publication atomically synchronizes a `schedule` resource. Enabling
255
+ scheduled training requires reviewer authority, including batched publication,
256
+ because it authorizes the same reservation as manual Train. The timer preserves
257
+ its pending occurrence through ordinary policy edits. Pause removes its pending
258
+ due time; resume, cadence changes and execution-owner changes start a future
259
+ occurrence. `synchronizeLearningSchedule` can initialize an existing current
260
+ policy, but refuses stale policies and does not reset an unchanged timer.
261
+
262
+ `createLearningScheduleWorker` runs a typed learning reservation in the same
263
+ repository transaction as an immutable `schedule_fire` and timer advance.
264
+ Duplicate replicas and restarts therefore cannot consume a second batch for an
265
+ occurrence. Downtime retains the original due time and coalesces subsequent
266
+ missed occurrences into one fire, recording the covered time and count.
267
+ Insufficient approved evidence records waiting without reserved spend. An active
268
+ chain, cooldown or daily budget limit records an explicit skip. Review approval
269
+ does not move the next due time or independently start training.
270
+
271
+ Other errors roll back reservation mutations and keep the original occurrence
272
+ for bounded retries. Exhausting the policy retry limit blocks the timer with a
273
+ retained diagnostic; a new reviewed policy revision may resume it. Hosts must
274
+ poll due timers for their execution owner and supply an authorized actor. This
275
+ worker does not execute a Work prompt or submit a provider Job; the existing
276
+ iteration dispatch worker owns that next transition.
@@ -4,6 +4,17 @@ import { learningRef, sameLearningRef } from "./contracts.js";
4
4
  import { LearningDomainError } from "./errors.js";
5
5
  import { requireLearningRelease, requireLearningResource } from "./repository.js";
6
6
  export async function sealLearningBatch(transaction, input, actorId, timestamp) {
7
+ const { batch, release, evidence, definition } = await prepareLearningBatch(transaction, input, actorId, timestamp);
8
+ for (const item of evidence) {
9
+ for (const reservation of taskFamilyReservations(item, definition))
10
+ await transaction.reserveFamilySplit(reservation.namespace, reservation.kind, reservation.key, reservation.split);
11
+ }
12
+ await transaction.put("batch", batch, 0, { parentId: definition.id });
13
+ await transaction.put("package", release, 0, { parentId: batch.id });
14
+ return [pointer("batch", batch), pointer("package", release)];
15
+ }
16
+ /** Performs the complete shared validation without reserving families or publishing resources. */
17
+ export async function prepareLearningBatch(transaction, input, actorId, timestamp) {
7
18
  const definition = await requireLearningRelease(transaction, "definition", input.taskDefinition);
8
19
  const binding = await requireLearningRelease(transaction, "binding", definition.rewardBinding);
9
20
  const rewards = await Promise.all(binding.sources.map((source) => requireLearningRelease(transaction, "reward", source.reward)));
@@ -26,13 +37,7 @@ export async function sealLearningBatch(transaction, input, actorId, timestamp)
26
37
  }
27
38
  const batch = sealTaskBatch({ id: input.batchId, definition, binding, rewards, purpose: input.purpose, evidence, decisions, priorSplits, actorId, now: timestamp });
28
39
  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)];
40
+ return { batch, release, evidence, definition };
36
41
  }
37
42
  export async function requireCurrentLearningEvidence(transaction, ref) {
38
43
  const evidence = await requireLearningRelease(transaction, "evidence", ref);
@@ -16,3 +16,7 @@ export * from "./iteration-reservation-contracts.js";
16
16
  export * from "./iteration-dispatch-contracts.js";
17
17
  export * from "./iteration-dispatch-worker.js";
18
18
  export * from "./iteration-candidate-decision.js";
19
+ export * from "./schedule-contracts.js";
20
+ export * from "./schedule-service.js";
21
+ export * from "./schedule-worker.js";
22
+ export * from "./policy-inspection.js";
@@ -0,0 +1,17 @@
1
+ /** Outstanding reservations survive midnight; settled spend belongs to its UTC day. */
2
+ export async function readLearningIterationBudget(transaction, chainId, now) {
3
+ const day = new Date(now).toISOString().slice(0, 10);
4
+ let reservedSpendUsd = 0;
5
+ let settledSpendUsd = 0;
6
+ let afterId;
7
+ do {
8
+ const page = await transaction.list("reservation", { parentId: chainId, limit: 100, ...(afterId ? { afterId } : {}) });
9
+ for (const { budget } of page.items) {
10
+ reservedSpendUsd += budget.reservedSpendUsd;
11
+ if (budget.settledAt && new Date(budget.settledAt).toISOString().slice(0, 10) === day)
12
+ settledSpendUsd += budget.settledSpendUsd;
13
+ }
14
+ afterId = page.nextCursor ?? undefined;
15
+ } while (afterId);
16
+ return { reservedSpendUsd, settledSpendUsd, committedSpendUsd: reservedSpendUsd + settledSpendUsd };
17
+ }
@@ -3,6 +3,7 @@ import { sealLearningBatch } from "./batch-service.js";
3
3
  import { LearningIterationSchema, learningRef, sameLearningRef } from "./contracts.js";
4
4
  import { LearningDomainError } from "./errors.js";
5
5
  import { inspectIterationEligibility, learningChainId, learningConsumptionId } from "./iteration-eligibility.js";
6
+ import { readLearningIterationBudget } from "./iteration-budget.js";
6
7
  import { LearningChainSchema, LearningConsumptionSchema, LearningIterationReservationSchema } from "./iteration-reservation-contracts.js";
7
8
  import { requireLearningRelease, requireLearningResource } from "./repository.js";
8
9
  /** The caller owns one scope-serialized transaction. This never submits a Job. */
@@ -89,18 +90,7 @@ export async function reserveLearningIteration(transaction, input, actorId, now)
89
90
  return [{ kind: "iteration", id, revision: 1 }, { kind: "reservation", id, revision: 1 }, ...pointers];
90
91
  }
91
92
  async function assertAvailableBudget(transaction, chainId, now, requested, maximum) {
92
- let committed = 0;
93
- let afterId;
94
- do {
95
- const page = await transaction.list("reservation", { parentId: chainId, limit: 100, ...(afterId ? { afterId } : {}) });
96
- for (const { budget } of page.items) {
97
- // Unsettled reservations carry across midnight and worker restarts.
98
- committed += budget.reservedSpendUsd;
99
- if (budget.settledAt && new Date(budget.settledAt).toISOString().slice(0, 10) === new Date(now).toISOString().slice(0, 10))
100
- committed += budget.settledSpendUsd;
101
- }
102
- afterId = page.nextCursor ?? undefined;
103
- } while (afterId);
104
- if (committed + requested > maximum)
93
+ const budget = await readLearningIterationBudget(transaction, chainId, now);
94
+ if (budget.committedSpendUsd + requested > maximum)
105
95
  throw new LearningDomainError("learning_daily_budget_exhausted", 409);
106
96
  }
@@ -0,0 +1,82 @@
1
+ import { ReleaseIdSchema, ReleaseTimestampSchema } from "@openpond/harness";
2
+ import { z } from "zod";
3
+ import { LearningRevisionRefSchema, learningRef, sameLearningRef } from "./contracts.js";
4
+ import { prepareLearningBatch } from "./batch-service.js";
5
+ import { LearningDomainError } from "./errors.js";
6
+ import { readLearningIterationBudget } from "./iteration-budget.js";
7
+ import { inspectIterationEligibility, learningChainId } from "./iteration-eligibility.js";
8
+ import { LearningChainSchema, LearningEligibilityCountsSchema } from "./iteration-reservation-contracts.js";
9
+ import { requireLearningRelease, requireLearningResource } from "./repository.js";
10
+ /** A scope-serialized snapshot of shared reservation readiness, not a Job guarantee.
11
+ * No iteration, timer, batch, consumption or operation record is written. */
12
+ export const LearningPolicyInspectionResultSchema = z.object({
13
+ policy: LearningRevisionRefSchema,
14
+ modelProjectId: ReleaseIdSchema,
15
+ inspectedAt: ReleaseTimestampSchema,
16
+ chain: LearningChainSchema.nullable(),
17
+ counts: LearningEligibilityCountsSchema.nullable(),
18
+ minimumApprovedExamples: z.number().int().positive(),
19
+ canReserve: z.boolean(),
20
+ blockers: z.array(z.object({ code: z.string().min(1).max(200), message: z.string().min(1).max(20_000) }).strict()).max(20),
21
+ cooldownUntil: ReleaseTimestampSchema.nullable(),
22
+ budget: z.object({
23
+ reservedSpendUsd: z.number().nonnegative(), settledSpendUsd: z.number().nonnegative(), committedSpendUsd: z.number().nonnegative(),
24
+ maximumIterationSpendUsd: z.number().nonnegative(), maximumDailySpendUsd: z.number().nonnegative(),
25
+ }).strict(),
26
+ }).strict();
27
+ export async function inspectLearningPolicy(transaction, reference, actorId, now) {
28
+ const policy = await requireLearningRelease(transaction, "policy", reference);
29
+ const current = await requireLearningResource(transaction, "policy", policy.id);
30
+ if (!sameLearningRef(learningRef(current), reference))
31
+ throw new LearningDomainError("learning_policy_revision_stale", 409);
32
+ const chainId = learningChainId(policy.modelProjectId);
33
+ const chain = await transaction.get("chain", chainId);
34
+ const budget = await readLearningIterationBudget(transaction, chainId, now);
35
+ const blockers = [];
36
+ const block = (code, message) => { blockers.push({ code, message }); };
37
+ if (!policy.enabled)
38
+ block("learning_policy_paused", "Learning is paused.");
39
+ if (policy.automation.accept || policy.automation.serve)
40
+ block("learning_automatic_acceptance_not_available", "Candidates require explicit acceptance and serving selection.");
41
+ if (chain?.activeIterationId)
42
+ block("learning_iteration_active", "An iteration is already active.");
43
+ const cooldownUntil = chain?.lastReservedAt && Date.parse(now) - Date.parse(chain.lastReservedAt) < policy.limits.cooldownSeconds * 1_000
44
+ ? new Date(Date.parse(chain.lastReservedAt) + policy.limits.cooldownSeconds * 1_000).toISOString() : null;
45
+ if (cooldownUntil)
46
+ block("learning_iteration_cooldown", "The training cooldown has not elapsed.");
47
+ if (budget.committedSpendUsd + policy.limits.maxIterationSpendUsd > policy.limits.maxDailySpendUsd) {
48
+ block("learning_daily_budget_exhausted", "The daily budget cannot cover another iteration's maximum spend.");
49
+ }
50
+ let counts = null;
51
+ try {
52
+ const eligibility = await inspectIterationEligibility(transaction, policy);
53
+ counts = eligibility.counts;
54
+ if (counts.eligible < policy.admission.minimumApprovedExamples) {
55
+ if (counts.awaitingReview > 0)
56
+ block("learning_waiting_for_review", "More approved tasks are required before training.");
57
+ else
58
+ block("learning_waiting_for_data", "More eligible new tasks are required before training.");
59
+ }
60
+ else {
61
+ await prepareLearningBatch(transaction, {
62
+ action: "seal_batch", operationId: "policy-inspection", batchId: "policy-inspection",
63
+ taskDefinition: policy.taskDefinition, purpose: eligibility.purpose,
64
+ evidence: eligibility.selected.map(item => learningRef(item.evidence)),
65
+ decisions: eligibility.selected.map(item => learningRef(item.decision)),
66
+ }, actorId, now);
67
+ }
68
+ }
69
+ catch (error) {
70
+ if (!(error instanceof LearningDomainError))
71
+ throw error;
72
+ // An unavailable count is not zero. Invalid sources or unsupported admission
73
+ // must remain visible while the Model's settings and history can still load.
74
+ block(error.code, error.message.slice(0, 20_000));
75
+ }
76
+ return LearningPolicyInspectionResultSchema.parse({
77
+ policy: learningRef(policy), modelProjectId: policy.modelProjectId, inspectedAt: now, chain,
78
+ counts, minimumApprovedExamples: policy.admission.minimumApprovedExamples, canReserve: blockers.length === 0,
79
+ blockers, cooldownUntil,
80
+ budget: { ...budget, maximumIterationSpendUsd: policy.limits.maxIterationSpendUsd, maximumDailySpendUsd: policy.limits.maxDailySpendUsd },
81
+ });
82
+ }
@@ -1,5 +1,6 @@
1
1
  import { LearningChainSchema, LearningConsumptionSchema, LearningIterationReservationSchema } from "./iteration-reservation-contracts.js";
2
2
  import { LearningIterationDispatchSchema } from "./iteration-dispatch-contracts.js";
3
+ import { LearningScheduleSchema, LearningScheduleFireSchema } from "./schedule-contracts.js";
3
4
  import { z } from "zod";
4
5
  import { contentHash } from "@openpond/harness";
5
6
  import { assertLearningContentHash, LearningSourceSchema, TaskAdmissionDecisionSchema, TaskBatchSchema, TaskDefinitionSchema, TaskEvidenceSchema, TaskFeedbackSchema, TaskGradeRunSchema, LearningPolicySchema, LearningIterationSchema } from "./contracts.js";
@@ -18,6 +19,7 @@ export const learningResourceSchemas = {
18
19
  decision: TaskAdmissionDecisionSchema, batch: TaskBatchSchema, policy: LearningPolicySchema,
19
20
  chain: LearningChainSchema, consumption: LearningConsumptionSchema, reservation: LearningIterationReservationSchema,
20
21
  dispatch: LearningIterationDispatchSchema,
22
+ schedule: LearningScheduleSchema, schedule_fire: LearningScheduleFireSchema,
21
23
  iteration: LearningIterationSchema, grade: TaskGradeRunSchema, package: TasksetReleaseSchema,
22
24
  };
23
25
  export const LearningResourceKindSchema = z.enum(Object.keys(learningResourceSchemas));
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ import { ReleaseHashSchema, ReleaseIdSchema, ReleaseTimestampSchema } from "@openpond/harness";
3
+ import { LearningRevisionRefSchema } from "./contracts.js";
4
+ /** The durable timer is operational state; every fire retains its own policy. */
5
+ export const LearningScheduleSchema = z.object({
6
+ schemaVersion: z.literal("openpond.learningSchedule.v1"),
7
+ id: ReleaseIdSchema,
8
+ revision: z.number().int().positive(),
9
+ policy: LearningRevisionRefSchema,
10
+ modelProjectId: ReleaseIdSchema,
11
+ executionOwner: z.enum(["local", "hosted"]),
12
+ state: z.enum(["scheduled", "disabled", "blocked"]),
13
+ intervalSeconds: z.number().int().min(60).max(31_536_000).nullable(),
14
+ nextRunAt: ReleaseTimestampSchema.nullable(),
15
+ lastFire: LearningRevisionRefSchema.nullable(),
16
+ consecutiveFailures: z.number().int().nonnegative(),
17
+ nextAttemptAt: ReleaseTimestampSchema.nullable(),
18
+ lastError: z.string().max(20_000).nullable(),
19
+ createdAt: ReleaseTimestampSchema,
20
+ updatedAt: ReleaseTimestampSchema,
21
+ }).strict().superRefine((value, context) => {
22
+ if (value.state !== "disabled" && (!value.intervalSeconds || !value.nextRunAt))
23
+ context.addIssue({ code: "custom", path: ["nextRunAt"], message: "An active timer must retain its cadence and due time." });
24
+ if (value.state === "disabled" && value.nextRunAt)
25
+ context.addIssue({ code: "custom", path: ["nextRunAt"], message: "A disabled timer cannot have a pending occurrence." });
26
+ });
27
+ /** Immutable occurrence evidence survives timer edits and coalesced downtime. */
28
+ export const LearningScheduleFireSchema = z.object({
29
+ schemaVersion: z.literal("openpond.learningScheduleFire.v1"),
30
+ id: ReleaseIdSchema,
31
+ revision: z.literal(1),
32
+ scheduleId: ReleaseIdSchema,
33
+ policy: LearningRevisionRefSchema,
34
+ scheduledAt: ReleaseTimestampSchema,
35
+ coalescedThroughAt: ReleaseTimestampSchema,
36
+ coalescedCount: z.number().int().nonnegative(),
37
+ outcome: z.enum(["reserved", "waiting_for_data", "waiting_for_review", "skipped"]),
38
+ reason: z.enum(["active_iteration", "cooldown", "daily_budget"]).nullable(),
39
+ iteration: z.object({ id: ReleaseIdSchema, revision: z.number().int().positive() }).strict().nullable(),
40
+ createdAt: ReleaseTimestampSchema,
41
+ contentHash: ReleaseHashSchema,
42
+ }).strict().superRefine((value, context) => {
43
+ if ((value.outcome === "skipped") !== Boolean(value.reason) || (value.outcome !== "skipped") !== Boolean(value.iteration))
44
+ context.addIssue({ code: "custom", path: ["outcome"], message: "A fire retains either its reservation or its explicit skip reason." });
45
+ });
@@ -0,0 +1,31 @@
1
+ import { contentHash } from "@openpond/harness";
2
+ import { assertLearningContentHash, learningRef, sameLearningRef } from "./contracts.js";
3
+ import { LearningScheduleSchema } from "./schedule-contracts.js";
4
+ import { LearningDomainError } from "./errors.js";
5
+ import { requireLearningResource } from "./repository.js";
6
+ export function learningScheduleId(policyId) { return `schedule-${contentHash(policyId)}`; }
7
+ /** Called atomically with policy publication. No external timer or Work is made. */
8
+ export async function synchronizeLearningSchedule(tx, policy, now) {
9
+ assertLearningContentHash(policy);
10
+ const current = await requireLearningResource(tx, "policy", policy.id);
11
+ if (!sameLearningRef(learningRef(current), learningRef(policy)))
12
+ throw new LearningDomainError("learning_policy_revision_stale", 409);
13
+ const id = learningScheduleId(policy.id);
14
+ const previous = await tx.get("schedule", id);
15
+ if (previous && sameLearningRef(previous.policy, learningRef(policy)))
16
+ return previous;
17
+ const intervalSeconds = policy.trigger.kind === "schedule" ? policy.trigger.intervalSeconds : null;
18
+ const enabled = policy.enabled && policy.automation.train && intervalSeconds !== null;
19
+ const preserveDue = previous?.nextRunAt && previous.intervalSeconds === intervalSeconds
20
+ && previous.executionOwner === policy.executionOwner;
21
+ const schedule = LearningScheduleSchema.parse({
22
+ schemaVersion: "openpond.learningSchedule.v1", id, revision: (previous?.revision ?? 0) + 1,
23
+ policy: learningRef(policy), modelProjectId: policy.modelProjectId, executionOwner: policy.executionOwner,
24
+ state: enabled ? "scheduled" : "disabled", intervalSeconds,
25
+ nextRunAt: enabled ? preserveDue ? previous.nextRunAt : new Date(Date.parse(now) + intervalSeconds * 1_000).toISOString() : null,
26
+ lastFire: previous?.lastFire ?? null, consecutiveFailures: 0, nextAttemptAt: null, lastError: null,
27
+ createdAt: previous?.createdAt ?? now, updatedAt: now,
28
+ });
29
+ await tx.put("schedule", schedule, previous?.revision ?? 0, { parentId: policy.modelProjectId, status: schedule.state });
30
+ return schedule;
31
+ }
@@ -0,0 +1,96 @@
1
+ import { contentHash } from "@openpond/harness";
2
+ import { learningRef, sealLearningContent } from "./contracts.js";
3
+ import { LearningDomainError } from "./errors.js";
4
+ import { reserveLearningIteration } from "./iteration-reservation-service.js";
5
+ import { LearningScheduleFireSchema, LearningScheduleSchema } from "./schedule-contracts.js";
6
+ import { requireLearningRelease, requireLearningResource } from "./repository.js";
7
+ const skipReasons = new Map([
8
+ ["learning_iteration_active", "active_iteration"], ["learning_iteration_cooldown", "cooldown"],
9
+ ["learning_daily_budget_exhausted", "daily_budget"],
10
+ ]);
11
+ /** Like a durable scheduled Work fire, the occurrence and timer advance commit
12
+ * together. Here the typed target is a learning reservation, never a prompt or
13
+ * provider submission. All work inside the transaction is repository-only. */
14
+ export function createLearningScheduleWorker(repository, options) {
15
+ const now = options.now ?? (() => new Date().toISOString());
16
+ async function run(scope, scheduleId, actorId) {
17
+ let attempt = null;
18
+ try {
19
+ return await repository.transaction(scope, async (tx) => {
20
+ const schedule = await requireLearningResource(tx, "schedule", scheduleId);
21
+ if (schedule.executionOwner !== options.executionOwner)
22
+ throw new LearningDomainError("learning_execution_owner_mismatch", 409);
23
+ const timestamp = now();
24
+ const time = Date.parse(timestamp);
25
+ if (schedule.state !== "scheduled" || !schedule.nextRunAt || Date.parse(schedule.nextRunAt) > time
26
+ || (schedule.nextAttemptAt && Date.parse(schedule.nextAttemptAt) > time))
27
+ return schedule;
28
+ attempt = { schedule, maxRetries: 0 };
29
+ const policy = await requireLearningRelease(tx, "policy", schedule.policy);
30
+ attempt.maxRetries = policy.limits.maxRetries;
31
+ if (policy.executionOwner !== schedule.executionOwner || policy.trigger.kind !== "schedule"
32
+ || policy.trigger.intervalSeconds !== schedule.intervalSeconds)
33
+ throw new LearningDomainError("learning_schedule_policy_mismatch", 409);
34
+ const scheduledAt = schedule.nextRunAt;
35
+ const interval = schedule.intervalSeconds * 1_000;
36
+ const due = Date.parse(scheduledAt);
37
+ const coalescedCount = Math.floor((time - due) / interval);
38
+ const id = `schedule-fire-${contentHash([schedule.id, scheduledAt])}`;
39
+ let outcome = "skipped";
40
+ let reason = null;
41
+ let iteration = null;
42
+ try {
43
+ const pointers = await reserveLearningIteration(tx, { action: "reserve_iteration", operationId: id,
44
+ policy: schedule.policy, trigger: { kind: "schedule", scheduledAt } }, actorId, timestamp);
45
+ const pointer = pointers.find(value => value.kind === "iteration");
46
+ if (!pointer)
47
+ throw new LearningDomainError("learning_schedule_reservation_missing", 409);
48
+ const reservation = await requireLearningResource(tx, "reservation", pointer.id);
49
+ outcome = reservation.outcome;
50
+ iteration = { id: pointer.id, revision: pointer.revision };
51
+ }
52
+ catch (error) {
53
+ // These admission checks run before reservation mutations. Every other
54
+ // error must roll back the whole fire, including any partial batch.
55
+ const skip = error instanceof LearningDomainError ? skipReasons.get(error.code) : undefined;
56
+ if (!skip)
57
+ throw error;
58
+ reason = skip;
59
+ }
60
+ const fire = LearningScheduleFireSchema.parse(sealLearningContent({
61
+ schemaVersion: "openpond.learningScheduleFire.v1", id, revision: 1, scheduleId,
62
+ policy: schedule.policy, scheduledAt, coalescedThroughAt: new Date(due + coalescedCount * interval).toISOString(),
63
+ coalescedCount, outcome, reason, iteration, createdAt: timestamp,
64
+ }));
65
+ await tx.put("schedule_fire", fire, 0, { parentId: schedule.id, status: outcome });
66
+ const updated = LearningScheduleSchema.parse({ ...schedule, revision: schedule.revision + 1,
67
+ nextRunAt: new Date(due + (coalescedCount + 1) * interval).toISOString(), lastFire: learningRef(fire),
68
+ consecutiveFailures: 0, nextAttemptAt: null, lastError: null, updatedAt: timestamp });
69
+ await tx.put("schedule", updated, schedule.revision, { parentId: schedule.modelProjectId, status: updated.state });
70
+ return updated;
71
+ });
72
+ }
73
+ catch (error) {
74
+ // Failed transactions keep the original due time and exact fire identity.
75
+ // A concurrent policy edit owns its new timer; never overwrite that state.
76
+ const failed = attempt;
77
+ if (!failed)
78
+ throw error;
79
+ return repository.transaction(scope, async (tx) => {
80
+ const current = await requireLearningResource(tx, "schedule", scheduleId);
81
+ if (current.revision !== failed.schedule.revision)
82
+ return current;
83
+ const timestamp = now();
84
+ const consecutiveFailures = current.consecutiveFailures + 1;
85
+ const blocked = consecutiveFailures > failed.maxRetries;
86
+ const updated = LearningScheduleSchema.parse({ ...current, revision: current.revision + 1,
87
+ state: blocked ? "blocked" : "scheduled", consecutiveFailures,
88
+ nextAttemptAt: blocked ? null : new Date(Date.parse(timestamp) + Math.min(60_000, 1_000 * 2 ** Math.min(consecutiveFailures - 1, 10))).toISOString(),
89
+ lastError: (error instanceof Error ? error.message : String(error)).slice(0, 20_000), updatedAt: timestamp });
90
+ await tx.put("schedule", updated, current.revision, { parentId: current.modelProjectId, status: updated.state });
91
+ return updated;
92
+ });
93
+ }
94
+ }
95
+ return { run };
96
+ }
@@ -1,6 +1,8 @@
1
1
  import { cancelLearningIterationReservation } from "./iteration-reservation-cancellation.js";
2
2
  import { commandLearningIterationDispatch } from "./iteration-dispatch-commands.js";
3
3
  import { reserveLearningIteration } from "./iteration-reservation-service.js";
4
+ import { synchronizeLearningSchedule } from "./schedule-service.js";
5
+ import { inspectLearningPolicy } from "./policy-inspection.js";
4
6
  import { requireCurrentLearningEvidence as currentEvidence, sealLearningBatch } from "./batch-service.js";
5
7
  import { saveAuthoringDraft, archiveAuthoringDraft, finalizeAuthoringDraft } from "./authoring-service.js";
6
8
  import { queueRewardCheck, cancelRewardCheck } from "./reward-check-service.js";
@@ -158,6 +160,8 @@ export function createLearningService(repository, options = {}) {
158
160
  }
159
161
  }
160
162
  await transaction.put(input.kind, resource, input.expectedRevision, input.kind === "policy" ? { parentId: input.content.modelProjectId } : undefined);
163
+ if (input.kind === "policy")
164
+ await synchronizeLearningSchedule(transaction, LearningPolicySchema.parse(resource), now());
161
165
  return pointer(input.kind, resource);
162
166
  }
163
167
  async function submit(transaction, input) {
@@ -324,6 +328,11 @@ export function createLearningService(repository, options = {}) {
324
328
  }
325
329
  return {
326
330
  command,
331
+ async inspectPolicy(context, reference) {
332
+ authorizeRead(context);
333
+ const ref = LearningRevisionRefSchema.parse(reference);
334
+ return repository.transaction(context.scope, transaction => inspectLearningPolicy(transaction, ref, context.actor.id, now()));
335
+ },
327
336
  async inspectEvidence(context, reference) {
328
337
  authorizeRead(context);
329
338
  const ref = LearningRevisionRefSchema.parse(reference);
@@ -362,6 +371,10 @@ function authorizeRead(context) {
362
371
  function authorize(context, input) {
363
372
  if (!context.scope.trim() || !context.actor.id.trim())
364
373
  throw new LearningDomainError("learning_scope_required", 400);
374
+ const publications = input.action === "publish" ? [input] : input.action === "publish_resources" ? input.resources : [];
375
+ if (context.actor.role !== "reviewer" && publications.some(value => value.kind === "policy" && value.content.enabled
376
+ && value.content.automation.train && value.content.trigger.kind === "schedule"))
377
+ throw new LearningDomainError("learning_review_not_authorized", 403);
365
378
  if (context.actor.role === "source") {
366
379
  const sourceId = input.action === "submit_example" ? input.example.sourceId : input.action === "submit_feedback" ? input.feedback.sourceId : null;
367
380
  if (!sourceId || sourceId !== context.actor.sourceId)
@@ -17,6 +17,7 @@ export function assertLearningRequestJson(raw) {
17
17
  /** Scope selection is never authorization; each host resolves its authenticated owner. */
18
18
  export const LearningCommandRequestSchema = z.object({ scope: ReleaseIdSchema, command: LearningCommandSchema }).strict();
19
19
  export const LearningReadRequestSchema = z.discriminatedUnion("action", [
20
+ z.object({ action: z.literal("inspect_policy"), scope: ReleaseIdSchema, policy: LearningRevisionRefSchema }).strict(),
20
21
  z.object({ action: z.literal("inspect_evidence"), scope: ReleaseIdSchema, evidence: LearningRevisionRefSchema }).strict(),
21
22
  z.object({ action: z.literal("get"), scope: ReleaseIdSchema, kind: LearningResourceKindSchema, id: ReleaseIdSchema, revision: z.number().int().positive().optional() }).strict(),
22
23
  z.object({ action: z.literal("list"), scope: ReleaseIdSchema, kind: LearningResourceKindSchema, parentId: ReleaseIdSchema.optional(), status: z.string().min(1).max(200).optional(), afterId: ReleaseIdSchema.optional(), limit: z.number().int().min(1).max(100).default(50) }).strict(),