world-model-optimizer 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (308) hide show
  1. llm_waterfall/LICENSE +21 -0
  2. llm_waterfall/__init__.py +53 -0
  3. llm_waterfall/adapters/__init__.py +36 -0
  4. llm_waterfall/adapters/anthropic.py +105 -0
  5. llm_waterfall/adapters/aws_mantle.py +47 -0
  6. llm_waterfall/adapters/azure_openai.py +71 -0
  7. llm_waterfall/adapters/base.py +51 -0
  8. llm_waterfall/adapters/bedrock.py +309 -0
  9. llm_waterfall/adapters/openai.py +130 -0
  10. llm_waterfall/classify.py +184 -0
  11. llm_waterfall/pricing.py +110 -0
  12. llm_waterfall/py.typed +0 -0
  13. llm_waterfall/types.py +295 -0
  14. llm_waterfall/waterfall.py +255 -0
  15. wmo/__init__.py +38 -0
  16. wmo/agents/__init__.py +7 -0
  17. wmo/agents/default.py +29 -0
  18. wmo/agents/meta.py +55 -0
  19. wmo/agents/optimizer.py +55 -0
  20. wmo/agents/project.py +928 -0
  21. wmo/cli/__init__.py +5 -0
  22. wmo/cli/agent_session.py +1123 -0
  23. wmo/cli/app.py +2489 -0
  24. wmo/cli/e2b_cmds.py +212 -0
  25. wmo/cli/eval_closed_loop.py +207 -0
  26. wmo/cli/harness_app.py +1147 -0
  27. wmo/cli/harness_distill.py +659 -0
  28. wmo/cli/hosted_session.py +880 -0
  29. wmo/cli/ingest_cmd.py +165 -0
  30. wmo/cli/model_roles.py +82 -0
  31. wmo/cli/platform_cmds.py +372 -0
  32. wmo/cli/route_app.py +274 -0
  33. wmo/cli/session_state.py +243 -0
  34. wmo/cli/ui.py +1107 -0
  35. wmo/cli/workspace_sync.py +504 -0
  36. wmo/config/__init__.py +60 -0
  37. wmo/config/card.py +129 -0
  38. wmo/config/config.py +367 -0
  39. wmo/config/dotenv.py +67 -0
  40. wmo/config/settings.py +128 -0
  41. wmo/config/store.py +177 -0
  42. wmo/conftest.py +19 -0
  43. wmo/connect/__init__.py +88 -0
  44. wmo/connect/apps.py +78 -0
  45. wmo/connect/brave.py +284 -0
  46. wmo/connect/connector.py +79 -0
  47. wmo/connect/credentials.py +164 -0
  48. wmo/connect/github.py +321 -0
  49. wmo/connect/google.py +627 -0
  50. wmo/connect/notion.py +790 -0
  51. wmo/connect/oauth.py +461 -0
  52. wmo/connect/slack.py +555 -0
  53. wmo/connect/store.py +199 -0
  54. wmo/connect/types.py +156 -0
  55. wmo/core/__init__.py +21 -0
  56. wmo/core/parsing.py +281 -0
  57. wmo/core/render.py +271 -0
  58. wmo/core/text.py +40 -0
  59. wmo/core/types.py +116 -0
  60. wmo/distill/__init__.py +14 -0
  61. wmo/distill/agents.py +140 -0
  62. wmo/distill/config.py +1006 -0
  63. wmo/distill/cost.py +437 -0
  64. wmo/distill/data.py +921 -0
  65. wmo/distill/deadlines.py +254 -0
  66. wmo/distill/fake_tinker.py +734 -0
  67. wmo/distill/gate.py +122 -0
  68. wmo/distill/loop.py +3499 -0
  69. wmo/distill/renderers.py +399 -0
  70. wmo/distill/rendering.py +620 -0
  71. wmo/distill/rollouts.py +726 -0
  72. wmo/distill/samples.py +195 -0
  73. wmo/distill/store.py +829 -0
  74. wmo/distill/teacher.py +714 -0
  75. wmo/distill/tokens.py +535 -0
  76. wmo/distill/tracking.py +552 -0
  77. wmo/distill/tripwire.py +411 -0
  78. wmo/distill/xtoken/byte_offsets.py +152 -0
  79. wmo/distill/xtoken/chunks.py +457 -0
  80. wmo/distill/xtoken/prompt_logprobs.py +475 -0
  81. wmo/distill/xtoken/teacher_render.py +346 -0
  82. wmo/engine/__init__.py +28 -0
  83. wmo/engine/autoconfig.py +367 -0
  84. wmo/engine/build.py +346 -0
  85. wmo/engine/demo.py +77 -0
  86. wmo/engine/eval_suites.py +245 -0
  87. wmo/engine/grounding.py +491 -0
  88. wmo/engine/knowledge.py +291 -0
  89. wmo/engine/loader.py +36 -0
  90. wmo/engine/play.py +92 -0
  91. wmo/engine/prompts.py +99 -0
  92. wmo/engine/replay.py +443 -0
  93. wmo/engine/reporting.py +58 -0
  94. wmo/engine/workspace.py +468 -0
  95. wmo/engine/world_model.py +568 -0
  96. wmo/env/__init__.py +22 -0
  97. wmo/env/base.py +121 -0
  98. wmo/env/closed_loop.py +229 -0
  99. wmo/env/episode.py +107 -0
  100. wmo/env/llm_agent.py +93 -0
  101. wmo/env/scenarios.py +73 -0
  102. wmo/evals/__init__.py +52 -0
  103. wmo/evals/agreement.py +110 -0
  104. wmo/evals/base.py +45 -0
  105. wmo/evals/closed_loop.py +480 -0
  106. wmo/evals/failover.py +96 -0
  107. wmo/evals/gold.py +127 -0
  108. wmo/evals/grid.py +394 -0
  109. wmo/evals/grid_plot.py +205 -0
  110. wmo/evals/harbor/__init__.py +27 -0
  111. wmo/evals/harbor/agent.py +573 -0
  112. wmo/evals/harbor/ctrf.py +171 -0
  113. wmo/evals/harbor/e2b_environment.py +587 -0
  114. wmo/evals/harbor/e2b_template_policy.py +144 -0
  115. wmo/evals/harbor/scorer.py +875 -0
  116. wmo/evals/harbor/tasks.py +140 -0
  117. wmo/evals/open_loop.py +194 -0
  118. wmo/evals/tasks.py +53 -0
  119. wmo/harness/__init__.py +51 -0
  120. wmo/harness/code_runtime.py +288 -0
  121. wmo/harness/create.py +1191 -0
  122. wmo/harness/delta.py +220 -0
  123. wmo/harness/doc.py +556 -0
  124. wmo/harness/e2b_ledger.py +342 -0
  125. wmo/harness/e2b_reap.py +476 -0
  126. wmo/harness/e2b_sandbox.py +350 -0
  127. wmo/harness/environment.py +35 -0
  128. wmo/harness/live_session.py +543 -0
  129. wmo/harness/mutate.py +343 -0
  130. wmo/harness/pi_e2b.py +1710 -0
  131. wmo/harness/pi_entry/entry.ts +268 -0
  132. wmo/harness/pi_entry/runner_frames.ts +92 -0
  133. wmo/harness/pi_entry/runner_live.ts +587 -0
  134. wmo/harness/pi_entry/runner_service.ts +270 -0
  135. wmo/harness/pi_entry/runner_stdio.ts +374 -0
  136. wmo/harness/pi_entry/runner_termination.ts +142 -0
  137. wmo/harness/pi_local.py +262 -0
  138. wmo/harness/pi_runtime.py +495 -0
  139. wmo/harness/pi_vendor.py +65 -0
  140. wmo/harness/population.py +509 -0
  141. wmo/harness/project_proposer.py +569 -0
  142. wmo/harness/proposer.py +977 -0
  143. wmo/harness/runner_link.py +619 -0
  144. wmo/harness/runtime.py +389 -0
  145. wmo/harness/scoring.py +247 -0
  146. wmo/harness/skills.py +116 -0
  147. wmo/harness/source_tree.py +319 -0
  148. wmo/harness/store.py +176 -0
  149. wmo/harness/tools.py +105 -0
  150. wmo/harness/vendor/manifest.sha256 +58 -0
  151. wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
  152. wmo/harness/vendor/pi-agent/LICENSE +21 -0
  153. wmo/harness/vendor/pi-agent/README.md +488 -0
  154. wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
  155. wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
  156. wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
  157. wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
  158. wmo/harness/vendor/pi-agent/docs/models.md +966 -0
  159. wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
  160. wmo/harness/vendor/pi-agent/package.json +60 -0
  161. wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
  162. wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
  163. wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
  164. wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
  165. wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
  166. wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
  167. wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
  168. wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
  169. wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
  170. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
  171. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
  172. wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
  173. wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
  174. wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
  175. wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
  176. wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
  177. wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
  178. wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
  179. wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
  180. wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
  181. wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
  182. wmo/harness/vendor/pi-agent/src/index.ts +44 -0
  183. wmo/harness/vendor/pi-agent/src/node.ts +2 -0
  184. wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
  185. wmo/harness/vendor/pi-agent/src/types.ts +428 -0
  186. wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
  187. wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
  188. wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
  189. wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
  190. wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
  191. wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
  192. wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
  193. wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
  194. wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
  195. wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
  196. wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
  197. wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
  198. wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
  199. wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
  200. wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
  201. wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
  202. wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
  203. wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
  204. wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
  205. wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
  206. wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
  207. wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
  208. wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
  209. wmo/harness/vendor/vendor_pi.sh +59 -0
  210. wmo/harness/workspace_patch.py +270 -0
  211. wmo/ingest/__init__.py +47 -0
  212. wmo/ingest/adapter.py +72 -0
  213. wmo/ingest/base.py +114 -0
  214. wmo/ingest/braintrust.py +339 -0
  215. wmo/ingest/detect.py +126 -0
  216. wmo/ingest/langfuse.py +291 -0
  217. wmo/ingest/langsmith.py +444 -0
  218. wmo/ingest/mastra.py +330 -0
  219. wmo/ingest/messages.py +170 -0
  220. wmo/ingest/normalize.py +679 -0
  221. wmo/ingest/otel_genai.py +69 -0
  222. wmo/ingest/otel_writer.py +100 -0
  223. wmo/ingest/phoenix.py +150 -0
  224. wmo/ingest/postgres.py +246 -0
  225. wmo/ingest/posthog.py +320 -0
  226. wmo/ingest/quality.py +28 -0
  227. wmo/ingest/stream.py +209 -0
  228. wmo/ingest/testdata/sample_otlp.json +60 -0
  229. wmo/ingest/testdata/sample_spans.jsonl +3 -0
  230. wmo/optimize/__init__.py +25 -0
  231. wmo/optimize/base.py +143 -0
  232. wmo/optimize/gepa.py +806 -0
  233. wmo/optimize/judge.py +262 -0
  234. wmo/optimize/judge_quality.py +359 -0
  235. wmo/optimize/knn.py +468 -0
  236. wmo/optimize/numeric.py +152 -0
  237. wmo/optimize/outcomes.py +103 -0
  238. wmo/optimize/policy.py +669 -0
  239. wmo/optimize/report.py +231 -0
  240. wmo/optimize/reward.py +129 -0
  241. wmo/optimize/routing.py +373 -0
  242. wmo/platform/__init__.py +6 -0
  243. wmo/platform/auth.py +115 -0
  244. wmo/platform/client.py +551 -0
  245. wmo/platform/credentials.py +126 -0
  246. wmo/platform/transfer.py +158 -0
  247. wmo/providers/__init__.py +40 -0
  248. wmo/providers/_bedrock_chat.py +155 -0
  249. wmo/providers/_openai_common.py +182 -0
  250. wmo/providers/_responses_common.py +472 -0
  251. wmo/providers/anthropic.py +134 -0
  252. wmo/providers/azure_openai.py +296 -0
  253. wmo/providers/base.py +300 -0
  254. wmo/providers/bedrock.py +312 -0
  255. wmo/providers/models.py +205 -0
  256. wmo/providers/openai.py +143 -0
  257. wmo/providers/openai_responses.py +240 -0
  258. wmo/providers/pool.py +170 -0
  259. wmo/providers/registry.py +73 -0
  260. wmo/providers/retry.py +151 -0
  261. wmo/providers/tinker.py +936 -0
  262. wmo/providers/waterfall.py +336 -0
  263. wmo/research/__init__.py +81 -0
  264. wmo/research/ablation.py +133 -0
  265. wmo/research/concurrency_plot.py +523 -0
  266. wmo/research/concurrency_run.py +240 -0
  267. wmo/research/concurrency_scaling.py +270 -0
  268. wmo/research/gepa_scaling.py +274 -0
  269. wmo/research/pipeline.py +198 -0
  270. wmo/research/scaling_split.py +82 -0
  271. wmo/research/scenario_fidelity.py +198 -0
  272. wmo/research/scenario_recovery.py +92 -0
  273. wmo/research/seed_stability.py +90 -0
  274. wmo/research/trace_scaling.py +348 -0
  275. wmo/retrieval/__init__.py +6 -0
  276. wmo/retrieval/embedders.py +105 -0
  277. wmo/retrieval/leakfree.py +52 -0
  278. wmo/retrieval/retriever.py +173 -0
  279. wmo/scenarios/__init__.py +58 -0
  280. wmo/scenarios/builder.py +152 -0
  281. wmo/scenarios/mining/__init__.py +27 -0
  282. wmo/scenarios/mining/clustering.py +171 -0
  283. wmo/scenarios/mining/facets.py +226 -0
  284. wmo/scenarios/mining/selection.py +220 -0
  285. wmo/scenarios/synthesis/__init__.py +6 -0
  286. wmo/scenarios/synthesis/scenario_set.py +63 -0
  287. wmo/scenarios/synthesis/synthesizer.py +85 -0
  288. wmo/scenarios/verification/__init__.py +17 -0
  289. wmo/scenarios/verification/judge.py +97 -0
  290. wmo/scenarios/verification/verify.py +135 -0
  291. wmo/serving/__init__.py +5 -0
  292. wmo/serving/builds.py +451 -0
  293. wmo/serving/chat.py +878 -0
  294. wmo/serving/endpoint_config.py +64 -0
  295. wmo/serving/savings.py +250 -0
  296. wmo/serving/server.py +553 -0
  297. wmo/serving/traces_source.py +206 -0
  298. wmo/telemetry.py +213 -0
  299. wmo/tracking/__init__.py +36 -0
  300. wmo/tracking/clock.py +24 -0
  301. wmo/tracking/metered.py +125 -0
  302. wmo/tracking/pricing.py +99 -0
  303. wmo/tracking/store.py +31 -0
  304. wmo/tracking/tracker.py +149 -0
  305. world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
  306. world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
  307. world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
  308. world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
