Run continual-learning jobs over verified licensed datasets

a548f804ef24 · Devin AI · · parent 7fd39028f8ac

Run continual-learning jobs over verified licensed datasets

One named internal buyer can now run a bounded continual-learning job whose
every input is named before a round starts. Admission resolves each training
and evaluation dataset through the licensed artifact catalog, so a job carries
the exact artifact, provenance, license, and listing digests it trained on,
plus the acceptance receipt that licensed the use. It binds the versioned
objective, the base model and its digest, the pinned training-code digest, and
the configuration digest, matches the fleet for its runtime class, and refuses
an expired license, a removed listing, an unadmitted buyer, model, or
evaluator, an unaffordable budget, or a fleet that cannot host the class.

Rounds run in the existing work lane under Horde, not a second scheduler. Each
round commits a durable checkpoint chained by state digest before the round
counter moves, so an interrupted job keeps what it proved: a resume continues
from the surviving checkpoint under the same admission digest and receipt
chain, while a replay is a new job that re-resolves every dataset and starts at
round zero, which is what makes a reproduced artifact digest evidence. A lost
checkpoint, a spent budget, or a stale license refuses the resume instead of
restarting the work silently.

A terminal artifact exists only after an admitted, independent evaluator passes
and the accepted-outcome contract grades the claim, and it binds the model,
every dataset digest, the checkpoint chain, the evaluation corpus and result,
the accepted outcome, and a settlement-ready payload that names the treasury
policy and the metered amount while stating that no custody moved here. The
job writes the catalog's delivery, verification, and settlement evidence, and
admission, usage, energy, training, evaluation, artifact, settlement, resume,
and refusal receipts export as one auditable record.

Closes #86

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>
Closes
#86

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified config/config.exs
  • modified config/runtime.exs
  • added lib/openagents/continual_learning.ex
  • added lib/openagents/continual_learning/artifact.ex
  • added lib/openagents/continual_learning/bounds.ex
  • added lib/openagents/continual_learning/checkpoint.ex
  • added lib/openagents/continual_learning/evaluator.ex
  • added lib/openagents/continual_learning/evaluator/reference.ex
  • added lib/openagents/continual_learning/job.ex
  • added lib/openagents/continual_learning/receipt.ex
  • added lib/openagents/continual_learning/runner.ex
  • added lib/openagents/continual_learning/trainer.ex
  • added lib/openagents/continual_learning/trainer/reference.ex
  • modified lib/openagents/work.ex
  • added lib/openagents/work/continual_learning_server.ex
  • modified lib/openagents/work/job.ex
  • added lib/openagents_web/controllers/continual_learning_controller.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823074000_create_continual_learning_jobs.exs
  • added test/openagents/continual_learning_test.exs
  • added test/openagents_web/controllers/continual_learning_controller_test.exs
  • added test/support/continual_learning/failing_evaluator.ex
  • added test/support/continual_learning/failing_grade_evaluator.ex
  • added test/support/continual_learning/failing_trainer.ex
  • added test/support/continual_learning/foreign_evaluator.ex
  • added test/support/continual_learning/gated_trainer.ex
  • added test/support/continual_learning/observer.ex
  • added test/support/fixtures/continual_learning_fixtures.ex

Diff

30 files changed, +3944 -2

config/config.exs modified +24

@@ -385,6 +385,30 @@ config :openagents, OpenAgents.Capacity,

385 385
  },
386 386
  buyer: nil
387 387
388
# Continual learning is off until an operator admits the named buyer, the base
389
# models, and the exact training code digest. The lane refuses rather than
390
# training on data or a model nobody admitted.
391
config :openagents, OpenAgents.ContinualLearning,
392
  enabled: false,
393
  buyer_ref: nil,
394
  buyer_class: "openagents_training",
395
  runtime_classes: ["standard", "strong"],
396
  admitted_base_models: %{},
397
  admitted_custody: ["openagents_managed"],
398
  maximum_rounds: 8,
399
  maximum_datasets: 4,
400
  wall_clock_ms: 900_000,
401
  maximum_state_bytes: 65_536,
402
  concurrency_limit: 1,
403
  training_code_digest: nil,
404
  trainer: OpenAgents.ContinualLearning.Trainer.Reference,
405
  evaluator: OpenAgents.ContinualLearning.Evaluator.Reference,
406
  class_watts: %{"standard" => 350, "strong" => 700, "batch" => 250},
407
  round_cost_usd_cents: %{"standard" => 2, "strong" => 4, "batch" => 1},
408
  settlement_unit: "usd_cents",
409
  outcome_repository: "OpenAgentsInc/openagents.com",
410
  outcome_issue_number: 86
411
388 412
# Import environment specific config. This must remain at the bottom
389 413
# of this file so it overrides the configuration defined above.
390 414
import_config "#{config_env()}.exs"
config/runtime.exs modified +30

@@ -117,6 +117,36 @@ config :openagents,

117 117
         buyer: capacity_buyer
118 118
       )
119 119
120
continual_learning_config = Application.get_env(:openagents, OpenAgents.ContinualLearning, [])
121
122
# The admitted base models are a JSON object of model reference to exact model
123
# digest. A model that is not listed here cannot be trained from, so widening
124
# the lane is an explicit operator act.
125
admitted_base_models =
126
  case optional_text.("OPENAGENTS_CONTINUAL_LEARNING_BASE_MODELS_JSON") do
127
    nil ->
128
      Keyword.get(continual_learning_config, :admitted_base_models, %{})
129
130
    encoded ->
131
      case Jason.decode(encoded) do
132
        {:ok, models} when is_map(models) ->
133
          models
134
135
        _invalid ->
136
          raise "environment variable OPENAGENTS_CONTINUAL_LEARNING_BASE_MODELS_JSON must be a JSON object"
137
      end
138
  end
139
140
config :openagents,
141
       OpenAgents.ContinualLearning,
142
       Keyword.merge(continual_learning_config,
143
         enabled: System.get_env("OPENAGENTS_CONTINUAL_LEARNING_ENABLED") == "true",
144
         buyer_ref: optional_text.("OPENAGENTS_CONTINUAL_LEARNING_BUYER_REF"),
145
         training_code_digest:
146
           optional_text.("OPENAGENTS_CONTINUAL_LEARNING_TRAINING_CODE_DIGEST"),
147
         admitted_base_models: admitted_base_models
148
       )
149
120 150
if config_env() == :dev do
121 151
  config :openagents, :openai_api_key, optional_text.("OPENAI_API_KEY")
122 152
  config :openagents, :openrouter_api_key, optional_text.("OPENROUTER_API_KEY")
lib/openagents/continual_learning.ex added +1144

@@ -0,0 +1,1144 @@

1
defmodule OpenAgents.ContinualLearning do
2
  @moduledoc """
3
  Bounded continual-learning jobs over verified licensed datasets.
4
5
  One named internal buyer starts a job that names a versioned objective, an
6
  admitted base model, exact licensed dataset references, an evaluation corpus,
7
  a budget, a runtime class, and a stopping policy. Admission resolves every
8
  dataset through `OpenAgents.ArtifactCatalog`, so a job holds the exact
9
  artifact, provenance, license, and listing digests it trained on, and a
10
  removed listing, an expired license, a license that does not admit training,
11
  or a buyer class the listing was not licensed to refuses before any capacity
12
  is spent. Fleet admission is `OpenAgents.Capacity.match/2`: this lane adds no
13
  second scheduler, and the run itself is an ordinary `work_jobs` row of kind
14
  `continual_learning` driven by `OpenAgents.Work.ContinualLearningServer`.
15
16
  Every round writes a durable checkpoint before it is counted, so resume and
17
  replay are different acts: a resume continues the surviving checkpoint chain
18
  under the same admission digest, and a replay is a new job that starts from
19
  round zero. A lost checkpoint refuses the resume instead of retraining
20
  silently.
21
22
  Evaluation is graded through `OpenAgents.AcceptedOutcome`, under the admitted
23
  evaluator policy, so a failed, unevidenced, or non-independent evaluation
24
  cannot produce a qualified artifact. A qualified artifact binds the exact base
25
  model, dataset, code, configuration, checkpoint, and evaluation digests, and
26
  the job's settlement-ready receipt names the buyer, the unit, the amount, and
27
  the treasury policy without moving money.
28
29
  See `INVARIANTS.md`, CONTINUAL-001.
30
  """
31
32
  import Ecto.Query
33
34
  alias OpenAgents.AcceptedOutcome
35
  alias OpenAgents.Accounts
36
  alias OpenAgents.Accounts.User
37
  alias OpenAgents.ArtifactCatalog
38
  alias OpenAgents.Capacity
39
  alias OpenAgents.ContinualLearning.Artifact
40
  alias OpenAgents.ContinualLearning.Bounds
41
  alias OpenAgents.ContinualLearning.Checkpoint
42
  alias OpenAgents.ContinualLearning.Job
43
  alias OpenAgents.ContinualLearning.Receipt
44
  alias OpenAgents.Provenance.Canonical
45
  alias OpenAgents.Repo
46
  alias OpenAgents.Settlement
47
  alias OpenAgents.Work
48
49
  @active_statuses ~w(queued running)
50
  @evaluation_purpose "evaluation"
51
  @training_purpose "delivery"
52
53
  # ── admission ──────────────────────────────────────────────────────────────
54
55
  @doc """
56
  Admits and starts one continual-learning job for the named buyer.
57
58
  Returns `{:ok, job}` with a queued job whose run is already supervised, or a
59
  typed refusal.
60
  """
61
  @spec start(User.t(), map()) :: {:ok, Job.t()} | {:error, term()}
62
  def start(%User{} = user, attributes) when is_map(attributes) do
63
    with :ok <- feature_enabled(),
64
         :ok <- operator(user),
65
         {:ok, buyer_ref} <- buyer_ref(attributes),
66
         {:ok, buyer_class} <- buyer_class(),
67
         {:ok, objective} <- objective(attributes),
68
         {:ok, objective_version} <- objective_version(attributes),
69
         {:ok, base_model} <- base_model(attributes),
70
         {:ok, training_code_digest} <- training_code_digest(),
71
         {:ok, configuration} <- configuration(attributes),
72
         {:ok, runtime_class} <- runtime_class(attributes),
73
         {:ok, conversation_id} <- identifier(attributes, :conversation_id),
74
         {:ok, owner_visitor_id} <- identifier(attributes, :owner_visitor_id),
75
         {:ok, datasets} <- datasets(attributes, buyer_ref, buyer_class, runtime_class),
76
         {:ok, evaluation} <- evaluation(attributes, buyer_ref, buyer_class, runtime_class),
77
         {:ok, budget} <- budget(attributes),
78
         {:ok, stopping_policy} <- stopping_policy(attributes),
79
         :ok <- concurrency(),
80
         {:ok, capacity_receipt} <- capacity(user, runtime_class, budget, stopping_policy) do
81
      admission = %{
82
        buyer_ref: buyer_ref,
83
        buyer_class: buyer_class,
84
        objective: objective,
85
        objective_version: objective_version,
86
        base_model_ref: base_model.ref,
87
        base_model_digest: base_model.digest,
88
        training_code_digest: training_code_digest,
89
        configuration: configuration,
90
        configuration_digest: Canonical.digest!(configuration),
91
        datasets: datasets,
92
        evaluation: evaluation,
93
        budget: budget,
94
        runtime_class: runtime_class,
95
        capacity_receipt: capacity_receipt,
96
        stopping_policy: stopping_policy,
97
        replay_of_id: Map.get(attributes, :replay_of_id)
98
      }
99
100
      with {:ok, job} <- insert_job(admission),
101
           {:ok, started} <-
102
             launch(job, conversation_id, owner_visitor_id, "admission") do
103
        {:ok, started}
104
      end
105
    end
106
  end
107
108
  def start(_user, _attributes), do: {:error, :operator_required}
109
110
  @doc "One job the buyer may read, or a typed refusal."
111
  @spec get(User.t(), String.t()) :: {:ok, Job.t()} | {:error, term()}
112
  def get(%User{} = user, id) when is_binary(id) do
113
    with :ok <- operator(user), do: fetch(id)
114
  end
115
116
  def get(_user, _id), do: {:error, :operator_required}
117
118
  @doc "The buyer's most recent jobs, newest first, bounded."
119
  @spec list(User.t(), pos_integer()) :: {:ok, [Job.t()]} | {:error, term()}
120
  def list(%User{} = user, limit \\ 50) do
121
    with :ok <- operator(user) do
122
      bounded = min(max(limit, 1), 200)
123
124
      buyer_ref = Bounds.buyer_ref()
125
126
      {:ok,
127
       Job
128
       |> where([job], job.buyer_ref == ^buyer_ref)
129
       |> order_by([job], desc: job.inserted_at)
130
       |> limit(^bounded)
131
       |> Repo.all()}
132
    end
133
  end
134
135
  # ── lifecycle ──────────────────────────────────────────────────────────────
136
137
  @doc """
138
  Cancels one active job.
139
140
  The durable row reaches `cancelled` here, so the round loop stops at its next
141
  boundary even when the worker is already gone.
142
  """
143
  @spec cancel(User.t(), String.t()) :: {:ok, Job.t()} | {:error, term()}
144
  def cancel(%User{} = user, id) when is_binary(id) do
145
    with :ok <- operator(user),
146
         {:ok, job} <- fetch(id) do
147
      cond do
148
        job.status == "cancelled" ->
149
          {:ok, job}
150
151
        Job.terminal?(job) ->
152
          {:error, :not_cancellable}
153
154
        true ->
155
          if job.work_job_id, do: Work.cancel_job(job.work_job_id)
156
          terminalize(job, "cancelled", "cancelled")
157
      end
158
    end
159
  end
160
161
  def cancel(_user, _id), do: {:error, :operator_required}
162
163
  @doc """
164
  Resumes one interrupted or budget-exhausted job from its surviving checkpoint.
165
166
  A resume is not a replay: the job keeps its admission digest, its receipt
167
  chain, and its checkpoints, and continues at the next round. It refuses when
168
  the checkpoint is gone, when a dataset's license no longer admits the job, or
169
  when the fleet cannot admit the runtime class again.
170
  """
171
  @spec resume(User.t(), String.t(), map()) :: {:ok, Job.t()} | {:error, term()}
172
  def resume(%User{} = user, id, attributes \\ %{}) do
173
    with :ok <- feature_enabled(),
174
         :ok <- operator(user),
175
         {:ok, job} <- fetch(id),
176
         :ok <- resumable(job),
177
         {:ok, checkpoint} <- surviving_checkpoint(job),
178
         :ok <- rounds_remaining(job),
179
         :ok <- budget_remaining(job),
180
         :ok <- reverify_datasets(job),
181
         {:ok, capacity_receipt} <-
182
           capacity(user, job.runtime_class, job.budget, job.stopping_policy),
183
         {:ok, conversation_id, owner_visitor_id} <- previous_surface(job, attributes),
184
         {:ok, resumed} <- mark_resumed(job, checkpoint, capacity_receipt) do
185
      launch(resumed, conversation_id, owner_visitor_id, "resume")
186
    end
187
  end
188
189
  @doc """
190
  Replays one job as a new job under the same admitted inputs.
191
192
  A replay re-resolves every licensed dataset and the fleet again, starts at
193
  round zero, and records the job it replays, so a reproducibility check never
194
  reuses the original job's checkpoints.
195
  """
196
  @spec replay(User.t(), String.t(), map()) :: {:ok, Job.t()} | {:error, term()}
197
  def replay(%User{} = user, id, attributes) when is_map(attributes) do
198
    with :ok <- operator(user),
199
         {:ok, job} <- fetch(id),
200
         {:ok, conversation_id, owner_visitor_id} <- previous_surface(job, attributes) do
201
      start(
202
        user,
203
        replay_attributes(job, %{
204
          conversation_id: conversation_id,
205
          owner_visitor_id: owner_visitor_id
206
        })
207
      )
208
    end
209
  end
210
211
  @doc "Records one append-only receipt for a job."
212
  @spec record_receipt(Job.t(), String.t(), map()) :: {:ok, Receipt.t()} | {:error, term()}
213
  def record_receipt(%Job{} = job, kind, payload) when is_binary(kind) and is_map(payload) do
214
    sequence = Repo.aggregate(from(r in Receipt, where: r.job_id == ^job.id), :count) + 1
215
    body = Map.put(payload, "recorded_at", DateTime.to_iso8601(DateTime.utc_now()))
216
217
    %Receipt{job_id: job.id}
218
    |> Receipt.changeset(%{
219
      kind: kind,
220
      sequence: sequence,
221
      receipt_ref: "continual-learning-#{kind}:#{job.id}:#{sequence}",
222
      payload: body,
223
      digest: Canonical.digest!(Map.put(body, "job_id", job.id))
224
    })
225
    |> Repo.insert()
226
  end
227
228
  @doc "The bounded evidence export for one job."
229
  @spec export_evidence(User.t(), String.t()) :: {:ok, map()} | {:error, term()}
230
  def export_evidence(%User{} = user, id) when is_binary(id) do
231
    with :ok <- operator(user),
232
         {:ok, job} <- fetch(id) do
233
      {:ok,
234
       %{
235
         "schema" => "openagents.continual_learning_evidence.v1",
236
         "exported_at" => DateTime.utc_now(),
237
         "job" => projection(job),
238
         "checkpoints" => Enum.map(checkpoints(job), &checkpoint_projection/1),
239
         "receipts" => Enum.map(receipts(job), &receipt_projection/1),
240
         "artifact" => artifact_projection(artifact(job))
241
       }}
242
    end
243
  end
244
245
  @doc "The ordered checkpoint chain of one job."
246
  @spec checkpoints(Job.t()) :: [Checkpoint.t()]
247
  def checkpoints(%Job{} = job) do
248
    Checkpoint
249
    |> where([checkpoint], checkpoint.job_id == ^job.id)
250
    |> order_by([checkpoint], asc: checkpoint.round)
251
    |> Repo.all()
252
  end
253
254
  @doc "The ordered receipts of one job."
255
  @spec receipts(Job.t()) :: [Receipt.t()]
256
  def receipts(%Job{} = job) do
257
    Receipt
258
    |> where([receipt], receipt.job_id == ^job.id)
259
    |> order_by([receipt], asc: receipt.sequence)
260
    |> Repo.all()
261
  end
262
263
  @doc "The terminal artifact of one job, or `nil`."
264
  @spec artifact(Job.t()) :: Artifact.t() | nil
265
  def artifact(%Job{} = job), do: Repo.get_by(Artifact, job_id: job.id)
266
267
  @doc "The latest checkpoint of one job, or `nil`."
268
  @spec latest_checkpoint(Job.t()) :: Checkpoint.t() | nil
269
  def latest_checkpoint(%Job{} = job) do
270
    Checkpoint
271
    |> where([checkpoint], checkpoint.job_id == ^job.id)
272
    |> order_by([checkpoint], desc: checkpoint.round)
273
    |> limit(1)
274
    |> Repo.one()
275
  end
276
277
  @doc "How many continual-learning jobs are queued or running right now."
278
  @spec active_count() :: non_neg_integer()
279
  def active_count do
280
    Repo.aggregate(from(job in Job, where: job.status in ^@active_statuses), :count)
281
  end
282
283
  @doc "Reloads one job by id."
284
  @spec fetch(String.t()) :: {:ok, Job.t()} | {:error, :not_found}
285
  def fetch(id) when is_binary(id) do
286
    case Ecto.UUID.cast(id) do
287
      {:ok, uuid} ->
288
        case Repo.get(Job, uuid) do
289
          nil -> {:error, :not_found}
290
          job -> {:ok, job}
291
        end
292
293
      :error ->
294
        {:error, :not_found}
295
    end
296
  end
297
298
  @doc "Moves a job's lifecycle fields."
299
  @spec update_lifecycle(Job.t(), map()) :: {:ok, Job.t()} | {:error, term()}
300
  def update_lifecycle(%Job{} = job, attributes) when is_map(attributes) do
301
    job
302
    |> Job.lifecycle_changeset(attributes)
303
    |> Repo.update()
304
  end
305
306
  @doc """
307
  Terminalizes a job once. An already-terminal job is returned unchanged, so a
308
  cancel racing the round loop cannot rewrite the first terminal state.
309
  """
310
  @spec terminalize(Job.t(), String.t(), String.t() | nil) :: {:ok, Job.t()} | {:error, term()}
311
  def terminalize(%Job{} = job, status, error_code) do
312
    Repo.transaction(fn ->
313
      locked =
314
        Job
315
        |> where([row], row.id == ^job.id)
316
        |> lock("FOR UPDATE")
317
        |> Repo.one()
318
319
      cond do
320
        is_nil(locked) ->
321
          Repo.rollback(:not_found)
322
323
        Job.terminal?(locked) ->
324
          locked
325
326
        true ->
327
          locked
328
          |> Job.lifecycle_changeset(%{
329
            status: status,
330
            error_code: error_code,
331
            completed_at: DateTime.utc_now()
332
          })
333
          |> Repo.update()
334
          |> case do
335
            {:ok, updated} -> updated
336
            {:error, reason} -> Repo.rollback(reason)
337
          end
338
      end
339
    end)
340
  end
341
342
  @doc "The public projection of one job."
343
  @spec projection(Job.t()) :: map()
344
  def projection(%Job{} = job) do
345
    %{
346
      "id" => job.id,
347
      "buyer_ref" => job.buyer_ref,
348
      "buyer_class" => job.buyer_class,
349
      "objective" => job.objective,
350
      "objective_version" => job.objective_version,
351
      "base_model_ref" => job.base_model_ref,
352
      "base_model_digest" => job.base_model_digest,
353
      "training_code_digest" => job.training_code_digest,
354
      "configuration_digest" => job.configuration_digest,
355
      "datasets" => job.datasets,
356
      "evaluation" => job.evaluation,
357
      "budget" => job.budget,
358
      "runtime_class" => job.runtime_class,
359
      "capacity_receipt" => job.capacity_receipt,
360
      "stopping_policy" => job.stopping_policy,
361
      "admission_digest" => job.admission_digest,
362
      "status" => job.status,
363
      "error_code" => job.error_code,
364
      "rounds_completed" => job.rounds_completed,
365
      "resume_count" => job.resume_count,
366
      "usage" => job.usage,
367
      "work_job_id" => job.work_job_id,
368
      "replay_of_id" => job.replay_of_id,
369
      "started_at" => job.started_at,
370
      "completed_at" => job.completed_at
371
    }
372
  end
373
374
  # ── dataset admission ──────────────────────────────────────────────────────
375
376
  @doc """
377
  Resolves one licensed dataset reference into its exact binding.
378
379
  The listing must be available, licensed to the job's buyer class, licensed
380
  for the requested use, and licensed for the custody the runtime class
381
  provides, and the buyer must already hold an admitted acceptance receipt.
382
  """
383
  @spec bind_dataset(map(), String.t(), String.t(), String.t(), String.t()) ::
384
          {:ok, map()} | {:error, term()}
385
  def bind_dataset(reference, purpose, buyer_ref, buyer_class, runtime_class)
386
      when is_map(reference) do
387
    with {:ok, listing_id} <- reference_field(reference, "listing_id"),
388
         {:ok, acceptance_ref} <- reference_field(reference, "acceptance_ref"),
389
         {:ok, access} <- authorize(listing_id, purpose, buyer_ref, acceptance_ref),
390
         {:ok, listing} <- available_listing(listing_id),
391
         :ok <- licensed_buyer_class(listing, buyer_class),
392
         :ok <- licensed_use(listing, purpose),
393
         :ok <- licensed_custody(listing, runtime_class) do
394
      {:ok,
395
       %{
396
         "listing_id" => listing.id,
397
         "acceptance_ref" => acceptance_ref,
398
         "purpose" => purpose,
399
         "source_ref_digest" => Canonical.sha256(access.source_ref),
400
         "artifact_digest" => listing.artifact_digest,
401
         "provenance_digest" => listing.provenance_digest,
402
         "license_digest" => listing.license_digest,
403
         "listing_digest" => listing.listing_digest,
404
         "license_contract_ref" => listing.license_contract_ref,
405
         "license_expires_at" => DateTime.to_iso8601(listing.license_expires_at),
406
         "record_count" => listing.record_count
407
       }}
408
    end
409
  end
410
411
  defp datasets(attributes, buyer_ref, buyer_class, runtime_class) do
412
    references = Map.get(attributes, :datasets)
413
414
    cond do
415
      not is_list(references) or references == [] ->
416
        {:error, :datasets_required}
417
418
      length(references) > Bounds.maximum_datasets() ->
419
        {:error, :too_many_datasets}
420
421
      true ->
422
        bind_all(references, @training_purpose, buyer_ref, buyer_class, runtime_class)
423
    end
424
  end
425
426
  defp bind_all(references, purpose, buyer_ref, buyer_class, runtime_class) do
427
    Enum.reduce_while(references, {:ok, []}, fn reference, {:ok, bound} ->
428
      case bind_dataset(reference, purpose, buyer_ref, buyer_class, runtime_class) do
429
        {:ok, binding} -> {:cont, {:ok, bound ++ [binding]}}
430
        {:error, reason} -> {:halt, {:error, reason}}
431
      end
432
    end)
433
  end
434
435
  defp authorize(listing_id, purpose, buyer_ref, acceptance_ref) do
436
    case ArtifactCatalog.authorize_source_access(listing_id, %{
437
           purpose: purpose,
438
           buyer_ref: buyer_ref,
439
           acceptance_ref: acceptance_ref
440
         }) do
441
      {:ok, access} ->
442
        {:ok, access}
443
444
      # The catalog distinguishes a listing that is gone from one whose license
445
      # window closed, and the refusal has to keep that distinction.
446
      {:error, reason} when reason in [:not_found, :listing_removed, :stale_license] ->
447
        {:error, {:dataset_unavailable, reason}}
448
449
      {:error, reason} ->
450
        {:error, {:dataset_not_authorized, reason}}
451
    end
452
  end
453
454
  defp available_listing(listing_id) do
455
    case ArtifactCatalog.get_public_listing(listing_id) do
456
      {:ok, listing} -> {:ok, listing}
457
      {:error, reason} -> {:error, {:dataset_unavailable, reason}}
458
    end
459
  end
460
461
  defp licensed_buyer_class(listing, buyer_class) do
462
    if listing.buyer_class == buyer_class,
463
      do: :ok,
464
      else: {:error, {:dataset_buyer_class_mismatch, listing.id}}
465
  end
466
467
  defp licensed_use(listing, purpose) do
468
    terms = listing.license_terms || %{}
469
    allowed = List.wrap(terms["allowed_uses"])
470
    use_name = if purpose == @evaluation_purpose, do: "evaluation", else: "training"
471
472
    cond do
473
      terms["opt_in"] != true -> {:error, {:consent_missing, listing.id}}
474
      use_name not in allowed -> {:error, {:use_not_licensed, listing.id, use_name}}
475
      true -> :ok
476
    end
477
  end
478
479
  defp licensed_custody(listing, runtime_class) do
480
    location = data_location(runtime_class)
481
    licensed = List.wrap((listing.license_terms || %{})["data_locations"])
482
483
    cond do
484
      location not in Bounds.admitted_custody() ->
485
        {:error, {:unsupported_custody, location}}
486
487
      licensed != [] and location not in licensed ->
488
        {:error, {:unsupported_custody, listing.id}}
489
490
      true ->
491
        :ok
492
    end
493
  end
494
495
  defp reverify_datasets(%Job{} = job) do
496
    bindings = job.datasets ++ List.wrap(get_in(job.evaluation, ["corpus"]))
497
498
    Enum.reduce_while(bindings, :ok, fn binding, :ok ->
499
      case bind_dataset(
500
             binding,
501
             binding["purpose"],
502
             job.buyer_ref,
503
             job.buyer_class,
504
             job.runtime_class
505
           ) do
506
        {:ok, rebound} ->
507
          if rebound["license_digest"] == binding["license_digest"] and
508
               rebound["artifact_digest"] == binding["artifact_digest"] do
509
            {:cont, :ok}
510
          else
511
            {:halt, {:error, {:dataset_moved, binding["listing_id"]}}}
512
          end
513
514
        {:error, reason} ->
515
          {:halt, {:error, reason}}
516
      end
517
    end)
518
  end
519
520
  # ── evaluation admission ───────────────────────────────────────────────────
521
522
  defp evaluation(attributes, buyer_ref, buyer_class, runtime_class) do
523
    case Map.get(attributes, :evaluation) do
524
      evaluation when is_map(evaluation) ->
525
        admit_evaluation(evaluation, buyer_ref, buyer_class, runtime_class)
526
527
      _missing ->
528
        {:error, :evaluation_required}
529
    end
530
  end
531
532
  defp admit_evaluation(evaluation, buyer_ref, buyer_class, runtime_class) do
533
    with {:ok, corpus} <-
534
           corpus(evaluation, buyer_ref, buyer_class, runtime_class),
535
         {:ok, verifier} <- verifier(evaluation),
536
         {:ok, criteria} <- acceptance_criteria(evaluation),
537
         {:ok, target} <- target_metric(evaluation) do
538
      {:ok,
539
       %{
540
         "corpus" => corpus,
541
         "corpus_digest" => Canonical.digest!(Enum.map(corpus, & &1["artifact_digest"])),
542
         "verifier" => verifier,
543
         "separation_required" => evaluation[:separation_required] == true,
544
         "acceptance_criteria" => criteria,
545
         "target_metric" => target.metric,
546
         "target_value" => target.value,
547
         "policy_version" => Map.get(evaluation, :policy_version, 1)
548
       }}
549
    end
550
  end
551
552
  defp corpus(evaluation, buyer_ref, buyer_class, runtime_class) do
553
    references = Map.get(evaluation, :corpus)
554
555
    cond do
556
      not is_list(references) or references == [] ->
557
        {:error, :evaluation_corpus_required}
558
559
      length(references) > Bounds.maximum_datasets() ->
560
        {:error, :too_many_datasets}
561
562
      true ->
563
        bind_all(references, @evaluation_purpose, buyer_ref, buyer_class, runtime_class)
564
    end
565
  end
566
567
  defp verifier(evaluation) do
568
    verifier = Map.get(evaluation, :verifier)
569
    separation = evaluation[:separation_required] == true
570
571
    cond do
572
      not is_map(verifier) or not is_binary(verifier[:id]) ->
573
        {:error, :evaluator_required}
574
575
      verifier[:admitted] != true ->
576
        {:error, :evaluator_not_admitted}
577
578
      separation and verifier[:independent_of_producer] != true ->
579
        {:error, :evaluator_not_independent}
580
581
      true ->
582
        {:ok,
583
         %{
584
           "id" => verifier[:id],
585
           "admitted" => true,
586
           "independent_of_producer" => verifier[:independent_of_producer] == true,
587
           "policy_digest" => Canonical.digest!(%{"verifier" => verifier[:id]})
588
         }}
589
    end
590
  end
591
592
  defp acceptance_criteria(evaluation) do
593
    criteria = List.wrap(Map.get(evaluation, :acceptance_criteria))
594
595
    if criteria != [] and Enum.all?(criteria, &(is_binary(&1) and String.trim(&1) != "")) do
596
      {:ok, criteria}
597
    else
598
      {:error, :acceptance_criteria_required}
599
    end
600
  end
601
602
  defp target_metric(evaluation) do
603
    metric = Map.get(evaluation, :target_metric)
604
    value = Map.get(evaluation, :target_value)
605
606
    if is_binary(metric) and metric != "" and is_number(value) do
607
      {:ok, %{metric: metric, value: value}}
608
    else
609
      {:error, :evaluation_target_required}
610
    end
611
  end
612
613
  # ── other admission checks ─────────────────────────────────────────────────
614
615
  defp feature_enabled do
616
    if Bounds.enabled?(), do: :ok, else: {:error, :continual_learning_disabled}
617
  end
618
619
  defp operator(user) do
620
    if Accounts.admin?(user), do: :ok, else: {:error, :operator_required}
621
  end
622
623
  defp buyer_ref(attributes) do
624
    admitted = Bounds.buyer_ref()
625
    requested = Map.get(attributes, :buyer_ref)
626
627
    cond do
628
      not is_binary(admitted) or admitted == "" -> {:error, :buyer_not_configured}
629
      requested != admitted -> {:error, :buyer_not_admitted}
630
      true -> {:ok, admitted}
631
    end
632
  end
633
634
  defp buyer_class do
635
    case Bounds.buyer_class() do
636
      value when is_binary(value) and value != "" -> {:ok, value}
637
      _missing -> {:error, :buyer_not_configured}
638
    end
639
  end
640
641
  defp objective(attributes) do
642
    case Map.get(attributes, :objective) do
643
      value when is_binary(value) ->
644
        trimmed = String.trim(value)
645
646
        if trimmed != "" and byte_size(trimmed) <= 2_000,
647
          do: {:ok, trimmed},
648
          else: {:error, :objective_invalid}
649
650
      _missing ->
651
        {:error, :objective_invalid}
652
    end
653
  end
654
655
  defp objective_version(attributes) do
656
    case Map.get(attributes, :objective_version) do
657
      value when is_integer(value) and value > 0 -> {:ok, value}
658
      _invalid -> {:error, :objective_version_invalid}
659
    end
660
  end
661
662
  defp base_model(attributes) do
663
    admitted = Bounds.admitted_base_models()
664
    requested = Map.get(attributes, :base_model_ref)
665
    digest = Map.get(attributes, :base_model_digest)
666
667
    case Map.fetch(admitted, requested) do
668
      {:ok, admitted_digest} when is_binary(digest) and digest != admitted_digest ->
669
        {:error, :base_model_digest_mismatch}
670
671
      {:ok, admitted_digest} ->
672
        {:ok, %{ref: requested, digest: admitted_digest}}
673
674
      :error ->
675
        {:error, :base_model_not_admitted}
676
    end
677
  end
678
679
  defp training_code_digest do
680
    case Bounds.training_code_digest() do
681
      value when is_binary(value) -> {:ok, value}
682
      _missing -> {:error, :training_code_not_pinned}
683
    end
684
  end
685
686
  defp configuration(attributes) do
687
    case Map.get(attributes, :configuration, %{}) do
688
      value when is_map(value) ->
689
        if byte_size(Jason.encode!(value)) <= 8_192,
690
          do: {:ok, value},
691
          else: {:error, :configuration_too_large}
692
693
      _invalid ->
694
        {:error, :configuration_invalid}
695
    end
696
  end
697
698
  defp runtime_class(attributes) do
699
    requested = Map.get(attributes, :runtime_class)
700
701
    if is_binary(requested) and requested in Bounds.runtime_classes(),
702
      do: {:ok, requested},
703
      else: {:error, :runtime_class_not_admitted}
704
  end
705
706
  defp budget(attributes) do
707
    case Map.get(attributes, :budget) do
708
      %{} = budget ->
709
        amount = budget[:usd_cents] || budget["usd_cents"]
710
711
        if is_integer(amount) and amount > 0,
712
          do: {:ok, %{"unit" => "usd_cents", "amount" => amount}},
713
          else: {:error, :budget_invalid}
714
715
      _missing ->
716
        {:error, :budget_invalid}
717
    end
718
  end
719
720
  defp stopping_policy(attributes) do
721
    policy = Map.get(attributes, :stopping_policy)
722
    rounds = is_map(policy) && (policy[:maximum_rounds] || policy["maximum_rounds"])
723
724
    cond do
725
      not is_map(policy) ->
726
        {:error, :stopping_policy_required}
727
728
      not (is_integer(rounds) and rounds > 0) ->
729
        {:error, :stopping_policy_required}
730
731
      rounds > Bounds.maximum_rounds() ->
732
        {:error, :stopping_policy_exceeds_bound}
733
734
      true ->
735
        minimum_improvement = policy[:minimum_improvement] || policy["minimum_improvement"] || 0.0
736
737
        {:ok,
738
         %{
739
           "maximum_rounds" => rounds,
740
           "minimum_improvement" => minimum_improvement,
741
           "wall_clock_ms" => Bounds.wall_clock_ms()
742
         }}
743
    end
744
  end
745
746
  defp concurrency do
747
    if active_count() < Bounds.concurrency_limit(),
748
      do: :ok,
749
      else: {:error, :continual_learning_at_capacity}
750
  end
751
752
  defp capacity(user, runtime_class, budget, stopping_policy) do
753
    requirement = %{
754
      "quantity" => 1,
755
      "isolation" => isolation(runtime_class),
756
      "egress" => "policy_broker",
757
      "data_location" => data_location(runtime_class),
758
      "target" => "openagents_managed",
759
      "tools" => ["shell"],
760
      "duration_seconds" => duration_seconds(stopping_policy),
761
      "budget" => %{"currency" => "usd_cents", "amount" => budget["amount"]}
762
    }
763
764
    case Capacity.match(user, requirement) do
765
      {:ok, match} ->
766
        candidate = Enum.find(match["candidates"], &(&1["class"] == runtime_class))
767
768
        if candidate do
769
          {:ok,
770
           %{
771
             "schema" => match["schema"],
772
             "matched_at" => match["generated_at"],
773
             "requirement" => match["requirement"],
774
             "class" => candidate["class"],
775
             "rank" => candidate["rank"],
776
             "evidence" => candidate["evidence"],
777
             "estimate" => candidate["estimate"]
778
           }}
779
        else
780
          {:error, {:capacity_unavailable, runtime_class}}
781
        end
782
783
      {:error, %{"error" => %{"code" => code}}} ->
784
        {:error, {:capacity_unavailable, code}}
785
    end
786
  end
787
788
  defp isolation("strong"), do: "managed_strong"
789
  defp isolation(_class), do: "managed_standard"
790
791
  defp data_location(_class), do: "openagents_managed"
792
793
  defp duration_seconds(stopping_policy) do
794
    stopping_policy
795
    |> Map.get("wall_clock_ms", Bounds.wall_clock_ms())
796
    |> div(1_000)
797
    |> max(1)
798
  end
799
800
  defp identifier(attributes, key) do
801
    case Map.get(attributes, key) do
802
      value when is_binary(value) and value != "" -> {:ok, value}
803
      _missing -> {:error, :"#{key}_required"}
804
    end
805
  end
806
807
  # A dataset reference arrives either from the JSON API (string keys) or from
808
  # an internal caller (atom keys); both name the same admitted listing.
809
  defp reference_field(reference, "listing_id"),
810
    do: reference_value(reference, "listing_id", :listing_id)
811
812
  defp reference_field(reference, "acceptance_ref"),
813
    do: reference_value(reference, "acceptance_ref", :acceptance_ref)
814
815
  defp reference_value(reference, string_key, atom_key) do
816
    case Map.get(reference, string_key) || Map.get(reference, atom_key) do
817
      value when is_binary(value) and value != "" -> {:ok, value}
818
      _missing -> {:error, {:dataset_reference_invalid, string_key}}
819
    end
820
  end
821
822
  # ── insertion and launch ───────────────────────────────────────────────────
823
824
  defp insert_job(admission) do
825
    digest =
826
      Canonical.digest!(%{
827
        "buyer_ref" => admission.buyer_ref,
828
        "objective" => admission.objective,
829
        "objective_version" => admission.objective_version,
830
        "base_model_digest" => admission.base_model_digest,
831
        "training_code_digest" => admission.training_code_digest,
832
        "configuration_digest" => admission.configuration_digest,
833
        "dataset_digests" => Enum.map(admission.datasets, & &1["artifact_digest"]),
834
        "license_digests" => Enum.map(admission.datasets, & &1["license_digest"]),
835
        "evaluation_corpus_digest" => admission.evaluation["corpus_digest"],
836
        "verifier_policy_digest" => admission.evaluation["verifier"]["policy_digest"],
837
        "runtime_class" => admission.runtime_class,
838
        "stopping_policy" => admission.stopping_policy,
839
        "budget" => admission.budget
840
      })
841
842
    Repo.transaction(fn ->
843
      changeset =
844
        Job.admission_changeset(%Job{}, Map.put(admission, :admission_digest, digest))
845
846
      with {:ok, job} <- Repo.insert(changeset),
847
           {:ok, _receipt} <-
848
             record_receipt(job, "admission", %{
849
               "admission_digest" => job.admission_digest,
850
               "buyer_ref" => job.buyer_ref,
851
               "buyer_class" => job.buyer_class,
852
               "objective_version" => job.objective_version,
853
               "base_model_ref" => job.base_model_ref,
854
               "base_model_digest" => job.base_model_digest,
855
               "training_code_digest" => job.training_code_digest,
856
               "configuration_digest" => job.configuration_digest,
857
               "datasets" => job.datasets,
858
               "evaluation" => Map.drop(job.evaluation, ["corpus"]),
859
               "evaluation_corpus" => job.evaluation["corpus"],
860
               "runtime_class" => job.runtime_class,
861
               "capacity_receipt" => job.capacity_receipt,
862
               "budget" => job.budget,
863
               "stopping_policy" => job.stopping_policy,
864
               "replay_of_id" => job.replay_of_id
865
             }) do
866
        job
867
      else
868
        {:error, reason} -> Repo.rollback(reason)
869
      end
870
    end)
871
  end
872
873
  defp launch(%Job{} = job, conversation_id, owner_visitor_id, cause) do