wmo/distill/loop.py ADDED
@@ -0,0 +1,3499 @@
1
+ """The on-policy distillation orchestrator: `run_distillation` and its step loop.
2
+
3
+ One run couples every distill layer end to end: preflight checks before any
4
+ spend, teacher-in-harness and student-before baselines on the holdout split,
5
+ the optional supervised warmup (teacher rollouts on the train split filtered
6
+ to `warmup.keep`, then `warmup.steps` cross_entropy passes so a student that
7
+ would sample only failures starts OPD from the teacher's successful
8
+ trajectories; `warmup.trajectories_from` loads another run's recorded
9
+ collection instead of collecting, charging nothing), then the training loop
10
+ (harbor rollouts from the current student sampler, prefix-merge datums,
11
+ teacher scoring, reverse-KL advantages, one optimizer step per training step
12
+ under the loss `train.loss` selects: `importance_sampling` or `ppo` over the
13
+ same advantage-carrying datums, or `topk_ce`, where the teacher instead top-k
14
+ scores each datum in one prefill-only request and the step trains rank-aligned
15
+ weighted cross_entropy replicas, the reverse-KL metric still coming from that
16
+ same request's realized logprobs), and finally the holdout gate that decides
17
+ whether the adapter is promoted into the `AdapterStore`.
18
+
19
+ Layout: everything lands in the `DistillRunStore` run directory. The rollout
20
+ collector writes its per-step `harbor/step-NNNN/` jobs dir under the run dir
21
+ for training batches (each trial's sampled token spans live inside its own
22
+ harbor trial dir, in `result.json`); eval batches (baselines, interim
23
+ evals, student-after) get their own isolated roots under
24
+ `eval-rollouts/<eval-name>/` so their harbor job dirs never collide with a
25
+ training step's, and the warmup phase's teacher trials likewise land under
26
+ `warmup-rollouts/`. Every batch (training step, warmup collection, eval)
27
+ additionally renders its first `train.log_sample_rollouts` episodes to
28
+ human-readable text under `samples/` and the tracker's samples table
29
+ (`wmo.distill.samples`), chat-template framing included.
30
+
31
+ The Tinker SDK stays an optional extra: the real service client is built
32
+ lazily only when no `service_client` is injected, and the thin `Sdk*`
33
+ adapters here (mirroring `SdkSampler` and `SdkLogprobScorer`) are the only
34
+ code that touches it. Every SDK call is deadline-bounded
35
+ (`wmo.distill.deadlines`), so a wedged session raises a typed
36
+ `TinkerDeadlineError` instead of hanging; see `SdkTrainingClient` for the
37
+ per-call idempotency reasoning that decides what expiry does. Tests drive
38
+ the whole loop with the deterministic fakes in `wmo.distill.fake_tinker`,
39
+ whose `FakeTrainingClient` asserts the tokens-in-tokens-out invariant on
40
+ every `forward_backward` batch.
41
+
42
+ Resume: cadenced `save_state` checkpoints land in the run store's manifest;
43
+ `resume=True` restores the latest checkpoint via `load_state` as the very
44
+ first call on a freshly created training client (tinker accepts LoadWeights
45
+ only on an uninitialized model, so preflight and the sampler refresh follow
46
+ the restore and validate the RESTORED weights), continues the
47
+ step count from the checkpoint, restores the prior sessions' USD spend from
48
+ the run store's spend ledger (written on every charge, so eval spend between
49
+ metrics rows survives), and reuses recorded baseline evals. A finished warmup
50
+ phase is recorded as the run store's `warmup.json` marker and never re-runs
51
+ (when no step checkpoint exists yet, its recorded post-warmup state is what
52
+ `load_state` restores); an UNfinished warmup re-runs whole, with the teacher
53
+ trials themselves resuming trial-level through harbor (the teacher's identity
54
+ is stable across sessions). Steps and student evals whose harbor job dirs
55
+ were left by a prior session re-run whole (the rollout collector wipes
56
+ stale-policy job dirs). A budget abort (`DistillBudgetError`), an
57
+ all-empty-batch abort (`DistillEmptyBatchError`, raised when consecutive
58
+ training steps show the student provider producing no completions) and a
59
+ degeneration abort (`DistillDegenerationError`, below) all carry the exact
60
+ resume command.
61
+
62
+ Degeneration tripwires: every step also pools the student's own sampled
63
+ logprobs and generation lengths into `entropy_per_token` and
64
+ `mean_generation_tokens` (`wmo.distill.tripwire`) and compares them against a
65
+ baseline THIS run measured at its first training step, which is persisted in
66
+ the checkpoint manifest so a resumed session never re-anchors on an already
67
+ degenerated policy. Breaches warn, and a kill-level streak aborts the run the
68
+ same way the budget path does. The thresholds are fractions of that measured
69
+ baseline, never absolute values; `TripwireConfig` records why.
70
+ """
71
+
72
+ from __future__ import annotations
73
+
74
+ import hashlib
75
+ import logging
76
+ import math
77
+ import random
78
+ import time
79
+ from collections.abc import Callable, Mapping, Sequence
80
+ from concurrent.futures import ThreadPoolExecutor
81
+ from pathlib import Path
82
+ from typing import TYPE_CHECKING, Literal, NoReturn, Protocol, cast, runtime_checkable
83
+ from uuid import uuid4
84
+
85
+ from llm_waterfall.types import ChatMessage
86
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
87
+
88
+ from wmo.config.store import validate_name
89
+ from wmo.distill.config import DistillConfig, PricingConfig
90
+ from wmo.distill.cost import (
91
+ METER_NAMES,
92
+ BudgetExhausted,
93
+ BudgetMeter,
94
+ CostLine,
95
+ MeterName,
96
+ SpanBilling,
97
+ batch_billing,
98
+ )
99
+ from wmo.distill.data import (
100
+ TopkCandidates,
101
+ TrainDatum,
102
+ attach_advantages,
103
+ build_datums,
104
+ build_topk_ce_datums,
105
+ to_tinker_datums,
106
+ to_tinker_sft_datums,
107
+ )
108
+ from wmo.distill.deadlines import TinkerDeadlineError, call_with_deadline, wait_with_deadline
109
+ from wmo.distill.gate import DistillGateRecord, gate_distillation
110
+ from wmo.distill.rendering import ChatRendering, RendererTokenizer, build_renderer
111
+ from wmo.distill.rollouts import RolloutStats, collect_rollouts, rollout_stats
112
+ from wmo.distill.samples import sample_rollouts, samples_markdown
113
+ from wmo.distill.store import (
114
+ AdapterStore,
115
+ DistillModelCard,
116
+ DistillRunStore,
117
+ WarmupRecord,
118
+ WarmupTrialsManifest,
119
+ build_handoff_toml,
120
+ )
121
+ from wmo.distill.teacher import (
122
+ CachedUsageTeacher,
123
+ EncodingTokenizer,
124
+ SdkLogprobScorer,
125
+ TeacherClient,
126
+ TinkerTeacher,
127
+ tokenizer_fingerprint_check,
128
+ )
129
+ from wmo.distill.tokens import TrialRecord
130
+ from wmo.distill.tracking import DistillTracker, build_tracker
131
+ from wmo.distill.tripwire import (
132
+ PolicyHealth,
133
+ TripwireBaseline,
134
+ TripwireBreach,
135
+ capture_baseline,
136
+ evaluate_breaches,
137
+ health_summary,
138
+ metric_ratio,
139
+ policy_health,
140
+ )
141
+ from wmo.harness.doc import (
142
+ MAX_OUTPUT_TOKENS_ID,
143
+ MAX_TURNS_ID,
144
+ TEMPERATURE_ID,
145
+ HarnessDoc,
146
+ Surface,
147
+ SurfaceKind,
148
+ )
149
+ from wmo.providers.base import ProviderConfig, ProviderKind
150
+ from wmo.providers.tinker import (
151
+ TINKER_API_KEY_ENV,
152
+ SampledSequenceLike,
153
+ SdkSampler,
154
+ evict_shared_sampling_client,
155
+ shared_sampling_client,
156
+ shared_service_client,
157
+ )
158
+
159
+ if TYPE_CHECKING:
160
+ import tinker
161
+ from tinker import types as tinker_types
162
+
163
+ logger = logging.getLogger(__name__)
164
+
165
+ _WARMUP_STREAMS = 8
166
+ """Concurrent throwaway calls issued against freshly published sampler weights.
167
+
168
+ Not 1: a single serial token woke the model but a 64-episode wave still lost ~21% of its
169
+ episodes at a mean of 0.7 turns, because the cost is the sampler's ramp to many simultaneous
170
+ streams, not one weight load. Not 64: the point is to trigger the ramp, and the wave itself
171
+ finishes it, so this trades a few seconds of warmup for the tail of a 45-step run."""
172
+
173
+ IMPORTANCE_SAMPLING_LOSS = "importance_sampling"
174
+ PPO_LOSS = "ppo"
175
+ """The clipped-ratio surrogate over the SAME datums as importance_sampling.
176
+
177
+ Both are values of the SDK's `types.LossFnType` (tinker 0.23.3) and both take
178
+ exactly the `to_tinker_datums` keyset; `ppo` differs only in what the service
179
+ does with the advantages: it bounds the update by clipping the policy ratio
180
+ (service-side epsilon, no `loss_fn_config` is sent) instead of relying on a
181
+ bounded advantage, which is why `train.loss = "ppo"` is the mode that wants
182
+ `advantage_clip` unset and `center_advantages = false`. With one
183
+ forward/backward per batch off a sampler refreshed every step, that ratio sits
184
+ at ~1 and the clip rarely binds (see `TrainConfig.loss`); `advantage_std` and
185
+ `grad_norm` in the metrics row are what actually show an outlier-driven step.
186
+ """
187
+
188
+ CROSS_ENTROPY_LOSS = "cross_entropy"
189
+ """The warmup phase's supervised loss (see `to_tinker_sft_datums` for the keyset)."""
190
+
191
+ ADVANTAGE_LOSS_BY_MODE = {
192
+ "importance_sampling": IMPORTANCE_SAMPLING_LOSS,
193
+ "ppo": PPO_LOSS,
194
+ }
195
+ """Wire `loss_fn` per `train.loss` mode that trains advantages (topk_ce is CE)."""
196
+
197
+ DEFAULT_TITO_MEAN_TOLERANCE = 0.25
198
+ """Max mean |issued - recomputed| logprob gap the preflight recompute accepts.
199
+
200
+ The fakes agree exactly, but the real service computes sampling and scoring on
201
+ different execution paths (kernel and batching differences), so individual
202
+ positions legitimately drift by a few hundredths of a nat (observed ~0.08 on
203
+ Qwen3.5-4B at temperature 1.0). That noise is zero-mean across the probe
204
+ sample, while the failures this check exists to catch (wrong sampler path,
205
+ tokenizer or renderer drift, SDK change) shift logprobs systematically by
206
+ whole nats. The mean bound separates those regimes robustly.
207
+ """
208
+
209
+ DEFAULT_TITO_MAX_TOLERANCE = 1.0
210
+ """Max |issued - recomputed| gap at any single position (catastrophic bound)."""
211
+
212
+ _PREFLIGHT_SAMPLE_TOKENS = 16
213
+ """Length of the preflight sample whose logprobs are recomputed (the TITO proof)."""
214
+
215
+ EVAL_ROLLOUTS_DIR = "eval-rollouts"
216
+ """Run-dir subdirectory holding each eval batch's isolated rollout root."""
217
+
218
+ WARMUP_ROLLOUTS_DIR = "warmup-rollouts"
219
+ """Run-dir subdirectory holding the warmup phase's teacher rollout root."""
220
+
221
+ TEACHER_BASELINE_EVAL = "baseline-teacher"
222
+ STUDENT_BEFORE_EVAL = "baseline-student-before"
223
+ STUDENT_AFTER_EVAL = "student-after"
224
+
225
+ DistillPhase = Literal[
226
+ "preflight", "baseline", "warmup", "rollouts", "training", "eval", "finalize", "gate"
227
+ ]
228
+
229
+
230
+ class DistillProgress(BaseModel):
231
+ """One typed progress event emitted to the `on_progress` callback."""
232
+
233
+ model_config = ConfigDict(frozen=True, extra="forbid")
234
+
235
+ phase: DistillPhase
236
+ message: str
237
+ total_steps: int = Field(ge=0)
238
+ step: int | None = None
239
+ """0-based training step the event belongs to; None for run-level phases."""
240
+
241
+ spent_usd: float = Field(ge=0.0)
242
+ """Total priced spend so far, including prior sessions of a resumed run."""
243
+
244
+
245
+ ProgressCallback = Callable[[DistillProgress], None]
246
+
247
+ LiveTrialPreflight = Callable[[ProviderConfig], None]
248
+ """The documented hook for the live single-trial pi preflight (Phase 6).
249
+
250
+ Called with the student's rollout provider config (kind TINKER, model = the
251
+ current sampler path) after every cheap preflight check has passed and before
252
+ any baseline or training spend. A real implementation runs one short pi trial
253
+ on a cheap task through the actual harbor stack and raises on failure (spans
254
+ missing, prefix property broken, tool calls not round-tripping); it is
255
+ deliberately NOT implemented in this module, which stays free of harbor
256
+ calls beyond `collect_rollouts`.
257
+ """
258
+
259
+
260
+ class DistillBudgetError(RuntimeError):
261
+ """The hard budget cap was hit; artifacts were persisted so the run can resume.
262
+
263
+ Attributes:
264
+ resume_command: The exact CLI command that resumes this run.
265
+ spent_usd: Total priced spend (including prior sessions) at abort.
266
+ max_usd: The configured `budget.max_usd` cap.
267
+ """
268
+
269
+ def __init__(
270
+ self, message: str, *, resume_command: str, spent_usd: float, max_usd: float
271
+ ) -> None:
272
+ super().__init__(message)
273
+ self.resume_command = resume_command
274
+ self.spent_usd = spent_usd
275
+ self.max_usd = max_usd
276
+
277
+
278
+ _DEFERRED_BASELINE_WAIT_S = 1800.0
279
+ """How long finalize waits for a concurrent run to write a deferred baseline.
280
+
281
+ 30 minutes covers a baseline arm that is merely slow (a retry, a long tail of
282
+ holdout trials) without letting a producer that DIED hang a finished training
283
+ run indefinitely. The trained weights are saved before the wait begins, so a
284
+ timeout costs a resume rather than the training."""
285
+
286
+ _DEFERRED_BASELINE_POLL_S = 15.0
287
+ """Poll interval while waiting; the file appears atomically, so this only sets
288
+ how quickly the wait notices."""
289
+
290
+ MAX_CONSECUTIVE_EMPTY_STEPS = 2
291
+ """Consecutive all-empty training steps tolerated before the run aborts.
292
+
293
+ An all-empty step ran trials (`trials > 0`) but every trial produced zero
294
+ token spans (`empty_span_trials == trials`) and therefore zero datums: the
295
+ student provider is producing no completions at all, so training cannot make
296
+ progress and further steps only burn rollout budget. One such step can be a
297
+ transient batch-wide outage that self-heals; two in a row means the provider
298
+ is down (the live failure mode was silently swallowed worker errors upstream)
299
+ and the run aborts with `DistillEmptyBatchError`. The streak is tracked
300
+ within one session and a non-empty step resets it.
301
+ """
302
+
303
+
304
+ class DistillEmptyBatchError(RuntimeError):
305
+ """Consecutive training steps produced no completions; the run aborted.
306
+
307
+ Mirrors `DistillBudgetError` ergonomics: state was checkpointed and the
308
+ error carries the exact resume command.
309
+
310
+ Attributes:
311
+ resume_command: The exact CLI command that resumes this run.
312
+ consecutive_steps: How many all-empty steps ran back to back.
313
+ """
314
+
315
+ def __init__(self, message: str, *, resume_command: str, consecutive_steps: int) -> None:
316
+ super().__init__(message)
317
+ self.resume_command = resume_command
318
+ self.consecutive_steps = consecutive_steps
319
+
320
+
321
+ class DistillDegenerationError(RuntimeError):
322
+ """The policy degenerated past a tripwire's kill bound; the run aborted.
323
+
324
+ Mirrors `DistillEmptyBatchError` ergonomics: state was checkpointed and the
325
+ error carries the exact resume command (a resumed run restarts from the last
326
+ healthy checkpoint and keeps the SAME persisted baseline).
327
+
328
+ Attributes:
329
+ resume_command: The exact CLI command that resumes this run.
330
+ consecutive_steps: How many kill-level steps ran back to back.
331
+ breaches: The final step's kill-level breaches, in report order.
332
+ """
333
+
334
+ def __init__(
335
+ self,
336
+ message: str,
337
+ *,
338
+ resume_command: str,
339
+ consecutive_steps: int,
340
+ breaches: Sequence[TripwireBreach],
341
+ ) -> None:
342
+ super().__init__(message)
343
+ self.resume_command = resume_command
344
+ self.consecutive_steps = consecutive_steps
345
+ self.breaches = tuple(breaches)
346
+
347
+
348
+ class DistillNullEvalError(RuntimeError):
349
+ """An eval batch produced no verifier evidence at all, so it has no solve rate.
350
+
351
+ Raised instead of writing a `DistillEvalReport` when no trial in the batch was graded, either
352
+ because none ran (the live failure was the E2B concurrent-sandbox cap: 51/51 trials
353
+ rate-limited, harbor jobs finishing in 52 to 199 seconds, and three `baseline-student-before`
354
+ reports written as 0.0%, then used as the no-regression leg of a promotion gate) or because
355
+ the verifier never produced a reward for any of them. A null measurement must stop the run,
356
+ not become a number.
357
+ """
358
+
359
+
360
+ class DistillEvalReport(BaseModel):
361
+ """One eval batch's persisted outcome (baselines, interim evals, student-after)."""
362
+
363
+ model_config = ConfigDict(frozen=True, extra="forbid")
364
+
365
+ name: str = Field(min_length=1)
366
+ provider_model: str = Field(min_length=1)
367
+ """The provider `model` the trials sampled (sampler path or teacher ref)."""
368
+
369
+ base_model: str | None = None
370
+ """The base model behind `provider_model` (the provider's `model_type`).
371
+
372
+ This is what `eval.student_baseline_from` reuse validates against: a
373
+ student's `provider_model` is a per-run sampler path and never matches
374
+ across runs, while the base model is exactly what a shared pre-training
375
+ baseline must agree on. None only on reports from before this field.
376
+ """
377
+
378
+ task_ids: list[str]
379
+ attempts: int = Field(ge=1)
380
+ trials: int = Field(ge=0)
381
+ solve_rate: float = Field(ge=0.0, le=1.0)
382
+ """Passing share of EXECUTED trials (infrastructure failures excluded)."""
383
+
384
+ graded_solve_rate: float = Field(default=0.0, ge=0.0, le=1.0)
385
+ """Mean graded test-pass score over `graded_trials`, beside the binary `solve_rate`.
386
+
387
+ The power metric for comparing two runs on a small holdout, where binary has no resolution;
388
+ `solve_rate` stays the headline and the gate's number. 0.0 with `graded_trials == 0` is a null
389
+ measurement, not a score. See `RolloutStats.graded_solve_rate`. 0.0 on reports from before this
390
+ field."""
391
+
392
+ graded_trials: int = Field(default=0, ge=0)
393
+ """Gradeable trials that carried a readable test report: the `graded_solve_rate`
394
+ denominator."""
395
+
396
+ empty_span_trials: int = Field(ge=0)
397
+
398
+ executed_trials: int = Field(default=0, ge=0)
399
+ """Trials that produced verifier evidence; 0 only on reports from before this field."""
400
+
401
+ infra_failed_trials: int = Field(default=0, ge=0)
402
+ """Trials excluded from `solve_rate` because no verifier reward exists for them.
403
+
404
+ Either the agent never ran or its work was never graded; see `RolloutStats.infra_failed_trials`
405
+ for why one count covers both and where the per-trial causes live."""
406
+
407
+ scaffold_loss_rate: float = Field(default=0.0, ge=0.0, le=1.0)
408
+ """Share of executed trials that never reached an explicit submit."""
409
+
410
+ stop_reason_counts: dict[str, int] = Field(default_factory=dict)
411
+ """Trials per recorded stop reason."""
412
+
413
+ source: str | None = None
414
+ """Provenance note when the report was imported from a prior run via
415
+ `eval.teacher_baseline_from` / `eval.student_baseline_from` instead of
416
+ measured here; None for reports this run's own trials produced."""
417
+
418
+
419
+ class StepMetrics(BaseModel):
420
+ """One training step's metrics row (the store adds the `step` key).
421
+
422
+ The row reports two accounting stages, and mixing them in a ratio is a
423
+ measurement bug. `fragments`, `fragmentation_rate`, `overflow_drops`, and
424
+ `overlong_drops` describe the BUILD stage: rollouts merged into datums,
425
+ before the teacher scored anything. Every other datum or token count
426
+ (`datums`, `mismatch_drops`, `clipped_tokens`, `loss_tokens`,
427
+ `context_tokens`, `clip_fraction`, the advantage stats, `pg_loss`,
428
+ `grad_norm`) describes the TRAINED batch, after the teacher's misaligned
429
+ rows were dropped, so `clipped_tokens / loss_tokens` equals
430
+ `clip_fraction` and the token counts are what forward_backward consumed.
431
+ The pre-drop volume is not duplicated here: the teacher scored every built
432
+ datum, so `teacher_prefill_tokens` already carries it, and
433
+ `mismatch_drops` says how many datums the gap cost.
434
+ """
435
+
436
+ model_config = ConfigDict(frozen=True, extra="forbid")
437
+
438
+ tasks: int = Field(ge=0)
439
+ trials: int = Field(ge=0)
440
+ solve_rate: float = Field(ge=0.0, le=1.0)
441
+ """Passing share of EXECUTED trials (infrastructure failures excluded)."""
442
+
443
+ graded_solve_rate: float = Field(default=0.0, ge=0.0, le=1.0)
444
+ """Mean graded test-pass score over `graded_trials`, beside the binary `solve_rate`.
445
+
446
+ Extra resolution for run-to-run comparison, never a replacement: `solve_rate` is the benchmark's
447
+ own definition and stays the headline. 0.0 with `graded_trials == 0` is a null measurement. See
448
+ `RolloutStats.graded_solve_rate`. 0.0 on rows from before this field."""
449
+
450
+ graded_trials: int = Field(default=0, ge=0)
451
+ """Gradeable trials that carried a readable test report: the `graded_solve_rate`
452
+ denominator."""
453
+
454
+ raw_solve_rate: float = Field(default=0.0, ge=0.0, le=1.0)
455
+ """Passing share of ALL trials, which is what advantage estimation sees."""
456
+
457
+ executed_trials: int = Field(default=0, ge=0)
458
+ infra_failed_trials: int = Field(default=0, ge=0)
459
+ """Trials with no verifier reward (nothing ran, or nothing graded it); not in `solve_rate`."""
460
+
461
+ scaffold_loss_rate: float = Field(default=0.0, ge=0.0, le=1.0)
462
+ """Share of executed trials that never reached an explicit submit.
463
+
464
+ The audit headline: 88.8% for Super, invisible in every artifact. A rise here means the harness,
465
+ not the model, is deciding the solve rate."""
466
+
467
+ stop_reason_counts: dict[str, int] = Field(default_factory=dict)
468
+ """Trials per recorded stop reason (`submitted`, `max_turns`, `budget`, `no_tool_call`,
469
+ `output_truncated`, `unparsed_tool_call`, `provider_error`, `unknown`)."""
470
+
471
+ empty_span_trials: int = Field(ge=0)
472
+ truncated_spans: int = Field(default=0, ge=0)
473
+ """Turns that sampled the full `sampling.max_tokens` and were cut off mid-answer.
474
+
475
+ Charts as `train/truncated_spans`. Nothing else reports it: harbor's own truncation guard is
476
+ unreachable (see `RolloutStats.truncated_spans`), so without this series a run whose output
477
+ cap is too low reads as a run whose model writes broken actions. 0 on rows from before this
478
+ field."""
479
+
480
+ datums: int = Field(ge=0)
481
+ """Datums the optimizer step trained on; under `train.loss = "topk_ce"`
482
+ these are the rank replicas, so k x the surviving source datums."""
483
+
484
+ fragments: int = Field(ge=0)
485
+ fragmentation_rate: float = Field(ge=0.0, le=1.0)
486
+ overflow_drops: int = Field(ge=0)
487
+ overlong_drops: int = Field(ge=0)
488
+ mismatch_drops: int = Field(ge=0)
489
+ clipped_tokens: int = Field(ge=0)
490
+ """Trained loss tokens whose raw advantage hit the clip bound (0 under
491
+ `topk_ce`, which clips nothing, and 0 when `train.advantage_clip` is
492
+ unset)."""
493
+
494
+ optimizer_updates: int = Field(default=1, ge=0)
495
+ """Optimizer updates this step took (`train.num_substeps`, or fewer if the
496
+ batch had less than that many replica groups).
497
+
498
+ Recorded because it is the quantity that most cheaply explains a null
499
+ result and was invisible for the project's first several runs: the loop
500
+ took ONE update per collected batch, so a 4-step run was 5 updates total
501
+ and could not have moved a rank-32 LoRA whatever the objective did. A run
502
+ summary that reports tokens but not updates hides that."""
503
+
504
+ loss_tokens: int = Field(ge=0)
505
+ """Sampled (mask 1.0) tokens in the trained batch, counted once per SOURCE
506
+ datum: under `topk_ce` the k rank replicas share one source's tokens, so
507
+ `student_train_tokens` is k x (`loss_tokens` + `context_tokens`)."""
508
+
509
+ context_tokens: int = Field(ge=0)
510
+ """Non-loss tokens in the trained batch, on `loss_tokens`' convention."""
511
+
512
+ reverse_kl_per_token: float | None
513
+ """mean(sampled_lp - teacher_lp) over every SCORED loss token; None when
514
+ none. This is the teacher-scoring stage's measurement, not the trained
515
+ batch's: a datum the teacher scored but the alignment check later dropped
516
+ still contributed the positions that did come back."""
517
+
518
+ entropy_per_token: float | None
519
+ """`-mean(sampled_logprobs)` over the batch's loss positions, pooled.
520
+
521
+ The degeneration tripwire's first signal, and the one a KL curve cannot
522
+ give: reverse KL can fall while the policy collapses into a single mode.
523
+ The tokens came from the policy that scored them, so this is an unbiased
524
+ single-sample estimator of that policy's entropy, pooled over the batch.
525
+ Read it as a LOWER bound on the T=1 entropy, since rollouts sample at
526
+ `sampling.temperature` (0.7 for the headline run) and that concentrates the
527
+ distribution the tokens are drawn from. Pooled over the whole batch, never
528
+ per episode (`wmo.distill.tripwire.policy_health` says why). None when the
529
+ batch recorded no sampled token."""
530
+
531
+ mean_generation_tokens: float | None
532
+ """Sampled tokens per episode, pooled over the batch's span-bearing episodes.
533
+
534
+ The tripwire's second signal, against the mirror pathology: pure KL gives
535
+ EOS no gradient, so a student either never learns to stop or collapses to
536
+ near-empty answers (a sibling lane fell 2,866 to about 50 tokens). Measured
537
+ from the recorded spans, so whole-episode datum drops (the longest episodes)
538
+ cannot deflate it. None when no episode recorded a sampled token."""
539
+
540
+ entropy_baseline: float | None
541
+ """`entropy_per_token` as measured at this run's first training step; None
542
+ until the baseline is captured. Every tripwire bound is a fraction of this,
543
+ never an absolute nats value (see `TripwireConfig`)."""
544
+
545
+ entropy_ratio: float | None
546
+ """`entropy_per_token / entropy_baseline`: the number the tripwire bounds.
547
+ 1.0 at the baseline step; None until a baseline exists."""
548
+
549
+ generation_tokens_baseline: float | None
550
+ """`mean_generation_tokens` at this run's first training step; None until
551
+ the baseline is captured."""
552
+
553
+ generation_tokens_ratio: float | None
554
+ """`mean_generation_tokens / generation_tokens_baseline`; None until a
555
+ baseline exists."""
556
+
557
+ reward_mean: float | None
558
+ """Mean verifier reward over the step's trials; None when no trial ran.
559
+
560
+ Harbor's verifier rewards are binary today, so this equals `solve_rate`
561
+ and only diverges if a verifier ever emits fractional rewards.
562
+ """
563
+
564
+ loss: Literal["importance_sampling", "ppo", "topk_ce"]
565
+ """Which objective this step trained (`train.loss`), so a metrics row and
566
+ a dashboard point say what produced them.
567
+
568
+ `importance_sampling` and `ppo` submit the same advantage-carrying datums
569
+ and differ only in the service-side loss (`ppo` clips the policy ratio);
570
+ `topk_ce` submits rank-aligned replicas under the `cross_entropy` wire
571
+ loss and carries no advantage metrics at all."""
572
+
573
+ advantage_mean: float | None
574
+ """Mean advantage over the trained loss tokens, exactly as trained (after
575
+ any clipping and any centering); None when nothing was trained or the
576
+ mode builds no advantages (`topk_ce`).
577
+
578
+ With the default objective (no clip, no centering) this is the mean
579
+ teacher-minus-student gap over the trained tokens, i.e. the negated
580
+ reverse KL: the number that should move toward 0 as the student closes on
581
+ the teacher. Under `train.center_advantages` it is ~0.0 by construction
582
+ and carries no information."""
583
+
584
+ advantage_std: float | None
585
+ """Population std over the same loss tokens; None when nothing was trained.
586
+
587
+ Read it next to `advantage_mean` under the unclipped objective: it is the
588
+ per-token magnitude the gradient actually sees, and a run whose std grows
589
+ without its mean moving is being driven by outlier tokens."""
590
+
591
+ clip_fraction: float = Field(ge=0.0, le=1.0)
592
+ """Trained loss tokens whose raw advantage hit the clip bound, as a
593
+ fraction of the trained loss tokens (0.0 when nothing was trained, and
594
+ 0.0 for the whole run when `train.advantage_clip` is unset)."""
595
+
596
+ pg_loss: float | None
597
+ """The batch loss the training backend reported for the step's
598
+ forward/backward; None when no optimizer step ran or the backend reported
599
+ no loss metric (tinker 0.23.3 has no typed loss field; see
600
+ `SDK_LOSS_METRIC_NAMES`)."""
601
+
602
+ grad_norm: float | None
603
+ """The gradient norm the optim step reported; None when no optimizer step
604
+ ran or the backend reported none (see `SDK_GRAD_NORM_METRIC_NAMES`)."""
605
+
606
+ sampler_path: str
607
+ """The tinker:// sampler path the step's rollouts sampled from."""
608
+
609
+ student_prefill_tokens: int = Field(ge=0)
610
+ student_cached_prefill_tokens: int = Field(ge=0)
611
+ student_sample_tokens: int = Field(ge=0)
612
+ student_train_tokens: int = Field(ge=0)
613
+ teacher_prefill_tokens: int = Field(ge=0)
614
+ teacher_cached_prefill_tokens: int = Field(ge=0)
615
+ teacher_sample_tokens: int = Field(ge=0)
616
+ usd: float = Field(ge=0.0)
617
+ """Priced spend since the previous metrics row (spend before the first row,
618
+ the baselines and any warmup collection, folds into that first row)."""
619
+
620
+ cumulative_usd: float = Field(ge=0.0)
621
+ """Total priced spend through this row, across every session of the run
622
+ (the budget meter's total, matching the spend ledger)."""
623
+
624
+
625
+ class WarmupMetrics(BaseModel):
626
+ """One warmup step's metrics row (the store adds the `step` key).
627
+
628
+ Warmup rows share `metrics.jsonl` with training rows and their step
629
+ indices restart at 0 for OPD, so the constant `phase` field is what
630
+ distinguishes them (training rows carry no `phase` key). A run whose
631
+ warmup was skipped (zero kept trials) writes exactly one row with
632
+ `datums = 0` so the degradation to pure OPD is visible in the metrics.
633
+ """
634
+
635
+ model_config = ConfigDict(frozen=True, extra="forbid")
636
+
637
+ phase: Literal["warmup"] = "warmup"
638
+ tasks: int = Field(ge=0)
639
+ trials: int = Field(ge=0)
640
+ """Teacher trials collected on the train split (task x rollouts_per_task)."""
641
+
642
+ kept_trials: int = Field(ge=0)
643
+ """Trials that survived the `warmup.keep` filter."""
644
+
645
+ solve_rate: float = Field(ge=0.0, le=1.0)
646
+ """The teacher's solve rate over the collected warmup trials."""
647
+
648
+ datums: int = Field(ge=0)
649
+ loss_tokens: int = Field(ge=0)
650
+ context_tokens: int = Field(ge=0)
651
+ learning_rate: float = Field(gt=0)
652
+ """The effective warmup LR (warmup.learning_rate or train.learning_rate)."""
653
+
654
+ student_prefill_tokens: int = Field(ge=0)
655
+ student_cached_prefill_tokens: int = Field(ge=0)
656
+ student_sample_tokens: int = Field(ge=0)
657
+ student_train_tokens: int = Field(ge=0)
658
+ teacher_prefill_tokens: int = Field(ge=0)
659
+ teacher_cached_prefill_tokens: int = Field(ge=0)
660
+ teacher_sample_tokens: int = Field(ge=0)
661
+ usd: float = Field(ge=0.0)
662
+ """Priced spend since the previous metrics row (the teacher collection and
663
+ any earlier baseline spend fold into warmup step 0's row)."""
664
+
665
+
666
+ class SpendSummary(BaseModel):
667
+ """Where the run's money went, in the cost module's line shape."""
668
+
669
+ model_config = ConfigDict(frozen=True, extra="forbid")
670
+
671
+ lines: list[CostLine]
672
+ """This session's actual per-meter tokens and USD."""
673
+
674
+ session_usd: float = Field(ge=0.0)
675
+ prior_usd: float = Field(ge=0.0)
676
+ """USD earlier sessions of a resumed run recorded in the spend ledger."""
677
+
678
+ total_usd: float = Field(ge=0.0)
679
+
680
+
681
+ class DistillResult(BaseModel):
682
+ """What `run_distillation` returns after the gate has been decided."""
683
+
684
+ model_config = ConfigDict(frozen=True, extra="forbid")
685
+
686
+ name: str
687
+ run_dir: str
688
+ steps_completed: int = Field(ge=0)
689
+ final_sampler_path: str
690
+ final_state_path: str
691
+ gate: DistillGateRecord
692
+ adapter_version: int | None
693
+ """The promoted AdapterStore version; None when the gate rejected."""
694
+
695
+ spend: SpendSummary
696
+
697
+
698
+ # -- injectable Tinker surface (fakes satisfy these directly) ------------------------------------
699
+
700
+
701
+ SDK_LOSS_METRIC_NAMES = frozenset({"loss", "total_loss"})
702
+ """Metric names accepted as the batch loss in a tinker metrics dict.
703
+
704
+ The pinned SDK (tinker 0.23.3) exposes no typed loss field anywhere:
705
+ `ForwardBackwardOutput` carries exactly `loss_fn_output_type`, the per-datum
706
+ `loss_fn_outputs` (field name -> TensorData; the cookbook reads a per-datum
707
+ "logprobs" tensor from it), and a server-populated `metrics: dict[str, float]`
708
+ whose only documented keys are MoE routing diagnostics. The cookbook's
709
+ off-policy distillation reads a `total_loss` key from that dict, so these
710
+ spellings (bare, or with the SDK chunk combiner's ":reduction" suffix) are
711
+ what `sdk_metric_value` recognizes; anything else stays un-surfaced.
712
+ """
713
+
714
+ SDK_GRAD_NORM_METRIC_NAMES = frozenset({"grad_norm"})
715
+ """Metric names accepted as the gradient norm in a tinker metrics dict.
716
+
717
+ `OptimStepResponse` (tinker 0.23.3) carries only an untyped
718
+ `metrics: Optional[Dict[str, float]]` with no documented keys, and no grad
719
+ norm appears anywhere in the SDK or the cookbook; the value is surfaced only
720
+ if the service ever reports one under this name.
721
+ """
722
+
723
+
724
+ def sdk_metric_value(metrics: Mapping[str, float] | None, names: frozenset[str]) -> float | None:
725
+ """Pull one named scalar out of a tinker metrics dict, if it is present.
726
+
727
+ Server metric keys carry a ":reduction" suffix (e.g. "total_loss:sum")
728
+ that the SDK's chunk combiner folds over, so the match is on the name
729
+ part before an optional suffix.
730
+
731
+ Args:
732
+ metrics: The SDK output's metrics mapping (None on some responses).
733
+ names: The metric names to accept.
734
+
735
+ Returns:
736
+ The first matching value in mapping order, or None when the backend
737
+ reported no such metric (never a fabricated stand-in).
738
+ """
739
+ if not metrics:
740
+ return None
741
+ for key, value in metrics.items():
742
+ if key.split(":", 1)[0] in names:
743
+ return float(value)
744
+ return None
745
+
746
+
747
+ class TrainStepOutput(BaseModel):
748
+ """What one `forward_backward` reports back, in loop currency.
749
+
750
+ Only real backend-reported values are ever set: `SdkTrainingClient`
751
+ extracts `loss` from `ForwardBackwardOutput.metrics` (see
752
+ `SDK_LOSS_METRIC_NAMES` for exactly what the SDK exposes), and None means
753
+ the backend reported nothing, which the loop logs as an absent metric.
754
+ """
755
+
756
+ model_config = ConfigDict(frozen=True, extra="forbid")
757
+
758
+ loss: float | None = None
759
+ """The batch loss the backend reported; None when it reported none."""
760
+
761
+
762
+ class OptimStepOutput(BaseModel):
763
+ """What one `optim_step` reports back, in loop currency.
764
+
765
+ `SdkTrainingClient` extracts `grad_norm` from `OptimStepResponse.metrics`
766
+ (see `SDK_GRAD_NORM_METRIC_NAMES`); None means the backend reported
767
+ nothing, which the loop logs as an absent metric.
768
+ """
769
+
770
+ model_config = ConfigDict(frozen=True, extra="forbid")
771
+
772
+ grad_norm: float | None = None
773
+ """The gradient norm the backend reported; None when it reported none."""
774
+
775
+
776
+ class DistillSamplingClient(Protocol):
777
+ """The sampling-client slice the loop uses, in token-id terms.
778
+
779
+ `wmo.distill.fake_tinker.FakeSamplingClient` satisfies this directly; real
780
+ `tinker.SamplingClient`s are adapted via `SdkSamplingClient`.
781
+ """
782
+
783
+ def sample(
784
+ self,
785
+ prompt_token_ids: list[int],
786
+ *,
787
+ max_tokens: int,
788
+ temperature: float,
789
+ ) -> SampledSequenceLike:
790
+ """Sample one sequence conditioned on the prompt token ids."""
791
+ ...
792
+
793
+ def compute_logprobs(self, token_ids: list[int]) -> list[float | None]:
794
+ """Per-position logprobs for the sequence; entry 0 is None."""
795
+ ...
796
+
797
+
798
+ @runtime_checkable
799
+ class TokenizerSource(Protocol):
800
+ """A sampling client that can also supply its model's tokenizer."""
801
+
802
+ def get_tokenizer(self) -> EncodingTokenizer:
803
+ """The tokenizer for the client's model."""
804
+ ...
805
+
806
+
807
+ class DistillTrainingClient(Protocol):
808
+ """The training-client slice the loop drives, in loop currency.
809
+
810
+ `forward_backward` takes the loop's own `TrainDatum`s; each backend owns
811
+ the conversion to its wire datum type (`SdkTrainingClient` converts via
812
+ `to_tinker_datums`, test shims convert to `FakeDatum`s), which keeps the
813
+ orchestrator identical for real and fake runs.
814
+ """
815
+
816
+ def get_tokenizer(self) -> EncodingTokenizer:
817
+ """The student base model's tokenizer."""
818
+ ...
819
+
820
+ def forward_backward(self, datums: Sequence[TrainDatum], loss_fn: str) -> TrainStepOutput:
821
+ """Accumulate gradients for one batch under the named loss.
822
+
823
+ Returns:
824
+ The backend-reported step output; `loss` is None when the
825
+ backend reported no loss metric (never fabricated).
826
+ """
827
+ ...
828
+
829
+ def optim_step(self, learning_rate: float) -> OptimStepOutput:
830
+ """Apply one optimizer step.
831
+
832
+ Returns:
833
+ The backend-reported output; `grad_norm` is None when the
834
+ backend reported no such metric (never fabricated).
835
+ """
836
+ ...
837
+
838
+ def save_state(self) -> str:
839
+ """Save resumable training state, returning its tinker:// path."""
840
+ ...
841
+
842
+ def load_state(self, path: str) -> None:
843
+ """Restore training state from a previously saved path."""
844
+ ...
845
+
846
+ def save_weights_for_sampler(self, name: str) -> str:
847
+ """Save current weights for sampling, returning the sampler path."""
848
+ ...
849
+
850
+
851
+ class DistillServiceClient(Protocol):
852
+ """The service-client slice the loop needs.
853
+
854
+ `wmo.distill.fake_tinker.FakeServiceClient` matches this shape (tests wrap
855
+ its training client to convert datums); the real SDK is adapted by
856
+ `SdkServiceClient`.
857
+ """
858
+
859
+ def create_lora_training_client(self, base_model: str, rank: int = 32) -> DistillTrainingClient:
860
+ """Create the LoRA training client for the student base model."""
861
+ ...
862
+
863
+ def create_sampling_client(self, model_path: str) -> DistillSamplingClient:
864
+ """Create a sampling client for a sampler path or base model name."""
865
+ ...
866
+
867
+
868
+ # -- real-SDK adapters (lazy tinker imports, mirroring SdkSampler/SdkLogprobScorer) --------------
869
+
870
+
871
+ class SdkSamplingClient:
872
+ """Adapts a real `tinker.SamplingClient` to `DistillSamplingClient`.
873
+
874
+ Args:
875
+ client: The SDK sampling client, normally fetched from the
876
+ process-wide shared cache in `wmo.providers.tinker`.
877
+ model: The shared-cache key the client was fetched under. When set, a
878
+ `TinkerDeadlineError` from any call evicts that cache entry so
879
+ every future user of the model string (including the harbor trial
880
+ providers sharing the cache) rebuilds through a fresh session;
881
+ None disables eviction for a directly constructed adapter.
882
+ """
883
+
884
+ def __init__(self, client: tinker.SamplingClient, *, model: str | None = None) -> None:
885
+ self._client = client
886
+ self._model = model
887
+ self._sampler = SdkSampler(client)
888
+ self._scorer = SdkLogprobScorer(client)
889
+
890
+ def _evict_on_deadline(self) -> None:
891
+ """Evict the wedged client from the shared cache (see class docstring)."""
892
+ if self._model is not None:
893
+ evict_shared_sampling_client(self._model, self._client)
894
+
895
+ def sample(
896
+ self,
897
+ prompt_token_ids: list[int],
898
+ *,
899
+ max_tokens: int,
900
+ temperature: float,
901
+ ) -> SampledSequenceLike:
902
+ """One synchronous sample through the provider's SDK adapter."""
903
+ try:
904
+ return self._sampler.sample(
905
+ prompt_token_ids, max_tokens=max_tokens, temperature=temperature
906
+ )
907
+ except TinkerDeadlineError:
908
+ self._evict_on_deadline()
909
+ raise
910
+
911
+ def compute_logprobs(self, token_ids: list[int]) -> list[float | None]:
912
+ """One synchronous compute_logprobs call on the full sequence."""
913
+ try:
914
+ return self._scorer.compute_logprobs(token_ids)
915
+ except TinkerDeadlineError:
916
+ self._evict_on_deadline()
917
+ raise
918
+
919
+ def topk_prompt_logprobs(
920
+ self, token_ids: list[int], k: int
921
+ ) -> tuple[list[float | None], list[TopkCandidates | None]]:
922
+ """One prefill-only sample returning realized and top-k prompt logprobs.
923
+
924
+ The teacher's topk_ce path (`TinkerTeacher.score_topk`) narrows its
925
+ injected scorer to `TopkLogprobScorer` at runtime; this delegation
926
+ makes the loop's teacher sampling client satisfy it against the real
927
+ SDK, with the same cache eviction on a deadline expiry as every
928
+ other call.
929
+ """
930
+ try:
931
+ return self._scorer.topk_prompt_logprobs(token_ids, k)
932
+ except TinkerDeadlineError:
933
+ self._evict_on_deadline()
934
+ raise
935
+
936
+ def get_tokenizer(self) -> EncodingTokenizer:
937
+ """The HF tokenizer for the client's base model (deadline-bounded fetch)."""
938
+ try:
939
+ return cast(
940
+ "EncodingTokenizer", call_with_deadline("connect", self._client.get_tokenizer)
941
+ )
942
+ except TinkerDeadlineError:
943
+ self._evict_on_deadline()
944
+ raise
945
+
946
+
947
+ class SdkTrainingClient:
948
+ """Adapts a real `tinker.TrainingClient` to `DistillTrainingClient`.
949
+
950
+ Save names carry a per-session nonce so a resumed run re-saving the same
951
+ step never collides with an earlier session's artifact names.
952
+
953
+ Every SDK call is deadline-bounded (`wmo.distill.deadlines`), and what a
954
+ deadline expiry does depends on the call's idempotency:
955
+
956
+ - `forward_backward` and `optim_step` are NOT idempotent mid-batch: the
957
+ request may have executed server-side before the deadline fired, so
958
+ re-submitting could accumulate the batch's gradients or apply the Adam
959
+ step twice. Expiry raises cleanly, aborting the step with state intact
960
+ (the loop's save_state cadence bounds what a resume loses).
961
+ - `save_state` and `save_weights_for_sampler` ARE safe to retry once:
962
+ they read state that does not change between the attempts, and each
963
+ save retry uses a fresh artifact name so a first attempt that completed
964
+ server-side after being abandoned can never collide.
965
+ - `load_state` is NOT retryable on the same client, and must be its FIRST
966
+ call: tinker accepts LoadWeights only on an uninitialized model, and an
967
+ abandoned request keeps running server-side. A live resume proved it,
968
+ dying with "LoadWeights can only be called on uninitialized models"
969
+ when the retry followed a restore the client had given up waiting for.
970
+ Expiry raises, and the retry belongs on a freshly created (still
971
+ uninitialized) client, which is what `_DistillRun._open_training_client`
972
+ does. `get_tokenizer` stays legal before a restore: the SDK answers it
973
+ from a metadata-only GetInfo request that never touches weights.
974
+ """
975
+
976
+ def __init__(self, client: tinker.TrainingClient) -> None:
977
+ self._client = client
978
+ self._session = uuid4().hex[:8]
979
+ self._save_counter = 0
980
+ self._initialized = False
981
+ """Whether a call that initializes the model server-side has been issued."""
982
+
983
+ def get_tokenizer(self) -> EncodingTokenizer:
984
+ """The student base model's HF tokenizer (deadline-bounded fetch).
985
+
986
+ Metadata only (the SDK resolves it through GetInfo), so it never
987
+ initializes the model and stays legal before `load_state`.
988
+ """
989
+ return cast("EncodingTokenizer", call_with_deadline("connect", self._client.get_tokenizer))
990
+
991
+ def forward_backward(self, datums: Sequence[TrainDatum], loss_fn: str) -> TrainStepOutput:
992
+ """Convert to real tinker datums and run one bounded forward/backward.
993
+
994
+ The loss decides the wire conversion: cross_entropy datums (the
995
+ warmup phase and topk_ce replicas) carry {target_tokens, weights},
996
+ while both advantage losses (importance_sampling and ppo) carry
997
+ {target_tokens, logprobs, advantages}; each loss rejects any other
998
+ keyset server-side. No `loss_fn_config` is sent, so ppo's ratio-clip
999
+ epsilon is the service default.
1000
+
1001
+ Never retried on a deadline expiry: gradients may already have been
1002
+ accumulated server-side, so a re-submit could count the batch twice.
1003
+ The typed error aborts the step; resume restores the last checkpoint.
1004
+
1005
+ Returns:
1006
+ The step output. The SDK's `ForwardBackwardOutput` has no typed
1007
+ loss field (only `loss_fn_output_type`, per-datum
1008
+ `loss_fn_outputs`, and the untyped `metrics` dict), so `loss` is
1009
+ whatever `metrics` reports under `SDK_LOSS_METRIC_NAMES`, or
1010
+ None when the service reports no such key.
1011
+ """
1012
+ converted = (
1013
+ to_tinker_sft_datums(datums)
1014
+ if loss_fn == CROSS_ENTROPY_LOSS
1015
+ else to_tinker_datums(datums)
1016
+ )
1017
+ self._initialized = True
1018
+ future = self._client.forward_backward(converted, cast("tinker_types.LossFnType", loss_fn))
1019
+ output = wait_with_deadline("forward_backward", future)
1020
+ return TrainStepOutput(loss=sdk_metric_value(output.metrics, SDK_LOSS_METRIC_NAMES))
1021
+
1022
+ def optim_step(self, learning_rate: float) -> OptimStepOutput:
1023
+ """One bounded Adam step at the given learning rate.
1024
+
1025
+ Never retried on a deadline expiry: the step may have been applied
1026
+ server-side before the deadline fired, and re-applying would
1027
+ double-step the optimizer. The typed error aborts the step cleanly.
1028
+
1029
+ Returns:
1030
+ The step output. The SDK's `OptimStepResponse` carries only the
1031
+ untyped optional `metrics` dict with no documented keys, so
1032
+ `grad_norm` is whatever `metrics` reports under
1033
+ `SDK_GRAD_NORM_METRIC_NAMES`, or None when absent.
1034
+ """
1035
+ from tinker import types
1036
+
1037
+ self._initialized = True
1038
+ response = wait_with_deadline(
1039
+ "optim_step", self._client.optim_step(types.AdamParams(learning_rate=learning_rate))
1040
+ )
1041
+ return OptimStepOutput(
1042
+ grad_norm=sdk_metric_value(response.metrics, SDK_GRAD_NORM_METRIC_NAMES)
1043
+ )
1044
+
1045
+ def save_state(self) -> str:
1046
+ """Save resumable training state under a session-unique name.
1047
+
1048
+ Retried once on a deadline expiry: saving snapshots state that does
1049
+ not change between the attempts, and the retry advances the name
1050
+ counter so a first attempt that completed server-side after being
1051
+ abandoned can never collide.
1052
+ """
1053
+ try:
1054
+ return self._save_state_once()
1055
+ except TinkerDeadlineError as exc:
1056
+ logger.warning("retrying save_state once with a fresh request: %s", exc)
1057
+ return self._save_state_once()
1058
+
1059
+ def _save_state_once(self) -> str:
1060
+ name = f"wmo-distill-{self._session}-state-{self._save_counter:04d}"
1061
+ self._save_counter += 1
1062
+ self._initialized = True
1063
+ return wait_with_deadline("save_state", self._client.save_state(name)).path
1064
+
1065
+ def load_state(self, path: str) -> None:
1066
+ """Restore training state from a tinker:// state path, first of all calls.
1067
+
1068
+ Never retried on this client: tinker accepts LoadWeights only on an
1069
+ uninitialized model and an abandoned request keeps running
1070
+ server-side, so a second attempt here is the live
1071
+ "LoadWeights can only be called on uninitialized models" failure. The
1072
+ deadline (`WMO_TINKER_DEADLINE_LOAD_STATE`) is the whole budget, and
1073
+ the caller retries on a fresh client.
1074
+
1075
+ Args:
1076
+ path: The tinker:// state path to restore.
1077
+
1078
+ Raises:
1079
+ RuntimeError: If a call that initializes the model already ran on
1080
+ this client, so the service could only reject the restore.
1081
+ TinkerDeadlineError: If the restore blows its deadline.
1082
+ """
1083
+ if self._initialized:
1084
+ raise RuntimeError(
1085
+ "load_state must be the first call on a tinker training client: the "
1086
+ "service accepts LoadWeights only on an uninitialized model, and this "
1087
+ "client already issued a weights call. Create a fresh training client "
1088
+ f"and restore {path} before anything else touches it"
1089
+ )
1090
+ self._initialized = True
1091
+ wait_with_deadline("load_state", self._client.load_state(path))
1092
+
1093
+ def save_weights_for_sampler(self, name: str) -> str:
1094
+ """Save sampler weights, returning the tinker:// sampler path.
1095
+
1096
+ Retried once on a deadline expiry: the weights do not change between
1097
+ the attempts, and the retry saves under a distinct "-r1" name so a
1098
+ first attempt that completed server-side after being abandoned can
1099
+ never collide.
1100
+ """
1101
+ try:
1102
+ return self._save_weights_once(f"{name}-{self._session}")
1103
+ except TinkerDeadlineError as exc:
1104
+ logger.warning("retrying save_weights_for_sampler once under a fresh name: %s", exc)
1105
+ return self._save_weights_once(f"{name}-{self._session}-r1")
1106
+
1107
+ def _save_weights_once(self, full_name: str) -> str:
1108
+ self._initialized = True
1109
+ future = self._client.save_weights_for_sampler(full_name)
1110
+ return wait_with_deadline("save_weights_for_sampler", future).path
1111
+
1112
+
1113
+ class SdkServiceClient:
1114
+ """Adapts a real `tinker.ServiceClient` to `DistillServiceClient`.
1115
+
1116
+ The wrapped service builds training clients; sampling clients come from
1117
+ the process-wide shared cache in `wmo.providers.tinker` (one
1118
+ `SamplingClient` per model string, shared with the rollout providers and
1119
+ the teacher), so per-refresh construction never adds server-side sessions
1120
+ beyond one per distinct sampler path.
1121
+ """
1122
+
1123
+ def __init__(self, service: tinker.ServiceClient) -> None:
1124
+ self._service = service
1125
+
1126
+ def create_lora_training_client(self, base_model: str, rank: int = 32) -> SdkTrainingClient:
1127
+ """Create the real LoRA training client for the student (deadline-bounded)."""
1128
+
1129
+ def build() -> tinker.TrainingClient:
1130
+ return self._service.create_lora_training_client(base_model=base_model, rank=rank)
1131
+
1132
+ return SdkTrainingClient(call_with_deadline("connect", build))
1133
+
1134
+ def create_sampling_client(self, model_path: str) -> SdkSamplingClient:
1135
+ """A sampling client for a tinker:// path or base model name (shared cache).
1136
+
1137
+ Fetched from (or built into) the process-wide cache, deadline-bounded;
1138
+ the returned adapter evicts the cache entry on a `TinkerDeadlineError`
1139
+ so every future user rebuilds through a fresh session.
1140
+ """
1141
+ return SdkSamplingClient(shared_sampling_client(model_path), model=model_path)
1142
+
1143
+
1144
+ def _build_sdk_service_client() -> SdkServiceClient:
1145
+ """Build the real service adapter (the `service_client=None` path).
1146
+
1147
+ The SDK's service client pins one live server-side session for the life
1148
+ of the process, so the loop adapts `wmo.providers.tinker`'s process-wide
1149
+ shared instance instead of constructing a second one.
1150
+
1151
+ Raises:
1152
+ ImportError: If the tinker SDK is not installed (distill extra).
1153
+ RuntimeError: If TINKER_API_KEY is missing from the environment.
1154
+ """
1155
+ return SdkServiceClient(shared_service_client())
1156
+
1157
+
1158
+ # -- samplers ------------------------------------------------------------------------------------
1159
+
1160
+
1161
+ def _seed_from_name(name: str) -> int:
1162
+ """A stable cross-process seed derived from the run name (hash() is salted)."""
1163
+ return int.from_bytes(hashlib.sha256(name.encode("utf-8")).digest()[:8], "big")
1164
+
1165
+
1166
+ def tinker_provider_config(model: str, base_model: str) -> ProviderConfig:
1167
+ """The rollout provider config for one Tinker model identity.
1168
+
1169
+ The single shape every distillation rollout samples through: the tinker
1170
+ provider kind (the only one that records token spans), `model` as the exact
1171
+ identity being sampled, and `model_type` as the base model whose renderer
1172
+ and tokenizer that identity uses. Both loop callers go through it (the
1173
+ student's current sampler weights, the teacher's stable model or
1174
+ checkpoint), and so does any out-of-loop caller that needs the same
1175
+ provider without standing up a training client (a rollout-only probe).
1176
+
1177
+ Args:
1178
+ model: The exact model string to sample: a `tinker://` sampler-weights
1179
+ path, a checkpoint ref, or a base model name.
1180
+ base_model: The base model name that names the renderer/tokenizer
1181
+ identity (equal to `model` when sampling a base model directly).
1182
+
1183
+ Returns:
1184
+ The validated provider config.
1185
+ """
1186
+ return ProviderConfig(kind=ProviderKind.TINKER, model=model, model_type=base_model)
1187
+
1188
+
1189
+ class TaskSampler:
1190
+ """A seeded shuffle-cycle over the train task ids.
1191
+
1192
+ Batches are unique within themselves and the cycle visits every task
1193
+ before repeating any, so coverage stays even. The sequence is a pure
1194
+ function of (task_ids, seed, draw count): a resumed run fast-forwards by
1195
+ the completed step count and continues the exact same batch sequence.
1196
+
1197
+ Args:
1198
+ task_ids: The train split's task ids; must be non-empty and unique.
1199
+ seed: Seed for the shuffle order.
1200
+
1201
+ Raises:
1202
+ ValueError: If `task_ids` is empty or contains duplicates.
1203
+ """
1204
+
1205
+ def __init__(self, task_ids: Sequence[str], *, seed: int) -> None:
1206
+ ids = list(task_ids)
1207
+ if not ids:
1208
+ raise ValueError("task_ids is empty; a distillation run needs at least one train task")
1209
+ if len(set(ids)) != len(ids):
1210
+ raise ValueError(
1211
+ "task_ids contains duplicates; pass each train task id exactly once "
1212
+ "(attempts per task come from train.group_size, not repeated ids)"
1213
+ )
1214
+ self._ids = ids
1215
+ self._rng = random.Random(seed)
1216
+ self._cycle: list[str] = []
1217
+ self._position = 0
1218
+ self._reshuffle()
1219
+
1220
+ def _reshuffle(self) -> None:
1221
+ self._cycle = list(self._ids)
1222
+ self._rng.shuffle(self._cycle)
1223
+ self._position = 0
1224
+
1225
+ def next_batch(self, n: int) -> list[str]:
1226
+ """The next batch of at most `n` unique task ids.
1227
+
1228
+ Args:
1229
+ n: Requested batch size; clamped to the split size.
1230
+
1231
+ Returns:
1232
+ `min(n, len(task_ids))` unique task ids in cycle order.
1233
+
1234
+ Raises:
1235
+ ValueError: If `n` is not positive.
1236
+ """
1237
+ if n < 1:
1238
+ raise ValueError(f"batch size must be >= 1, got {n}")
1239
+ size = min(n, len(self._ids))
1240
+ batch: list[str] = []
1241
+ while len(batch) < size:
1242
+ if self._position >= len(self._cycle):
1243
+ self._reshuffle()
1244
+ candidate = self._cycle[self._position]
1245
+ self._position += 1
1246
+ if candidate not in batch:
1247
+ batch.append(candidate)
1248
+ return batch
1249
+
1250
+
1251
+ class StudentSampler:
1252
+ """Owns the student's sampler-weights refresh cadence.
1253
+
1254
+ `refresh(step)` saves the training client's current weights under a
1255
+ step-stamped name and swaps in a sampling client for them; the exposed
1256
+ `sampler_path` is the tinker:// path rollout provider configs point at.
1257
+ Refreshing the same step twice is a no-op (the finalize path may land on
1258
+ a step the cadence already refreshed).
1259
+
1260
+ Args:
1261
+ service: The service client sampler clients are created from.
1262
+ training: The training client whose weights are saved.
1263
+ run_name: Stamped into every sampler-weights save name.
1264
+ """
1265
+
1266
+ def __init__(
1267
+ self,
1268
+ service: DistillServiceClient,
1269
+ training: DistillTrainingClient,
1270
+ run_name: str,
1271
+ ) -> None:
1272
+ self._service = service
1273
+ self._training = training
1274
+ self._run_name = run_name
1275
+ self._path: str | None = None
1276
+ self._client: DistillSamplingClient | None = None
1277
+ self._last_step: int | None = None
1278
+
1279
+ def refresh(self, step: int, *, tag: str | None = None) -> str:
1280
+ """Save current weights for sampling and swap in a fresh client.
1281
+
1282
+ Args:
1283
+ step: The 0-based training step the weights are current FOR (the
1284
+ step about to sample from them); stamps the save name.
1285
+ tag: Optional label forcing a save even when `step` was already
1286
+ refreshed. The post-warmup refresh needs it: step 0 was
1287
+ refreshed before preflight, but warmup then CHANGED the
1288
+ weights, so the dedup must not short-circuit. The tag also
1289
+ stamps the save name so the two step-0 artifacts cannot
1290
+ collide.
1291
+
1292
+ Returns:
1293
+ The new (or, when `step` was already refreshed and no tag forces
1294
+ a save, current) tinker:// sampler path.
1295
+
1296
+ Raises:
1297
+ ValueError: If `step` is negative.
1298
+ """
1299
+ if step < 0:
1300
+ raise ValueError(f"refresh step must be >= 0, got {step}")
1301
+ if tag is None and self._path is not None and self._last_step == step:
1302
+ return self._path
1303
+ label = f"step-{step:04d}" if tag is None else f"{tag}-step-{step:04d}"
1304
+ path = self._training.save_weights_for_sampler(f"{self._run_name}-{label}")
1305
+ self._client = self._service.create_sampling_client(path)
1306
+ self._path = path
1307
+ self._last_step = step
1308
+ self._warm(step, path)
1309
+ logger.debug("student sampler refreshed for step %d: %s", step, path)
1310
+ return path
1311
+
1312
+ def _warm(self, step: int, path: str) -> None:
1313
+ """Serve throwaway tokens CONCURRENTLY so the batch never races a cold sampler.
1314
+
1315
+ Freshly published sampler weights are not immediately hot. Launching the whole wave at
1316
+ once means every episode's FIRST call races the same weight load, and the losers exhaust
1317
+ their retries and report `provider_error` at turn 0-2 while the survivors go on to run 24+
1318
+ turns cleanly.
1319
+
1320
+ Two live measurements shaped this, and the second is why the warmup is concurrent rather
1321
+ than a single call:
1322
+
1323
+ 1. A 51-episode eval wave lost 7 episodes (~15%) at a mean of 2.0 turns, against 6% for
1324
+ the same model sampled from already-warm BASE weights. A base-weights probe therefore
1325
+ cannot detect this at all.
1326
+ 2. After adding a ONE-token serial warmup, a 64-episode training wave still lost 7 of its
1327
+ first 34 episodes (~21%) at a mean of **0.7 turns**. One token wakes the model but does
1328
+ not make it scale: the cost is not a single weight load, it is the sampler's ramp to
1329
+ serving many streams at once, so the warmup has to exercise concurrency too.
1330
+
1331
+ `_WARMUP_STREAMS` concurrent throwaway calls approximate the wave's own fan-out. Failure
1332
+ of any stream is deliberately NOT fatal: a sampler that cannot serve one token will fail
1333
+ loudly in the batch anyway, and aborting a paid run inside a warmup trades a partial batch
1334
+ for no batch.
1335
+ """
1336
+ client = self._client
1337
+ if client is None: # pragma: no cover - refresh() just assigned it
1338
+ return
1339
+
1340
+ def once() -> None:
1341
+ client.sample(prompt_token_ids=[0], max_tokens=1, temperature=0.0)
1342
+
1343
+ failures: list[BaseException] = []
1344
+ with ThreadPoolExecutor(max_workers=_WARMUP_STREAMS) as pool:
1345
+ for future in [pool.submit(once) for _ in range(_WARMUP_STREAMS)]:
1346
+ error = future.exception()
1347
+ if error is not None:
1348
+ failures.append(error)
1349
+ if failures:
1350
+ first = failures[0]
1351
+ logger.warning(
1352
+ "student sampler warmup for step %d (%s): %d of %d streams did not serve a token "
1353
+ "(first: %s: %s); launching the batch anyway, but expect turn-0 provider_error "
1354
+ "losses if the weights are still loading",
1355
+ step,
1356
+ path,
1357
+ len(failures),
1358
+ _WARMUP_STREAMS,
1359
+ type(first).__name__,
1360
+ first,
1361
+ )
1362
+
1363
+ @property
1364
+ def sampler_path(self) -> str:
1365
+ """The current tinker:// sampler path."""
1366
+ if self._path is None:
1367
+ raise RuntimeError(
1368
+ "the student sampler has no weights yet; call refresh() before reading sampler_path"
1369
+ )
1370
+ return self._path
1371
+
1372
+ @property
1373
+ def client(self) -> DistillSamplingClient:
1374
+ """The sampling client for the current weights."""
1375
+ if self._client is None:
1376
+ raise RuntimeError(
1377
+ "the student sampler has no weights yet; call refresh() before reading client"
1378
+ )
1379
+ return self._client
1380
+
1381
+ def provider_config(self, base_model: str) -> ProviderConfig:
1382
+ """The rollout provider config pointing at the current weights.
1383
+
1384
+ Args:
1385
+ base_model: The student base model name (renderer identity).
1386
+ """
1387
+ return tinker_provider_config(self.sampler_path, base_model)
1388
+
1389
+
1390
+ # -- preflight -----------------------------------------------------------------------------------
1391
+
1392
+
1393
+ def tito_recompute_check(
1394
+ client: DistillSamplingClient,
1395
+ prompt_token_ids: Sequence[int],
1396
+ *,
1397
+ sample_tokens: int = _PREFLIGHT_SAMPLE_TOKENS,
1398
+ temperature: float = 1.0,
1399
+ mean_tolerance: float = DEFAULT_TITO_MEAN_TOLERANCE,
1400
+ max_tolerance: float = DEFAULT_TITO_MAX_TOLERANCE,
1401
+ ) -> None:
1402
+ """The live tokens-in-tokens-out proof: sample, then recompute agreement.
1403
+
1404
+ Samples a short sequence, then asks the same client to `compute_logprobs`
1405
+ on prompt + sampled tokens and requires the recomputed logprobs at the
1406
+ sampled positions to agree with the issued ones. Two bounds apply: the
1407
+ MEAN absolute gap across the sample must stay within `mean_tolerance`
1408
+ (sampler/scorer kernel noise is zero-mean; systematic corruption is not),
1409
+ and no single position may exceed `max_tolerance` (a wrong sampler path,
1410
+ tokenizer drift, or SDK change shifts logprobs by whole nats). Failing
1411
+ either means the sampling and scoring paths do not see the same tokens,
1412
+ which would silently corrupt every training datum.
1413
+
1414
+ Args:
1415
+ client: The student sampling client under test.
1416
+ prompt_token_ids: A non-empty probe prompt.
1417
+ sample_tokens: How many tokens to sample.
1418
+ temperature: Sampling temperature; 1.0 keeps issued logprobs directly
1419
+ comparable to recomputed model logprobs.
1420
+ mean_tolerance: Max acceptable mean |issued - recomputed| over the
1421
+ sampled positions.
1422
+ max_tolerance: Max acceptable |issued - recomputed| at any position.
1423
+
1424
+ Raises:
1425
+ RuntimeError: On empty prompt/sample, missing or misaligned logprobs,
1426
+ or disagreement beyond either bound; the message names the
1427
+ offending statistic and both values.
1428
+ """
1429
+ prompt = list(prompt_token_ids)
1430
+ if not prompt:
1431
+ raise RuntimeError(
1432
+ "the TITO recompute check needs a non-empty probe prompt; the renderer "
1433
+ "produced no tokens for the preflight message, so check the renderer "
1434
+ "and tokenizer for the student base model"
1435
+ )
1436
+ sequence = client.sample(prompt, max_tokens=sample_tokens, temperature=temperature)
1437
+ sampled = list(sequence.tokens)
1438
+ issued = sequence.logprobs
1439
+ if not sampled:
1440
+ raise RuntimeError(
1441
+ "the TITO recompute check sampled no tokens; the student sampler "
1442
+ "returned an empty sequence for a fresh prompt, so check the sampler "
1443
+ "weights path and the model's availability"
1444
+ )
1445
+ if issued is None or len(issued) != len(sampled):
1446
+ got = "no logprobs" if issued is None else f"{len(issued)} logprobs"
1447
+ raise RuntimeError(
1448
+ f"the TITO recompute check got {got} for {len(sampled)} sampled tokens; "
1449
+ "per-token logprobs are required for tokens-in-tokens-out training, so "
1450
+ "check the tinker SDK version pin"
1451
+ )
1452
+ full = prompt + sampled
1453
+ recomputed = client.compute_logprobs(full)
1454
+ if len(recomputed) != len(full):
1455
+ raise RuntimeError(
1456
+ f"compute_logprobs returned {len(recomputed)} entries for the "
1457
+ f"{len(full)}-token recompute sequence; it must return one entry per "
1458
+ "position, so check the tinker SDK version pin"
1459
+ )
1460
+ gaps: list[float] = []
1461
+ for offset, issued_lp in enumerate(issued):
1462
+ recomputed_lp = recomputed[len(prompt) + offset]
1463
+ if recomputed_lp is None:
1464
+ raise RuntimeError(
1465
+ f"TITO recompute returned no logprob at sampled position {offset} "
1466
+ f"(sampler issued {issued_lp:.4f}). The scoring path cannot see "
1467
+ "the student's own tokens, so training data would be corrupt; "
1468
+ "check that the sampler path and base model match and that the "
1469
+ "pinned tinker SDK is unchanged"
1470
+ )
1471
+ gap = abs(recomputed_lp - issued_lp)
1472
+ if gap > max_tolerance:
1473
+ raise RuntimeError(
1474
+ f"TITO recompute disagreement at sampled position {offset}: the "
1475
+ f"sampler issued logprob {issued_lp:.4f} but compute_logprobs "
1476
+ f"returned {recomputed_lp:.4f} (gap {gap:.4f} > per-position "
1477
+ f"bound {max_tolerance}). A gap this large means the sampling "
1478
+ "and scoring paths disagree on the student's own tokens, so "
1479
+ "training data would be corrupt; check that the sampler path "
1480
+ "and base model match and that the pinned tinker SDK is unchanged"
1481
+ )
1482
+ gaps.append(gap)
1483
+ mean_gap = sum(gaps) / len(gaps)
1484
+ if mean_gap > mean_tolerance:
1485
+ worst = max(range(len(gaps)), key=gaps.__getitem__)
1486
+ raise RuntimeError(
1487
+ f"TITO recompute disagreement: mean |issued - recomputed| logprob "
1488
+ f"gap {mean_gap:.4f} over {len(gaps)} sampled tokens exceeds "
1489
+ f"{mean_tolerance} (worst position {worst}: gap {gaps[worst]:.4f}). "
1490
+ "Sampler/scorer kernel noise is zero-mean, so a systematic gap "
1491
+ "means the paths disagree on the student's own tokens and training "
1492
+ "data would be corrupt; check that the sampler path and base model "
1493
+ "match and that the pinned tinker SDK is unchanged"
1494
+ )
1495
+ logger.info(
1496
+ "TITO recompute check passed: mean gap %.4f, max gap %.4f over %d tokens",
1497
+ mean_gap,
1498
+ max(gaps),
1499
+ len(gaps),
1500
+ )
1501
+
1502
+
1503
+ # -- internal helpers ----------------------------------------------------------------------------
1504
+
1505
+
1506
+ def resume_command(name: str, run_dir: Path) -> str:
1507
+ """The CLI command that resumes a distillation run.
1508
+
1509
+ The CLI layer (`wmo/cli/harness_distill.py`) reuses this helper so the
1510
+ command printed on a budget abort stays the command that actually works.
1511
+ On resume the run's pinned config is the `config.toml` snapshot inside
1512
+ the run dir. `name` must be the agent string as the user typed it (an
1513
+ @ref included) or the printed command trips the CLI resume conflict
1514
+ check.
1515
+ """
1516
+ return f"wmo optimize harness {name} harbor --mode distill --run-dir {run_dir} --resume"
1517
+
1518
+
1519
+ def pin_rollout_params(harness: HarnessDoc, cfg: DistillConfig) -> HarnessDoc:
1520
+ """Pin the config's rollout knobs onto the harness document the trials run.
1521
+
1522
+ The pi runtimes read their sampling temperature, turn cap, and per-call
1523
+ output cap from the document's param surfaces, so `[sampling]` and
1524
+ `[rollout] max_turns` only take effect by being written INTO the document:
1525
+ `sampling.temperature` -> `param:temperature`, `rollout.max_turns` ->
1526
+ `param:max-turns`, `sampling.max_tokens` -> `param:max-output-tokens`.
1527
+ The result is a pure function of (seed document, config), so every session
1528
+ and step of a run derives the identical document (and harbor job identity).
1529
+
1530
+ A temperature other than 1.0 is allowed but warned about: the sampler's
1531
+ issued logprobs are temperature-scaled while the teacher's
1532
+ `compute_logprobs` are not, which biases the reverse-KL advantages and the
1533
+ importance-sampling correction.
1534
+
1535
+ Args:
1536
+ harness: The seed harness document.
1537
+ cfg: The validated run config.
1538
+
1539
+ Returns:
1540
+ A new validated document with the three param surfaces replaced.
1541
+ """
1542
+ if cfg.sampling.temperature != 1.0:
1543
+ logger.warning(
1544
+ "sampling.temperature = %s: sampler-issued logprobs are temperature-scaled "
1545
+ "but teacher logprobs are not, so reverse-KL advantages are biased; use "
1546
+ "temperature = 1.0 for faithful importance weights",
1547
+ cfg.sampling.temperature,
1548
+ )
1549
+ replacements = {
1550
+ TEMPERATURE_ID: str(cfg.sampling.temperature),
1551
+ MAX_TURNS_ID: str(cfg.rollout.max_turns),
1552
+ MAX_OUTPUT_TOKENS_ID: str(cfg.sampling.max_tokens),
1553
+ }
1554
+ surfaces = [surface for surface in harness.surfaces if surface.id not in replacements]
1555
+ surfaces.extend(
1556
+ Surface(id=surface_id, kind=SurfaceKind.PARAM, content=content)
1557
+ for surface_id, content in replacements.items()
1558
+ )
1559
+ # Reconstruct (not model_copy) so the document re-validates as a whole.
1560
+ return HarnessDoc(name=harness.name, version=harness.version, surfaces=surfaces)
1561
+
1562
+
1563
+ def _batch_reverse_kl(
1564
+ datums: Sequence[TrainDatum], rows: Sequence[Sequence[float | None]]
1565
+ ) -> float | None:
1566
+ """The batch's reverse KL per token from realized teacher logprobs.
1567
+
1568
+ `mean(sampled_lp - teacher_lp)` over every scored loss token; None when
1569
+ nothing was scored. Both loss modes feed this the teacher's logprobs of
1570
+ the REALIZED (student-sampled) tokens (`TeacherClient.score` rows, or
1571
+ `TeacherTopkScores.realized`), so the metric means the same thing across
1572
+ modes.
1573
+
1574
+ Args:
1575
+ datums: The step's datums.
1576
+ rows: One per-position realized-logprob row per datum, aligned with
1577
+ `datums`; misaligned rows are skipped (the datum path drops and
1578
+ counts them separately).
1579
+ """
1580
+ kl_sum = 0.0
1581
+ kl_count = 0
1582
+ for datum, row in zip(datums, rows, strict=True):
1583
+ if len(row) != len(datum.model_input_tokens):
1584
+ continue # misaligned row: the datum builders drop and count it
1585
+ for position, teacher_lp in enumerate(row):
1586
+ if teacher_lp is None:
1587
+ continue
1588
+ kl_sum += datum.sampled_logprobs[position] - teacher_lp
1589
+ kl_count += 1
1590
+ return kl_sum / kl_count if kl_count else None
1591
+
1592
+
1593
+ def _split_cached_prefill(submitted: int, cached: int) -> tuple[int, int]:
1594
+ """Split one batch's scoring volume into (uncached, cached) billable tokens.
1595
+
1596
+ Pure arithmetic, separated from the charging so the guards can be tested
1597
+ without a loop instance. Both inputs are DELTAS of monotonic counters, and
1598
+ both can arrive nonsensical:
1599
+
1600
+ - `cached` can be negative when the teacher rebuilt its scorer mid-batch
1601
+ (the wedged-session path drops and replaces it, restarting the counter).
1602
+ Left alone that would CREDIT the run for spend it really made.
1603
+ - `cached` can exceed `submitted` for the same reason, or if a response
1604
+ counts cache hits over a prompt the caller did not attribute to this
1605
+ batch, which would bill negative uncached tokens.
1606
+
1607
+ Args:
1608
+ submitted: Tokens submitted for scoring in this batch.
1609
+ cached: Tokens the service reported serving from its prefix cache.
1610
+
1611
+ Returns:
1612
+ `(uncached, cached)`, both non-negative and summing to `max(submitted, 0)`.
1613
+ """
1614
+ total = max(submitted, 0)
1615
+ billable_cached = min(max(cached, 0), total)
1616
+ return total - billable_cached, billable_cached
1617
+
1618
+
1619
+ def _teacher_cached_usage(teacher: TeacherClient) -> int:
1620
+ """The teacher's cumulative service-cached prefill tokens, or 0 if unknown.
1621
+
1622
+ Args:
1623
+ teacher: The run's teacher client.
1624
+
1625
+ Returns:
1626
+ `cached_usage()` when the teacher reports one, else 0 — which bills every
1627
+ scoring token at the uncached rate, the behaviour the ledger had before
1628
+ the field was readable.
1629
+ """
1630
+ if isinstance(teacher, CachedUsageTeacher):
1631
+ return teacher.cached_usage()
1632
+ return 0
1633
+
1634
+
1635
+ def _teacher_rows(
1636
+ teacher: TeacherClient, datums: Sequence[TrainDatum]
1637
+ ) -> tuple[list[list[float | None]], float | None]:
1638
+ """Score datums with the teacher and compute the batch's reverse KL.
1639
+
1640
+ The teacher consumes the loop's own datums and returns one per-position
1641
+ row per datum in the compute_logprobs convention (loss positions carry a
1642
+ logprob; everything else, including a position-0 loss token the teacher
1643
+ cannot condition on, stays None and is dropped loudly downstream by
1644
+ `attach_advantages`).
1645
+
1646
+ Args:
1647
+ teacher: The teacher backend.
1648
+ datums: The step's datums.
1649
+
1650
+ Returns:
1651
+ The per-position teacher rows (aligned one to one with `datums`) and
1652
+ the batch's reverse KL per token, `mean(sampled_lp - teacher_lp)` over
1653
+ every scored loss token (None when nothing was scored).
1654
+ """
1655
+ rows = teacher.score(list(datums))
1656
+ return rows, _batch_reverse_kl(datums, rows)
1657
+
1658
+
1659
+ def _graded_phrase(graded_solve_rate: float, graded_trials: int) -> str:
1660
+ """One progress-line clause for the graded rate, saying so when there is none.
1661
+
1662
+ A batch with no readable test report has no graded score, and its stats carry 0.0 as a
1663
+ placeholder; printing that as "graded 0.000" would report a null measurement as a total failure.
1664
+ """
1665
+ if not graded_trials:
1666
+ return "no graded score (no readable test report)"
1667
+ return f"graded {graded_solve_rate:.3f} over {graded_trials} graded trial(s)"
1668
+
1669
+
1670
+ def _measured_graded_rate(report: DistillEvalReport) -> float | None:
1671
+ """The report's graded solve rate, or None when it measured none.
1672
+
1673
+ None covers both an eval whose trials left no readable test report and a baseline imported from
1674
+ a run that predates the field (`graded_trials == 0`), so neither charts a fabricated 0.0.
1675
+ """
1676
+ return report.graded_solve_rate if report.graded_trials else None
1677
+
1678
+
1679
+ class _RunBudget:
1680
+ """This session's `BudgetMeter` plus the USD prior sessions already spent.
1681
+
1682
+ The cap is enforced over the TOTAL (prior + session) so a resumed run
1683
+ cannot spend the budget twice; the inner meter never enforces on its own.
1684
+ Every charge is pushed through `on_spend` (the run store's spend ledger)
1685
+ so every recorded charge survives a crash (metrics rows alone would lose
1686
+ everything charged since the last completed step; work still in flight when
1687
+ a session dies is charged only when its batch returns).
1688
+ """
1689
+
1690
+ def __init__(
1691
+ self,
1692
+ pricing: PricingConfig,
1693
+ max_usd: float | None,
1694
+ prior_usd: float,
1695
+ *,
1696
+ on_spend: Callable[[float], None] | None = None,
1697
+ ) -> None:
1698
+ self._meter = BudgetMeter(pricing, max_usd=None)
1699
+ self._max_usd = max_usd
1700
+ self.prior_usd = prior_usd
1701
+ self._on_spend = on_spend
1702
+
1703
+ def charge(self, meter: MeterName, tokens: int) -> None:
1704
+ """Record actual token usage against one meter and persist the total."""
1705
+ self._meter.charge(meter, tokens)
1706
+ if self._on_spend is not None:
1707
+ self._on_spend(self.total_usd)
1708
+
1709
+ def check(self) -> None:
1710
+ """Enforce the cap over the total spend.
1711
+
1712
+ Raises:
1713
+ BudgetExhausted: When the cap is set and the total exceeds it.
1714
+ """
1715
+ if self._max_usd is not None and self.total_usd > self._max_usd:
1716
+ raise BudgetExhausted(self.total_usd, self._max_usd)
1717
+
1718
+ @property
1719
+ def session_usd(self) -> float:
1720
+ """Priced USD spent by this session only."""
1721
+ return self._meter.spent_usd
1722
+
1723
+ @property
1724
+ def total_usd(self) -> float:
1725
+ """Priced USD across prior sessions and this one."""
1726
+ return self.prior_usd + self._meter.spent_usd
1727
+
1728
+ def tokens(self, meter: MeterName) -> int:
1729
+ """This session's tokens charged to one meter."""
1730
+ return self._meter.tokens(meter)
1731
+
1732
+ def lines(self) -> list[CostLine]:
1733
+ """This session's actuals in the estimate's line shape."""
1734
+ return self._meter.lines()
1735
+
1736
+
1737
+ class _DistillRun:
1738
+ """One orchestrated distillation run; `run_distillation` drives it."""
1739
+
1740
+ def __init__(
1741
+ self,
1742
+ name: str,
1743
+ cfg: DistillConfig,
1744
+ harness: HarnessDoc,
1745
+ train_task_ids: Sequence[str],
1746
+ holdout_task_ids: Sequence[str],
1747
+ run_dir: Path,
1748
+ *,
1749
+ service: DistillServiceClient,
1750
+ adapter_store: AdapterStore,
1751
+ on_progress: ProgressCallback | None,
1752
+ live_trial_preflight: LiveTrialPreflight | None,
1753
+ tracker: DistillTracker,
1754
+ cli_agent: str | None = None,
1755
+ ) -> None:
1756
+ self._name = name
1757
+ # The agent string resume commands print; the run/adapter name strips any
1758
+ # @ref, but a resume must be invoked with the string the run started with.
1759
+ self._cli_agent = cli_agent if cli_agent is not None else name
1760
+ self._cfg = cfg
1761
+ self._harness = pin_rollout_params(harness, cfg)
1762
+ self._train_ids = list(train_task_ids)
1763
+ self._holdout_ids = list(holdout_task_ids)
1764
+ self._run_dir = run_dir
1765
+ self._service = service
1766
+ self._adapters = adapter_store
1767
+ self._on_progress = on_progress
1768
+ self._live_trial_preflight = live_trial_preflight
1769
+ self._tracker = tracker
1770
+ self._store = DistillRunStore(run_dir)
1771
+ self._teacher_identity = cfg.teacher.checkpoint or cfg.teacher.model
1772
+ # Set by _preflight (the renderer needs the training client's tokenizer);
1773
+ # kept for sample-rollout logging across every later batch.
1774
+ self._rendering: ChatRendering | None = None
1775
+ # Set up by execute():
1776
+ self._training: DistillTrainingClient
1777
+ self._teacher: TinkerTeacher
1778
+ self._teacher_client: DistillSamplingClient
1779
+ self._sampler: StudentSampler
1780
+ self._tasks: TaskSampler
1781
+ self._budget: _RunBudget
1782
+ self._prev_tokens: dict[MeterName, int] = dict.fromkeys(METER_NAMES, 0)
1783
+ self._prev_usd = 0.0
1784
+ self._empty_step_streak = 0
1785
+ self._degeneration_streak = 0
1786
+ # Loaded from the run manifest on resume (never re-measured there);
1787
+ # captured at this session's first training step otherwise.
1788
+ self._tripwire_baseline: TripwireBaseline | None = None
1789
+ self._resumed = False
1790
+
1791
+ # -- plumbing --------------------------------------------------------------------------------
1792
+
1793
+ def _emit(self, phase: DistillPhase, message: str, *, step: int | None = None) -> None:
1794
+ logger.info("[%s] %s", phase, message)
1795
+ if self._on_progress is not None:
1796
+ self._on_progress(
1797
+ DistillProgress(
1798
+ phase=phase,
1799
+ message=message,
1800
+ total_steps=self._cfg.train.steps,
1801
+ step=step,
1802
+ spent_usd=max(self._budget.total_usd, 0.0),
1803
+ )
1804
+ )
1805
+
1806
+ def _abort_for_budget(self, exc: BudgetExhausted, *, completed_step: int | None) -> NoReturn:
1807
+ """Persist what can be persisted, then raise the typed budget error.
1808
+
1809
+ Args:
1810
+ exc: The meter's exhaustion error.
1811
+ completed_step: The last fully completed training step of this
1812
+ session, or None when no step completed (nothing new to
1813
+ checkpoint; recorded artifacts are reused on resume).
1814
+ """
1815
+ if completed_step is not None:
1816
+ state_path = self._training.save_state()
1817
+ self._store.record_checkpoint(completed_step, state_path, self._sampler.sampler_path)
1818
+ saved = f"training state was saved (checkpoint at step {completed_step})"
1819
+ else:
1820
+ saved = (
1821
+ "no training step completed in this session, so the run resumes "
1822
+ "from its recorded artifacts"
1823
+ )
1824
+ command = resume_command(self._cli_agent, self._run_dir)
1825
+ raise DistillBudgetError(
1826
+ f"budget exhausted: ${exc.spent_usd:.2f} spent against the "
1827
+ f"${exc.max_usd:.2f} cap (budget.max_usd); {saved}. Raise budget.max_usd "
1828
+ f"in {self._store.config_path} and resume with: {command}",
1829
+ resume_command=command,
1830
+ spent_usd=exc.spent_usd,
1831
+ max_usd=exc.max_usd,
1832
+ ) from exc
1833
+
1834
+ def _check_empty_batch_streak(self, step: int, trials: int, empty: int, datums: int) -> None:
1835
+ """Track all-empty training steps and abort after the tolerated streak.
1836
+
1837
+ Args:
1838
+ step: The 0-based training step that just completed its metrics row.
1839
+ trials: The step's trial count.
1840
+ empty: Trials that recorded no token span.
1841
+ datums: Trainable datums the step produced.
1842
+
1843
+ Raises:
1844
+ DistillEmptyBatchError: After `MAX_CONSECUTIVE_EMPTY_STEPS` steps
1845
+ in a row where every trial produced zero spans and no datums
1846
+ (a step with zero trials counts: no trials is at least as dead
1847
+ as all-empty trials); the step's state is checkpointed first
1848
+ and the message carries the exact resume command.
1849
+ """
1850
+ if not (empty == trials and datums == 0):
1851
+ self._empty_step_streak = 0
1852
+ return
1853
+ self._empty_step_streak += 1
1854
+ logger.warning(
1855
+ "step %d is all-empty (%d trial(s), every one without token spans); "
1856
+ "%d/%d consecutive empty step(s) before the run aborts",
1857
+ step,
1858
+ trials,
1859
+ self._empty_step_streak,
1860
+ MAX_CONSECUTIVE_EMPTY_STEPS,
1861
+ )
1862
+ if self._empty_step_streak < MAX_CONSECUTIVE_EMPTY_STEPS:
1863
+ return
1864
+ state_path = self._training.save_state()
1865
+ self._store.record_checkpoint(step, state_path, self._sampler.sampler_path)
1866
+ command = resume_command(self._cli_agent, self._run_dir)
1867
+ raise DistillEmptyBatchError(
1868
+ f"aborting after {self._empty_step_streak} consecutive training steps in "
1869
+ "which every trial produced zero token spans (0 trainable datums): the "
1870
+ "trials are producing no completions, so training cannot make progress. "
1871
+ "Two causes account for nearly all of these: provider or session failures "
1872
+ "upstream in the rollout trials (see the runner logs for worker completion "
1873
+ "warnings), or, with harbor.backend = 'e2b', trials dying at sandbox "
1874
+ "creation because the E2B account is at its concurrent-sandbox cap (run "
1875
+ "`wmo e2b reap` to see what is holding the slots). Run artifacts were "
1876
+ f"persisted (checkpoint at step {step}), so once the cause is fixed resume "
1877
+ f"with: {command}",
1878
+ resume_command=command,
1879
+ consecutive_steps=self._empty_step_streak,
1880
+ )
1881
+
1882
+ def _arm_tripwire(self, step: int, health: PolicyHealth) -> TripwireBaseline | None:
1883
+ """Return the run's tripwire baseline, capturing it at the first step.
1884
+
1885
+ Capture happens exactly once per RUN, not per session: a baseline read
1886
+ back from the manifest on resume is kept as is, because re-measuring
1887
+ against a policy that already degenerated is what makes a resumed run's
1888
+ tripwire blind. Capture is deliberately NOT gated on
1889
+ `tripwire.enabled`: the baseline and the per-step ratios cost nothing
1890
+ and stay in `metrics.jsonl` even when the abort is switched off.
1891
+
1892
+ Args:
1893
+ step: The 0-based training step being measured.
1894
+ health: That step's batch-pooled health.
1895
+
1896
+ Returns:
1897
+ The armed baseline, or None when no step has been measurable yet (an
1898
+ all-empty batch cannot serve as a reference); the next step retries.
1899
+ """
1900
+ if self._tripwire_baseline is not None:
1901
+ return self._tripwire_baseline
1902
+ captured = capture_baseline(step, health)
1903
+ if captured is None:
1904
+ logger.warning(
1905
+ "step %d measured no usable policy baseline (%d episode(s) with spans, "
1906
+ "%d sampled token(s)), so the degeneration tripwire stays unarmed; the "
1907
+ "next step retries",
1908
+ step,
1909
+ health.episodes,
1910
+ health.sampled_tokens,
1911
+ )
1912
+ return None
1913
+ self._tripwire_baseline = captured
1914
+ self._store.write_tripwire_baseline(captured)
1915
+ cfg = self._cfg.tripwire
1916
+ logger.info(
1917
+ "degeneration tripwire armed from step %d: entropy %.4f nats/token, %.0f sampled "
1918
+ "tokens/episode (%d episode(s), %d token(s)). Thresholds are FRACTIONS of these, "
1919
+ "never absolutes: entropy warn %.2f / kill %.2f, length warn %.2f / kill %.2f, "
1920
+ "kill after %d consecutive step(s)%s",
1921
+ captured.step,
1922
+ captured.entropy_per_token,
1923
+ captured.mean_generation_tokens,
1924
+ captured.episodes,
1925
+ captured.sampled_tokens,
1926
+ cfg.entropy_warn_frac,
1927
+ cfg.entropy_kill_frac,
1928
+ cfg.length_warn_frac,
1929
+ cfg.length_kill_frac,
1930
+ cfg.kill_consecutive_steps,
1931
+ "" if cfg.enabled else " (tripwire.enabled = false: metrics only, no abort)",
1932
+ )
1933
+ return captured
1934
+
1935
+ def _check_degeneration(
1936
+ self, step: int, health: PolicyHealth, baseline: TripwireBaseline | None
1937
+ ) -> None:
1938
+ """Warn on a degeneration breach, and abort after a kill-level streak.
1939
+
1940
+ Args:
1941
+ step: The 0-based training step that just completed its metrics row.
1942
+ health: That step's batch-pooled health.
1943
+ baseline: The run's armed baseline, or None when none exists yet.
1944
+
1945
+ Raises:
1946
+ DistillDegenerationError: After `tripwire.kill_consecutive_steps`
1947
+ steps in a row at kill level; the step's state is checkpointed
1948
+ first and the message carries the exact resume command. A step
1949
+ that measured nothing at all breaks neither the streak nor the
1950
+ silence: it is not evidence, and the empty-batch abort owns it.
1951
+ """
1952
+ cfg = self._cfg.tripwire
1953
+ if not cfg.enabled or baseline is None:
1954
+ return
1955
+ if baseline.step == step:
1956
+ # The baseline step is its own reference (ratio 1.0), so it can only
1957
+ # breach through a misconfigured fraction above 1.0. It never fires.
1958
+ return
1959
+ if health.entropy_per_token is None and health.mean_generation_tokens is None:
1960
+ # An all-empty batch is no evidence either way, so it neither breaches
1961
+ # nor clears a streak (the empty-batch abort is what owns that case).
1962
+ return
1963
+ breaches = evaluate_breaches(cfg, baseline, health)
1964
+ for breach in breaches:
1965
+ logger.warning("degeneration tripwire at step %d: %s", step, breach.describe())
1966
+ kills = [breach for breach in breaches if breach.level == "kill"]
1967
+ if not kills:
1968
+ self._degeneration_streak = 0
1969
+ return
1970
+ self._degeneration_streak += 1
1971
+ if self._degeneration_streak < cfg.kill_consecutive_steps:
1972
+ logger.warning(
1973
+ "step %d is at a degeneration KILL level; %d/%d consecutive step(s) before "
1974
+ "the run aborts (a healthy step resets the streak)",
1975
+ step,
1976
+ self._degeneration_streak,
1977
+ cfg.kill_consecutive_steps,
1978
+ )
1979
+ return
1980
+ state_path = self._training.save_state()
1981
+ self._store.record_checkpoint(step, state_path, self._sampler.sampler_path)
1982
+ command = resume_command(self._cli_agent, self._run_dir)
1983
+ detail = "; ".join(breach.describe() for breach in kills)
1984
+ raise DistillDegenerationError(
1985
+ f"aborting after {self._degeneration_streak} consecutive training steps at a "
1986
+ f"degeneration kill level: {detail}. The student's own sampled tokens collapsed "
1987
+ "against the baseline this run measured at its first training step, which is the "
1988
+ "failure reverse KL alone cannot show (KL falls while the policy degenerates): "
1989
+ "entropy falling is mode collapse, length falling is answers collapsing toward "
1990
+ "empty. Read the step's solve_rate beside it (a real collapse takes the solve rate "
1991
+ "with it, a student that merely got more efficient does not), then lower "
1992
+ "train.learning_rate or restart from an earlier checkpoint before spending more. "
1993
+ "Run artifacts were "
1994
+ f"persisted (checkpoint at step {step}), and the recorded baseline is reused as is "
1995
+ f"on resume, so a resumed run is not re-anchored on the collapse: {command}",
1996
+ resume_command=command,
1997
+ consecutive_steps=self._degeneration_streak,
1998
+ breaches=kills,
1999
+ )
2000
+
2001
+ def _charge_teacher_scoring(self, usage_before: int, cached_before: int) -> None:
2002
+ """Charge one batch of teacher scoring, splitting off what the service cached.
2003
+
2004
+ The cached split is the SERVICE's own `prompt_cache_hit_tokens`, not a
2005
+ wmo-side guess at which prefixes repeat (that is what `batch_billing`
2006
+ does for rollouts). Before this was read, every scoring token was billed
2007
+ at the uncached rate, which is why the run ledger is a ceiling rather
2008
+ than a bill: the one calibration available put the estimate at \\$1,332
2009
+ against a true \\$1,140.92.
2010
+
2011
+ Falls back to charging everything uncached when the teacher cannot report
2012
+ a cache count, so an injected fake or a future SDK that drops the field
2013
+ can only ever make the ledger conservative, never optimistic.
2014
+
2015
+ Args:
2016
+ usage_before: `teacher.usage()` sampled before the scoring call.
2017
+ cached_before: `cached_usage()` sampled at the same moment.
2018
+ """
2019
+ uncached, cached = _split_cached_prefill(
2020
+ self._teacher.usage() - usage_before,
2021
+ _teacher_cached_usage(self._teacher) - cached_before,
2022
+ )
2023
+ self._budget.charge("teacher_prefill", uncached)
2024
+ self._budget.charge("teacher_cached_prefill", cached)
2025
+
2026
+ def _charge_rollout_billing(self, billing: SpanBilling, *, teacher: bool) -> None:
2027
+ """Charge one rollout batch's per-request billing to its model's meters.
2028
+
2029
+ Both models bill the same way (unique tokens at the full prefill
2030
+ rate, the repeated per-request volume at the cached rate, sampled
2031
+ tokens at the sampling rate); only the meter family differs.
2032
+ Teacher-in-harness episodes bill teacher_sample on what they
2033
+ generate, which is what estimate_run_cost projects for them.
2034
+
2035
+ Args:
2036
+ billing: The batch's measured volumes (`batch_billing`).
2037
+ teacher: Charge the teacher meters (teacher-in-harness episodes:
2038
+ warmup collection, the gate's teacher baseline) instead of
2039
+ the student meters.
2040
+ """
2041
+ if teacher:
2042
+ self._budget.charge("teacher_prefill", billing.unique_tokens)
2043
+ self._budget.charge("teacher_cached_prefill", billing.cached_tokens)
2044
+ self._budget.charge("teacher_sample", billing.sampled_tokens)
2045
+ else:
2046
+ self._budget.charge("student_prefill", billing.unique_tokens)
2047
+ self._budget.charge("student_cached_prefill", billing.cached_tokens)
2048
+ self._budget.charge("student_sample", billing.sampled_tokens)
2049
+
2050
+ def _meter_deltas(self) -> dict[MeterName, int]:
2051
+ """Per-meter token deltas since the previous metrics row's cursor."""
2052
+ return {
2053
+ meter: self._budget.tokens(meter) - self._prev_tokens[meter] for meter in METER_NAMES
2054
+ }
2055
+
2056
+ def _student_provider(self) -> ProviderConfig:
2057
+ return self._sampler.provider_config(self._cfg.student.base_model)
2058
+
2059
+ def _teacher_provider(self) -> ProviderConfig:
2060
+ return tinker_provider_config(self._teacher_identity, self._cfg.teacher.model)
2061
+
2062
+ def _log_sample_rollouts(
2063
+ self, *, kind: str, name: str, step: int | None, records: Sequence[TrialRecord]
2064
+ ) -> None:
2065
+ """Persist and track one batch's first sample rollouts as readable text.
2066
+
2067
+ The first `train.log_sample_rollouts` span-bearing trials render with
2068
+ the chat template's special tokens kept (`wmo.distill.samples`) and
2069
+ land in `samples/<name>.md` plus the tracker's samples table under
2070
+ `kind` ("train" for training batches, "warmup" for the warmup
2071
+ collection, "eval-<key>" for eval batches; file stems are
2072
+ "step-NNNN", "warmup", and "eval-<key>" respectively). 0 disables,
2073
+ and a batch with no span-bearing trial writes nothing. Skipped
2074
+ defensively when preflight has not built the renderer yet.
2075
+ """
2076
+ limit = self._cfg.train.log_sample_rollouts
2077
+ if limit == 0 or self._rendering is None:
2078
+ return
2079
+ samples = sample_rollouts(records, self._rendering, limit)
2080
+ if not samples:
2081
+ return
2082
+ self._store.write_samples(name, samples_markdown(samples))
2083
+ self._tracker.log_samples(kind, step, samples)
2084
+
2085
+ # -- preflight -------------------------------------------------------------------------------
2086
+
2087
+ def _preflight(self) -> None:
2088
+ """Every check that must pass before the run spends real money.
2089
+
2090
+ Order is cheapest first: renderer resolution and the tokenizer
2091
+ fingerprint cost nothing; the pings and the TITO recompute cost a few
2092
+ tokens. The live single-trial pi preflight is the injected
2093
+ `live_trial_preflight` hook (see `LiveTrialPreflight`).
2094
+ """
2095
+ cfg = self._cfg
2096
+ self._emit("preflight", "running preflight checks before any spend")
2097
+ tokenizer = self._training.get_tokenizer()
2098
+ # The renderer must exist for the base model before any trial runs; the
2099
+ # tokenizer satisfies the renderer's slice at runtime (rendering.py makes
2100
+ # the same cast for the cookbook's loosely typed tokenizer parameter).
2101
+ rendering: ChatRendering = build_renderer(
2102
+ cfg.student.base_model, cast("RendererTokenizer", tokenizer)
2103
+ )
2104
+ # Kept for sample-rollout logging: every batch renders its first few
2105
+ # episodes to readable text via decode_with_specials.
2106
+ self._rendering = rendering
2107
+ if isinstance(self._teacher_client, TokenizerSource):
2108
+ tokenizer_fingerprint_check(
2109
+ cfg.student.base_model,
2110
+ self._teacher_identity,
2111
+ tokenizer,
2112
+ self._teacher_client.get_tokenizer(),
2113
+ )
2114
+ else:
2115
+ logger.info(
2116
+ "teacher sampling client exposes no tokenizer; skipping the "
2117
+ "fingerprint check (injected clients are assumed same-tokenizer)"
2118
+ )
2119
+ prompt_ids = rendering.build_generation_prompt([ChatMessage(role="user", content="ping")])
2120
+ try:
2121
+ sequence = self._sampler.client.sample(prompt_ids, max_tokens=1, temperature=0.0)
2122
+ except Exception as exc: # noqa: BLE001 - re-raised with actionable context
2123
+ raise RuntimeError(
2124
+ f"student preflight ping failed for {self._sampler.sampler_path!r} "
2125
+ f"(base model {cfg.student.base_model!r}): {exc}; check that the "
2126
+ f"base model is still in Tinker's lineup and that "
2127
+ f"{TINKER_API_KEY_ENV} is valid"
2128
+ ) from exc
2129
+ if not sequence.tokens:
2130
+ raise RuntimeError(
2131
+ f"student preflight ping for {cfg.student.base_model!r} sampled no "
2132
+ "tokens; the sampler returned an empty sequence, so check the model "
2133
+ "name and the sampler weights path"
2134
+ )
2135
+ verify = self._teacher.verify()
2136
+ if not verify.ok:
2137
+ raise RuntimeError(
2138
+ f"teacher preflight ping failed for {verify.model!r}: {verify.detail}; "
2139
+ "check that the teacher model/checkpoint is still available on Tinker "
2140
+ "and shares the student's tokenizer"
2141
+ )
2142
+ tito_recompute_check(self._sampler.client, prompt_ids)
2143
+ if self._live_trial_preflight is not None:
2144
+ self._live_trial_preflight(self._student_provider())
2145
+ self._emit("preflight", "preflight checks passed")
2146
+
2147
+ # -- evals -----------------------------------------------------------------------------------
2148
+
2149
+ def _load_eval(self, key: str) -> DistillEvalReport | None:
2150
+ path = self._store.evals_dir / f"{key}.json"
2151
+ try:
2152
+ text = path.read_text(encoding="utf-8")
2153
+ except FileNotFoundError:
2154
+ return None
2155
+ try:
2156
+ return DistillEvalReport.model_validate_json(text)
2157
+ except ValidationError as exc:
2158
+ raise ValueError(
2159
+ f"corrupt eval report at {path}: {exc}; delete the file so the "
2160
+ "resumed run re-runs this eval"
2161
+ ) from exc
2162
+
2163
+ def _run_eval(
2164
+ self,
2165
+ key: str,
2166
+ task_ids: Sequence[str],
2167
+ attempts: int,
2168
+ provider: ProviderConfig,
2169
+ *,
2170
+ phase: DistillPhase,
2171
+ teacher_metered: bool,
2172
+ completed_step: int | None,
2173
+ ) -> DistillEvalReport:
2174
+ """Run one eval batch as harbor trials and persist its report.
2175
+
2176
+ Args:
2177
+ key: The eval's store key; also names its isolated rollout root.
2178
+ task_ids: Exact task ids to evaluate on.
2179
+ attempts: Attempts per task (the eval's k).
2180
+ provider: The worker provider config for the trials.
2181
+ phase: The progress phase to emit under.
2182
+ teacher_metered: Charge the batch to the teacher meters (sampled
2183
+ tokens at teacher_sample plus per-request prefill; see
2184
+ `_charge_rollout_billing`) instead of the student meters.
2185
+ completed_step: Forwarded to the budget abort for checkpointing.
2186
+ """
2187
+ cfg = self._cfg
2188
+ self._emit(
2189
+ phase,
2190
+ f"eval {key}: {len(list(task_ids))} task(s) x {attempts} attempt(s) "
2191
+ f"of {provider.model}",
2192
+ step=completed_step,
2193
+ )
2194
+ eval_cfg = cfg.model_copy(
2195
+ update={"train": cfg.train.model_copy(update={"group_size": attempts})}
2196
+ )
2197
+ records, stats = collect_rollouts(
2198
+ 0,
2199
+ task_ids,
2200
+ eval_cfg,
2201
+ self._harness,
2202
+ provider,
2203
+ self._run_dir / EVAL_ROLLOUTS_DIR / validate_name(key),
2204
+ )
2205
+ self._charge_rollout_billing(batch_billing(records), teacher=teacher_metered)
2206
+ self._log_sample_rollouts(
2207
+ kind=f"eval-{key}", name=f"eval-{key}", step=completed_step, records=records
2208
+ )
2209
+ if stats.trials and not stats.executed_trials:
2210
+ raise DistillNullEvalError(
2211
+ f"eval {key} is a NULL measurement, not a 0.0: not one of {stats.trials} trial(s) "
2212
+ "carries a verifier reward, so no solve rate exists to record. Read the cells' "
2213
+ f"`infra-failure:` notes under {self._run_dir / EVAL_ROLLOUTS_DIR / key} for the "
2214
+ "exact causes. Trials that never started are almost always the E2B "
2215
+ "concurrent-sandbox account cap (a distill trial holds one sandbox: harbor's "
2216
+ "task environment), so lower train.trial_concurrency (currently "
2217
+ f"{cfg.train.trial_concurrency}), reap orphaned sandboxes, and re-run; trials that "
2218
+ "ran but were never graded (VerifierTimeoutError) usually need a longer "
2219
+ "verifier.override_timeout_sec in the harbor job template. Writing 0.0% here "
2220
+ "would put a null baseline behind gate.require_no_regression"
2221
+ )
2222
+ report = DistillEvalReport(
2223
+ name=key,
2224
+ provider_model=provider.model,
2225
+ base_model=provider.model_type,
2226
+ task_ids=list(task_ids),
2227
+ attempts=attempts,
2228
+ trials=stats.trials,
2229
+ solve_rate=stats.solve_rate,
2230
+ graded_solve_rate=stats.graded_solve_rate,
2231
+ graded_trials=stats.graded_trials,
2232
+ empty_span_trials=stats.empty_span_trials,
2233
+ executed_trials=stats.executed_trials,
2234
+ infra_failed_trials=stats.infra_failed_trials,
2235
+ scaffold_loss_rate=stats.scaffold_loss_rate,
2236
+ stop_reason_counts=dict(stats.stop_reason_counts),
2237
+ )
2238
+ if stats.infra_failed_trials or stats.scaffold_loss_rate:
2239
+ self._emit(
2240
+ phase,
2241
+ f"eval {key}: solve rate {stats.solve_rate:.3f} over "
2242
+ f"{stats.executed_trials}/{stats.trials} executed trial(s) "
2243
+ f"({stats.infra_failed_trials} infra failure(s) excluded), "
2244
+ f"{_graded_phrase(stats.graded_solve_rate, stats.graded_trials)}, scaffold loss "
2245
+ f"{stats.scaffold_loss_rate:.0%} {stats.stop_reason_counts}",
2246
+ step=completed_step,
2247
+ )
2248
+ self._store.write_eval(key, report)
2249
+ self._tracker.log_eval(
2250
+ key,
2251
+ report.solve_rate,
2252
+ completed_step,
2253
+ graded_solve_rate=_measured_graded_rate(report),
2254
+ )
2255
+ try:
2256
+ self._budget.check()
2257
+ except BudgetExhausted as exc:
2258
+ self._abort_for_budget(exc, completed_step=completed_step)
2259
+ return report
2260
+
2261
+ def _eval_or_load(
2262
+ self,
2263
+ key: str,
2264
+ task_ids: Sequence[str],
2265
+ attempts: int,
2266
+ provider: ProviderConfig,
2267
+ *,
2268
+ phase: DistillPhase,
2269
+ teacher_metered: bool,
2270
+ reuse: bool,
2271
+ pin_provider: bool = False,
2272
+ completed_step: int | None = None,
2273
+ baseline_from: str | None = None,
2274
+ baseline_from_field: str = "",
2275
+ ) -> DistillEvalReport:
2276
+ """One baseline eval: this run's recorded copy, a prior run's report, or trials.
2277
+
2278
+ Precedence: a report already recorded under this run's `evals/` wins
2279
+ when `reuse` is set (a resume must not re-import or re-run anything,
2280
+ and an imported baseline was copied there on the first session), then
2281
+ a configured `baseline_from` path imports a prior run's report, and
2282
+ only otherwise do trials actually run.
2283
+
2284
+ A reused report must have been measured at the same `attempts` (the
2285
+ gate compares solve rates, so mixing estimators of different k would
2286
+ silently invalidate the verdict), and with `pin_provider` also under
2287
+ the same provider model (the teacher's identity is stable across
2288
+ sessions; student sampler paths carry a per-session nonce and are
2289
+ intentionally NOT pinned).
2290
+
2291
+ Raises:
2292
+ RuntimeError: If a recorded report conflicts with the current
2293
+ config; the message says which knob changed and how to
2294
+ recover.
2295
+ """
2296
+ if reuse:
2297
+ existing = self._load_eval(key)
2298
+ if existing is not None:
2299
+ if existing.attempts != attempts:
2300
+ raise RuntimeError(
2301
+ f"recorded eval {key!r} was measured at k={existing.attempts} but the "
2302
+ f"config now asks for k={attempts}; the gate would compare solve "
2303
+ "rates from different estimators. Restore the original gate/eval k "
2304
+ "in the config and resume, or start a fresh run dir"
2305
+ )
2306
+ if pin_provider and existing.provider_model != provider.model:
2307
+ raise RuntimeError(
2308
+ f"recorded eval {key!r} was measured against {existing.provider_model!r} "
2309
+ f"but the config now names {provider.model!r}; a mid-run model swap "
2310
+ "would gate against a stale baseline. Restore the original model in "
2311
+ "the config and resume, or start a fresh run dir"
2312
+ )
2313
+ logger.info("reusing recorded eval %s (solve rate %.3f)", key, existing.solve_rate)
2314
+ return existing
2315
+ if baseline_from is not None:
2316
+ return self._import_baseline(
2317
+ key,
2318
+ baseline_from,
2319
+ baseline_from_field,
2320
+ task_ids,
2321
+ attempts,
2322
+ provider,
2323
+ phase=phase,
2324
+ # The teacher's provider model (its stable identity) must match
2325
+ # across runs; a student provider model is a per-run sampler
2326
+ # path, so student reuse matches on base_model alone.
2327
+ match_provider_model=teacher_metered,
2328
+ )
2329
+ return self._run_eval(
2330
+ key,
2331
+ task_ids,
2332
+ attempts,
2333
+ provider,
2334
+ phase=phase,
2335
+ teacher_metered=teacher_metered,
2336
+ completed_step=completed_step,
2337
+ )
2338
+
2339
+ def _await_deferred_baselines(self) -> None:
2340
+ """Block until every configured deferred baseline file exists, or time out.
2341
+
2342
+ `eval.defer_baselines` exists so training can start immediately while a
2343
+ SEPARATE process measures the gate references, and the two are only
2344
+ joined here. Nothing sequences them, so "training finished first" is a
2345
+ real outcome rather than a theoretical one: a short training run, or a
2346
+ baseline arm that hit a retry, and the import would fail a run that had
2347
+ already paid for every step.
2348
+
2349
+ Waiting is the right response because the producer is expected to
2350
+ finish and the file is the only thing missing. The wait is bounded so a
2351
+ producer that DIED cannot hang the run forever, and the caller's import
2352
+ then raises its usual message naming the config field. The trained
2353
+ weights are already saved by this point either way, so the worst case
2354
+ is a resume rather than lost training.
2355
+ """
2356
+ pending = {
2357
+ field: Path(value)
2358
+ for field, value in (
2359
+ ("eval.teacher_baseline_from", self._cfg.eval.teacher_baseline_from),
2360
+ ("eval.student_baseline_from", self._cfg.eval.student_baseline_from),
2361
+ )
2362
+ if value is not None
2363
+ }
2364
+ missing = {field: path for field, path in pending.items() if not path.is_file()}
2365
+ if not missing:
2366
+ return
2367
+ deadline = time.monotonic() + _DEFERRED_BASELINE_WAIT_S
2368
+ for field, path in missing.items():
2369
+ logger.warning(
2370
+ "deferred baseline %s (%s) is not written yet; waiting up to %.0f min for the "
2371
+ "concurrent baseline run. The final sampler and state are already saved, so a "
2372
+ "timeout costs a resume and not the training.",
2373
+ field,
2374
+ path,
2375
+ _DEFERRED_BASELINE_WAIT_S / 60,
2376
+ )
2377
+ while time.monotonic() < deadline:
2378
+ still_missing = [path for path in missing.values() if not path.is_file()]
2379
+ if not still_missing:
2380
+ logger.info("every deferred baseline landed; importing them now")
2381
+ return
2382
+ time.sleep(_DEFERRED_BASELINE_POLL_S)
2383
+ # Fall through: the caller's import raises the message that names the config field
2384
+ # and the fix, which is more useful than a generic timeout error here.
2385
+ logger.error(
2386
+ "deferred baseline(s) still missing after %.0f min: %s",
2387
+ _DEFERRED_BASELINE_WAIT_S / 60,
2388
+ ", ".join(str(path) for path in missing.values() if not path.is_file()),
2389
+ )
2390
+
2391
+ def _import_baseline(
2392
+ self,
2393
+ key: str,
2394
+ source: str,
2395
+ config_field: str,
2396
+ task_ids: Sequence[str],
2397
+ attempts: int,
2398
+ provider: ProviderConfig,
2399
+ *,
2400
+ phase: DistillPhase,
2401
+ match_provider_model: bool,
2402
+ ) -> DistillEvalReport:
2403
+ """Reuse a prior run's recorded baseline eval instead of running trials.
2404
+
2405
+ The report is validated against this run before it is trusted: it must
2406
+ cover exactly this run's holdout task ids, carry at least `attempts`
2407
+ (this run's `gate.k`) attempts per task, and be measured on the same
2408
+ model (the provider model for the teacher, the recorded `base_model`
2409
+ for the student, whose provider model is a per-run sampler path). A
2410
+ valid report is copied into this run's `evals/` with a provenance
2411
+ `source` note and logged and tracked like a freshly run eval; nothing
2412
+ is charged to the budget.
2413
+
2414
+ Args:
2415
+ key: The eval's store key (the baseline's canonical name).
2416
+ source: The prior run's `evals/<key>.json` path from the config.
2417
+ config_field: The config field naming `source`, for error messages.
2418
+ task_ids: This run's holdout task ids.
2419
+ attempts: This run's `gate.k`.
2420
+ provider: The provider config the baseline WOULD have run with.
2421
+ phase: The progress phase to emit under.
2422
+ match_provider_model: Also require the report's `provider_model`
2423
+ to equal the provider's model (the teacher baseline; a
2424
+ teacher's identity is stable across runs).
2425
+
2426
+ Returns:
2427
+ The validated report, re-stamped with this run's provenance note.
2428
+
2429
+ Raises:
2430
+ ValueError: On a missing or unparsable file or any validation
2431
+ mismatch; each message names the config field and the fix.
2432
+ """
2433
+ path = Path(source)
2434
+ hint = f"or unset {config_field} to run the baseline here"
2435
+ try:
2436
+ text = path.read_text(encoding="utf-8")
2437
+ except FileNotFoundError as exc:
2438
+ raise ValueError(
2439
+ f"{config_field} points at {path}, which does not exist; point it "
2440
+ f"at a prior run's evals/{key}.json {hint}"
2441
+ ) from exc
2442
+ try:
2443
+ report = DistillEvalReport.model_validate_json(text)
2444
+ except ValidationError as exc:
2445
+ raise ValueError(
2446
+ f"{config_field} points at {path}, which is not a valid eval "
2447
+ f"report: {exc}; point it at a prior run's evals/{key}.json {hint}"
2448
+ ) from exc
2449
+ if set(report.task_ids) != set(task_ids):
2450
+ missing = sorted(set(task_ids) - set(report.task_ids))
2451
+ extra = sorted(set(report.task_ids) - set(task_ids))
2452
+ raise ValueError(
2453
+ f"{config_field}: the report at {path} was measured on a different "
2454
+ f"task set than this run's holdout split (missing: "
2455
+ f"{', '.join(missing) or 'none'}; extra: {', '.join(extra) or 'none'}); "
2456
+ f"a baseline only transfers between runs gating on the identical "
2457
+ f"holdout split, so reuse one that matches {hint}"
2458
+ )
2459
+ if report.attempts < attempts:
2460
+ raise ValueError(
2461
+ f"{config_field}: the report at {path} recorded {report.attempts} "
2462
+ f"attempt(s) per task but this run's gate.k is {attempts}; reuse a "
2463
+ f"baseline measured with at least gate.k attempts {hint}"
2464
+ )
2465
+ if match_provider_model and report.provider_model != provider.model:
2466
+ raise ValueError(
2467
+ f"{config_field}: the report at {path} evaluated "
2468
+ f"{report.provider_model!r} but this run's teacher is "
2469
+ f"{provider.model!r}; reuse a baseline of the same teacher {hint}"
2470
+ )
2471
+ expected_base = provider.model_type or provider.model
2472
+ if report.base_model is None and not match_provider_model:
2473
+ raise ValueError(
2474
+ f"{config_field}: the report at {path} records no base_model (it "
2475
+ f"predates the field), so it cannot be validated against this run's "
2476
+ f"base model {expected_base!r}; re-run that baseline on current code "
2477
+ f"{hint}"
2478
+ )
2479
+ if report.base_model is not None and report.base_model != expected_base:
2480
+ raise ValueError(
2481
+ f"{config_field}: the report at {path} was measured on base model "
2482
+ f"{report.base_model!r} but this run uses {expected_base!r}; a "
2483
+ f"baseline only transfers between runs on the same base model, so "
2484
+ f"reuse one that matches {hint}"
2485
+ )
2486
+ stamped = report.model_copy(
2487
+ update={"name": key, "source": f"reused from {path} via {config_field}"}
2488
+ )
2489
+ self._store.write_eval(key, stamped)
2490
+ self._tracker.log_eval(
2491
+ key, stamped.solve_rate, None, graded_solve_rate=_measured_graded_rate(stamped)
2492
+ )
2493
+ self._emit(
2494
+ phase,
2495
+ f"eval {key}: reused {path} (solve rate {stamped.solve_rate:.3f}, "
2496
+ f"{len(stamped.task_ids)} task(s) x {stamped.attempts} attempt(s)); "
2497
+ "no trials run",
2498
+ )
2499
+ return stamped
2500
+
2501
+ # -- warmup ----------------------------------------------------------------------------------
2502
+
2503
+ def _append_warmup_row(self, warmup_step: int, row: WarmupMetrics) -> None:
2504
+ """Persist and track one warmup row, advancing the per-row delta cursors."""
2505
+ self._store.append_metrics(warmup_step, row)
2506
+ self._tracker.log_warmup_step(warmup_step, row)
2507
+ self._prev_tokens = {meter: self._budget.tokens(meter) for meter in METER_NAMES}
2508
+ self._prev_usd = self._budget.session_usd
2509
+
2510
+ def _warmup_row(
2511
+ self,
2512
+ *,
2513
+ trials: int,
2514
+ kept_trials: int,
2515
+ solve_rate: float,
2516
+ datums: int,
2517
+ loss_tokens: int,
2518
+ context_tokens: int,
2519
+ learning_rate: float,
2520
+ ) -> WarmupMetrics:
2521
+ """One warmup metrics row carrying the meter deltas since the last row."""
2522
+ deltas = self._meter_deltas()
2523
+ return WarmupMetrics(
2524
+ tasks=len(self._train_ids),
2525
+ trials=trials,
2526
+ kept_trials=kept_trials,
2527
+ solve_rate=solve_rate,
2528
+ datums=datums,
2529
+ loss_tokens=loss_tokens,
2530
+ context_tokens=context_tokens,
2531
+ learning_rate=learning_rate,
2532
+ student_prefill_tokens=deltas["student_prefill"],
2533
+ student_cached_prefill_tokens=deltas["student_cached_prefill"],
2534
+ student_sample_tokens=deltas["student_sample"],
2535
+ student_train_tokens=deltas["student_train"],
2536
+ teacher_prefill_tokens=deltas["teacher_prefill"],
2537
+ teacher_cached_prefill_tokens=deltas["teacher_cached_prefill"],
2538
+ teacher_sample_tokens=deltas["teacher_sample"],
2539
+ usd=max(self._budget.session_usd - self._prev_usd, 0.0),
2540
+ )
2541
+
2542
+ def _load_warmup_trials(self, source_dir: Path) -> tuple[list[TrialRecord], RolloutStats]:
2543
+ """Load another run's warmup collection instead of collecting rollouts.
2544
+
2545
+ The `warmup.trajectories_from` path: the source run's collection wrote
2546
+ its assembled (unfiltered) trial records to `warmup-trials.json`, so
2547
+ this run reuses them for its own CE passes. The source run paid for
2548
+ the teacher rollouts, so loading charges no meter; the `keep` filter
2549
+ still applies to the loaded records at the call site.
2550
+
2551
+ Args:
2552
+ source_dir: The source run's directory.
2553
+
2554
+ Returns:
2555
+ The manifest's trial records plus their recomputed batch stats.
2556
+
2557
+ Raises:
2558
+ RuntimeError: If the source run has no warmup trial manifest (its
2559
+ warmup collection never completed).
2560
+ ValueError: If the manifest's teacher does not match this run's
2561
+ teacher identity.
2562
+ """
2563
+ manifest = DistillRunStore(source_dir).read_warmup_trials()
2564
+ if manifest is None:
2565
+ raise RuntimeError(
2566
+ f"warmup.trajectories_from points at {source_dir}, but no warmup trial "
2567
+ f"manifest exists at {DistillRunStore(source_dir).warmup_trials_path}; the "
2568
+ "manifest is written when a run's warmup collection finishes, so point "
2569
+ "trajectories_from at a run dir whose warmup collected teacher rollouts, "
2570
+ "or unset it to collect here"
2571
+ )
2572
+ if manifest.teacher_model != self._teacher_identity:
2573
+ raise ValueError(
2574
+ f"the warmup trials in {source_dir} were sampled by teacher "
2575
+ f"{manifest.teacher_model!r}, but this run's teacher is "
2576
+ f"{self._teacher_identity!r}; warmup must train on THIS teacher's "
2577
+ "trajectories, so point warmup.trajectories_from at a run with a "
2578
+ "matching teacher, or unset it to collect fresh"
2579
+ )
2580
+ # THIS run's output cap, not the source run's: the truncation count is read
2581
+ # beside this run's metrics and must answer "would these turns be cut off here".
2582
+ return list(manifest.records), rollout_stats(
2583
+ manifest.records, max_tokens=self._cfg.sampling.max_tokens
2584
+ )
2585
+
2586
+ def _collect_warmup_trials(self) -> tuple[list[TrialRecord], RolloutStats]:
2587
+ """Collect the warmup phase's teacher rollouts and persist their manifest.
2588
+
2589
+ Runs the teacher through the pi harness on every train task
2590
+ (`warmup.rollouts_per_task` attempts each, isolated under
2591
+ `warmup-rollouts/`), writes the assembled records to
2592
+ `warmup-trials.json` (so other runs can load this collection via
2593
+ `warmup.trajectories_from`), and charges the teacher meters.
2594
+ """
2595
+ cfg = self._cfg
2596
+ warmup = cfg.warmup
2597
+ self._emit(
2598
+ "warmup",
2599
+ f"collecting teacher trajectories: {len(self._train_ids)} train task(s) x "
2600
+ f"{warmup.rollouts_per_task} attempt(s) of {self._teacher_identity}",
2601
+ )
2602
+ # The collector reads attempts from train.group_size, the same override
2603
+ # trick _run_eval uses for its k.
2604
+ warmup_cfg = cfg.model_copy(
2605
+ update={"train": cfg.train.model_copy(update={"group_size": warmup.rollouts_per_task})}
2606
+ )
2607
+ records, roll_stats = collect_rollouts(
2608
+ 0,
2609
+ self._train_ids,
2610
+ warmup_cfg,
2611
+ self._harness,
2612
+ self._teacher_provider(),
2613
+ self._run_dir / WARMUP_ROLLOUTS_DIR,
2614
+ )
2615
+ # The manifest lands before the keep filter (a loading run may filter
2616
+ # differently) and before the budget check (the collection is complete
2617
+ # evidence even when this run aborts right after paying for it).
2618
+ self._store.write_warmup_trials(
2619
+ WarmupTrialsManifest(teacher_model=self._teacher_identity, records=records)
2620
+ )
2621
+ # Teacher-in-harness billing, same as the gate's teacher baseline:
2622
+ # sampled tokens at teacher_sample, per-request prefill split between
2623
+ # teacher_prefill (unique) and teacher_cached_prefill (repeats).
2624
+ self._charge_rollout_billing(batch_billing(records), teacher=True)
2625
+ try:
2626
+ self._budget.check()
2627
+ except BudgetExhausted as exc:
2628
+ self._abort_for_budget(exc, completed_step=None)
2629
+ return records, roll_stats
2630
+
2631
+ def _warmup(self) -> None:
2632
+ """The supervised warmup phase: teacher trials, keep-filter, SFT passes.
2633
+
2634
+ The teacher trials come from `_collect_warmup_trials` (fresh rollouts,
2635
+ charged to the teacher meters) or, when `warmup.trajectories_from` is
2636
+ set, from `_load_warmup_trials` (another run's recorded collection,
2637
+ charging nothing). Either way the kept trials merge into cross_entropy
2638
+ datums through the same prefix merge OPD uses (the teacher SAMPLED
2639
+ these tokens, so they are exact sampled ids and need no advantages),
2640
+ and the student trains `warmup.steps` passes. Each pass is one
2641
+ forward_backward over the FULL warmup datum list plus one optim_step:
2642
+ the set is small (train tasks x rollouts_per_task, filtered), so
2643
+ full-batch epochs keep the schedule deterministic and resumable
2644
+ without a datum-level cursor. Zero kept datums degrade the run to
2645
+ pure OPD (warning + one metrics row + the skip recorded), never an
2646
+ abort. Afterwards the state is saved and the sampler force-refreshed
2647
+ so OPD step 0 samples the warmed student, and `warmup.json` marks the
2648
+ phase done for resumes.
2649
+ """
2650
+ cfg = self._cfg
2651
+ warmup = cfg.warmup
2652
+ learning_rate = (
2653
+ warmup.learning_rate if warmup.learning_rate is not None else cfg.train.learning_rate
2654
+ )
2655
+ if warmup.trajectories_from is not None:
2656
+ source_dir = Path(warmup.trajectories_from)
2657
+ self._emit(
2658
+ "warmup",
2659
+ f"loading teacher trajectories from {source_dir} "
2660
+ "(the collection was paid by the source run; nothing is charged)",
2661
+ )
2662
+ records, roll_stats = self._load_warmup_trials(source_dir)
2663
+ sourced = "loaded"
2664
+ else:
2665
+ records, roll_stats = self._collect_warmup_trials()
2666
+ sourced = "collected"
2667
+
2668
+ kept = [record for record in records if warmup.keep == "all" or record.passed]
2669
+ # Sampled from the KEPT trials: the SFT set is what warmup trains on,
2670
+ # so those are the episodes worth reading.
2671
+ self._log_sample_rollouts(kind="warmup", name="warmup", step=None, records=kept)
2672
+ datums, datum_stats = build_datums(kept, cfg)
2673
+ if not datums:
2674
+ reason = (
2675
+ f"warmup {sourced} {roll_stats.trials} teacher trial(s) but kept "
2676
+ f"{len(kept)} under keep={warmup.keep!r} and built 0 datums"
2677
+ )
2678
+ logger.warning(
2679
+ "%s (teacher solve rate %.2f, %d trial(s) without spans, %d overflow "
2680
+ "drop(s), %d overlong drop(s)); skipping warmup, the run degrades to "
2681
+ "pure on-policy distillation",
2682
+ reason,
2683
+ roll_stats.solve_rate,
2684
+ roll_stats.empty_span_trials,
2685
+ datum_stats.overflow_drops,
2686
+ datum_stats.overlong_drops,
2687
+ )
2688
+ self._append_warmup_row(
2689
+ 0,
2690
+ self._warmup_row(
2691
+ trials=roll_stats.trials,
2692
+ kept_trials=len(kept),
2693
+ solve_rate=roll_stats.solve_rate,
2694
+ datums=0,
2695
+ loss_tokens=0,
2696
+ context_tokens=0,
2697
+ learning_rate=learning_rate,
2698
+ ),
2699
+ )
2700
+ self._store.write_warmup(
2701
+ WarmupRecord(
2702
+ steps=0,
2703
+ trials=roll_stats.trials,
2704
+ kept_trials=len(kept),
2705
+ datums=0,
2706
+ skipped_reason=reason,
2707
+ )
2708
+ )
2709
+ self._emit("warmup", f"warmup skipped: {reason}")
2710
+ return
2711
+
2712
+ train_tokens = sum(len(datum.model_input_tokens) for datum in datums)
2713
+ self._emit(
2714
+ "warmup",
2715
+ f"training {warmup.steps} cross_entropy pass(es) over {len(datums)} "
2716
+ f"datum(s) from {len(kept)}/{roll_stats.trials} kept teacher trial(s) "
2717
+ f"(lr {learning_rate:g})",
2718
+ )
2719
+ for warmup_step in range(warmup.steps):
2720
+ self._training.forward_backward(datums, loss_fn=CROSS_ENTROPY_LOSS)
2721
+ self._training.optim_step(learning_rate)
2722
+ self._budget.charge("student_train", train_tokens)
2723
+ self._append_warmup_row(
2724
+ warmup_step,
2725
+ self._warmup_row(
2726
+ trials=roll_stats.trials,
2727
+ kept_trials=len(kept),
2728
+ solve_rate=roll_stats.solve_rate,
2729
+ datums=len(datums),
2730
+ loss_tokens=datum_stats.loss_tokens,
2731
+ context_tokens=datum_stats.context_tokens,
2732
+ learning_rate=learning_rate,
2733
+ ),
2734
+ )
2735
+ try:
2736
+ self._budget.check()
2737
+ except BudgetExhausted as exc:
2738
+ # No warmup.json yet: an interrupted warmup re-runs whole on
2739
+ # resume (the teacher trials resume trial-level via harbor).
2740
+ self._abort_for_budget(exc, completed_step=None)
2741
+ state_path = self._training.save_state()
2742
+ # The tag forces the save: step 0 was refreshed before preflight, and
2743
+ # warmup changed the weights, so OPD step 0 must sample fresh ones.
2744
+ sampler_path = self._sampler.refresh(0, tag="warmup")
2745
+ self._store.write_warmup(
2746
+ WarmupRecord(
2747
+ steps=warmup.steps,
2748
+ trials=roll_stats.trials,
2749
+ kept_trials=len(kept),
2750
+ datums=len(datums),
2751
+ state_path=state_path,
2752
+ sampler_path=sampler_path,
2753
+ )
2754
+ )
2755
+ self._emit(
2756
+ "warmup",
2757
+ f"warmup complete: {warmup.steps} pass(es) over {len(datums)} datum(s); "
2758
+ f"OPD starts from {sampler_path}",
2759
+ )
2760
+
2761
+ # -- the step loop ---------------------------------------------------------------------------
2762
+
2763
+ def _train_step(self, step: int) -> None:
2764
+ cfg = self._cfg
2765
+ batch = self._tasks.next_batch(cfg.train.tasks_per_batch)
2766
+ self._emit(
2767
+ "rollouts",
2768
+ f"step {step + 1}/{cfg.train.steps}: {len(batch)} task(s) x "
2769
+ f"{cfg.train.group_size} attempt(s) from {self._sampler.sampler_path}",
2770
+ step=step,
2771
+ )
2772
+ sampler_path = self._sampler.sampler_path
2773
+ # Training rollouts may run under a tighter turn cap than evals do (see
2774
+ # `TrainConfig.rollout_max_turns`), via the same model_copy override `_run_eval` uses
2775
+ # for its attempt count. Evals keep `rollout.max_turns` so student-before and
2776
+ # student-after stay like-for-like -- capping the measurement too would make the paired
2777
+ # delta report the config change rather than the training.
2778
+ rollout_cfg = cfg
2779
+ if cfg.train.rollout_max_turns is not None:
2780
+ rollout_cfg = cfg.model_copy(
2781
+ update={
2782
+ "rollout": cfg.rollout.model_copy(
2783
+ update={"max_turns": cfg.train.rollout_max_turns}
2784
+ )
2785
+ }
2786
+ )
2787
+ records, roll_stats = collect_rollouts(
2788
+ step, batch, rollout_cfg, self._harness, self._student_provider(), self._run_dir
2789
+ )
2790
+ self._charge_rollout_billing(batch_billing(records), teacher=False)
2791
+ self._log_sample_rollouts(kind="train", name=f"step-{step:04d}", step=step, records=records)
2792
+
2793
+ # Degeneration signals come from the RAW spans (before any datum drop),
2794
+ # and the baseline is armed here so this step's row already carries its
2795
+ # own ratios; the breach check itself runs after the row is persisted.
2796
+ health = policy_health(records)
2797
+ tripwire_baseline = self._arm_tripwire(step, health)
2798
+
2799
+ datums, datum_stats = build_datums(records, cfg)
2800
+ teacher_usage_before = self._teacher.usage()
2801
+ teacher_cached_before = _teacher_cached_usage(self._teacher)
2802
+ advantage_mean: float | None = None
2803
+ advantage_std: float | None = None
2804
+ clipped_tokens = 0
2805
+ # Token counts of the batch that actually trains, per source datum.
2806
+ # `datum_stats` counts what the teacher was ASKED to score, which is
2807
+ # larger whenever a misaligned teacher row drops a datum; reporting
2808
+ # that as the step's volume would overstate the batch and break
2809
+ # `clipped_tokens / loss_tokens == clip_fraction` (both clip counters
2810
+ # are post-drop). See `StepMetrics` for the two-stage contract.
2811
+ trained_loss_tokens = 0
2812
+ trained_context_tokens = 0
2813
+ if cfg.train.loss == "topk_ce":
2814
+ # One prefill-only teacher request per datum yields the top-k
2815
+ # candidate rows AND the realized logprobs, so the reverse-KL
2816
+ # metric below means exactly what it means in the default mode.
2817
+ try:
2818
+ scores = self._teacher.score_topk(datums, cfg.train.topk)
2819
+ finally:
2820
+ # Charged even when scoring raises mid-batch: the pool joins
2821
+ # every submitted call before propagating, so the whole batch
2822
+ # was billed server-side whether or not a row came back.
2823
+ self._charge_teacher_scoring(teacher_usage_before, teacher_cached_before)
2824
+ reverse_kl = _batch_reverse_kl(datums, scores.realized)
2825
+ trained, topk_stats = build_topk_ce_datums(datums, scores.topk, cfg.train.topk)
2826
+ loss_fn = CROSS_ENTROPY_LOSS
2827
+ mismatch_drops = topk_stats.mismatch_drops
2828
+ trained_loss_tokens = topk_stats.loss_tokens
2829
+ trained_context_tokens = topk_stats.context_tokens
2830
+ else:
2831
+ try:
2832
+ rows, reverse_kl = _teacher_rows(self._teacher, datums)
2833
+ finally:
2834
+ # Same contract as the topk_ce branch above.
2835
+ self._charge_teacher_scoring(teacher_usage_before, teacher_cached_before)
2836
+ trained, adv_stats = attach_advantages(datums, rows, cfg)
2837
+ # importance_sampling and ppo ride identical datums (see
2838
+ # `to_tinker_datums`); only the service-side loss differs.
2839
+ loss_fn = ADVANTAGE_LOSS_BY_MODE[cfg.train.loss]
2840
+ mismatch_drops = adv_stats.mismatch_drops
2841
+ advantage_mean = adv_stats.advantage_mean
2842
+ advantage_std = adv_stats.advantage_std
2843
+ clipped_tokens = adv_stats.clipped_tokens
2844
+ trained_loss_tokens = adv_stats.loss_tokens
2845
+ trained_context_tokens = adv_stats.context_tokens
2846
+
2847
+ # In topk_ce mode this is k x the source CE volume (the k rank
2848
+ # replicas each carry the full sequence), which is exactly what
2849
+ # forward_backward consumes, so the student_train meter stays honest.
2850
+ train_tokens = sum(len(datum.model_input_tokens) for datum in trained)
2851
+ pg_loss: float | None = None
2852
+ grad_norm: float | None = None
2853
+ optimizer_updates = 0
2854
+ if trained:
2855
+ logger.info(
2856
+ "step %d: %d datum(s) into forward_backward under loss_fn %r (train.loss = %r)",
2857
+ step,
2858
+ len(trained),
2859
+ loss_fn,
2860
+ cfg.train.loss,
2861
+ )
2862
+ # Minibatch into `train.num_substeps` optimizer updates. At 1 (the historical
2863
+ # behaviour) this is one forward_backward over everything and a single update -- a
2864
+ # whole batch of agent rollouts, measured at ~$66, buying ONE gradient step. The
2865
+ # rollouts are already paid for by the time we get here, so extra updates over the
2866
+ # same datums cost nothing and are the cheapest learning signal available.
2867
+ #
2868
+ # Shuffled, not chunked in order: datums arrive grouped by episode (one per turn),
2869
+ # so contiguous minibatches would be maximally correlated. Seeded on the step so a
2870
+ # rerun reproduces the same minibatches.
2871
+ #
2872
+ # Updates 2..N are off-policy w.r.t. the weights that sampled the batch, which is
2873
+ # the standard PPO setting and what `loss = "ppo"`'s ratio clip exists for. At
2874
+ # num_substeps = 1 that clip is inert by construction: sampling and training share
2875
+ # the same weights, so the ratio is identically 1.
2876
+ # Shuffle GROUPS, not datums. Under topk_ce one source token becomes k rank
2877
+ # replicas that share a model input and whose weights renormalize to 1 ACROSS the
2878
+ # group, so splitting a group over two updates would apply one token's target
2879
+ # distribution at two different weight states. Grouping by shared model input keeps
2880
+ # replicas together whatever k is, and is a no-op for one-datum-per-source losses.
2881
+ groups: list[list[TrainDatum]] = []
2882
+ for datum in trained:
2883
+ if groups and tuple(groups[-1][0].model_input_tokens) == tuple(
2884
+ datum.model_input_tokens
2885
+ ):
2886
+ groups[-1].append(datum)
2887
+ else:
2888
+ groups.append([datum])
2889
+ if cfg.train.num_substeps > 1:
2890
+ # Only reorder when it buys something: at 1 substep the shuffle cannot change
2891
+ # the gradient and would only churn the batch order runs have always had.
2892
+ random.Random(_seed_from_name(f"{self._name}-substep-{step}")).shuffle(groups)
2893
+ substeps = min(cfg.train.num_substeps, len(groups))
2894
+ per = math.ceil(len(groups) / substeps)
2895
+ chunks = [
2896
+ [datum for group in groups[i : i + per] for datum in group]
2897
+ for i in range(0, len(groups), per)
2898
+ ]
2899
+ losses: list[float] = []
2900
+ for index, chunk in enumerate(chunks):
2901
+ train_output = self._training.forward_backward(chunk, loss_fn=loss_fn)
2902
+ optim_output = self._training.optim_step(cfg.train.learning_rate)
2903
+ if train_output.loss is not None:
2904
+ losses.append(train_output.loss)
2905
+ grad_norm = optim_output.grad_norm
2906
+ if len(chunks) > 1:
2907
+ logger.info(
2908
+ "step %d substep %d/%d: %d datum(s), loss %s",
2909
+ step,
2910
+ index + 1,
2911
+ len(chunks),
2912
+ len(chunk),
2913
+ "n/a" if train_output.loss is None else f"{train_output.loss:.4f}",
2914
+ )
2915
+ # Mean over substeps: the reported loss stays comparable to the single-update runs.
2916
+ pg_loss = sum(losses) / len(losses) if losses else None
2917
+ optimizer_updates = len(chunks)
2918
+ self._budget.charge("student_train", train_tokens)
2919
+ else:
2920
+ logger.warning(
2921
+ "step %d produced no trainable datums (%d trial(s), %d without spans, "
2922
+ "%d overflow drop(s), %d overlong drop(s), %d teacher mismatch "
2923
+ "drop(s)); skipping the optimizer step",
2924
+ step,
2925
+ roll_stats.trials,
2926
+ roll_stats.empty_span_trials,
2927
+ datum_stats.overflow_drops,
2928
+ datum_stats.overlong_drops,
2929
+ mismatch_drops,
2930
+ )
2931
+
2932
+ deltas = self._meter_deltas()
2933
+ metrics = StepMetrics(
2934
+ tasks=len(batch),
2935
+ trials=roll_stats.trials,
2936
+ solve_rate=roll_stats.solve_rate,
2937
+ graded_solve_rate=roll_stats.graded_solve_rate,
2938
+ graded_trials=roll_stats.graded_trials,
2939
+ raw_solve_rate=roll_stats.raw_solve_rate,
2940
+ executed_trials=roll_stats.executed_trials,
2941
+ infra_failed_trials=roll_stats.infra_failed_trials,
2942
+ scaffold_loss_rate=roll_stats.scaffold_loss_rate,
2943
+ stop_reason_counts=dict(roll_stats.stop_reason_counts),
2944
+ empty_span_trials=roll_stats.empty_span_trials,
2945
+ truncated_spans=roll_stats.truncated_spans,
2946
+ datums=len(trained),
2947
+ fragments=datum_stats.fragments,
2948
+ fragmentation_rate=datum_stats.fragmentation_rate,
2949
+ overflow_drops=datum_stats.overflow_drops,
2950
+ overlong_drops=datum_stats.overlong_drops,
2951
+ mismatch_drops=mismatch_drops,
2952
+ clipped_tokens=clipped_tokens,
2953
+ optimizer_updates=optimizer_updates,
2954
+ loss_tokens=trained_loss_tokens,
2955
+ context_tokens=trained_context_tokens,
2956
+ reverse_kl_per_token=reverse_kl,
2957
+ entropy_per_token=health.entropy_per_token,
2958
+ mean_generation_tokens=health.mean_generation_tokens,
2959
+ entropy_baseline=(
2960
+ tripwire_baseline.entropy_per_token if tripwire_baseline is not None else None
2961
+ ),
2962
+ entropy_ratio=metric_ratio(
2963
+ health.entropy_per_token,
2964
+ tripwire_baseline.entropy_per_token if tripwire_baseline is not None else None,
2965
+ ),
2966
+ generation_tokens_baseline=(
2967
+ tripwire_baseline.mean_generation_tokens if tripwire_baseline is not None else None
2968
+ ),
2969
+ generation_tokens_ratio=metric_ratio(
2970
+ health.mean_generation_tokens,
2971
+ tripwire_baseline.mean_generation_tokens if tripwire_baseline is not None else None,
2972
+ ),
2973
+ reward_mean=(
2974
+ sum(record.reward for record in records) / len(records) if records else None
2975
+ ),
2976
+ loss=cfg.train.loss,
2977
+ advantage_mean=advantage_mean,
2978
+ advantage_std=advantage_std,
2979
+ clip_fraction=(clipped_tokens / trained_loss_tokens if trained_loss_tokens else 0.0),
2980
+ pg_loss=pg_loss,
2981
+ grad_norm=grad_norm,
2982
+ sampler_path=sampler_path,
2983
+ student_prefill_tokens=deltas["student_prefill"],
2984
+ student_cached_prefill_tokens=deltas["student_cached_prefill"],
2985
+ student_sample_tokens=deltas["student_sample"],
2986
+ student_train_tokens=deltas["student_train"],
2987
+ teacher_prefill_tokens=deltas["teacher_prefill"],
2988
+ teacher_cached_prefill_tokens=deltas["teacher_cached_prefill"],
2989
+ teacher_sample_tokens=deltas["teacher_sample"],
2990
+ usd=max(self._budget.session_usd - self._prev_usd, 0.0),
2991
+ cumulative_usd=max(self._budget.total_usd, 0.0),
2992
+ )
2993
+ self._store.append_metrics(step, metrics)
2994
+ self._tracker.log_step(step, metrics)
2995
+ self._prev_tokens = {meter: self._budget.tokens(meter) for meter in METER_NAMES}
2996
+ self._prev_usd = self._budget.session_usd
2997
+ kl_text = "n/a" if reverse_kl is None else f"{reverse_kl:.4f}"
2998
+ self._emit(
2999
+ "training",
3000
+ f"step {step + 1}/{cfg.train.steps}: solve rate "
3001
+ f"{roll_stats.solve_rate:.2f} ({roll_stats.executed_trials}/{roll_stats.trials} "
3002
+ f"executed), "
3003
+ f"{_graded_phrase(roll_stats.graded_solve_rate, roll_stats.graded_trials)}, "
3004
+ f"scaffold loss {roll_stats.scaffold_loss_rate:.0%}, reverse KL/token "
3005
+ f"{kl_text}, {health_summary(health, tripwire_baseline)}, "
3006
+ f"{len(trained)} datum(s) under {cfg.train.loss}",
3007
+ step=step,
3008
+ )
3009
+
3010
+ try:
3011
+ self._budget.check()
3012
+ except BudgetExhausted as exc:
3013
+ self._abort_for_budget(exc, completed_step=step)
3014
+
3015
+ self._check_empty_batch_streak(
3016
+ step, roll_stats.trials, roll_stats.empty_span_trials, len(trained)
3017
+ )
3018
+ self._check_degeneration(step, health, tripwire_baseline)
3019
+
3020
+ if (step + 1) % cfg.train.sampler_refresh_every == 0:
3021
+ self._sampler.refresh(step + 1)
3022
+ if (step + 1) % cfg.train.save_state_every == 0:
3023
+ state_path = self._training.save_state()
3024
+ self._store.record_checkpoint(step, state_path, self._sampler.sampler_path)
3025
+ if cfg.eval.every > 0 and (step + 1) % cfg.eval.every == 0:
3026
+ subsample = self._interim_eval_tasks()
3027
+ self._run_eval(
3028
+ f"step-{step:04d}",
3029
+ subsample,
3030
+ cfg.eval.k,
3031
+ self._student_provider(),
3032
+ phase="eval",
3033
+ teacher_metered=False,
3034
+ completed_step=step,
3035
+ )
3036
+
3037
+ def _interim_eval_tasks(self) -> list[str]:
3038
+ """A fixed, seeded train subsample so interim evals compare across steps."""
3039
+ count = min(self._cfg.eval.tasks, len(self._train_ids))
3040
+ rng = random.Random(_seed_from_name(f"{self._name}/interim-eval"))
3041
+ return rng.sample(self._train_ids, count)
3042
+
3043
+ # -- execution -------------------------------------------------------------------------------
3044
+
3045
+ def _open_training_client(self, restore_path: str | None) -> DistillTrainingClient:
3046
+ """Create the student's training client, restoring state as its first call.
3047
+
3048
+ Tinker accepts LoadWeights only on an uninitialized model, so on
3049
+ resume `load_state` must run before ANYTHING else touches the client:
3050
+ a sampler-weights save, a forward pass, or a preflight sample would
3051
+ make the restore impossible and silently lose every trained step. For
3052
+ the same reason a deadline expiry cannot be retried on the same
3053
+ client, since the abandoned request may have initialized it
3054
+ server-side (a live resume died exactly that way); the retry opens a
3055
+ fresh, still-uninitialized client instead.
3056
+
3057
+ Args:
3058
+ restore_path: The tinker:// state path to restore, or None for a
3059
+ fresh run, which starts from the base model with no restore.
3060
+
3061
+ Returns:
3062
+ The training client, with `restore_path` already loaded when one
3063
+ was given.
3064
+
3065
+ Raises:
3066
+ TinkerDeadlineError: If both restore attempts blow their deadline.
3067
+ """
3068
+ cfg = self._cfg
3069
+ client = self._service.create_lora_training_client(
3070
+ cfg.student.base_model, cfg.student.lora_rank
3071
+ )
3072
+ if restore_path is None:
3073
+ return client
3074
+ try:
3075
+ client.load_state(restore_path)
3076
+ except TinkerDeadlineError as exc:
3077
+ logger.warning(
3078
+ "load_state timed out; retrying on a FRESH training client, because the "
3079
+ "abandoned request may have initialized this one (tinker then refuses "
3080
+ "LoadWeights): %s",
3081
+ exc,
3082
+ )
3083
+ client = self._service.create_lora_training_client(
3084
+ cfg.student.base_model, cfg.student.lora_rank
3085
+ )
3086
+ client.load_state(restore_path)
3087
+ return client
3088
+
3089
+ def execute(self, *, resume: bool) -> DistillResult:
3090
+ """Run (or resume) the whole distillation to a gate verdict."""
3091
+ cfg = self._cfg
3092
+ store = self._store
3093
+ self._resumed = resume
3094
+ if resume:
3095
+ if not store.config_path.exists():
3096
+ raise RuntimeError(
3097
+ f"nothing to resume in {self._run_dir}: no config.toml snapshot "
3098
+ "from a prior run; start a fresh run without resume"
3099
+ )
3100
+ if store.gate_path.exists():
3101
+ raise RuntimeError(
3102
+ f"the run in {self._run_dir} already completed: its gate verdict is "
3103
+ f"recorded at {store.gate_path} (see model_card.json for the final "
3104
+ "artifacts). Resuming a finished run would re-spend the holdout eval "
3105
+ "and could promote a duplicate adapter version; start a fresh "
3106
+ "--run-dir to distill again"
3107
+ )
3108
+ latest = store.latest_checkpoint()
3109
+ start_step = latest.step + 1 if latest is not None else 0
3110
+ warmup_record = store.read_warmup()
3111
+ # The spend ledger is written on every charge, so it carries spend
3112
+ # the metrics rows never see (baselines, interim and finalize
3113
+ # evals); summing metrics rows is only the pre-ledger fallback.
3114
+ recorded_spend = store.read_spend()
3115
+ prior_usd = recorded_spend if recorded_spend is not None else store.budget_spent()
3116
+ # Reused exactly as recorded, never re-measured: a fresh baseline
3117
+ # taken after a collapse would treat the collapse as normal and the
3118
+ # tripwire would never fire again. None only when the prior session
3119
+ # never completed a measurable step.
3120
+ self._tripwire_baseline = store.read_tripwire_baseline()
3121
+ if self._tripwire_baseline is not None:
3122
+ logger.info(
3123
+ "reusing the recorded degeneration baseline from step %d (entropy "
3124
+ "%.4f nats/token, %.0f sampled tokens/episode)",
3125
+ self._tripwire_baseline.step,
3126
+ self._tripwire_baseline.entropy_per_token,
3127
+ self._tripwire_baseline.mean_generation_tokens,
3128
+ )
3129
+ else:
3130
+ if store.config_path.exists() or store.last_step() is not None:
3131
+ raise ValueError(
3132
+ f"run dir {self._run_dir} already holds a distillation run; pass "
3133
+ "resume=True to continue it, or choose a fresh run dir"
3134
+ )
3135
+ latest = None
3136
+ start_step = 0
3137
+ warmup_record = None
3138
+ prior_usd = 0.0
3139
+ # The snapshot is refreshed on resume too: the caller's config wins (the
3140
+ # documented budget-abort recovery is editing budget.max_usd and resuming).
3141
+ store.snapshot_config(cfg)
3142
+
3143
+ restore_path: str | None = None
3144
+ if latest is not None:
3145
+ logger.info(
3146
+ "resuming %s from checkpoint step %d (%s)",
3147
+ self._name,
3148
+ latest.step,
3149
+ latest.state_path,
3150
+ )
3151
+ restore_path = latest.state_path
3152
+ elif warmup_record is not None and warmup_record.state_path is not None:
3153
+ # Warmup finished but no OPD step was checkpointed yet: without
3154
+ # this restore, a resumed session would start OPD from the COLD
3155
+ # student and silently lose the warmup it already paid for.
3156
+ logger.info(
3157
+ "resuming %s from the post-warmup state (%s)",
3158
+ self._name,
3159
+ warmup_record.state_path,
3160
+ )
3161
+ restore_path = warmup_record.state_path
3162
+ # The restore is part of opening the client because it must be its
3163
+ # FIRST call (see _open_training_client): the teacher client, the
3164
+ # student sampler refresh, and preflight all come after, so preflight
3165
+ # validates the RESTORED weights rather than the bare base model.
3166
+ self._training = self._open_training_client(restore_path)
3167
+ self._teacher_client = self._service.create_sampling_client(self._teacher_identity)
3168
+ self._teacher = TinkerTeacher(cfg.teacher, sampling_client=self._teacher_client)
3169
+ self._sampler = StudentSampler(self._service, self._training, self._name)
3170
+ self._sampler.refresh(start_step)
3171
+ self._budget = _RunBudget(
3172
+ cfg.pricing, cfg.budget.max_usd, prior_usd, on_spend=store.write_spend
3173
+ )
3174
+ self._tasks = TaskSampler(self._train_ids, seed=_seed_from_name(self._name))
3175
+ for _ in range(start_step):
3176
+ self._tasks.next_batch(cfg.train.tasks_per_batch)
3177
+
3178
+ self._preflight()
3179
+ try:
3180
+ self._budget.check()
3181
+ except BudgetExhausted as exc:
3182
+ self._abort_for_budget(exc, completed_step=None)
3183
+
3184
+ # Baselines feed the GATE only -- nothing in the training loop reads them -- so they are
3185
+ # not on the critical path for starting work. Deferred, they run at finalize instead,
3186
+ # which frees the training loop to start immediately while a SEPARATE process measures
3187
+ # them concurrently. That process must have its own HOME: harbor's task cache is a
3188
+ # hardcoded `~/.cache/harbor` (no env override) and `_copy_task_source_to_target` does an
3189
+ # UNCONDITIONAL rmtree+copytree of the task dir even when the cache is warm, so two
3190
+ # concurrent harbor jobs sharing a HOME delete each other's tasks mid-run.
3191
+ if cfg.eval.defer_baselines:
3192
+ self._deferred_baselines = True
3193
+ for step in range(start_step, cfg.train.steps):
3194
+ self._train_step(step)
3195
+ return self._finalize(None, None)
3196
+
3197
+ teacher_report = self._eval_or_load(
3198
+ TEACHER_BASELINE_EVAL,
3199
+ self._holdout_ids,
3200
+ cfg.gate.k,
3201
+ self._teacher_provider(),
3202
+ phase="baseline",
3203
+ teacher_metered=True,
3204
+ reuse=resume,
3205
+ pin_provider=True,
3206
+ baseline_from=cfg.eval.teacher_baseline_from,
3207
+ baseline_from_field="eval.teacher_baseline_from",
3208
+ )
3209
+ before_report = self._eval_or_load(
3210
+ STUDENT_BEFORE_EVAL,
3211
+ self._holdout_ids,
3212
+ cfg.gate.k,
3213
+ self._student_provider(),
3214
+ phase="baseline",
3215
+ teacher_metered=False,
3216
+ reuse=resume,
3217
+ baseline_from=cfg.eval.student_baseline_from,
3218
+ baseline_from_field="eval.student_baseline_from",
3219
+ )
3220
+
3221
+ if cfg.warmup.steps > 0:
3222
+ if warmup_record is not None:
3223
+ logger.info(
3224
+ "warmup already recorded in %s; skipping (%s)",
3225
+ store.warmup_path,
3226
+ warmup_record.skipped_reason
3227
+ or f"{warmup_record.steps} step(s) over {warmup_record.datums} datum(s)",
3228
+ )
3229
+ elif start_step > 0:
3230
+ logger.info(
3231
+ "resumed past step checkpoints (start step %d) with no warmup "
3232
+ "record; skipping warmup, the student already trained OPD steps",
3233
+ start_step,
3234
+ )
3235
+ else:
3236
+ self._warmup()
3237
+
3238
+ if start_step >= cfg.train.steps:
3239
+ logger.info(
3240
+ "all %d training step(s) already completed; skipping to finalize",
3241
+ cfg.train.steps,
3242
+ )
3243
+ for step in range(start_step, cfg.train.steps):
3244
+ self._train_step(step)
3245
+
3246
+ return self._finalize(teacher_report, before_report)
3247
+
3248
+ def _finalize(
3249
+ self,
3250
+ teacher_report: DistillEvalReport | None,
3251
+ before_report: DistillEvalReport | None,
3252
+ ) -> DistillResult:
3253
+ cfg = self._cfg
3254
+ # Save the trained weights BEFORE anything that can fail on an external dependency.
3255
+ # Deferred baselines import a file a CONCURRENT process writes, and that import used to
3256
+ # run first: if the producer had not finished, the run raised having already paid for
3257
+ # every training step, and the final sampler and state were never written. Ordering the
3258
+ # save first means the worst case costs a resume, not the training.
3259
+ self._emit("finalize", "saving final training state and sampler weights")
3260
+ final_sampler = self._sampler.refresh(cfg.train.steps)
3261
+ final_state = self._training.save_state()
3262
+ final_step = max(cfg.train.steps - 1, 0)
3263
+ self._store.record_checkpoint(final_step, final_state, final_sampler)
3264
+
3265
+ if teacher_report is None or before_report is None:
3266
+ # Deferred: measure (or import) the gate references now that training is done. The
3267
+ # concurrent baseline process has usually written its reports long before this, so
3268
+ # `eval.teacher_baseline_from` / `student_baseline_from` normally import rather than
3269
+ # re-measure, and no second harbor job ever runs beside the training one.
3270
+ self._emit("baseline", "measuring deferred gate baselines after training")
3271
+ self._await_deferred_baselines()
3272
+ teacher_report = self._eval_or_load(
3273
+ TEACHER_BASELINE_EVAL,
3274
+ self._holdout_ids,
3275
+ cfg.gate.k,
3276
+ self._teacher_provider(),
3277
+ phase="baseline",
3278
+ teacher_metered=True,
3279
+ reuse=True,
3280
+ pin_provider=True,
3281
+ baseline_from=cfg.eval.teacher_baseline_from,
3282
+ baseline_from_field="eval.teacher_baseline_from",
3283
+ )
3284
+ before_report = self._eval_or_load(
3285
+ STUDENT_BEFORE_EVAL,
3286
+ self._holdout_ids,
3287
+ cfg.gate.k,
3288
+ self._student_provider(),
3289
+ phase="baseline",
3290
+ teacher_metered=False,
3291
+ reuse=True,
3292
+ baseline_from=cfg.eval.student_baseline_from,
3293
+ baseline_from_field="eval.student_baseline_from",
3294
+ )
3295
+ # Reuse a recorded student-after report on resume: a budget abort inside
3296
+ # a prior session's finalize already paid for it, and the resumed weights
3297
+ # restore the same final checkpoint it measured.
3298
+ after_report = self._eval_or_load(
3299
+ STUDENT_AFTER_EVAL,
3300
+ self._holdout_ids,
3301
+ cfg.gate.k,
3302
+ self._student_provider(),
3303
+ phase="eval",
3304
+ teacher_metered=False,
3305
+ reuse=self._resumed,
3306
+ completed_step=final_step,
3307
+ )
3308
+ record = gate_distillation(
3309
+ teacher_report.solve_rate,
3310
+ before_report.solve_rate,
3311
+ after_report.solve_rate,
3312
+ cfg.gate,
3313
+ )
3314
+ self._store.write_gate(record)
3315
+ card = DistillModelCard(
3316
+ base_model=cfg.student.base_model,
3317
+ lora_rank=cfg.student.lora_rank,
3318
+ teacher_model=self._teacher_identity,
3319
+ sampler_path=final_sampler,
3320
+ state_path=final_state,
3321
+ steps_completed=cfg.train.steps,
3322
+ gate=record,
3323
+ )
3324
+ self._store.write_model_card(card)
3325
+ version: int | None = None
3326
+ if record.accepted:
3327
+ version = self._adapters.save_version(self._name, card)
3328
+ self._store.write_handoff(
3329
+ build_handoff_toml(final_sampler, base_model=cfg.student.base_model)
3330
+ )
3331
+ logger.info("adapter %s v%d promoted: %s", self._name, version, record.reason)
3332
+ else:
3333
+ logger.warning("adapter %s not promoted: %s", self._name, record.reason)
3334
+ self._emit("gate", record.reason)
3335
+ self._tracker.log_summary(
3336
+ gate_accepted=record.accepted,
3337
+ gate_reason=record.reason,
3338
+ teacher_solve_rate=record.teacher_solve_rate,
3339
+ student_before_solve_rate=record.student_before_solve_rate,
3340
+ student_after_solve_rate=record.student_after_solve_rate,
3341
+ total_usd=self._budget.total_usd,
3342
+ steps_completed=cfg.train.steps,
3343
+ )
3344
+ spend = SpendSummary(
3345
+ lines=self._budget.lines(),
3346
+ session_usd=self._budget.session_usd,
3347
+ prior_usd=self._budget.prior_usd,
3348
+ total_usd=self._budget.total_usd,
3349
+ )
3350
+ return DistillResult(
3351
+ name=self._name,
3352
+ run_dir=str(self._run_dir),
3353
+ steps_completed=cfg.train.steps,
3354
+ final_sampler_path=final_sampler,
3355
+ final_state_path=final_state,
3356
+ gate=record,
3357
+ adapter_version=version,
3358
+ spend=spend,
3359
+ )
3360
+
3361
+
3362
+ def run_distillation(
3363
+ name: str,
3364
+ cfg: DistillConfig,
3365
+ harness: HarnessDoc,
3366
+ train_task_ids: Sequence[str],
3367
+ holdout_task_ids: Sequence[str],
3368
+ run_dir: Path,
3369
+ *,
3370
+ resume: bool = False,
3371
+ on_progress: ProgressCallback | None = None,
3372
+ service_client: DistillServiceClient | None = None,
3373
+ adapter_store: AdapterStore | None = None,
3374
+ live_trial_preflight: LiveTrialPreflight | None = None,
3375
+ tracker: DistillTracker | None = None,
3376
+ cli_agent: str | None = None,
3377
+ ) -> DistillResult:
3378
+ """Run one on-policy distillation end to end (preflight to gate verdict).
3379
+
3380
+ The flow: preflight (renderer resolution, tokenizer fingerprint, one-token
3381
+ student and teacher pings, the sample-then-recompute TITO proof, and the
3382
+ optional live-trial hook), holdout baselines (teacher-in-harness and
3383
+ student-before, each importable from a prior run's report via
3384
+ `eval.teacher_baseline_from` / `eval.student_baseline_from` instead of
3385
+ running trials), the optional supervised warmup when `warmup.steps > 0`
3386
+ (teacher rollouts on the train split, or another run's recorded collection
3387
+ when `warmup.trajectories_from` is set; the `warmup.keep`-filtered trials
3388
+ merge into cross_entropy datums for `warmup.steps` full-batch passes, then
3389
+ a state save and a forced sampler refresh so OPD starts from the warmed
3390
+ student; zero kept trials skip the phase with a warning instead of
3391
+ aborting), the
3392
+ training step loop (harbor rollouts, prefix-merge datums, teacher scoring,
3393
+ reverse-KL advantages, importance_sampling forward/backward and one
3394
+ optimizer step, metrics row, budget enforcement, then the sampler-refresh
3395
+ / save-state / interim-eval cadences), and finally the student-after
3396
+ holdout eval feeding `gate_distillation`. An accepted gate saves the
3397
+ adapter version (champion alias) and writes the serving handoff into the
3398
+ run dir.
3399
+
3400
+ Args:
3401
+ name: The adapter/run name (a safe single path segment).
3402
+ cfg: The validated run config; snapshotted into the run dir.
3403
+ harness: The pinned document whose hash keys every trial's harbor job
3404
+ dir; the rollout agent itself is harbor's terminus-2.
3405
+ train_task_ids: The train split's task ids (unique, non-empty).
3406
+ holdout_task_ids: The holdout split's task ids (unique, non-empty,
3407
+ disjoint from the train split); baselines and the gate run here.
3408
+ run_dir: The run's artifact directory (`DistillRunStore` layout plus
3409
+ the rollout collector's per-step dirs and per-eval
3410
+ `eval-rollouts/<name>/` roots).
3411
+ resume: Continue an aborted run in `run_dir`: restores the latest
3412
+ checkpoint via `load_state` (or the recorded post-warmup state
3413
+ when no step checkpoint exists yet), continues the step count,
3414
+ restores prior USD spend from the run store's spend ledger,
3415
+ reuses recorded baseline evals, and never re-runs a warmup whose
3416
+ `warmup.json` record exists. The passed `cfg` wins over the old
3417
+ snapshot (the budget-abort recovery is editing `budget.max_usd`
3418
+ and resuming).
3419
+ on_progress: Optional callback receiving typed `DistillProgress`
3420
+ events as phases advance.
3421
+ service_client: Injectable Tinker service surface; tests pass fakes
3422
+ built on `wmo.distill.fake_tinker`. None builds the real SDK
3423
+ client lazily (requires the distill extra and TINKER_API_KEY).
3424
+ adapter_store: Where accepted adapters are versioned; defaults to the
3425
+ project-local `.wmo` store.
3426
+ live_trial_preflight: The Phase 6 live single-trial pi preflight hook
3427
+ (see `LiveTrialPreflight`); None skips it.
3428
+ tracker: Injectable run tracker; None builds one from the config's
3429
+ `[wandb]` section via `build_tracker` (the no-op `NullTracker`
3430
+ unless `wandb.enabled` is set). `finish()` is guaranteed on both
3431
+ the finalize and budget-abort paths.
3432
+ cli_agent: The agent string as the user typed it (it may carry an
3433
+ `@ref`, which `name` strips); resume commands print this string
3434
+ so the printed command passes the CLI's resume conflict check.
3435
+ None falls back to `name`.
3436
+
3437
+ Returns:
3438
+ The `DistillResult`: gate record, final sampler/state paths, run dir,
3439
+ adapter version (None when rejected), and the spend summary.
3440
+
3441
+ Raises:
3442
+ ValueError: On invalid name/splits, a fresh run pointed at a used
3443
+ run dir, a `eval.*_baseline_from` report that fails validation
3444
+ (wrong task set, too few attempts, or a different model), or
3445
+ wandb tracking enabled without credentials.
3446
+ RuntimeError: On preflight failures (each message says what to fix),
3447
+ or resume with nothing to resume.
3448
+ ImportError: If no service client is injected and the tinker SDK is
3449
+ not installed, or wandb tracking is enabled without the wandb SDK.
3450
+ DistillBudgetError: When `budget.max_usd` is exhausted; state is
3451
+ persisted and the error carries the exact resume command.
3452
+ DistillEmptyBatchError: When `MAX_CONSECUTIVE_EMPTY_STEPS` training
3453
+ steps in a row produce only span-less trials (the student
3454
+ provider is producing no completions); state is persisted and
3455
+ the error carries the exact resume command.
3456
+ """
3457
+ validate_name(name)
3458
+ train_ids = list(train_task_ids)
3459
+ holdout_ids = list(holdout_task_ids)
3460
+ if not train_ids or len(set(train_ids)) != len(train_ids):
3461
+ raise ValueError(
3462
+ "train_task_ids must be non-empty and unique; pass the train split's exact task ids"
3463
+ )
3464
+ if not holdout_ids or len(set(holdout_ids)) != len(holdout_ids):
3465
+ raise ValueError(
3466
+ "holdout_task_ids must be non-empty and unique; the baselines and the "
3467
+ "promotion gate are measured on the holdout split"
3468
+ )
3469
+ overlap = sorted(set(train_ids) & set(holdout_ids))
3470
+ if overlap:
3471
+ raise ValueError(
3472
+ f"task id(s) {', '.join(overlap)} appear in BOTH splits; the gate is "
3473
+ "only meaningful on tasks the student never trained on, so make the "
3474
+ "splits disjoint"
3475
+ )
3476
+ service = service_client if service_client is not None else _build_sdk_service_client()
3477
+ # Built (and so credential-checked) before any spend: a misconfigured
3478
+ # tracker must fail fast, not after paid baselines.
3479
+ resolved_tracker = tracker if tracker is not None else build_tracker(cfg, run_dir, name)
3480
+ run = _DistillRun(
3481
+ name,
3482
+ cfg,
3483
+ harness,
3484
+ train_ids,
3485
+ holdout_ids,
3486
+ run_dir,
3487
+ service=service,
3488
+ adapter_store=adapter_store if adapter_store is not None else AdapterStore(),
3489
+ on_progress=on_progress,
3490
+ live_trial_preflight=live_trial_preflight,
3491
+ tracker=resolved_tracker,
3492
+ cli_agent=cli_agent,
3493
+ )
3494
+ try:
3495
+ return run.execute(resume=resume)
3496
+ finally:
3497
+ # Both terminal paths (finalize and the budget abort) close the
3498
+ # tracking run, so a resumed session starts a fresh wandb run.
3499
+ resolved_tracker.finish()