874
    case Work.start_continual_learning(%{
875
           conversation_id: conversation_id,
876
           owner_visitor_id: owner_visitor_id,
877
           surface: "text",
878
           goal: job.objective,
879
           delegation: %{
880
             "continual_learning_job_id" => job.id,
881
             "admission_digest" => job.admission_digest,
882
             "cause" => cause,
883
             "resume_count" => job.resume_count
884
           },
885
           authority_snapshot: %{
886
             "buyer_ref" => job.buyer_ref,
887
             "buyer_class" => job.buyer_class,
888
             "runtime_class" => job.runtime_class,
889
             "base_model_ref" => job.base_model_ref,
890
             "base_model_digest" => job.base_model_digest,
891
             "training_code_digest" => job.training_code_digest,
892
             "verifier_id" => job.evaluation["verifier"]["id"]
893
           },
894
           budget_snapshot: Bounds.snapshot(job.runtime_class)
895
         }) do
896
      {:ok, work_job} ->
897
        update_lifecycle(job, %{work_job_id: work_job.id})
898
899
      {:error, reason} ->
900
        _refusal = record_receipt(job, "refusal", %{"reason" => inspect(reason)})
901
        _terminal = terminalize(job, "failed", "worker_start_failed")
902
        {:error, reason}
903
    end
904
  end
905
906
  defp resumable(%Job{} = job) do
907
    if Job.resumable?(job), do: :ok, else: {:error, :not_resumable}
908
  end
909
910
  defp surviving_checkpoint(%Job{} = job) do
911
    case latest_checkpoint(job) do
912
      nil -> {:error, :checkpoint_missing}
913
      %Checkpoint{lost: true} -> {:error, :checkpoint_lost}
914
      %Checkpoint{} = checkpoint -> verify_chain(job, checkpoint)
915
    end
916
  end
917
918
  defp verify_chain(%Job{} = job, %Checkpoint{} = checkpoint) do
919
    recomputed = Canonical.digest!(checkpoint.state)
920
921
    if recomputed == checkpoint.state_digest and checkpoint.round == job.rounds_completed do
922
      {:ok, checkpoint}
923
    else
924
      {:error, :checkpoint_lost}
925
    end
926
  end
927
928
  # A resume spends the admitted budget, so a job that already spent all of it
929
  # has to be admitted again rather than resumed into the same stop.
930
  defp budget_remaining(%Job{} = job) do
931
    spent = Map.get(job.usage || %{}, "cost_usd_cents", 0)
932
    amount = job.budget["amount"] || 0
933
934
    if spent + Bounds.round_cost_usd_cents(job.runtime_class) <= amount,
935
      do: :ok,
936
      else: {:error, :budget_exhausted}
937
  end
938
939
  defp rounds_remaining(%Job{} = job) do
940
    maximum = job.stopping_policy["maximum_rounds"] || Bounds.maximum_rounds()
941
    if job.rounds_completed < maximum, do: :ok, else: {:error, :stopping_policy_satisfied}
942
  end
943
944
  defp previous_surface(%Job{work_job_id: nil}, attributes) do
945
    with {:ok, conversation_id} <- identifier(attributes, :conversation_id),
946
         {:ok, owner_visitor_id} <- identifier(attributes, :owner_visitor_id) do
947
      {:ok, conversation_id, owner_visitor_id}
948
    end
949
  end
950
951
  defp previous_surface(%Job{work_job_id: work_job_id}, attributes) do
952
    case Work.get_job(work_job_id) do
953
      nil -> previous_surface(%Job{work_job_id: nil}, attributes)
954
      work_job -> {:ok, work_job.conversation_id, work_job.owner_visitor_id}
955
    end
956
  end
957
958
  defp mark_resumed(%Job{} = job, %Checkpoint{} = checkpoint, capacity_receipt) do
959
    with {:ok, _receipt} <-
960
           record_receipt(job, "resume", %{
961
             "from_round" => checkpoint.round,
962
             "checkpoint_digest" => checkpoint.state_digest,
963
             "admission_digest" => job.admission_digest,
964
             "previous_status" => job.status,
965
             "previous_work_job_id" => job.work_job_id,
966
             "resume_count" => job.resume_count + 1,
967
             "capacity_receipt" => capacity_receipt
968
           }),
969
         {:ok, resumed} <-
970
           update_lifecycle(job, %{
971
             status: "queued",
972
             error_code: nil,
973
             completed_at: nil,
974
             resume_count: job.resume_count + 1,
975
             work_job_id: nil
976
           }) do
977
      {:ok, resumed}
978
    end
979
  end
980
981
  defp replay_attributes(%Job{} = job, attributes) do
982
    %{
983
      buyer_ref: job.buyer_ref,
984
      objective: job.objective,
985
      objective_version: job.objective_version,
986
      base_model_ref: job.base_model_ref,
987
      base_model_digest: job.base_model_digest,
988
      configuration: job.configuration,
989
      runtime_class: job.runtime_class,
990
      datasets: Enum.map(job.datasets, &Map.take(&1, ["listing_id", "acceptance_ref"])),
991
      evaluation: %{
992
        corpus:
993
          Enum.map(
994
            List.wrap(job.evaluation["corpus"]),
995
            &Map.take(&1, ["listing_id", "acceptance_ref"])
996
          ),
997
        verifier: %{
998
          id: job.evaluation["verifier"]["id"],
999
          admitted: true,
1000
          independent_of_producer: job.evaluation["verifier"]["independent_of_producer"]
1001
        },
1002
        separation_required: job.evaluation["separation_required"],
1003
        acceptance_criteria: job.evaluation["acceptance_criteria"],
1004
        target_metric: job.evaluation["target_metric"],
1005
        target_value: job.evaluation["target_value"],
1006
        policy_version: job.evaluation["policy_version"]
1007
      },
1008
      budget: %{usd_cents: job.budget["amount"]},
1009
      stopping_policy: %{
1010
        maximum_rounds: job.stopping_policy["maximum_rounds"],
1011
        minimum_improvement: job.stopping_policy["minimum_improvement"]
1012
      },
1013
      replay_of_id: job.id,
1014
      conversation_id: Map.get(attributes, :conversation_id),
1015
      owner_visitor_id: Map.get(attributes, :owner_visitor_id)
1016
    }
1017
  end
1018
1019
  # ── settlement readiness ───────────────────────────────────────────────────
1020
1021
  @doc """
1022
  The settlement-ready receipt payload for a qualified job.
1023
1024
  The lane records authority and evidence, never custody: the payload names the
1025
  buyer, the unit, the metered amount, the treasury policy that would pay it,
1026
  and the artifact it settles, and states that no transfer happened here.
1027
  """
1028
  @spec settlement_payload(Job.t(), map(), map()) :: map()
1029
  def settlement_payload(%Job{} = job, artifact_payload, usage) do
1030
    %{
1031
      "settlement_policy_id" => Settlement.policy_id(),
1032
      "unit" => Bounds.settlement_unit(),
1033
      "buyer_ref" => job.buyer_ref,
1034
      "buyer_class" => job.buyer_class,
1035
      "amount" => usage["cost_usd_cents"],
1036
      "budget" => job.budget,
1037
      "artifact_digest" => artifact_payload["artifact_digest"],
1038
      "accepted_outcome_state" => "accepted",
1039
      "usage" => usage,
1040
      "transferred" => false,
1041
      "custody" => "no_custody_moves_in_this_lane"
1042
    }
1043
  end
1044
1045
  @doc """
1046
  Grades one evaluation result against the accepted-outcome contract.
1047
1048
  The claim is built from the admitted evaluator policy and the job's own
1049
  identity, so the contract, not this lane, decides whether the artifact is
1050
  qualified.
1051
  """
1052
  @spec grade(Job.t(), map(), map()) ::
1053
          {:accepted, map()} | {:not_accepted, atom(), [term()]} | {:not_applicable, atom()}
1054
  def grade(%Job{} = job, result, %{repository: repository, issue_number: issue_number}) do
1055
    policy = job.evaluation
1056
1057
    AcceptedOutcome.evaluate(%{
1058
      actor: :agent,
1059
      agents_enabled: true,
1060
      issue: %{
1061
        number: issue_number,
1062
        repository: repository,
1063
        sections: %{
1064
          problem: job.objective,
1065
          scope: "continual-learning job #{job.id}",
1066
          acceptance_criteria: policy["acceptance_criteria"],
1067
          success_metrics: "#{policy["target_metric"]} >= #{policy["target_value"]}"
1068
        }
1069
      },
1070
      attempt: %{
1071
        issue_number: issue_number,
1072
        repository: repository,
1073
        authority: job.buyer_ref,
1074
        budget: job.budget,
1075
        revision: job.admission_digest
1076
      },
1077
      verification: %{
1078
        verifier: %{
1079
          id: policy["verifier"]["id"],
1080
          admitted: policy["verifier"]["admitted"] == true,
1081
          independent_of_producer: policy["verifier"]["independent_of_producer"] == true
1082
        },
1083
        falsifier: result.falsifier,
1084
        terminal_result: result.terminal_result,
1085
        separation_required: policy["separation_required"] == true,
1086
        false_green_classes: []
1087
      },
1088
      evidence:
1089
        Enum.map(result.criteria, fn item ->
1090
          %{
1091
            criterion: item["criterion"],
1092
            receipt: item["receipt"],
1093
            visibility: visibility(item["visibility"])
1094
          }
1095
        end)
1096
    })
1097
  end
1098
1099
  defp visibility("public"), do: :public
1100
  defp visibility(_restricted), do: :restricted
1101
1102
  # ── projections ────────────────────────────────────────────────────────────
1103
1104
  defp checkpoint_projection(%Checkpoint{} = checkpoint) do
1105
    %{
1106
      "round" => checkpoint.round,
1107
      "state_digest" => checkpoint.state_digest,
1108
      "parent_digest" => checkpoint.parent_digest,
1109
      "metrics" => checkpoint.metrics,
1110
      "usage" => checkpoint.usage,
1111
      "energy" => checkpoint.energy,
1112
      "lost" => checkpoint.lost,
1113
      "recorded_at" => checkpoint.inserted_at
1114
    }
1115
  end
1116
1117
  defp receipt_projection(%Receipt{} = receipt) do
1118
    %{
1119
      "kind" => receipt.kind,
1120
      "sequence" => receipt.sequence,
1121
      "receipt_ref" => receipt.receipt_ref,
1122
      "digest" => receipt.digest,
1123
      "payload" => receipt.payload
1124
    }
1125
  end
1126
1127
  defp artifact_projection(nil), do: nil
1128
1129
  defp artifact_projection(%Artifact{} = artifact) do
1130
    %{
1131
      "model_ref" => artifact.model_ref,
1132
      "model_digest" => artifact.model_digest,
1133
      "base_model_digest" => artifact.base_model_digest,
1134
      "training_code_digest" => artifact.training_code_digest,
1135
      "configuration_digest" => artifact.configuration_digest,
1136
      "dataset_bindings" => artifact.dataset_bindings,
1137
      "checkpoint_digests" => artifact.checkpoint_digests,
1138
      "evaluation_result" => artifact.evaluation_result,
1139
      "accepted_outcome" => artifact.accepted_outcome,
1140
      "settlement" => artifact.settlement,
1141
      "artifact_digest" => artifact.artifact_digest
1142
    }
1143
  end
1144
end
lib/openagents/continual_learning/artifact.ex added +85

@@ -0,0 +1,85 @@

1
defmodule OpenAgents.ContinualLearning.Artifact do
2
  @moduledoc """
3
  The terminal model artifact of one continual-learning job.
4
5
  The artifact digest is taken over the exact base model, the exact licensed
6
  dataset bindings, the exact training code, the exact configuration, the
7
  ordered checkpoint chain, and the exact evaluation inputs and results, so a
8
  reader can reproduce the identity of what was produced without trusting the
9
  producer.
10
  """
11
12
  use Ecto.Schema
13
  import Ecto.Changeset
14
15
  @digest_regex ~r/\A[0-9a-f]{64}\z/
16
17
  @primary_key {:id, :binary_id, autogenerate: true}
18
  @foreign_key_type :binary_id
19
  @timestamps_opts [type: :utc_datetime_usec]
20
21
  schema "continual_learning_artifacts" do
22
    belongs_to :job, OpenAgents.ContinualLearning.Job
23
    field :model_ref, :string
24
    field :model_digest, :string
25
    field :base_model_digest, :string
26
    field :training_code_digest, :string
27
    field :configuration_digest, :string
28
    field :dataset_bindings, {:array, :map}, default: []
29
    field :checkpoint_digests, {:array, :string}, default: []
30
    field :evaluation_result, :map, default: %{}
31
    field :accepted_outcome, :map, default: %{}
32
    field :settlement, :map, default: %{}
33
    field :artifact_digest, :string
34
35
    timestamps()
36
  end
37
38
  @type t :: %__MODULE__{}
39
40
  def changeset(artifact, attributes) do
41
    artifact
42
    |> cast(attributes, [
43
      :model_ref,
44
      :model_digest,
45
      :base_model_digest,
46
      :training_code_digest,
47
      :configuration_digest,
48
      :dataset_bindings,
49
      :checkpoint_digests,
50
      :evaluation_result,
51
      :accepted_outcome,
52
      :settlement,
53
      :artifact_digest
54
    ])
55
    |> validate_required([
56
      :model_ref,
57
      :model_digest,
58
      :base_model_digest,
59
      :training_code_digest,
60
      :configuration_digest,
61
      :evaluation_result,
62
      :accepted_outcome,
63
      :artifact_digest
64
    ])
65
    |> validate_format(:model_digest, @digest_regex)
66
    |> validate_format(:base_model_digest, @digest_regex)
67
    |> validate_format(:training_code_digest, @digest_regex)
68
    |> validate_format(:configuration_digest, @digest_regex)
69
    |> validate_format(:artifact_digest, @digest_regex)
70
    |> validate_checkpoint_chain()
71
    |> unique_constraint(:job_id)
72
    |> unique_constraint(:artifact_digest)
73
    |> foreign_key_constraint(:job_id)
74
  end
75
76
  defp validate_checkpoint_chain(changeset) do
77
    digests = get_field(changeset, :checkpoint_digests) || []
78
79
    if digests != [] and Enum.all?(digests, &Regex.match?(@digest_regex, &1)) do
80
      changeset
81
    else
82
      add_error(changeset, :checkpoint_digests, "must name the ordered checkpoint chain")
83
    end
84
  end
85
end
lib/openagents/continual_learning/bounds.ex added +97

@@ -0,0 +1,97 @@

1
defmodule OpenAgents.ContinualLearning.Bounds do
2
  @moduledoc """
3
  The bounds one continual-learning job is admitted under.
4
5
  Every limit a run can spend — rounds, wall clock, checkpoint size, budget,
6
  runtime classes, admitted base models, admitted custody — is read here and
7
  snapshotted onto the job row at admission, so a configuration change cannot
8
  widen a run that is already admitted.
9
  """
10
11
  @kind "continual_learning"
12
13
  @doc "The `work_jobs` kind a continual-learning run uses."
14
  def kind, do: @kind
15
16
  @doc "The configured continual-learning settings."
17
  def settings, do: Application.get_env(:openagents, OpenAgents.ContinualLearning, [])
18
19
  @doc "Whether the continual-learning lane is admitted in this runtime."
20
  def enabled?, do: Keyword.get(settings(), :enabled) == true
21
22
  @doc "The one named buyer this lane serves, or `nil` when no buyer is named."
23
  def buyer_ref, do: Keyword.get(settings(), :buyer_ref)
24
25
  @doc "The buyer class every consumed listing must be licensed to."
26
  def buyer_class, do: Keyword.get(settings(), :buyer_class)
27
28
  @doc "The runtime classes a job may request, as capacity class identifiers."
29
  def runtime_classes, do: Keyword.get(settings(), :runtime_classes, [])
30
31
  @doc "The admitted base models as `%{model_ref => digest}`."
32
  def admitted_base_models, do: Keyword.get(settings(), :admitted_base_models, %{})
33
34
  @doc "The custody classes, as capacity data locations, the lane may train in."
35
  def admitted_custody, do: Keyword.get(settings(), :admitted_custody, [])
36
37
  @doc "The largest number of training rounds one job may run."
38
  def maximum_rounds, do: Keyword.get(settings(), :maximum_rounds, 8)
39
40
  @doc "The largest number of licensed datasets one job may admit."
41
  def maximum_datasets, do: Keyword.get(settings(), :maximum_datasets, 4)
42
43
  @doc "The wall clock one job is admitted for, in milliseconds."
44
  def wall_clock_ms, do: Keyword.get(settings(), :wall_clock_ms, 900_000)
45
46
  @doc "The largest checkpoint state one round may durably store, in bytes."
47
  def maximum_state_bytes, do: Keyword.get(settings(), :maximum_state_bytes, 65_536)
48
49
  @doc "How many continual-learning jobs may run at once."
50
  def concurrency_limit, do: Keyword.get(settings(), :concurrency_limit, 1)
51
52
  @doc "The exact training code the lane runs, as a digest."
53
  def training_code_digest, do: Keyword.get(settings(), :training_code_digest)
54
55
  @doc "The trainer implementation."
56
  def trainer,
57
    do: Keyword.get(settings(), :trainer, OpenAgents.ContinualLearning.Trainer.Reference)
58
59
  @doc "The evaluator implementation."
60
  def evaluator,
61
    do: Keyword.get(settings(), :evaluator, OpenAgents.ContinualLearning.Evaluator.Reference)
62
63
  @doc "The average power draw of one runtime class, in watts."
64
  def class_watts, do: Keyword.get(settings(), :class_watts, %{})
65
66
  @doc "The settlement unit every settlement-ready receipt is denominated in."
67
  def settlement_unit, do: Keyword.get(settings(), :settlement_unit, "usd_cents")
68
69
  @doc "The cost one round of a runtime class meters, in US cents."
70
  def round_cost_usd_cents(runtime_class) when is_binary(runtime_class) do
71
    settings()
72
    |> Keyword.get(:round_cost_usd_cents, %{})
73
    |> Map.get(runtime_class, 1)
74
  end
75
76
  @doc "The forge issue every accepted outcome of this lane is graded against."
77
  def outcome_issue do
78
    %{
79
      repository: Keyword.get(settings(), :outcome_repository),
80
      issue_number: Keyword.get(settings(), :outcome_issue_number)
81
    }
82
  end
83
84
  @doc """
85
  The immutable bounds snapshot recorded on the job row at admission.
86
  """
87
  def snapshot(runtime_class) when is_binary(runtime_class) do
88
    %{
89
      "maximum_rounds" => maximum_rounds(),
90
      "wall_clock_ms" => wall_clock_ms(),
91
      "maximum_state_bytes" => maximum_state_bytes(),
92
      "runtime_class" => runtime_class,
93
      "watts" => Map.get(class_watts(), runtime_class, 0),
94
      "training_code_digest" => training_code_digest()
95
    }
96
  end
97
end
lib/openagents/continual_learning/checkpoint.ex added +81

@@ -0,0 +1,81 @@

1
defmodule OpenAgents.ContinualLearning.Checkpoint do
2
  @moduledoc """
3
  One durable checkpoint of a continual-learning job, written before the round
4
  that produced it is counted.
5
6
  Checkpoints form a digest chain: each one names its parent, so a resume can
7
  prove it continued the surviving state rather than starting a different run.
8
  A checkpoint marked `lost` is evidence that the state is gone; a resume that
9
  finds one refuses instead of silently retraining.
10
  """
11
12
  use Ecto.Schema
13
  import Ecto.Changeset
14
15
  @digest_regex ~r/\A[0-9a-f]{64}\z/
16
17
  @primary_key {:id, :binary_id, autogenerate: true}
18
  @foreign_key_type :binary_id
19
  @timestamps_opts [type: :utc_datetime_usec]
20
21
  schema "continual_learning_checkpoints" do
22
    belongs_to :job, OpenAgents.ContinualLearning.Job
23
    field :round, :integer
24
    field :state, :map, default: %{}
25
    field :state_digest, :string
26
    field :parent_digest, :string
27
    field :metrics, :map, default: %{}
28
    field :usage, :map, default: %{}
29
    field :energy, :map, default: %{}
30
    field :lost, :boolean, default: false
31
32
    timestamps()
33
  end
34
35
  @type t :: %__MODULE__{}
36
37
  def changeset(checkpoint, attributes, maximum_state_bytes) do
38
    checkpoint
39
    |> cast(attributes, [
40
      :round,
41
      :state,
42
      :state_digest,
43
      :parent_digest,
44
      :metrics,
45
      :usage,
46
      :energy,
47
      :lost
48
    ])
49
    |> validate_required([:round, :state, :state_digest, :metrics])
50
    |> validate_number(:round, greater_than: 0)
51
    |> validate_format(:state_digest, @digest_regex)
52
    |> validate_parent_digest()
53
    |> validate_state_size(maximum_state_bytes)
54
    |> unique_constraint([:job_id, :round])
55
    |> unique_constraint([:job_id, :state_digest])
56
    |> foreign_key_constraint(:job_id)
57
  end
58
59
  @doc "Marks a checkpoint's state as unrecoverable."
60
  def loss_changeset(checkpoint) do
61
    change(checkpoint, %{lost: true, state: %{}})
62
  end
63
64
  defp validate_parent_digest(changeset) do
65
    case get_field(changeset, :parent_digest) do
66
      nil -> changeset
67
      value when is_binary(value) -> validate_format(changeset, :parent_digest, @digest_regex)
68
      _invalid -> add_error(changeset, :parent_digest, "is invalid")
69
    end
70
  end
71
72
  defp validate_state_size(changeset, maximum_state_bytes) do
73
    state = get_field(changeset, :state)
74
75
    if is_map(state) and byte_size(Jason.encode!(state)) <= maximum_state_bytes do
76
      changeset
77
    else
78
      add_error(changeset, :state, "exceeds #{maximum_state_bytes} bytes")
79
    end
80
  end
81
end
lib/openagents/continual_learning/evaluator.ex added +32

@@ -0,0 +1,32 @@

1
defmodule OpenAgents.ContinualLearning.Evaluator do
2
  @moduledoc """
3
  The evaluator contract: grade one trained checkpoint against the admitted
4
  evaluation corpus.
5
6
  The evaluator receives the exact evaluation inputs the admission authorized
7
  and returns a terminal result with per-criterion evidence. It reports its own
8
  identity and whether it is independent of the trainer, and
9
  `OpenAgents.ContinualLearning.Runner` refuses a result whose reported identity
10
  does not match the admitted evaluator policy, so separation is checked against
11
  the policy rather than asserted by the evaluator.
12
  """
13
14
  @type context :: %{
15
          job: OpenAgents.ContinualLearning.Job.t(),
16
          corpus: [map()],
17
          checkpoint: OpenAgents.ContinualLearning.Checkpoint.t(),
18
          policy: map()
19
        }
20
21
  @type result :: %{
22
          verifier: map(),
23
          falsifier: String.t(),
24
          terminal_result: :passed | :failed,
25
          criteria: [map()],
26
          metrics: map(),
27
          usage: map(),
28
          duration_ms: non_neg_integer()
29
        }
30
31
  @callback evaluate(context()) :: {:ok, result()} | {:error, atom()}
32
end
lib/openagents/continual_learning/evaluator/reference.ex added +59

@@ -0,0 +1,59 @@

1
defmodule OpenAgents.ContinualLearning.Evaluator.Reference do
2
  @moduledoc """
3
  The reference evaluator for the first bounded workflow.
4
5
  It grades the surviving checkpoint against the admitted target metric and
6
  reports its own identity, the falsifier it ran, and one evidence receipt per
7
  admitted acceptance criterion. The result is deterministic in the checkpoint
8
  metrics, so an unchanged run grades identically.
9
  """
10
11
  @behaviour OpenAgents.ContinualLearning.Evaluator
12
13
  alias OpenAgents.Provenance.Canonical
14
15
  @impl true
16
  def evaluate(%{job: job, checkpoint: checkpoint, policy: policy, corpus: corpus}) do
17
    metric = Map.get(policy, "target_metric", "score")
18
    target = Map.get(policy, "target_value", 0.0)
19
    observed = Map.get(checkpoint.metrics, metric)
20
21
    if is_number(observed) do
22
      passed = observed >= target
23
24
      {:ok,
25
       %{
26
         verifier: Map.get(policy, "verifier", %{}),
27
         falsifier:
28
           "the same checkpoint graded below #{metric} #{target} fails, and a corpus digest " <>
29
             "that does not match the admitted evaluation inputs fails",
30
         terminal_result: (passed && :passed) || :failed,
31
         criteria: criteria(job, policy, corpus, metric, observed),
32
         metrics: %{
33
           metric => observed,
34
           "target_value" => target,
35
           "corpus_records" => length(corpus)
36
         },
37
         usage: %{"input_tokens" => 500, "output_tokens" => 100, "total_tokens" => 600},
38
         duration_ms: 500
39
       }}
40
    else
41
      {:error, :evaluation_metric_missing}
42
    end
43
  end
44
45
  defp criteria(job, policy, corpus, metric, observed) do
46
    corpus_digest = Canonical.digest!(Enum.map(corpus, & &1["artifact_digest"]))
47
48
    for criterion <- Map.get(policy, "acceptance_criteria", []) do
49
      %{
50
        "criterion" => criterion,
51
        "receipt" =>
52
          "continual-learning-evaluation:#{job.id}:#{Canonical.sha256(criterion)}"
53
          |> String.slice(0, 256),
54
        "visibility" => "restricted",
55
        "observed" => %{metric => observed, "corpus_digest" => corpus_digest}
56
      }
57
    end
58
  end
59
end
lib/openagents/continual_learning/job.ex added +157

@@ -0,0 +1,157 @@

1
defmodule OpenAgents.ContinualLearning.Job do
2
  @moduledoc """
3
  One admitted continual-learning job.
4
5
  The row is the durable admission record: the named buyer, the versioned
6
  objective, the exact base model, the exact licensed dataset bindings, the
7
  evaluation inputs and evaluator policy, the budget, the runtime class with
8
  its capacity evidence, the stopping policy, and the digest over all of them.
9
  Identity never changes after admission; only lifecycle fields move.
10
  """
11
12
  use Ecto.Schema
13
  import Ecto.Changeset
14
15
  @statuses ~w(queued running completed failed interrupted budget_exhausted cancelled)
16
  @terminal_statuses ~w(completed failed interrupted budget_exhausted cancelled)
17
  @resumable_statuses ~w(interrupted budget_exhausted)
18
  @digest_regex ~r/\A[0-9a-f]{64}\z/
19
20
  @primary_key {:id, :binary_id, autogenerate: true}
21
  @foreign_key_type :binary_id
22
  @timestamps_opts [type: :utc_datetime_usec]
23
24
  schema "continual_learning_jobs" do
25
    field :buyer_ref, :string
26
    field :buyer_class, :string
27
    field :objective, :string
28
    field :objective_version, :integer
29
    field :base_model_ref, :string
30
    field :base_model_digest, :string
31
    field :training_code_digest, :string
32
    field :configuration, :map, default: %{}
33
    field :configuration_digest, :string
34
    field :datasets, {:array, :map}, default: []
35
    field :evaluation, :map, default: %{}
36
    field :budget, :map, default: %{}
37
    field :runtime_class, :string
38
    field :capacity_receipt, :map, default: %{}
39
    field :stopping_policy, :map, default: %{}
40
    field :admission_digest, :string
41
    field :status, :string, default: "queued"
42
    field :error_code, :string
43
    field :rounds_completed, :integer, default: 0
44
    field :resume_count, :integer, default: 0
45
    field :usage, :map, default: %{}
46
    field :started_at, :utc_datetime_usec
47
    field :completed_at, :utc_datetime_usec
48
49
    belongs_to :work_job, OpenAgents.Work.Job
50
    belongs_to :replay_of, __MODULE__
51
52
    has_many :checkpoints, OpenAgents.ContinualLearning.Checkpoint, foreign_key: :job_id
53
    has_many :receipts, OpenAgents.ContinualLearning.Receipt, foreign_key: :job_id
54
    has_one :artifact, OpenAgents.ContinualLearning.Artifact, foreign_key: :job_id
55
56
    timestamps()
57
  end
58
59
  @type t :: %__MODULE__{}
60
61
  def statuses, do: @statuses
62
  def terminal_statuses, do: @terminal_statuses
63
  def resumable_statuses, do: @resumable_statuses
64
65
  def terminal?(%__MODULE__{status: status}), do: status in @terminal_statuses
66
  def resumable?(%__MODULE__{status: status}), do: status in @resumable_statuses
67
68
  @doc "The immutable admission identity."
69
  def admission_changeset(job, attributes) do
70
    job
71
    |> cast(attributes, [
72
      :buyer_ref,
73
      :buyer_class,
74
      :objective,
75
      :objective_version,
76
      :base_model_ref,
77
      :base_model_digest,
78
      :training_code_digest,
79
      :configuration,
80
      :configuration_digest,
81
      :datasets,
82
      :evaluation,
83
      :budget,
84
      :runtime_class,
85
      :capacity_receipt,
86
      :stopping_policy,
87
      :admission_digest,
88
      :replay_of_id
89
    ])
90
    |> validate_required([
91
      :buyer_ref,
92
      :buyer_class,
93
      :objective,
94
      :objective_version,
95
      :base_model_ref,
96
      :base_model_digest,
97
      :training_code_digest,
98
      :configuration_digest,
99
      :evaluation,
100
      :budget,
101
      :runtime_class,
102
      :capacity_receipt,
103
      :stopping_policy,
104
      :admission_digest
105
    ])
106
    |> validate_length(:buyer_ref, min: 1, max: 256)
107
    |> validate_length(:buyer_class, min: 1, max: 128)
108
    |> validate_length(:objective, min: 1, max: 2_000)
109
    |> validate_number(:objective_version, greater_than: 0)
110
    |> validate_length(:base_model_ref, min: 1, max: 256)
111
    |> validate_format(:base_model_digest, @digest_regex)
112
    |> validate_format(:training_code_digest, @digest_regex)
113
    |> validate_format(:configuration_digest, @digest_regex)
114
    |> validate_format(:admission_digest, @digest_regex)
115
    |> validate_datasets()
116
    |> foreign_key_constraint(:replay_of_id)
117
  end
118
119
  @doc "Moves the job through its lifecycle without touching admission identity."
120
  def lifecycle_changeset(job, attributes) do
121
    job
122
    |> cast(attributes, [
123
      :status,
124
      :error_code,
125
      :rounds_completed,
126
      :resume_count,
127
      :usage,
128
      :work_job_id,
129
      :started_at,
130
      :completed_at
131
    ])
132
    |> validate_inclusion(:status, @statuses)
133
    |> validate_number(:rounds_completed, greater_than_or_equal_to: 0)
134
    |> validate_number(:resume_count, greater_than_or_equal_to: 0)
135
    |> validate_length(:error_code, max: 128)
136
    |> unique_constraint(:work_job_id)
137
    |> foreign_key_constraint(:work_job_id)
138
  end
139
140
  defp validate_datasets(changeset) do
141
    datasets = get_field(changeset, :datasets) || []
142
143
    valid? =
144
      datasets != [] and
145
        Enum.all?(datasets, fn dataset ->
146
          is_map(dataset) and
147
            Enum.all?(
148
              ~w(listing_id acceptance_ref artifact_digest provenance_digest license_digest listing_digest),
149
              &match?(value when is_binary(value) and value != "", Map.get(dataset, &1))
150
            )
151
        end)
152
153
    if valid?,
154
      do: changeset,
155
      else: add_error(changeset, :datasets, "must bind at least one admitted licensed dataset")
156
  end
157
end
lib/openagents/continual_learning/receipt.ex added +47

@@ -0,0 +1,47 @@

1
defmodule OpenAgents.ContinualLearning.Receipt do
2
  @moduledoc """
3
  One append-only receipt of a continual-learning job.
4
5
  The sequence is dense per job, so a reader can tell a missing receipt from a
6
  receipt that was never written, and every payload carries its own canonical
7
  digest.
8
  """
9
10
  use Ecto.Schema
11
  import Ecto.Changeset
12
13
  @kinds ~w(admission usage energy training evaluation artifact settlement resume refusal)
14
  @digest_regex ~r/\A[0-9a-f]{64}\z/
15
16
  @primary_key {:id, :binary_id, autogenerate: true}
17
  @foreign_key_type :binary_id
18
  @timestamps_opts [type: :utc_datetime_usec]
19
20
  schema "continual_learning_receipts" do
21
    belongs_to :job, OpenAgents.ContinualLearning.Job
22
    field :kind, :string
23
    field :sequence, :integer
24
    field :receipt_ref, :string
25
    field :payload, :map, default: %{}
26
    field :digest, :string
27
28
    timestamps()
29
  end
30
31
  @type t :: %__MODULE__{}
32
33
  def kinds, do: @kinds
34
35
  def changeset(receipt, attributes) do
36
    receipt
37
    |> cast(attributes, [:kind, :sequence, :receipt_ref, :payload, :digest])
38
    |> validate_required([:kind, :sequence, :receipt_ref, :payload, :digest])
39
    |> validate_inclusion(:kind, @kinds)
40
    |> validate_number(:sequence, greater_than: 0)
41
    |> validate_length(:receipt_ref, min: 1, max: 256)
42
    |> validate_format(:digest, @digest_regex)
43
    |> unique_constraint([:job_id, :sequence])
44
    |> unique_constraint(:receipt_ref)
45
    |> foreign_key_constraint(:job_id)
46
  end
47
end
lib/openagents/continual_learning/runner.ex added +475

@@ -0,0 +1,475 @@

1
defmodule OpenAgents.ContinualLearning.Runner do
2
  @moduledoc """
3
  The bounded round loop of one admitted continual-learning job.
4
5
  The loop owns nothing about scheduling: it is called by
6
  `OpenAgents.Work.ContinualLearningServer` inside an ordinary `work_jobs` row.
7
  What it owns is the durable order of events. Each round writes its checkpoint,
8
  its usage receipt, its energy receipt, and its training receipt before the
9
  round counts, so a run that dies between rounds resumes from committed state
10
  and never from a round it only started.
11
12
  The loop stops at the first bound it reaches: the stopping policy, the budget,
13
  a cancel already written to the row, or a trainer refusal. Only a run that
14
  reached its stopping policy is evaluated, and only an evaluation the
15
  accepted-outcome contract accepts produces an artifact, a settlement-ready
16
  receipt, and the catalog transaction evidence for every consumed listing.
17
  """
18
19
  require Logger
20
21
  alias OpenAgents.ArtifactCatalog
22
  alias OpenAgents.ContinualLearning
23
  alias OpenAgents.ContinualLearning.Artifact
24
  alias OpenAgents.ContinualLearning.Bounds
25
  alias OpenAgents.ContinualLearning.Checkpoint
26
  alias OpenAgents.ContinualLearning.Job
27
  alias OpenAgents.Provenance.Canonical
28
  alias OpenAgents.Repo
29
30
  @doc """
31
  Runs one job to a terminal state and returns it.
32
33
  The return is always a terminal job row: the loop writes the terminal status
34
  itself, so the caller only has to report it.
35
  """
36
  @spec run(String.t()) :: {:ok, Job.t()} | {:error, term()}
37
  def run(job_id) when is_binary(job_id) do
38
    with {:ok, job} <- ContinualLearning.fetch(job_id),
39
         {:ok, running} <- start_running(job) do
40
      {:ok, loop(running)}
41
    end
42
  end
43
44
  defp start_running(%Job{} = job) do
45
    if Job.terminal?(job) do
46
      {:error, :already_terminal}
47
    else
48
      ContinualLearning.update_lifecycle(job, %{
49
        status: "running",
50
        started_at: job.started_at || DateTime.utc_now()
51
      })
52
    end
53
  end
54
55
  defp loop(%Job{} = job) do
56
    case next_round(job) do
57
      {:continue, round} ->
58
        case train(job, round) do
59
          {:ok, advanced} -> loop(advanced)
60
          {:stop, terminal} -> terminal
61
        end
62
63
      {:stop, :stopping_policy_satisfied} ->
64
        evaluate(job)
65
66
      {:stop, reason} ->
67
        terminal(job, terminal_status(reason), Atom.to_string(reason))
68
    end
69
  end
70
71
  defp next_round(%Job{} = job) do
72
    {:ok, current} = ContinualLearning.fetch(job.id)
73
    maximum = job.stopping_policy["maximum_rounds"] || Bounds.maximum_rounds()
74
75
    cond do
76
      current.status == "cancelled" -> {:stop, :cancelled}
77
      Job.terminal?(current) -> {:stop, :already_terminal}
78
      job.rounds_completed >= maximum -> {:stop, :stopping_policy_satisfied}
79
      target_reached?(job) -> {:stop, :stopping_policy_satisfied}
80
      exhausted_budget?(job) -> {:stop, :budget_exhausted}
81
      true -> {:continue, job.rounds_completed + 1}
82
    end
83
  end
84
85
  defp train(%Job{} = job, round) do
86
    parent = ContinualLearning.latest_checkpoint(job)
87
88
    context = %{
89
      job: job,
90
      round: round,
91
      datasets: job.datasets,
92
      configuration: job.configuration,
93
      parent_state: (parent && parent.state) || %{},
94
      parent_digest: parent && parent.state_digest
95
    }
96
97
    case Bounds.trainer().train_round(context) do
98
      {:ok, result} ->
99
        commit_round(job, round, parent, result)
100
101
      {:error, reason} ->
102
        _receipt = ContinualLearning.record_receipt(job, "refusal", %{"reason" => code(reason)})
103
        {:stop, terminal(job, "failed", code(reason))}
104
    end
105
  end
106
107
  defp commit_round(%Job{} = job, round, parent, result) do
108
    energy = energy(job, result)
109
    usage = round_usage(job, result, energy)
110
111
    checkpoint_attributes = %{
112
      round: round,
113
      state: result.state,
114
      state_digest: Canonical.digest!(result.state),
115
      parent_digest: parent && parent.state_digest,
116
      metrics: result.metrics,
117
      usage: usage,
118
      energy: energy
119
    }
120
121
    outcome =
122
      Repo.transaction(fn ->
123
        checkpoint =
124
          %Checkpoint{job_id: job.id}
125
          |> Checkpoint.changeset(checkpoint_attributes, Bounds.maximum_state_bytes())
126
          |> Repo.insert()
127
          |> unwrap()
128
129
        _usage_receipt =
130
          ContinualLearning.record_receipt(job, "usage", %{
131
            "round" => round,
132
            "usage" => usage,
133
            "budget" => job.budget
134
          })
135
          |> unwrap()
136
137
        _energy_receipt =
138
          ContinualLearning.record_receipt(job, "energy", %{
139
            "round" => round,
140
            "energy" => energy,
141
            "runtime_class" => job.runtime_class
142
          })
143
          |> unwrap()
144
145
        _training_receipt =
146
          ContinualLearning.record_receipt(job, "training", %{
147
            "round" => round,
148
            "checkpoint_digest" => checkpoint.state_digest,
149
            "parent_digest" => checkpoint.parent_digest,
150
            "metrics" => result.metrics,
151
            "dataset_digests" => Enum.map(job.datasets, & &1["artifact_digest"]),
152
            "training_code_digest" => job.training_code_digest
153
          })
154
          |> unwrap()
155
156
        job
157
        |> Job.lifecycle_changeset(%{
158
          rounds_completed: round,
159
          usage: accumulate(job.usage, usage)
160
        })
161
        |> Repo.update()
162
        |> unwrap()
163
      end)
164
165
    case outcome do
166
      {:ok, advanced} -> {:ok, advanced}
167
      {:error, reason} -> {:stop, terminal(job, "failed", code(reason))}
168
    end
169
  end
170
171
  defp evaluate(%Job{} = job) do
172
    checkpoint = ContinualLearning.latest_checkpoint(job)
173
174
    context = %{
175
      job: job,
176
      corpus: List.wrap(job.evaluation["corpus"]),
177
      checkpoint: checkpoint,
178
      policy: job.evaluation
179
    }
180
181
    case Bounds.evaluator().evaluate(context) do
182
      {:ok, result} ->
183
        if admitted_evaluator?(job, result) do
184
          qualify(job, checkpoint, result)
185
        else
186
          _receipt =
187
            ContinualLearning.record_receipt(job, "evaluation", %{
188
              "state" => "unverifiable",
189
              "reason" => "evaluator_identity_mismatch",
190
              "admitted_verifier" => job.evaluation["verifier"]["id"],
191
              "reported_verifier" => result.verifier["id"]
192
            })
193
194
          terminal(job, "failed", "evaluator_identity_mismatch")
195
        end
196
197
      {:error, reason} ->
198
        _receipt =
199
          ContinualLearning.record_receipt(job, "evaluation", %{
200
            "state" => "unverifiable",
201
            "reason" => code(reason)
202
          })
203
204
        terminal(job, "failed", code(reason))
205
    end
206
  end
207
208
  # Separation is checked against the admitted policy, so an evaluator that
209
  # reports an identity the admission never admitted cannot grade the run.
210
  defp admitted_evaluator?(%Job{} = job, result) do
211
    is_map(result.verifier) and result.verifier["id"] == job.evaluation["verifier"]["id"]
212
  end
213
214
  defp qualify(%Job{} = job, checkpoint, result) do
215
    graded =
216
      ContinualLearning.grade(job, result, Bounds.outcome_issue())
217
218
    case graded do
219
      {:accepted, outcome} ->
220
        _receipt =
221
          ContinualLearning.record_receipt(job, "evaluation", %{
222
            "state" => "accepted",
223
            "verifier" => result.verifier,
224
            "falsifier" => result.falsifier,
225
            "terminal_result" => Atom.to_string(result.terminal_result),
226
            "metrics" => result.metrics,
227
            "corpus_digest" => job.evaluation["corpus_digest"],
228
            "criteria" => Enum.map(outcome.criteria, & &1.criterion)
229
          })
230
231
        produce_artifact(job, checkpoint, result, outcome)
232
233
      {:not_accepted, type, reasons} ->
234
        _receipt =
235
          ContinualLearning.record_receipt(job, "evaluation", %{
236
            "state" => "not_accepted",
237
            "type" => Atom.to_string(type),
238
            "reasons" => Enum.map(reasons, &inspect/1),
239
            "verifier" => result.verifier,
240
            "terminal_result" => Atom.to_string(result.terminal_result)
241
          })
242
243
        terminal(job, "failed", "evaluation_#{type}")
244
245
      {:not_applicable, exemption} ->
246
        _receipt =
247
          ContinualLearning.record_receipt(job, "evaluation", %{
248
            "state" => "not_applicable",
249
            "exemption" => Atom.to_string(exemption)
250
          })
251
252
        terminal(job, "failed", "evaluation_not_applicable")
253
    end
254
  end
255
256
  defp produce_artifact(%Job{} = job, checkpoint, result, outcome) do
257
    chain = job |> ContinualLearning.checkpoints() |> Enum.map(& &1.state_digest)
258
    corpus = List.wrap(job.evaluation["corpus"])
259
260
    evaluation_result = %{
261
      "verifier" => result.verifier,
262
      "falsifier" => result.falsifier,
263
      "terminal_result" => Atom.to_string(result.terminal_result),
264
      "metrics" => result.metrics,
265
      "corpus_digest" => job.evaluation["corpus_digest"],
266
      "target_metric" => job.evaluation["target_metric"],
267
      "target_value" => job.evaluation["target_value"]
268
    }
269
270
    accepted_outcome = %{
271
      "state" => "accepted",
272
      "revision" => outcome.revision,
273
      "verifier" => outcome.verifier,
274
      "criteria" => Enum.map(outcome.criteria, & &1.criterion),
275
      "issue_number" => outcome.issue_number,
276
      "repository" => outcome.repository
277
    }
278
279
    identity = %{
280
      "base_model_ref" => job.base_model_ref,
281
      "base_model_digest" => job.base_model_digest,
282
      "training_code_digest" => job.training_code_digest,
283
      "configuration_digest" => job.configuration_digest,
284
      "dataset_bindings" => job.datasets,
285
      "evaluation_corpus" => corpus,
286
      "checkpoint_digests" => chain,
287
      "final_checkpoint_digest" => checkpoint.state_digest,
288
      "evaluation_result" => evaluation_result,
289
      "objective_version" => job.objective_version
290
    }
291
292
    artifact_digest = Canonical.digest!(identity)
293
    usage = Map.put(job.usage || %{}, "rounds", job.rounds_completed)
294
295
    artifact_attributes = %{
296
      model_ref: "#{job.base_model_ref}+cl.#{job.objective_version}",
297
      model_digest: Canonical.digest!(Map.take(identity, ["checkpoint_digests"])),
298
      base_model_digest: job.base_model_digest,
299
      training_code_digest: job.training_code_digest,
300
      configuration_digest: job.configuration_digest,
301
      dataset_bindings: job.datasets,
302
      checkpoint_digests: chain,
303
      evaluation_result: evaluation_result,
304
      accepted_outcome: accepted_outcome,
305
      artifact_digest: artifact_digest
306
    }
307
308
    settlement =
309
      ContinualLearning.settlement_payload(
310
        job,
311
        %{"artifact_digest" => artifact_digest},
312
        usage
313
      )
314
315
    outcome_of_insert =
316
      Repo.transaction(fn ->
317
        artifact =
318
          %Artifact{job_id: job.id}
319
          |> Artifact.changeset(Map.put(artifact_attributes, :settlement, settlement))
320
          |> Repo.insert()
321
          |> unwrap()
322
323
        _artifact_receipt =
324
          ContinualLearning.record_receipt(job, "artifact", %{
325
            "artifact_digest" => artifact.artifact_digest,
326
            "model_ref" => artifact.model_ref,
327
            "model_digest" => artifact.model_digest,
328
            "checkpoint_digests" => artifact.checkpoint_digests,
329
            "dataset_bindings" => artifact.dataset_bindings,
330
            "accepted_outcome" => artifact.accepted_outcome
331
          })
332
          |> unwrap()
333
334
        _settlement_receipt =
335
          ContinualLearning.record_receipt(job, "settlement", settlement) |> unwrap()
336
337
        artifact
338
      end)
339
340
    case outcome_of_insert do
341
      {:ok, artifact} ->
342
        _catalog = record_catalog_evidence(job, artifact)
343
        terminal(job, "completed", nil)
344
345
      {:error, reason} ->
346
        terminal(job, "failed", code(reason))
347
    end
348
  end
349
350
  # Usage reconciles back into the catalog's own transaction chain: the datasets
351
  # the job consumed reach `delivery`, `verification`, and `settlement` against
352
  # the acceptance receipt the buyer already held, so the licensed side of the
353
  # trade carries the same evidence as the model side.
354
  defp record_catalog_evidence(%Job{} = job, %Artifact{} = artifact) do
355
    bindings = job.datasets ++ List.wrap(job.evaluation["corpus"])
356
357
    Enum.each(bindings, fn binding ->
358
      with {:ok, delivery} <-
359
             catalog_transaction(job, binding, "delivery", binding["acceptance_ref"], nil),
360
           {:ok, verification} <-
361
             catalog_transaction(job, binding, "verification", delivery.receipt_ref, nil),
362
           {:ok, _settlement} <-
363
             catalog_transaction(
364
               job,
365
               binding,
366
               "settlement",
367
               verification.receipt_ref,
368
               artifact.artifact_digest
369
             ) do
370
        :ok
371
      else
372
        {:error, reason} ->
373
          Logger.warning(
374
            "continual_learning_catalog_evidence_skipped job=#{job.id} " <>
375
              "listing=#{binding["listing_id"]} code=#{code(reason)}"
376
          )
377
      end
378
    end)
379
  end
380
381
  defp catalog_transaction(job, binding, action, predecessor_ref, external_ref) do
382
    ArtifactCatalog.record_transaction(binding["listing_id"], action, %{
383
      receipt_ref: "continual-learning:#{job.id}:#{binding["purpose"]}:#{action}",
384
      predecessor_ref: predecessor_ref,
385
      external_ref: external_ref || "continual-learning-job:#{job.id}",
386
      buyer_ref: job.buyer_ref,
387
      buyer_class: job.buyer_class,
388
      artifact_digest: binding["artifact_digest"],
389
      provenance_digest: binding["provenance_digest"],
390
      license_digest: binding["license_digest"],
391
      listing_digest: binding["listing_digest"],
392
      metadata: %{
393
        "continual_learning_job_id" => job.id,
394
        "admission_digest" => job.admission_digest,
395
        "purpose" => binding["purpose"]
396
      }
397
    })
398
  end
399
400
  defp terminal(%Job{} = job, status, error_code) do
401
    case ContinualLearning.terminalize(job, status, error_code) do
402
      {:ok, terminal} -> terminal
403
      {:error, _reason} -> job
404
    end
405
  end
406
407
  defp terminal_status(:budget_exhausted), do: "budget_exhausted"
408
  defp terminal_status(:cancelled), do: "cancelled"
409
  defp terminal_status(_reason), do: "failed"
410
411
  defp exhausted_budget?(%Job{} = job) do
412
    spent = Map.get(job.usage || %{}, "cost_usd_cents", 0)
413
    spent + round_cost(job) > job.budget["amount"]
414
  end
415
416
  defp target_reached?(%Job{} = job) do
417
    metric = job.evaluation["target_metric"]
418
    target = job.evaluation["target_value"]
419
420
    case ContinualLearning.latest_checkpoint(job) do
421
      nil ->
422
        false
423
424
      checkpoint ->
425
        observed = Map.get(checkpoint.metrics, metric)
426
        is_number(observed) and is_number(target) and observed >= target
427
    end
428
  end
429
430
  defp round_cost(%Job{runtime_class: runtime_class}),
431
    do: Bounds.round_cost_usd_cents(runtime_class)
432
433
  defp round_usage(%Job{} = job, result, energy) do
434
    result.usage
435
    |> Map.put("duration_ms", result.duration_ms)
436
    |> Map.put("cost_usd_cents", round_cost(job))
437
    |> Map.put("joules", energy["joules"])
438
  end
439
440
  defp energy(%Job{} = job, result) do
441
    watts = Map.get(Bounds.class_watts(), job.runtime_class, 0)
442
    joules = Float.round(watts * result.duration_ms / 1_000, 3)
443
444
    %{
445
      "runtime_class" => job.runtime_class,
446
      "watts" => watts,
447
      "duration_ms" => result.duration_ms,
448
      "joules" => joules,
449
      "method" => "measured runtime multiplied by the runtime class power draw"
450
    }
451
  end
452
453
  defp accumulate(previous, usage) when is_map(previous) do
454
    Enum.reduce(usage, previous, fn {key, value}, acc ->
455
      case {Map.get(acc, key), value} do
456
        {nil, value} ->
457
          Map.put(acc, key, value)
458
459
        {existing, value} when is_number(existing) and is_number(value) ->
460
          Map.put(acc, key, existing + value)
461
462
        {_existing, value} ->
463
          Map.put(acc, key, value)
464
      end
465
    end)
466
  end
467
468
  defp unwrap({:ok, record}), do: record
469
  defp unwrap({:error, reason}), do: Repo.rollback(reason)
470
471
  defp code(reason) when is_atom(reason), do: Atom.to_string(reason)
472
  defp code({reason, _detail}) when is_atom(reason), do: Atom.to_string(reason)
473
  defp code(%Ecto.Changeset{}), do: "invalid_record"
474
  defp code(_reason), do: "continual_learning_failed"
475
end
lib/openagents/continual_learning/trainer.ex added +29

@@ -0,0 +1,29 @@

1
defmodule OpenAgents.ContinualLearning.Trainer do
2
  @moduledoc """
3
  The trainer contract: one bounded training round over admitted licensed data.
4
5
  A trainer never resolves data itself. It receives the exact dataset bindings
6
  the admission already authorized, the surviving checkpoint state, and the
7
  round number, and returns the next state with the metrics, usage, and runtime
8
  the round spent. Everything durable — the checkpoint, the receipts, the
9
  stopping decision — belongs to `OpenAgents.ContinualLearning.Runner`.
10
  """
11
12
  @type context :: %{
13
          job: OpenAgents.ContinualLearning.Job.t(),
14
          round: pos_integer(),
15
          datasets: [map()],
16
          configuration: map(),
17
          parent_state: map(),
18
          parent_digest: String.t() | nil
19
        }
20
21
  @type round_result :: %{
22
          state: map(),
23
          metrics: map(),
24
          usage: map(),
25
          duration_ms: non_neg_integer()
26
        }
27
28
  @callback train_round(context()) :: {:ok, round_result()} | {:error, atom()}
29
end
lib/openagents/continual_learning/trainer/reference.ex added +53

@@ -0,0 +1,53 @@

1
defmodule OpenAgents.ContinualLearning.Trainer.Reference do
2
  @moduledoc """
3
  The reference trainer for the first bounded workflow.
4
5
  The round is deterministic in its inputs: the state is the canonical digest
6
  chain over the admitted objective, the base model, the licensed dataset
7
  digests, the configuration, and the parent state, and the metrics improve
8
  along a fixed schedule. Two jobs admitted under identical inputs therefore
9
  produce identical checkpoints and an identical artifact digest, which is what
10
  makes the reproducibility canary a test rather than a claim.
11
12
  A production trainer replaces this module through the `:trainer` setting; the
13
  durable contract around it does not change.
14
  """
15
16
  @behaviour OpenAgents.ContinualLearning.Trainer
17
18
  @impl true
19
  def train_round(%{job: job, round: round} = context) do
20
    state = %{
21
      "round" => round,
22
      "objective_version" => job.objective_version,
23
      "base_model_digest" => job.base_model_digest,
24
      "training_code_digest" => job.training_code_digest,
25
      "configuration_digest" => job.configuration_digest,
26
      "dataset_digests" => Enum.map(context.datasets, & &1["artifact_digest"]),
27
      "parent_digest" => context.parent_digest
28
    }
29
30
    {:ok,
31
     %{
32
       state: state,
33
       metrics: metrics(round),
34
       usage: %{
35
         "input_tokens" => 1_000 * round,
36
         "output_tokens" => 250 * round,
37
         "total_tokens" => 1_250 * round,
38
         "records_seen" => 100 * round
39
       },
40
       duration_ms: 1_000
41
     }}
42
  end
43
44
  defp metrics(round) do
45
    loss = Float.round(1.0 / (round + 1), 6)
46
47
    %{
48
      "round" => round,
49
      "loss" => loss,
50
      "score" => Float.round(1.0 - loss, 6)
51
    }
52
  end
53
end
lib/openagents/work.ex modified +46

@@ -132,6 +132,28 @@ defmodule OpenAgents.Work do

132 132
    end
133 133
  end
134 134
135
  @doc """
136
  Start a durable continual-learning run (CONTINUAL-001): the bounded round loop
137
  in `OpenAgents.ContinualLearning.Runner`, driven by
138
  `OpenAgents.Work.ContinualLearningServer` on the same row, statuses, fence,
139
  and recovery sweep as every other kind.
140
141
  Admission belongs to `OpenAgents.ContinualLearning.start/2`; this only creates
142
  the row and starts the worker.
143
  """
144
  def start_continual_learning(attributes) when is_map(attributes) do
145
    with {:ok, job} <- create_job(Map.put(attributes, :kind, "continual_learning")) do
146
      case start_worker(OpenAgents.Work.ContinualLearningServer, job.id) do
147
        {:ok, _pid} ->
148
          {:ok, job}
149
150
        {:error, reason} ->
151
          _failure = finish_job(job.id, "failed", error_code: "worker_start_failed")
152
          {:error, reason}
153
      end
154
    end
155
  end
156
135 157
  # Start a job's worker as a cluster-wide singleton under Horde. Horde routes
136 158
  # the child to whichever member `choose_node` picks and relocates it to a
137 159
  # survivor if that node dies. `{:already_started, pid}` is success: the

@@ -662,6 +684,8 @@ defmodule OpenAgents.Work do

662 684
663 685
  defp worker_module("delegation"), do: OpenAgents.Work.DelegationServer
664 686
  defp worker_module("scv"), do: OpenAgents.Work.ScvServer
687
688
  defp worker_module("continual_learning"), do: OpenAgents.Work.ContinualLearningServer
665 689
  defp worker_module(_kind), do: OpenAgents.Work.JobServer
666 690
667 691
  @doc """

@@ -829,6 +853,28 @@ defmodule OpenAgents.Work do

829 853
    end
830 854
  end
831 855
856
  # A continual-learning run reports rounds and checkpoints, not tool calls, and
857
  # an interruption is resumable from its last committed checkpoint rather than
858
  # something to start over.
859
  defp fallback_report(_repo, %Job{kind: "continual_learning"} = locked_job, status) do
860
    delegation = locked_job.delegation || %{}
861
    reference = delegation["continual_learning_job_id"] || locked_job.id
862
863
    case status do
864
      "interrupted" ->
865
        "Continual-learning job #{reference} was interrupted by a server restart. " <>
866
          "Its committed checkpoints survive; resume it to continue from the last " <>
867
          "one. Objective: #{locked_job.goal}"
868
869
      "cancelled" ->
870
        "Continual-learning job #{reference} was cancelled. Objective: #{locked_job.goal}"
871
872
      _other ->
873
        "Continual-learning job #{reference} ended #{status}. " <>
874
          "Objective: #{locked_job.goal}"
875
    end
876
  end
877
832 878
  defp fallback_report(repo, locked_job, status) do
833 879
    steps =
834 880
      repo.all(
lib/openagents/work/continual_learning_server.ex added +228

@@ -0,0 +1,228 @@

1
defmodule OpenAgents.Work.ContinualLearningServer do
2
  @moduledoc """
3
  Supervised worker for one durable continual-learning run (CONTINUAL-001).
4
5
  Like `OpenAgents.Work.ScvServer`, this drives no model loop: it runs the
6
  bounded round loop in `OpenAgents.ContinualLearning.Runner` to completion in
7
  its own process, on the same row, statuses, Horde singleton, generation fence,
8
  recovery sweep, and report-into-conversation ending as every other kind. No
9
  second scheduler exists.
10
11
  An adopted run is not silently retrained. A worker that finds itself on a
12
  later generation stops the run as `interrupted`, which leaves the committed
13
  checkpoints in place for an explicit resume, so continuing a run is always an
14
  authorized act with its own receipt rather than a side effect of a restart.
15
  """
16
17
  # :transient — a crash on the same node is retried, and Horde relocates the
18
  # singleton to a survivor when its node dies. A clean finish is not restarted.
19
  use GenServer, restart: :transient
20
21
  alias OpenAgents.ContinualLearning
22
  alias OpenAgents.ContinualLearning.Job, as: LearningJob
23
  alias OpenAgents.ContinualLearning.Runner
24
  alias OpenAgents.Work
25
26
  # The runner enforces the stopping policy and the budget; this is the backstop
27
  # for a round loop that somehow outlives its admitted wall clock.
28
  @deadline_grace_ms 60_000
29
30
  def start_link(job_id) do
31
    GenServer.start_link(__MODULE__, job_id, name: via(job_id))
32
  end
33
34
  @impl true
35
  def init(job_id) do
36
    case Work.claim_for_run(job_id) do
37
      {:ok, %{generation: generation} = claimed} when generation > 1 ->
38
        _interrupted = interrupt(claimed)
39
        {:stop, :normal}
40
41
      {:ok, claimed} ->
42
        {:ok, %{job: claimed}, {:continue, :train}}
43
44
      {:error, _reason} ->
45
        {:stop, :normal}
46
    end
47
  end
48
49
  @impl true
50
  def handle_continue(:train, %{job: job}) do
51
    case learning_job_id(job) do
52
      nil ->
53
        _finished =
54
          Work.finish_job(job.id, "failed", error_code: "continual_learning_job_missing")
55
56
        {:stop, :normal, %{job: job}}
57
58
      learning_job_id ->
59
        deadline = Process.send_after(self(), :deadline, wall_clock_ms(job) + @deadline_grace_ms)
60
61
        task =
62
          Task.Supervisor.async_nolink(OpenAgents.ProviderTaskSupervisor, fn ->
63
            Runner.run(learning_job_id)
64
          end)
65
66
        {:noreply, %{job: job, learning_job_id: learning_job_id, deadline: deadline, task: task}}
67
    end
68
  end
69
70
  @impl true
71
  def handle_cast(:cancel, state) do
72
    _shutdown = Task.shutdown(state.task, :brutal_kill)
73
    _terminal = terminalize(state, "cancelled", "cancelled")
74
75
    _report =
76
      Work.append_report_delta(
77
        state.job,
78
        "Continual-learning job cancelled by the operator. Committed checkpoints and " <>
79
          "receipts are kept as evidence."
80
      )
81
82
    _finished = Work.finish_job(state.job.id, "cancelled", error_code: "cancelled")
83
    {:stop, :normal, state}
84
  end
85
86
  @impl true
87
  def handle_info({reference, result}, %{task: %{ref: reference}} = state) do
88
    Process.demonitor(reference, [:flush])
89
    _timer = cancel_deadline(state)
90
    {status, report, usage, code} = summarize(result)
91
    _appended = Work.append_report_delta(state.job, report)
92
    _finished = Work.finish_job(state.job.id, status, error_code: code, usage: usage)
93
    {:stop, :normal, state}
94
  end
95
96
  def handle_info(:deadline, state) do
97
    _shutdown = Task.shutdown(state.task, :brutal_kill)
98
    _terminal = terminalize(state, "interrupted", "wall_clock_exceeded")
99
100
    _report =
101
      Work.append_report_delta(
102
        state.job,
103
        "Continual-learning job exceeded its admitted wall clock and was stopped. " <>
104
          "Resume it to continue from its last committed checkpoint."
105
      )
106
107
    _finished = Work.finish_job(state.job.id, "interrupted", error_code: "wall_clock_exceeded")
108
    {:stop, :normal, state}
109
  end
110
111
  def handle_info({:DOWN, reference, :process, _pid, _reason}, %{task: %{ref: reference}} = state) do
112
    _timer = cancel_deadline(state)
113
    _terminal = terminalize(state, "interrupted", "worker_exited")
114
115
    _report =
116
      Work.append_report_delta(
117
        state.job,
118
        "The continual-learning worker stopped unexpectedly. Resume the job to " <>
119
          "continue from its last committed checkpoint."
120
      )
121
122
    _finished = Work.finish_job(state.job.id, "interrupted", error_code: "worker_exited")
123
    {:stop, :normal, state}
124
  end
125
126
  def handle_info(_message, state), do: {:noreply, state}
127
128
  # ── internal ───────────────────────────────────────────────────────────────
129
130
  defp interrupt(job) do
131
    with learning_job_id when is_binary(learning_job_id) <- learning_job_id(job),
132
         {:ok, learning_job} <- ContinualLearning.fetch(learning_job_id) do
133
      _receipt =
134
        ContinualLearning.record_receipt(learning_job, "refusal", %{
135
          "reason" => "adopted_after_restart",
136
          "detail" => "the run is resumable from its last committed checkpoint"
137
        })
138
139
      _terminal = ContinualLearning.terminalize(learning_job, "interrupted", "runtime_restarted")
140
    end
141
142
    Work.finish_job(job.id, "interrupted", error_code: "runtime_restarted")
143
  end
144
145
  defp terminalize(%{learning_job_id: learning_job_id}, status, code) do
146
    case ContinualLearning.fetch(learning_job_id) do
147
      {:ok, learning_job} -> ContinualLearning.terminalize(learning_job, status, code)
148
      {:error, reason} -> {:error, reason}
149
    end
150
  end
151
152
  defp learning_job_id(job) do
153
    case (job.delegation || %{})["continual_learning_job_id"] do
154
      value when is_binary(value) -> value
155
      _missing -> nil
156
    end
157
  end
158
159
  defp wall_clock_ms(job) do
160
    case (job.budget_snapshot || %{})["wall_clock_ms"] do
161
      value when is_integer(value) and value > 0 -> value
162
      _missing -> 900_000
163
    end
164
  end
165
166
  defp cancel_deadline(%{deadline: reference}) when is_reference(reference),
167
    do: Process.cancel_timer(reference)
168
169
  defp cancel_deadline(_state), do: :ok
170
171
  defp summarize({:ok, %LearningJob{} = learning_job}) do
172
    {work_status(learning_job.status), report_text(learning_job), usage(learning_job),
173
     learning_job.error_code}
174
  end
175
176
  defp summarize({:error, reason}) do
177
    {"failed", "Continual-learning job could not run: #{code(reason)}.", nil, code(reason)}
178
  end
179
180
  defp summarize(_other) do
181
    {"failed", "Continual-learning job ended without a terminal record.", nil,
182
     "continual_learning_failed"}
183
  end
184
185
  defp work_status("completed"), do: "completed"
186
  defp work_status("cancelled"), do: "cancelled"
187
  defp work_status("interrupted"), do: "interrupted"
188
  defp work_status("budget_exhausted"), do: "budget_exhausted"
189
  defp work_status(_status), do: "failed"
190
191
  defp report_text(%LearningJob{} = learning_job) do
192
    artifact = ContinualLearning.artifact(learning_job)
193
194
    header =
195
      "Continual-learning job #{learning_job.id} — #{human_status(learning_job.status)}. " <>
196
        "Objective version #{learning_job.objective_version} on #{learning_job.base_model_ref}. " <>
197
        "Rounds: #{learning_job.rounds_completed}."
198
199
    detail =
200
      if artifact do
201
        "Artifact digest #{artifact.artifact_digest}, model #{artifact.model_ref}, " <>
202
          "over #{length(artifact.checkpoint_digests)} checkpoints and " <>
203
          "#{length(artifact.dataset_bindings)} licensed datasets."
204
      else
205
        "No artifact was produced. Reason: #{learning_job.error_code || "unknown"}."
206
      end
207
208
    "#{header}\n\n#{detail}"
209
  end
210
211
  defp usage(%LearningJob{usage: usage}) when is_map(usage) and map_size(usage) > 0 do
212
    Map.take(usage, ["input_tokens", "output_tokens", "total_tokens"])
213
  end
214
215
  defp usage(_learning_job), do: nil
216
217
  defp human_status("completed"), do: "completed"
218
  defp human_status("cancelled"), do: "cancelled"
219
  defp human_status("interrupted"), do: "interrupted"
220
  defp human_status("budget_exhausted"), do: "stopped at its budget"
221
  defp human_status(other), do: "ended (#{other})"
222
223
  defp code(reason) when is_atom(reason), do: Atom.to_string(reason)
224
  defp code({reason, _detail}) when is_atom(reason), do: Atom.to_string(reason)
225
  defp code(_reason), do: "continual_learning_failed"
226
227
  defp via(job_id), do: {:via, Horde.Registry, {OpenAgents.HordeRegistry, {:work_job, job_id}}}
228
end
lib/openagents/work/job.ex modified +1 -1

@@ -15,7 +15,7 @@ defmodule OpenAgents.Work.Job do

15 15
  @statuses ~w(queued running completed failed interrupted budget_exhausted cancelled)
16 16
  @terminal_statuses ~w(completed failed interrupted budget_exhausted cancelled)
17 17
  @surfaces ~w(text voice)
18
  @kinds ~w(deep_work delegation coding scv)
18
  @kinds ~w(deep_work delegation coding scv continual_learning)
19 19
  @machine_tiers ~w(probe curated shell)
20 20
  @maximum_goal_bytes 2_000
21 21
  @maximum_context_hint_bytes 2_000
lib/openagents_web/controllers/continual_learning_controller.ex added +200

@@ -0,0 +1,200 @@

1
defmodule OpenAgentsWeb.ContinualLearningController do
2
  @moduledoc """
3
  Operator-authenticated continual-learning jobs for the one named internal
4
  buyer (CONTINUAL-001).
5
6
  The endpoints are the whole lane: start a job over admitted licensed datasets,
7
  read it, cancel it, resume it from its surviving checkpoint, replay it as a new
8
  job, and export its evidence. Every refusal is a typed code, so a caller learns
9
  which bound it hit rather than reading a generic failure.
10
  """
11
12
  use OpenAgentsWeb, :controller
13
14
  alias OpenAgents.ContinualLearning
15
16
  def create(conn, params) do
17
    case ContinualLearning.start(conn.assigns.current_user, admission(params)) do
18
      {:ok, job} ->
19
        conn
20
        |> put_status(:created)
21
        |> json(%{"job" => ContinualLearning.projection(job)})
22
23
      {:error, reason} ->
24
        refusal(conn, reason)
25
    end
26
  end
27
28
  def index(conn, params) do
29
    case ContinualLearning.list(conn.assigns.current_user, limit(params)) do
30
      {:ok, jobs} ->
31
        json(conn, %{"jobs" => Enum.map(jobs, &ContinualLearning.projection/1)})
32
33
      {:error, reason} ->
34
        refusal(conn, reason)
35
    end
36
  end
37
38
  def show(conn, %{"id" => id}) do
39
    case ContinualLearning.get(conn.assigns.current_user, id) do
40
      {:ok, job} -> json(conn, %{"job" => ContinualLearning.projection(job)})
41
      {:error, reason} -> refusal(conn, reason)
42
    end
43
  end
44
45
  def cancel(conn, %{"id" => id}) do
46
    case ContinualLearning.cancel(conn.assigns.current_user, id) do
47
      {:ok, job} -> json(conn, %{"job" => ContinualLearning.projection(job)})
48
      {:error, reason} -> refusal(conn, reason)
49
    end
50
  end
51
52
  def resume(conn, %{"id" => id} = params) do
53
    case ContinualLearning.resume(conn.assigns.current_user, id, surface(params)) do
54
      {:ok, job} -> json(conn, %{"job" => ContinualLearning.projection(job)})
55
      {:error, reason} -> refusal(conn, reason)
56
    end
57
  end
58
59
  def replay(conn, %{"id" => id} = params) do
60
    case ContinualLearning.replay(conn.assigns.current_user, id, surface(params)) do
61
      {:ok, job} ->
62
        conn
63
        |> put_status(:created)
64
        |> json(%{"job" => ContinualLearning.projection(job)})
65
66
      {:error, reason} ->
67
        refusal(conn, reason)
68
    end
69
  end
70
71
  def evidence(conn, %{"id" => id}) do
72
    case ContinualLearning.export_evidence(conn.assigns.current_user, id) do
73
      {:ok, export} ->
74
        conn
75
        |> put_resp_header(
76
          "content-disposition",
77
          ~s(attachment; filename="continual-learning-evidence-#{id}.json")
78
        )
79
        |> json(export)
80
81
      {:error, reason} ->
82
        refusal(conn, reason)
83
    end
84
  end
85
86
  # The admission arrives as JSON with string keys. Only the admitted shape is
87
  # read across the boundary: nothing here turns caller text into an atom, and an
88
  # unexpected key is dropped rather than carried into the durable row.
89
  defp admission(params) do
90
    %{
91
      buyer_ref: params["buyer_ref"],
92
      objective: params["objective"],
93
      objective_version: params["objective_version"],
94
      base_model_ref: params["base_model_ref"],
95
      base_model_digest: params["base_model_digest"],
96
      configuration: params["configuration"] || %{},
97
      datasets: dataset_references(params["datasets"]),
98
      evaluation: evaluation(params["evaluation"] || %{}),
99
      budget: budget(params["budget"] || %{}),
100
      runtime_class: params["runtime_class"],
101
      stopping_policy: stopping_policy(params["stopping_policy"] || %{}),
102
      conversation_id: params["conversation_id"],
103
      owner_visitor_id: params["owner_visitor_id"]
104
    }
105
  end
106
107
  defp dataset_references(references) when is_list(references) do
108
    Enum.map(references, fn reference ->
109
      if is_map(reference),
110
        do: Map.take(reference, ["listing_id", "acceptance_ref"]),
111
        else: %{}
112
    end)
113
  end
114
115
  defp dataset_references(_references), do: []
116
117
  defp evaluation(evaluation) when is_map(evaluation) do
118
    %{
119
      corpus: dataset_references(evaluation["corpus"]),
120
      verifier: verifier(evaluation["verifier"] || %{}),
121
      separation_required: evaluation["separation_required"] == true,
122
      acceptance_criteria: evaluation["acceptance_criteria"],
123
      target_metric: evaluation["target_metric"],
124
      target_value: evaluation["target_value"],
125
      policy_version: evaluation["policy_version"] || 1
126
    }
127
  end
128
129
  defp verifier(verifier) when is_map(verifier) do
130
    %{
131
      id: verifier["id"],
132
      admitted: verifier["admitted"] == true,
133
      independent_of_producer: verifier["independent_of_producer"] == true
134
    }
135
  end
136
137
  defp budget(budget) when is_map(budget), do: %{usd_cents: budget["usd_cents"]}
138
139
  defp stopping_policy(policy) when is_map(policy) do
140
    %{
141
      maximum_rounds: policy["maximum_rounds"],
142
      minimum_improvement: policy["minimum_improvement"] || 0.0
143
    }
144
  end
145
146
  defp surface(params) do
147
    %{
148
      conversation_id: params["conversation_id"],
149
      owner_visitor_id: params["owner_visitor_id"]
150
    }
151
  end
152
153
  defp limit(params) do
154
    case Integer.parse(to_string(params["limit"] || "50")) do
155
      {value, ""} when value > 0 -> value
156
      _invalid -> 50
157
    end
158
  end
159
160
  defp refusal(conn, reason) do
161
    conn
162
    |> put_status(status_for(reason))
163
    |> json(%{"error" => code(reason)})
164
  end
165
166
  defp status_for(:not_found), do: :not_found
167
  defp status_for(:operator_required), do: :forbidden
168
  defp status_for(:continual_learning_disabled), do: :service_unavailable
169
  defp status_for(:continual_learning_at_capacity), do: :conflict
170
  defp status_for(:buyer_not_configured), do: :service_unavailable
171
  defp status_for(:buyer_not_admitted), do: :forbidden
172
  defp status_for(:training_code_not_pinned), do: :service_unavailable
173
  defp status_for(:not_cancellable), do: :conflict
174
  defp status_for(:not_resumable), do: :conflict
175
  defp status_for(:budget_exhausted), do: :conflict
176
  defp status_for(:checkpoint_lost), do: :conflict
177
  defp status_for(:checkpoint_missing), do: :conflict
178
  defp status_for(:stopping_policy_satisfied), do: :conflict
179
  defp status_for({:capacity_unavailable, _detail}), do: :service_unavailable
180
  defp status_for({:dataset_not_authorized, _detail}), do: :forbidden
181
  defp status_for({:dataset_unavailable, _detail}), do: :conflict
182
  defp status_for({:consent_missing, _listing_id}), do: :forbidden
183
  defp status_for({:use_not_licensed, _listing_id, _use}), do: :forbidden
184
  defp status_for({:unsupported_custody, _detail}), do: :forbidden
185
  defp status_for({:dataset_moved, _listing_id}), do: :conflict
186
  defp status_for(_reason), do: :unprocessable_entity
187
188
  defp code(reason) when is_atom(reason), do: Atom.to_string(reason)
189
190
  defp code({reason, detail}) when is_atom(reason) and (is_atom(detail) or is_binary(detail)),
191
    do: "#{reason}:#{detail}"
192
193
  defp code({reason, _detail}) when is_atom(reason), do: Atom.to_string(reason)
194
195
  defp code({reason, detail, extra}) when is_atom(reason),
196
    do: "#{reason}:#{detail}:#{extra}"
197
198
  defp code(%Ecto.Changeset{}), do: "invalid_continual_learning_job"
199
  defp code(_reason), do: "continual_learning_failed"
200
end
lib/openagents_web/route_authority.ex modified +9

@@ -239,6 +239,15 @@ defmodule OpenAgentsWeb.RouteAuthority do

239 239
        verb not in [:get, :head]
240 240
      )
241 241
242
  defp policy(%{path: "/api/operator/continual-learning" <> _path, verb: verb}),
243
    do:
244
      declaration(
245
        :operator,
246
        "configured operator GitHub ID",
247
        "continual-learning:operate",
248
        verb not in [:get, :head]
249
      )
250
242 251
  defp policy(%{path: "/api/v3/device/authorizations" <> _path, verb: :post}),
243 252
    do:
244 253
      declaration(
lib/openagents_web/router.ex modified +8

@@ -237,6 +237,14 @@ defmodule OpenAgentsWeb.Router do

237 237
    post "/artifact-listings/:id/source-authorizations",
238 238
         ArtifactListingAdminController,
239 239
         :authorize
240
241
    post "/continual-learning/jobs", ContinualLearningController, :create
242
    get "/continual-learning/jobs", ContinualLearningController, :index
243
    get "/continual-learning/jobs/:id", ContinualLearningController, :show
244
    post "/continual-learning/jobs/:id/cancellation", ContinualLearningController, :cancel
245
    post "/continual-learning/jobs/:id/resumptions", ContinualLearningController, :resume
246
    post "/continual-learning/jobs/:id/replays", ContinualLearningController, :replay
247
    get "/continual-learning/jobs/:id/evidence", ContinualLearningController, :evidence
240 248
  end
241 249
242 250
  scope "/admin", OpenAgentsWeb do
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -249,7 +249,8 @@

249 249
    20260823070000,
250 250
    20260823071500,
251 251
    20260823072000,
252
    20260823073000
252
    20260823073000,
253
    20260823074000
253 254
  ],
254 255
  "required_tables": [
255 256
    "users",
priv/repo/migrations/20260823074000_create_continual_learning_jobs.exs added +133

@@ -0,0 +1,133 @@

1
defmodule OpenAgents.Repo.Migrations.CreateContinualLearningJobs do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:continual_learning_jobs, primary_key: false) do
6
      add :id, :uuid, primary_key: true
7
      add :buyer_ref, :text, null: false
8
      add :buyer_class, :text, null: false
9
      add :objective, :text, null: false
10
      add :objective_version, :integer, null: false
11
      add :base_model_ref, :text, null: false
12
      add :base_model_digest, :text, null: false
13
      add :training_code_digest, :text, null: false
14
      add :configuration, :map, null: false, default: fragment("'{}'::jsonb")
15
      add :configuration_digest, :text, null: false
16
      add :datasets, {:array, :map}, null: false, default: []
17
      add :evaluation, :map, null: false, default: fragment("'{}'::jsonb")
18
      add :budget, :map, null: false, default: fragment("'{}'::jsonb")
19
      add :runtime_class, :text, null: false
20
      add :capacity_receipt, :map, null: false, default: fragment("'{}'::jsonb")
21
      add :stopping_policy, :map, null: false, default: fragment("'{}'::jsonb")
22
      add :admission_digest, :text, null: false
23
      add :status, :text, null: false, default: "queued"
24
      add :error_code, :text
25
      add :rounds_completed, :integer, null: false, default: 0
26
      add :resume_count, :integer, null: false, default: 0
27
      add :usage, :map, null: false, default: fragment("'{}'::jsonb")
28
      add :work_job_id, references(:work_jobs, type: :uuid, on_delete: :restrict)
29
      add :replay_of_id, references(:continual_learning_jobs, type: :uuid, on_delete: :restrict)
30
      add :started_at, :utc_datetime_usec
31
      add :completed_at, :utc_datetime_usec
32
33
      timestamps(type: :utc_datetime_usec)
34
    end
35
36
    create constraint(:continual_learning_jobs, :continual_learning_jobs_status,
37
             check:
38
               "status IN ('queued', 'running', 'completed', 'failed', 'interrupted', 'budget_exhausted', 'cancelled')"
39
           )
40
41
    create constraint(:continual_learning_jobs, :continual_learning_jobs_rounds,
42
             check: "rounds_completed >= 0 AND resume_count >= 0"
43
           )
44
45
    create constraint(:continual_learning_jobs, :continual_learning_jobs_not_own_replay,
46
             check: "replay_of_id IS NULL OR replay_of_id <> id"
47
           )
48
49
    create index(:continual_learning_jobs, [:buyer_ref])
50
    create index(:continual_learning_jobs, [:status])
51
    create index(:continual_learning_jobs, [:admission_digest])
52
    create unique_index(:continual_learning_jobs, [:work_job_id])
53
54
    create table(:continual_learning_checkpoints, primary_key: false) do
55
      add :id, :uuid, primary_key: true
56
57
      add :job_id,
58
          references(:continual_learning_jobs, type: :uuid, on_delete: :delete_all),
59
          null: false
60
61
      add :round, :integer, null: false
62
      add :state, :map, null: false, default: fragment("'{}'::jsonb")
63
      add :state_digest, :text, null: false
64
      add :parent_digest, :text
65
      add :metrics, :map, null: false, default: fragment("'{}'::jsonb")
66
      add :usage, :map, null: false, default: fragment("'{}'::jsonb")
67
      add :energy, :map, null: false, default: fragment("'{}'::jsonb")
68
      add :lost, :boolean, null: false, default: false
69
70
      timestamps(type: :utc_datetime_usec)
71
    end
72
73
    create constraint(:continual_learning_checkpoints, :continual_learning_checkpoints_round,
74
             check: "round > 0"
75
           )
76
77
    create unique_index(:continual_learning_checkpoints, [:job_id, :round])
78
    create unique_index(:continual_learning_checkpoints, [:job_id, :state_digest])
79
80
    create table(:continual_learning_receipts, primary_key: false) do
81
      add :id, :uuid, primary_key: true
82
83
      add :job_id,
84
          references(:continual_learning_jobs, type: :uuid, on_delete: :delete_all),
85
          null: false
86
87
      add :kind, :text, null: false
88
      add :sequence, :integer, null: false
89
      add :receipt_ref, :text, null: false
90
      add :payload, :map, null: false, default: fragment("'{}'::jsonb")
91
      add :digest, :text, null: false
92
93
      timestamps(type: :utc_datetime_usec)
94
    end
95
96
    create constraint(:continual_learning_receipts, :continual_learning_receipts_kind,
97
             check:
98
               "kind IN ('admission', 'usage', 'energy', 'training', 'evaluation', 'artifact', 'settlement', 'resume', 'refusal')"
99
           )
100
101
    create unique_index(:continual_learning_receipts, [:job_id, :sequence])
102
    create unique_index(:continual_learning_receipts, [:receipt_ref])
103
    create index(:continual_learning_receipts, [:job_id, :kind])
104
105
    create table(:continual_learning_artifacts, primary_key: false) do
106
      add :id, :uuid, primary_key: true
107
108
      add :job_id,
109
          references(:continual_learning_jobs, type: :uuid, on_delete: :delete_all),
110
          null: false
111
112
      add :model_ref, :text, null: false
113
      add :model_digest, :text, null: false
114
      add :base_model_digest, :text, null: false
115
      add :training_code_digest, :text, null: false
116
      add :configuration_digest, :text, null: false
117
      add :dataset_bindings, {:array, :map}, null: false, default: []
118
      add :checkpoint_digests, {:array, :text}, null: false, default: []
119
      add :evaluation_result, :map, null: false, default: fragment("'{}'::jsonb")
120
      add :accepted_outcome, :map, null: false, default: fragment("'{}'::jsonb")
121
      add :settlement, :map, null: false, default: fragment("'{}'::jsonb")
122
      add :artifact_digest, :text, null: false
123
124
      timestamps(type: :utc_datetime_usec)
125
    end
126
127
    create unique_index(:continual_learning_artifacts, [:job_id])
128
    # Two jobs admitted under identical inputs must produce the same artifact
129
    # digest, so the digest is indexed for lookup and never made unique: a
130
    # replay that reproduces the digest is the evidence, not a conflict.
131
    create index(:continual_learning_artifacts, [:artifact_digest])
132
  end
133
end
test/openagents/continual_learning_test.exs added +574

@@ -0,0 +1,574 @@

1
defmodule OpenAgents.ContinualLearningTest do
2
  @moduledoc """
3
  CONTINUAL-001: the lane that trains on verified licensed datasets.
4
5
  The refusals come first — a disabled lane, a signed-in non-operator, a buyer
6
  the lane never admitted, a base-model digest that does not match, a license
7
  that does not admit training, a removed listing, an evaluator that is not
8
  independent when separation is required, an unavailable fleet — because a
9
  lane that cannot refuse cannot be trusted with licensed data. The canary run
10
  then goes end to end and proves the artifact binds the exact datasets,
11
  licenses, code, configuration, checkpoints, and evaluation it claims.
12
  """
13
14
  use OpenAgents.DataCase, async: false
15
16
  alias OpenAgents.AccountsFixtures
17
  alias OpenAgents.ArtifactCatalog
18
  alias OpenAgents.Conversations
19
  alias OpenAgents.ContinualLearning
20
  alias OpenAgents.ContinualLearning.Checkpoint
21
  alias OpenAgents.ContinualLearningFixtures, as: Fixtures
22
  alias OpenAgents.ContinualLearningStubs
23
  alias OpenAgents.Provenance.Canonical
24
  alias OpenAgents.Repo
25
26
  setup do
27
    Ecto.Adapters.SQL.Sandbox.mode(OpenAgents.Repo, {:shared, self()})
28
29
    previous_capacity = Application.get_env(:openagents, OpenAgents.Capacity, [])
30
31
    previous =
32
      for key <- [:admin_github_ids, :capacity_test_evidence] do
33
        {key, Application.get_env(:openagents, key)}
34
      end
35
36
    Application.put_env(:openagents, OpenAgents.ContinualLearning, Fixtures.settings())
37
38
    Application.put_env(
39
      :openagents,
40
      OpenAgents.Capacity,
41
      Keyword.merge(previous_capacity, evidence_source: OpenAgents.CapacityEvidenceStub)
42
    )
43
44
    Application.put_env(:openagents, :capacity_test_evidence, Fixtures.capacity_evidence())
45
46
    on_exit(fn ->
47
      Application.put_env(:openagents, OpenAgents.Capacity, previous_capacity)
48
      Application.delete_env(:openagents, OpenAgents.ContinualLearning)
49
      ContinualLearningStubs.Observer.forget()
50
51
      for {key, value} <- previous do
52
        if is_nil(value),
53
          do: Application.delete_env(:openagents, key),
54
          else: Application.put_env(:openagents, key, value)
55
      end
56
    end)
57
58
    :ok
59
  end
60
61
  describe "admission" do
62
    test "a disabled lane refuses before authority is considered" do
63
      %{operator: operator, conversation: conversation} = account("cl-disabled")
64
      configure(enabled: false)
65
66
      assert {:error, :continual_learning_disabled} =
67
               ContinualLearning.start(operator, admission(conversation))
68
    end
69
70
    test "a signed-in non-operator cannot start, read, cancel, or export a job" do
71
      %{conversation: conversation} = account("cl-operator")
72
      user = AccountsFixtures.repository_user_fixture("cl-non-operator")
73
74
      assert {:error, :operator_required} =
75
               ContinualLearning.start(user, admission(conversation))
76
77
      assert {:error, :operator_required} = ContinualLearning.get(user, Ecto.UUID.generate())
78
      assert {:error, :operator_required} = ContinualLearning.list(user)
79
      assert {:error, :operator_required} = ContinualLearning.cancel(user, Ecto.UUID.generate())
80
81
      assert {:error, :operator_required} =
82
               ContinualLearning.export_evidence(user, Ecto.UUID.generate())
83
    end
84
85
    test "only the named buyer the lane admits may start a job" do
86
      %{operator: operator, conversation: conversation} = account("cl-buyer")
87
88
      assert {:error, :buyer_not_admitted} =
89
               ContinualLearning.start(
90
                 operator,
91
                 admission(conversation, %{buyer_ref: "buyer:someone-else"})
92
               )
93
94
      configure(buyer_ref: nil)
95
96
      assert {:error, :buyer_not_configured} =
97
               ContinualLearning.start(operator, admission(conversation))
98
    end
99
100
    test "the base model, its digest, and the training code are all pinned" do
101
      %{operator: operator, conversation: conversation} = account("cl-model")
102
103
      assert {:error, :base_model_not_admitted} =
104
               ContinualLearning.start(
105
                 operator,
106
                 admission(conversation, %{base_model_ref: "someone/else-1"})
107
               )
108
109
      assert {:error, :base_model_digest_mismatch} =
110
               ContinualLearning.start(
111
                 operator,
112
                 admission(conversation, %{base_model_digest: Canonical.sha256("other")})
113
               )
114
115
      configure(training_code_digest: nil)
116
117
      assert {:error, :training_code_not_pinned} =
118
               ContinualLearning.start(operator, admission(conversation))
119
    end
120
121
    test "the runtime class, budget, and stopping policy stay inside their bounds" do
122
      %{operator: operator, conversation: conversation} = account("cl-bounds")
123
124
      assert {:error, :runtime_class_not_admitted} =
125
               ContinualLearning.start(
126
                 operator,
127
                 admission(conversation, %{runtime_class: "gigantic"})
128
               )
129
130
      assert {:error, :budget_invalid} =
131
               ContinualLearning.start(operator, admission(conversation, %{budget: %{}}))
132
133
      assert {:error, :stopping_policy_exceeds_bound} =
134
               ContinualLearning.start(
135
                 operator,
136
                 admission(conversation, %{stopping_policy: %{maximum_rounds: 9}})
137
               )
138
139
      assert {:error, :stopping_policy_required} =
140
               ContinualLearning.start(operator, admission(conversation, %{stopping_policy: %{}}))
141
    end
142
143
    test "an unlicensed use, a withdrawn consent, and a removed listing all refuse" do
144
      %{operator: operator, conversation: conversation} = account("cl-license")
145
146
      evaluation_only =
147
        Fixtures.licensed_dataset!(%{
148
          license_terms: %{
149
            "opt_in" => true,
150
            "allowed_uses" => ["evaluation"],
151
            "redistribution" => "prohibited"
152
          }
153
        })
154
155
      assert {:error, {:use_not_licensed, _id, "training"}} =
156
               ContinualLearning.start(
157
                 operator,
158
                 admission(conversation, %{
159
                   datasets: [Fixtures.dataset_reference(evaluation_only)]
160
                 })
161
               )
162
163
      expired = Fixtures.licensed_dataset!()
164
      Fixtures.expire_license!(expired)
165
166
      assert {:error, {:dataset_unavailable, :stale_license}} =
167
               ContinualLearning.start(
168
                 operator,
169
                 admission(conversation, %{datasets: [Fixtures.dataset_reference(expired)]})
170
               )
171
172
      removed = Fixtures.licensed_dataset!()
173
174
      {:ok, _receipt} =
175
        ArtifactCatalog.remove_listing(removed.listing.id, %{
176
          reason: "the contributor withdrew consent",
177
          receipt_ref: "artifact-removal:#{System.unique_integer([:positive])}",
178
          actor_ref: "operator:test"
179
        })
180
181
      assert {:error, {:dataset_unavailable, :listing_removed}} =
182
               ContinualLearning.start(
183
                 operator,
184
                 admission(conversation, %{datasets: [Fixtures.dataset_reference(removed)]})
185
               )
186
    end
187
188
    test "a dataset the buyer never accepted is refused before any capacity is spent" do
189
      %{operator: operator, conversation: conversation} = account("cl-acceptance")
190
      unaccepted = Fixtures.licensed_dataset!()
191
192
      assert {:error, {:dataset_not_authorized, :not_authorized}} =
193
               ContinualLearning.start(
194
                 operator,
195
                 admission(conversation, %{
196
                   datasets: [
197
                     %{
198
                       listing_id: unaccepted.listing.id,
199
                       acceptance_ref: "artifact-transaction:never-issued"
200
                     }
201
                   ]
202
                 })
203
               )
204
205
      assert ContinualLearning.active_count() == 0
206
    end
207
208
    test "an unadmitted or non-independent evaluator cannot grade the run" do
209
      %{operator: operator, conversation: conversation} = account("cl-evaluator")
210
211
      assert {:error, :evaluator_not_admitted} =
212
               ContinualLearning.start(
213
                 operator,
214
                 admission(conversation, %{
215
                   evaluation:
216
                     evaluation(conversation, %{
217
                       verifier: %{
218
                         id: "verifier:x",
219
                         admitted: false,
220
                         independent_of_producer: true
221
                       }
222
                     })
223
                 })
224
               )
225
226
      assert {:error, :evaluator_not_independent} =
227
               ContinualLearning.start(
228
                 operator,
229
                 admission(conversation, %{
230
                   evaluation:
231
                     evaluation(conversation, %{
232
                       verifier: %{
233
                         id: "verifier:x",
234
                         admitted: true,
235
                         independent_of_producer: false
236
                       }
237
                     })
238
                 })
239
               )
240
    end
241
242
    test "an unavailable fleet class refuses instead of queueing" do
243
      %{operator: operator, conversation: conversation} = account("cl-capacity")
244
      Application.put_env(:openagents, :capacity_test_evidence, {:error, :unavailable})
245
246
      assert {:error, {:capacity_unavailable, _detail}} =
247
               ContinualLearning.start(operator, admission(conversation))
248
    end
249
250
    test "the concurrency ceiling refuses a second concurrent job" do
251
      %{operator: operator, conversation: conversation} = account("cl-concurrency")
252
      ContinualLearningStubs.Observer.watch(self())
253
      configure(trainer: ContinualLearningStubs.GatedTrainer)
254
255
      assert {:ok, job} = ContinualLearning.start(operator, admission(conversation))
256
      assert_receive {:round_started, 1, trainer}, 5_000
257
258
      assert {:error, :continual_learning_at_capacity} =
259
               ContinualLearning.start(operator, admission(conversation))
260
261
      Process.exit(trainer, :kill)
262
      assert Fixtures.await_terminal!(job.id).status == "interrupted"
263
    end
264
  end
265
266
  describe "the canary run" do
267
    test "one admitted job trains, evaluates, and binds a reproducible artifact" do
268
      %{operator: operator, conversation: conversation} = account("cl-run")
269
270
      assert {:ok, started} = ContinualLearning.start(operator, admission(conversation))
271
      assert started.status == "queued"
272
      assert started.admission_digest =~ ~r/\A[0-9a-f]{64}\z/
273
      assert started.work_job_id
274
275
      job = Fixtures.await_terminal!(started.id)
276
      assert job.status == "completed"
277
      assert job.rounds_completed == 2
278
279
      # Every round committed a checkpoint, chained to its parent.
280
      checkpoints = ContinualLearning.checkpoints(job)
281
      assert Enum.map(checkpoints, & &1.round) == [1, 2]
282
      assert Enum.at(checkpoints, 1).parent_digest == Enum.at(checkpoints, 0).state_digest
283
284
      for checkpoint <- checkpoints do
285
        assert Canonical.digest!(checkpoint.state) == checkpoint.state_digest
286
        assert checkpoint.energy["joules"] > 0
287
        assert checkpoint.usage["cost_usd_cents"] == 2
288
      end
289
290
      # The artifact binds the exact inputs the admission authorized.
291
      artifact = ContinualLearning.artifact(job)
292
      assert artifact.base_model_digest == Fixtures.base_model_digest()
293
      assert artifact.training_code_digest == Fixtures.training_code_digest()
294
      assert artifact.configuration_digest == job.configuration_digest
295
      assert artifact.checkpoint_digests == Enum.map(checkpoints, & &1.state_digest)
296
      assert [binding] = artifact.dataset_bindings
297
      assert binding["license_digest"] == List.first(job.datasets)["license_digest"]
298
      assert artifact.accepted_outcome["state"] == "accepted"
299
      assert artifact.accepted_outcome["revision"] == job.admission_digest
300
      assert artifact.accepted_outcome["issue_number"] == 86
301
      assert artifact.evaluation_result["terminal_result"] == "passed"
302
      assert artifact.evaluation_result["corpus_digest"] == job.evaluation["corpus_digest"]
303
304
      # Settlement-ready evidence names the buyer and the policy, and states
305
      # that no custody moved here.
306
      assert artifact.settlement["buyer_ref"] == Fixtures.buyer_ref()
307
      assert artifact.settlement["unit"] == "usd_cents"
308
      assert artifact.settlement["amount"] == 4
309
      assert artifact.settlement["transferred"] == false
310
311
      # The receipt chain explains the whole run, in order, append-only.
312
      kinds = job |> ContinualLearning.receipts() |> Enum.map(& &1.kind)
313
      assert List.first(kinds) == "admission"
314
315
      for kind <- ~w(usage energy training evaluation artifact settlement) do
316
        assert kind in kinds
317
      end
318
319
      # The licensed side of the trade reconciles into the catalog's own chain.
320
      listing_id = List.first(job.datasets)["listing_id"]
321
      {:ok, history} = ArtifactCatalog.export_listing_history(listing_id)
322
      actions = Enum.map(history["receipts"], & &1["action"])
323
      assert "delivery" in actions
324
      assert "verification" in actions
325
      assert "settlement" in actions
326
    end
327
328
    test "a replay is a new job from round zero that reproduces the artifact digest" do
329
      %{operator: operator, conversation: conversation} = account("cl-replay")
330
331
      {:ok, first} = ContinualLearning.start(operator, admission(conversation))
332
      first = Fixtures.await_terminal!(first.id)
333
      assert first.status == "completed"
334
335
      assert {:ok, replay} =
336
               ContinualLearning.replay(operator, first.id, %{
337
                 conversation_id: conversation.id,
338
                 owner_visitor_id: conversation.visitor_id
339
               })
340
341
      assert replay.id != first.id
342
      assert replay.replay_of_id == first.id
343
      assert replay.rounds_completed == 0
344
      assert replay.admission_digest == first.admission_digest
345
346
      replay = Fixtures.await_terminal!(replay.id)
347
      assert replay.status == "completed"
348
349
      # Reproducibility is the claim the digests have to carry.
350
      assert ContinualLearning.artifact(replay).artifact_digest ==
351
               ContinualLearning.artifact(first).artifact_digest
352
353
      # The replay trained its own checkpoints rather than reusing any.
354
      assert Enum.map(ContinualLearning.checkpoints(replay), & &1.round) == [1, 2]
355
    end
356
357
    test "a trainer failure and an unverifiable evaluation both refuse the artifact" do
358
      %{operator: operator, conversation: conversation} = account("cl-trainer-failure")
359
      configure(trainer: ContinualLearningStubs.FailingTrainer)
360
361
      {:ok, job} = ContinualLearning.start(operator, admission(conversation))
362
      job = Fixtures.await_terminal!(job.id)
363
      assert job.status == "failed"
364
      assert job.error_code == "trainer_unavailable"
365
      assert ContinualLearning.artifact(job) == nil
366
      assert ContinualLearning.checkpoints(job) == []
367
368
      configure(evaluator: ContinualLearningStubs.FailingEvaluator)
369
      %{conversation: other} = account("cl-evaluator-failure")
370
      {:ok, unverifiable} = ContinualLearning.start(operator, admission(other))
371
      unverifiable = Fixtures.await_terminal!(unverifiable.id)
372
      assert unverifiable.status == "failed"
373
      assert unverifiable.error_code == "evaluator_unavailable"
374
      assert ContinualLearning.artifact(unverifiable) == nil
375
    end
376
377
    test "a failed grade and a foreign evaluator identity both refuse the artifact" do
378
      %{operator: operator, conversation: conversation} = account("cl-grade")
379
      configure(evaluator: ContinualLearningStubs.FailingGradeEvaluator)
380
381
      {:ok, refused} = ContinualLearning.start(operator, admission(conversation))
382
      refused = Fixtures.await_terminal!(refused.id)
383
      assert refused.status == "failed"
384
      assert refused.error_code == "evaluation_failed"
385
      assert ContinualLearning.artifact(refused) == nil
386
387
      configure(evaluator: ContinualLearningStubs.ForeignEvaluator)
388
      %{conversation: other} = account("cl-foreign-evaluator")
389
      {:ok, foreign} = ContinualLearning.start(operator, admission(other))
390
      foreign = Fixtures.await_terminal!(foreign.id)
391
      assert foreign.status == "failed"
392
      assert foreign.error_code == "evaluator_identity_mismatch"
393
      assert ContinualLearning.artifact(foreign) == nil
394
    end
395
396
    test "an exhausted budget stops the run at a checkpoint the buyer can resume" do
397
      %{operator: operator, conversation: conversation} = account("cl-budget")
398
399
      {:ok, job} =
400
        ContinualLearning.start(
401
          operator,
402
          admission(conversation, %{
403
            budget: %{usd_cents: 2},
404
            stopping_policy: %{maximum_rounds: 3}
405
          })
406
        )
407
408
      job = Fixtures.await_terminal!(job.id)
409
      assert job.status == "budget_exhausted"
410
      assert job.rounds_completed == 1
411
      assert ContinualLearning.artifact(job) == nil
412
413
      # The surviving checkpoint records the round the money bought, and the
414
      # spent budget refuses a resume that could not pay for another round.
415
      assert %{round: 1} = ContinualLearning.latest_checkpoint(job)
416
      assert {:error, :budget_exhausted} = ContinualLearning.resume(operator, job.id)
417
    end
418
419
    test "cancellation stops the round loop and keeps the committed evidence" do
420
      %{operator: operator, conversation: conversation} = account("cl-cancel")
421
      ContinualLearningStubs.Observer.watch(self())
422
      configure(trainer: ContinualLearningStubs.GatedTrainer)
423
424
      {:ok, job} =
425
        ContinualLearning.start(
426
          operator,
427
          admission(conversation, %{
428
            stopping_policy: %{maximum_rounds: 4},
429
            evaluation: evaluation(conversation, %{target_value: 0.95})
430
          })
431
        )
432
433
      assert_receive {:round_started, 1, trainer}, 5_000
434
      send(trainer, :proceed)
435
      assert_receive {:round_started, 2, next}, 5_000
436
437
      assert {:ok, cancelled} = ContinualLearning.cancel(operator, job.id)
438
      assert cancelled.status == "cancelled"
439
      send(next, :proceed)
440
441
      terminal = Fixtures.await_terminal!(job.id)
442
      assert terminal.status == "cancelled"
443
      assert terminal.rounds_completed < 4
444
      assert ContinualLearning.artifact(terminal) == nil
445
      assert ContinualLearning.checkpoints(terminal) != []
446
    end
447
  end
448
449
  describe "resume" do
450
    test "a resume continues the surviving chain under the same admission" do
451
      %{operator: operator, conversation: conversation} = account("cl-resume")
452
      interrupted = interrupt_after_first_round!(operator, conversation)
453
      assert interrupted.status == "interrupted"
454
      assert interrupted.rounds_completed == 1
455
456
      configure(trainer: OpenAgents.ContinualLearning.Trainer.Reference)
457
458
      assert {:ok, resumed} = ContinualLearning.resume(operator, interrupted.id)
459
      assert resumed.id == interrupted.id
460
      assert resumed.resume_count == 1
461
      assert resumed.admission_digest == interrupted.admission_digest
462
      assert resumed.rounds_completed == 1
463
464
      terminal = Fixtures.await_terminal!(resumed.id)
465
      assert terminal.status == "completed"
466
      assert terminal.rounds_completed == 2
467
468
      # The resume continued the surviving chain instead of retraining round one.
469
      checkpoints = ContinualLearning.checkpoints(terminal)
470
      assert Enum.map(checkpoints, & &1.round) == [1, 2]
471
      assert Enum.at(checkpoints, 1).parent_digest == Enum.at(checkpoints, 0).state_digest
472
473
      # The resume is an authorized act with its own receipt.
474
      resume_receipts =
475
        terminal |> ContinualLearning.receipts() |> Enum.filter(&(&1.kind == "resume"))
476
477
      assert [receipt] = resume_receipts
478
      assert receipt.payload["from_round"] == 1
479
      assert receipt.payload["admission_digest"] == terminal.admission_digest
480
    end
481
482
    test "a lost checkpoint refuses the resume instead of retraining silently" do
483
      %{operator: operator, conversation: conversation} = account("cl-checkpoint-loss")
484
      interrupted = interrupt_after_first_round!(operator, conversation)
485
486
      {:ok, _lost} =
487
        interrupted
488
        |> ContinualLearning.latest_checkpoint()
489
        |> Checkpoint.loss_changeset()
490
        |> Repo.update()
491
492
      assert {:error, :checkpoint_lost} = ContinualLearning.resume(operator, interrupted.id)
493
      assert {:ok, reloaded} = ContinualLearning.fetch(interrupted.id)
494
      assert reloaded.rounds_completed == 1
495
    end
496
497
    test "a completed job is not resumable and a satisfied policy stops the resume" do
498
      %{operator: operator, conversation: conversation} = account("cl-not-resumable")
499
500
      {:ok, job} = ContinualLearning.start(operator, admission(conversation))
501
      completed = Fixtures.await_terminal!(job.id)
502
      assert completed.status == "completed"
503
504
      assert {:error, :not_resumable} = ContinualLearning.resume(operator, completed.id)
505
    end
506
  end
507
508
  describe "evidence" do
509
    test "the export carries the admission, the chain, the receipts, and the artifact" do
510
      %{operator: operator, conversation: conversation} = account("cl-evidence")
511
512
      {:ok, job} = ContinualLearning.start(operator, admission(conversation))
513
      job = Fixtures.await_terminal!(job.id)
514
515
      assert {:ok, evidence} = ContinualLearning.export_evidence(operator, job.id)
516
      assert evidence["schema"] == "openagents.continual_learning_evidence.v1"
517
      assert evidence["job"]["admission_digest"] == job.admission_digest
518
      assert length(evidence["checkpoints"]) == 2
519
      assert evidence["artifact"]["artifact_digest"]
520
521
      # The projection never carries the licensed source location, only its
522
      # digest, so evidence cannot become an access path.
523
      serialized = Jason.encode!(evidence)
524
      refute serialized =~ "vault://"
525
526
      assert {:ok, jobs} = ContinualLearning.list(operator)
527
      assert job.id in Enum.map(jobs, & &1.id)
528
    end
529
  end
530
531
  # Kills the worker between the first and the second round, which is the fault
532
  # a resume exists for: one committed checkpoint, no terminal artifact.
533
  defp interrupt_after_first_round!(operator, conversation) do
534
    ContinualLearningStubs.Observer.watch(self())
535
    configure(trainer: ContinualLearningStubs.GatedTrainer)
536
537
    {:ok, job} = ContinualLearning.start(operator, admission(conversation))
538
    assert_receive {:round_started, 1, first}, 5_000
539
    send(first, :proceed)
540
    assert_receive {:round_started, 2, second}, 5_000
541
    Process.exit(second, :kill)
542
543
    Fixtures.await_terminal!(job.id)
544
  end
545
546
  defp configure(overrides) do
547
    Application.put_env(
548
      :openagents,
549
      OpenAgents.ContinualLearning,
550
      Fixtures.settings(overrides)
551
    )
552
  end
553
554
  defp admission(conversation, overrides \\ %{}) do
555
    training = Fixtures.licensed_dataset!()
556
    evaluation = Fixtures.licensed_dataset!()
557
    Fixtures.admission(conversation, training, evaluation, overrides)
558
  end
559
560
  defp evaluation(conversation, overrides) do
561
    conversation
562
    |> admission()
563
    |> Map.fetch!(:evaluation)
564
    |> Map.merge(overrides)
565
  end
566
567
  defp account(login) do
568
    user = AccountsFixtures.repository_user_fixture(login)
569
    {:ok, conversation} = Conversations.ensure_conversation(user)
570
    configured = Application.get_env(:openagents, :admin_github_ids, [])
571
    Application.put_env(:openagents, :admin_github_ids, [user.github_id | configured])
572
    %{operator: user, conversation: conversation}
573
  end
574
end
test/openagents_web/controllers/continual_learning_controller_test.exs added +163

@@ -0,0 +1,163 @@

1
defmodule OpenAgentsWeb.ContinualLearningControllerTest do
2
  @moduledoc """
3
  The operator API of the continual-learning lane (CONTINUAL-001).
4
5
  The route is the only way into the lane, so it has to refuse before it
6
  admits: an anonymous caller, a signed-in caller who is not an operator, and a
7
  buyer the lane never admitted all get a typed refusal. One admitted job then
8
  walks the whole surface — create, list, read, cancel, replay, and evidence.
9
  """
10
11
  use OpenAgentsWeb.ConnCase, async: false
12
13
  alias OpenAgents.ContinualLearning
14
  alias OpenAgents.ContinualLearningFixtures, as: Fixtures
15
  alias OpenAgents.Conversations
16
17
  setup do
18
    Ecto.Adapters.SQL.Sandbox.mode(OpenAgents.Repo, {:shared, self()})
19
    previous_capacity = Application.get_env(:openagents, OpenAgents.Capacity, [])
20
    previous_evidence = Application.get_env(:openagents, :capacity_test_evidence)
21
22
    Application.put_env(:openagents, OpenAgents.ContinualLearning, Fixtures.settings())
23
24
    Application.put_env(
25
      :openagents,
26
      OpenAgents.Capacity,
27
      Keyword.merge(previous_capacity, evidence_source: OpenAgents.CapacityEvidenceStub)
28
    )
29
30
    Application.put_env(:openagents, :capacity_test_evidence, Fixtures.capacity_evidence())
31
32
    on_exit(fn ->
33
      Application.put_env(:openagents, OpenAgents.Capacity, previous_capacity)
34
      Application.delete_env(:openagents, OpenAgents.ContinualLearning)
35
36
      if is_nil(previous_evidence),
37
        do: Application.delete_env(:openagents, :capacity_test_evidence),
38
        else: Application.put_env(:openagents, :capacity_test_evidence, previous_evidence)
39
    end)
40
41
    :ok
42
  end
43
44
  test "the lane refuses an anonymous caller and a signed-in non-operator", %{conn: conn} do
45
    anonymous = post(conn, ~p"/api/operator/continual-learning/jobs", %{})
46
    assert json_response(anonymous, 401)
47
48
    user = github_user("continual-learning-regular")
49
    signed_in = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
50
    refused = post(signed_in, ~p"/api/operator/continual-learning/jobs", %{})
51
    assert json_response(refused, 403) == %{"error" => "operator_required"}
52
  end
53
54
  test "an operator starts, reads, cancels, replays, and exports one job" do
55
    user = github_user("continual-learning-operator")
56
    grant_operator(user)
57
    {:ok, conversation} = Conversations.ensure_conversation(user)
58
59
    created =
60
      operator_conn(user)
61
      |> post(~p"/api/operator/continual-learning/jobs", payload(conversation))
62
63
    assert %{"job" => %{"id" => id, "status" => "queued", "admission_digest" => digest}} =
64
             json_response(created, 201)
65
66
    assert digest =~ ~r/\A[0-9a-f]{64}\z/
67
68
    listed = get(operator_conn(user), ~p"/api/operator/continual-learning/jobs")
69
    assert %{"jobs" => jobs} = json_response(listed, 200)
70
    assert id in Enum.map(jobs, & &1["id"])
71
72
    shown = get(operator_conn(user), ~p"/api/operator/continual-learning/jobs/#{id}")
73
    assert %{"job" => %{"id" => ^id, "buyer_ref" => buyer_ref}} = json_response(shown, 200)
74
    assert buyer_ref == Fixtures.buyer_ref()
75
76
    completed = Fixtures.await_terminal!(id)
77
    assert completed.status == "completed"
78
79
    evidence = get(operator_conn(user), ~p"/api/operator/continual-learning/jobs/#{id}/evidence")
80
    assert %{"artifact" => artifact, "checkpoints" => checkpoints} = json_response(evidence, 200)
81
    assert artifact["artifact_digest"]
82
    assert length(checkpoints) == 2
83
84
    assert ["attachment; filename=\"continual-learning-evidence-" <> _rest] =
85
             get_resp_header(evidence, "content-disposition")
86
87
    # A terminal job cannot be cancelled or resumed, but it can be replayed.
88
    cancelled =
89
      post(operator_conn(user), ~p"/api/operator/continual-learning/jobs/#{id}/cancellation", %{})
90
91
    assert json_response(cancelled, 409) == %{"error" => "not_cancellable"}
92
93
    resumed =
94
      post(operator_conn(user), ~p"/api/operator/continual-learning/jobs/#{id}/resumptions", %{})
95
96
    assert json_response(resumed, 409) == %{"error" => "not_resumable"}
97
98
    replayed =
99
      post(operator_conn(user), ~p"/api/operator/continual-learning/jobs/#{id}/replays", %{})
100
101
    assert %{"job" => %{"id" => replay_id, "replay_of_id" => ^id}} = json_response(replayed, 201)
102
    assert Fixtures.await_terminal!(replay_id).status == "completed"
103
  end
104
105
  test "a buyer the lane never admitted is refused with its own code", %{conn: conn} do
106
    user = github_user("continual-learning-wrong-buyer")
107
    grant_operator(user)
108
    {:ok, conversation} = Conversations.ensure_conversation(user)
109
110
    refused =
111
      post(
112
        operator_conn(user),
113
        ~p"/api/operator/continual-learning/jobs",
114
        conversation |> payload() |> Map.put("buyer_ref", "buyer:someone-else")
115
      )
116
117
    assert json_response(refused, 403) == %{"error" => "buyer_not_admitted"}
118
    assert ContinualLearning.active_count() == 0
119
120
    _ = conn
121
  end
122
123
  defp operator_conn(user) do
124
    Plug.Test.init_test_session(build_conn(), %{"user_id" => user.id})
125
  end
126
127
  defp payload(conversation) do
128
    training = Fixtures.licensed_dataset!()
129
    evaluation = Fixtures.licensed_dataset!()
130
131
    %{
132
      "buyer_ref" => Fixtures.buyer_ref(),
133
      "objective" => "Improve tool selection on consented support traces.",
134
      "objective_version" => 1,
135
      "base_model_ref" => Fixtures.base_model_ref(),
136
      "base_model_digest" => Fixtures.base_model_digest(),
137
      "configuration" => %{"learning_rate" => "3e-4"},
138
      "runtime_class" => "standard",
139
      "conversation_id" => conversation.id,
140
      "owner_visitor_id" => conversation.visitor_id,
141
      "datasets" => [dataset(training)],
142
      "evaluation" => %{
143
        "corpus" => [dataset(evaluation)],
144
        "verifier" => %{
145
          "id" => "verifier:openagents-eval-1",
146
          "admitted" => true,
147
          "independent_of_producer" => true
148
        },
149
        "separation_required" => true,
150
        "acceptance_criteria" => ["tool-selection score reaches the admitted target"],
151
        "target_metric" => "score",
152
        "target_value" => 0.6,
153
        "policy_version" => 1
154
      },
155
      "budget" => %{"usd_cents" => 100},
156
      "stopping_policy" => %{"maximum_rounds" => 2, "minimum_improvement" => 0.0}
157
    }
158
  end
159
160
  defp dataset(%{listing: listing, acceptance_ref: acceptance_ref}) do
161
    %{"listing_id" => listing.id, "acceptance_ref" => acceptance_ref}
162
  end
163
end
test/support/continual_learning/failing_evaluator.ex added +8

@@ -0,0 +1,8 @@

1
defmodule OpenAgents.ContinualLearningStubs.FailingEvaluator do
2
  @moduledoc false
3
4
  @behaviour OpenAgents.ContinualLearning.Evaluator
5
6
  @impl true
7
  def evaluate(_context), do: {:error, :evaluator_unavailable}
8
end
test/support/continual_learning/failing_grade_evaluator.ex added +25

@@ -0,0 +1,25 @@

1
defmodule OpenAgents.ContinualLearningStubs.FailingGradeEvaluator do
2
  @moduledoc false
3
4
  @behaviour OpenAgents.ContinualLearning.Evaluator
5
6
  @impl true
7
  def evaluate(%{job: job, policy: policy}) do
8
    {:ok,
9
     %{
10
       verifier: policy["verifier"],
11
       falsifier: "a checkpoint above the admitted target passes",
12
       terminal_result: :failed,
13
       criteria: [
14
         %{
15
           "criterion" => List.first(policy["acceptance_criteria"]),
16
           "receipt" => "continual-learning-evaluation:#{job.id}:stub",
17
           "visibility" => "restricted"
18
         }
19
       ],
20
       metrics: %{"score" => 0.0},
21
       usage: %{"total_tokens" => 10},
22
       duration_ms: 10
23
     }}
24
  end
25
end
test/support/continual_learning/failing_trainer.ex added +8

@@ -0,0 +1,8 @@

1
defmodule OpenAgents.ContinualLearningStubs.FailingTrainer do
2
  @moduledoc false
3
4
  @behaviour OpenAgents.ContinualLearning.Trainer
5
6
  @impl true
7
  def train_round(_context), do: {:error, :trainer_unavailable}
8
end
test/support/continual_learning/foreign_evaluator.ex added +29

@@ -0,0 +1,29 @@

1
defmodule OpenAgents.ContinualLearningStubs.ForeignEvaluator do
2
  @moduledoc false
3
4
  @behaviour OpenAgents.ContinualLearning.Evaluator
5
6
  @impl true
7
  def evaluate(%{job: job, policy: policy}) do
8
    {:ok,
9
     %{
10
       verifier: %{
11
         "id" => "verifier:never-admitted",
12
         "admitted" => true,
13
         "independent_of_producer" => true
14
       },
15
       falsifier: "a checkpoint below the admitted target fails",
16
       terminal_result: :passed,
17
       criteria: [
18
         %{
19
           "criterion" => List.first(policy["acceptance_criteria"]),
20
           "receipt" => "continual-learning-evaluation:#{job.id}:stub",
21
           "visibility" => "restricted"
22
         }
23
       ],
24
       metrics: %{"score" => 1.0},
25
       usage: %{"total_tokens" => 10},
26
       duration_ms: 10
27
     }}
28
  end
29
end
test/support/continual_learning/gated_trainer.ex added +19

@@ -0,0 +1,19 @@

1
defmodule OpenAgents.ContinualLearningStubs.GatedTrainer do
2
  @moduledoc false
3
4
  @behaviour OpenAgents.ContinualLearning.Trainer
5
6
  alias OpenAgents.ContinualLearning.Trainer.Reference
7
  alias OpenAgents.ContinualLearningStubs.Observer
8
9
  @impl true
10
  def train_round(context) do
11
    send(Observer.pid(), {:round_started, context.round, self()})
12
13
    receive do
14
      :proceed -> Reference.train_round(context)
15
    after
16
      10_000 -> {:error, :gate_timeout}
17
    end
18
  end
19
end
test/support/continual_learning/observer.ex added +11

@@ -0,0 +1,11 @@

1
defmodule OpenAgents.ContinualLearningStubs.Observer do
2
  @moduledoc false
3
4
  @key :continual_learning_test_observer
5
6
  def watch(pid), do: Application.put_env(:openagents, @key, pid)
7
8
  def pid, do: Application.get_env(:openagents, @key)
9
10
  def forget, do: Application.delete_env(:openagents, @key)
11
end
test/support/fixtures/continual_learning_fixtures.ex added +167

@@ -0,0 +1,167 @@

1
defmodule OpenAgents.ContinualLearningFixtures do
2
  @moduledoc false
3
4
  import Ecto.Query, only: [from: 2]
5
6
  alias OpenAgents.ArtifactCatalog
7
  alias OpenAgents.ArtifactCatalogFixtures
8
  alias OpenAgents.ContinualLearning
9
  alias OpenAgents.Provenance.Canonical
10
11
  @buyer_ref "buyer:openagents-training"
12
  @base_model "openagents/base-1"
13
14
  def buyer_ref, do: @buyer_ref
15
  def base_model_ref, do: @base_model
16
  def base_model_digest, do: Canonical.sha256("base-model-1")
17
  def training_code_digest, do: Canonical.sha256("training-code-1")
18
19
  @doc "The continual-learning settings one admitted canary run needs."
20
  def settings(overrides \\ []) do
21
    Keyword.merge(
22
      [
23
        enabled: true,
24
        buyer_ref: @buyer_ref,
25
        buyer_class: "openagents_training",
26
        runtime_classes: ["standard", "strong"],
27
        admitted_base_models: %{@base_model => base_model_digest()},
28
        admitted_custody: ["openagents_managed"],
29
        maximum_rounds: 8,
30
        maximum_datasets: 4,
31
        wall_clock_ms: 900_000,
32
        maximum_state_bytes: 65_536,
33
        concurrency_limit: 1,
34
        training_code_digest: training_code_digest(),
35
        trainer: OpenAgents.ContinualLearning.Trainer.Reference,
36
        evaluator: OpenAgents.ContinualLearning.Evaluator.Reference,
37
        class_watts: %{"standard" => 350, "strong" => 700, "batch" => 250},
38
        round_cost_usd_cents: %{"standard" => 2, "strong" => 4, "batch" => 1},
39
        settlement_unit: "usd_cents",
40
        outcome_repository: "OpenAgentsInc/openagents.com",
41
        outcome_issue_number: 86
42
      ],
43
      overrides
44
    )
45
  end
46
47
  @doc "Capacity evidence one fresh standard class admits a job against."
48
  def capacity_evidence do
49
    now = DateTime.utc_now() |> DateTime.truncate(:second)
50
51
    {:ok,
52
     %{
53
       "classes" => [
54
         %{
55
           "id" => "standard",
56
           "logical" => 30,
57
           "active_reservations" => 4,
58
           "observed_limit" => 24,
59
           "reported_free" => 8,
60
           "queued" => 0,
61
           "observed_at" => DateTime.to_iso8601(now)
62
         }
63
       ]
64
     }}
65
  end
66
67
  @doc """
68
  Publishes one licensed dataset listing and walks it to an admitted acceptance
69
  receipt, which is what the buyer must already hold before a job can bind it.
70
  """
71
  def licensed_dataset!(overrides \\ %{}) do
72
    listing = ArtifactCatalogFixtures.publish_listing!(overrides)
73
74
    {:ok, offer} =
75
      ArtifactCatalog.record_transaction(
76
        listing.id,
77
        "offer",
78
        ArtifactCatalogFixtures.transaction_attributes(
79
          listing,
80
          listing.publication_receipt_ref,
81
          %{
82
            buyer_ref: @buyer_ref
83
          }
84
        )
85
      )
86
87
    {:ok, acceptance} =
88
      ArtifactCatalog.record_transaction(
89
        listing.id,
90
        "acceptance",
91
        ArtifactCatalogFixtures.transaction_attributes(listing, offer.receipt_ref, %{
92
          buyer_ref: @buyer_ref
93
        })
94
      )
95
96
    %{listing: listing, acceptance_ref: acceptance.receipt_ref}
97
  end
98
99
  @doc "Moves one listing's license window into the past, as an expiry does."
100
  def expire_license!(%{listing: listing}) do
101
    now = DateTime.utc_now()
102
103
    {1, _returned} =
104
      OpenAgents.Repo.update_all(
105
        from(l in OpenAgents.ArtifactCatalog.Listing, where: l.id == ^listing.id),
106
        set: [
107
          license_effective_at: DateTime.add(now, -7_200, :second),
108
          license_expires_at: DateTime.add(now, -60, :second)
109
        ]
110
      )
111
112
    :ok
113
  end
114
115
  @doc "One admitted dataset reference for `ContinualLearning.start/2`."
116
  def dataset_reference(%{listing: listing, acceptance_ref: acceptance_ref}) do
117
    %{listing_id: listing.id, acceptance_ref: acceptance_ref}
118
  end
119
120
  @doc "The admission attributes of one bounded canary job."
121
  def admission(conversation, training, evaluation, overrides \\ %{}) do
122
    Map.merge(
123
      %{
124
        buyer_ref: @buyer_ref,
125
        objective: "Improve tool selection on consented support traces.",
126
        objective_version: 1,
127
        base_model_ref: @base_model,
128
        base_model_digest: base_model_digest(),
129
        configuration: %{"learning_rate" => "3e-4"},
130
        runtime_class: "standard",
131
        conversation_id: conversation.id,
132
        owner_visitor_id: conversation.visitor_id,
133
        datasets: [dataset_reference(training)],
134
        evaluation: %{
135
          corpus: [dataset_reference(evaluation)],
136
          verifier: %{
137
            id: "verifier:openagents-eval-1",
138
            admitted: true,
139
            independent_of_producer: true
140
          },
141
          separation_required: true,
142
          acceptance_criteria: ["tool-selection score reaches the admitted target"],
143
          target_metric: "score",
144
          target_value: 0.6,
145
          policy_version: 1
146
        },
147
        budget: %{usd_cents: 100},
148
        stopping_policy: %{maximum_rounds: 2, minimum_improvement: 0.0}
149
      },
150
      overrides
151
    )
152
  end
153
154
  @doc "Waits for one continual-learning job to reach a terminal status."
155
  def await_terminal!(job_id, attempts \\ 200) do
156
    Enum.reduce_while(1..attempts, nil, fn _attempt, _accumulator ->
157
      {:ok, job} = ContinualLearning.fetch(job_id)
158
159
      if OpenAgents.ContinualLearning.Job.terminal?(job) do
160
        {:halt, job}
161
      else
162
        Process.sleep(25)
163
        {:cont, job}
164
      end
165
    end)
166
  end
167
end

This page updates live while a promote is in flight · changelog