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/store.py ADDED
@@ -0,0 +1,829 @@
1
+ """Distillation artifacts on disk: the per-run directory and the adapter store.
2
+
3
+ `DistillRunStore` owns one run's artifact directory. Everything a run produces
4
+ or needs to resume lives under it:
5
+
6
+ <run_dir>/
7
+ config.toml # exact snapshot of the DistillConfig the run started with
8
+ metrics.jsonl # one JSON row per warmup/training step, appended as steps finish
9
+ spend.json # cumulative priced USD across every session, updated per charge
10
+ warmup.json # the WarmupRecord marker; its existence means warmup is done
11
+ warmup-trials.json # the warmup collection's TrialRecords (loadable by other runs)
12
+ evals/<name>.json # interim and final eval payloads, keyed by eval name
13
+ samples/<name>.md # per-batch sample episode rollouts (step-NNNN, warmup, eval-<name>)
14
+ checkpoints.json # manifest of saved tinker:// state + sampler paths, plus the
15
+ # degeneration tripwires' baseline (it must survive --resume)
16
+ gate.json # the DistillGateRecord verdict
17
+ model_card.json # the run's DistillModelCard
18
+ handoff.toml # the [models.agent] serving snippet for the user
19
+ harbor/step-NNNN/ # per-step harbor jobs dirs (written by the rollout collector;
20
+ # each trial dir's result.json carries that trial's token spans)
21
+ warmup-rollouts/ # the warmup phase's isolated rollout root (teacher trials)
22
+
23
+ `AdapterStore` mirrors `wmo/harness/store.py`'s `HarnessStore` idiom for the
24
+ trained adapters themselves: `.wmo/adapters/<name>/` accumulates immutable
25
+ `vN/model_card.json` versions, and movable aliases in `aliases.toml` mark
26
+ deployment state (promotion and rollback are re-pointing, never rewriting).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ import tomllib
34
+ from datetime import UTC, datetime
35
+ from pathlib import Path
36
+
37
+ import tomli_w
38
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
39
+
40
+ from wmo.config.store import validate_name
41
+ from wmo.core.types import JsonObject
42
+ from wmo.distill.config import DistillConfig, load_distill_config, snapshot_toml
43
+ from wmo.distill.gate import DistillGateRecord
44
+ from wmo.distill.tokens import TrialRecord
45
+ from wmo.distill.tripwire import TripwireBaseline
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ def write_text_atomic(path: Path, text: str) -> None:
51
+ """Write `text` to `path` via a same-directory tmp file plus atomic replace.
52
+
53
+ Every durable file in the run dir goes through this, including the ones
54
+ other modules own (`wmo.distill.tracking`'s wandb run record): a torn
55
+ write to checkpoints.json (or the config snapshot, warmup record, gate
56
+ verdict, wandb run id) would otherwise corrupt the resume path exactly
57
+ when a crash made resume necessary.
58
+
59
+ Args:
60
+ path: The destination file; its parent directory must exist.
61
+ text: The complete file contents, written as UTF-8.
62
+ """
63
+ tmp_path = path.with_name(path.name + ".tmp")
64
+ tmp_path.write_text(text, encoding="utf-8")
65
+ tmp_path.replace(path)
66
+
67
+
68
+ ADAPTERS_DIR = "adapters"
69
+ CHAMPION_ALIAS = "champion"
70
+
71
+ # Tinker's OpenAI-compatible serving endpoint: the production service base URL
72
+ # plus the /oai/api/v1 OpenAI-compat prefix (a bare /v1 returns 404; verified
73
+ # live 2026-07-23 with a completion from a trained adapter). The endpoint is
74
+ # beta, so runs may override it in config; artifacts record the value used.
75
+ DEFAULT_TINKER_OPENAI_ENDPOINT = (
76
+ "https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1"
77
+ )
78
+
79
+ _ALIASES_FILE = "aliases.toml"
80
+ _CARD_FILE = "model_card.json"
81
+
82
+ _CONFIG_FILE = "config.toml"
83
+ _METRICS_FILE = "metrics.jsonl"
84
+ _SPEND_FILE = "spend.json"
85
+ _WARMUP_FILE = "warmup.json"
86
+ _WARMUP_TRIALS_FILE = "warmup-trials.json"
87
+ _CHECKPOINTS_FILE = "checkpoints.json"
88
+ _GATE_FILE = "gate.json"
89
+ _MODEL_CARD_FILE = "model_card.json"
90
+ _HANDOFF_FILE = "handoff.toml"
91
+ _EVALS_DIR = "evals"
92
+ _SAMPLES_DIR = "samples"
93
+
94
+
95
+ def _utc_now_iso() -> str:
96
+ return datetime.now(UTC).isoformat()
97
+
98
+
99
+ class SpendLedger(BaseModel):
100
+ """The spend.json shape: cumulative priced USD across every session of the run."""
101
+
102
+ model_config = ConfigDict(extra="forbid")
103
+
104
+ total_usd: float = Field(ge=0.0)
105
+
106
+
107
+ class WarmupRecord(BaseModel):
108
+ """The warmup.json shape: proof one session finished (or skipped) warmup.
109
+
110
+ The record exists exactly when the warmup phase reached its terminal
111
+ outcome, so a resumed run never re-runs it. It is NOT a checkpoint
112
+ manifest entry: checkpoint steps drive the resume step count, and warmup
113
+ precedes step 0. `state_path` lets a resume that has no step checkpoint
114
+ yet restore the warmed weights instead of starting cold.
115
+ """
116
+
117
+ model_config = ConfigDict(extra="forbid")
118
+
119
+ steps: int = Field(ge=0)
120
+ """Warmup optimizer steps actually applied (0 when the phase was skipped)."""
121
+
122
+ trials: int = Field(ge=0)
123
+ """Teacher trials collected on the train split."""
124
+
125
+ kept_trials: int = Field(ge=0)
126
+ """Trials that survived the `warmup.keep` filter."""
127
+
128
+ datums: int = Field(ge=0)
129
+ """SFT datums the kept trials merged into."""
130
+
131
+ state_path: str | None = None
132
+ """The post-warmup tinker:// training-state path; None when skipped."""
133
+
134
+ sampler_path: str | None = None
135
+ """The post-warmup tinker:// sampler-weights path; None when skipped."""
136
+
137
+ skipped_reason: str | None = None
138
+ """Why the phase trained nothing (e.g. zero passing trials); None when it ran."""
139
+
140
+
141
+ class WarmupTrialsManifest(BaseModel):
142
+ """The warmup-trials.json shape: the warmup collection's assembled trials.
143
+
144
+ Written when the warmup phase's teacher rollouts finish assembling, BEFORE
145
+ the `warmup.keep` filter, so another run loading the collection through
146
+ `warmup.trajectories_from` may filter differently. The teacher identity is
147
+ recorded so a loading run can refuse trajectories another teacher sampled.
148
+ """
149
+
150
+ model_config = ConfigDict(extra="forbid")
151
+
152
+ teacher_model: str = Field(min_length=1)
153
+ """The teacher identity (checkpoint or model ref) that sampled the trials."""
154
+
155
+ records: list[TrialRecord]
156
+ """Every assembled trial record from the collection, unfiltered."""
157
+
158
+
159
+ class CheckpointRecord(BaseModel):
160
+ """One saved training checkpoint: the exact tinker:// artifacts for a step."""
161
+
162
+ model_config = ConfigDict(frozen=True, extra="forbid")
163
+
164
+ step: int = Field(ge=0)
165
+ """0-based training step the checkpoint was saved after."""
166
+
167
+ state_path: str = Field(min_length=1)
168
+ """The tinker:// training-state path (feeds load_state on resume)."""
169
+
170
+ sampler_path: str = Field(min_length=1)
171
+ """The tinker:// sampler-weights path current at save time."""
172
+
173
+
174
+ class CheckpointManifest(BaseModel):
175
+ """The checkpoints.json shape: every saved checkpoint, plus resume-scoped state."""
176
+
177
+ model_config = ConfigDict(extra="forbid")
178
+
179
+ checkpoints: list[CheckpointRecord] = Field(default_factory=list)
180
+
181
+ tripwire_baseline: TripwireBaseline | None = None
182
+ """The degeneration tripwires' reference, measured at the run's first
183
+ training step (None before that step, or on manifests predating the field).
184
+
185
+ It rides the run manifest rather than a metrics row because it must survive
186
+ `--resume`: a resumed session that re-measures its baseline would anchor on
187
+ an already-degenerated policy, making the collapse the new normal and the
188
+ tripwire blind. See `wmo.distill.tripwire.TripwireBaseline`."""
189
+
190
+
191
+ class DistillModelCard(BaseModel):
192
+ """The durable record of one distilled adapter: exact refs plus provenance.
193
+
194
+ Tinker's model lineup churns, so the card pins the exact base model,
195
+ teacher, and tinker:// artifact paths rather than relying on names staying
196
+ resolvable. `name` and `version` are stamped by `AdapterStore.save_version`.
197
+ """
198
+
199
+ model_config = ConfigDict(extra="forbid")
200
+
201
+ name: str = ""
202
+ version: int = 0
203
+ base_model: str = Field(min_length=1)
204
+ lora_rank: int = Field(ge=1)
205
+ teacher_model: str = Field(min_length=1)
206
+ sampler_path: str = Field(min_length=1)
207
+ """The tinker:// sampler-weights path serving this adapter."""
208
+
209
+ state_path: str | None = None
210
+ """The tinker:// training-state path, for training this adapter further."""
211
+
212
+ steps_completed: int = Field(ge=0)
213
+ created_at: str = Field(default_factory=_utc_now_iso)
214
+ gate: DistillGateRecord | None = None
215
+ """The promotion verdict that admitted this version, when one was run."""
216
+
217
+
218
+ class DistillRunStore:
219
+ """One distillation run's artifact directory (layout in the module docstring)."""
220
+
221
+ def __init__(self, run_dir: str | Path) -> None:
222
+ self.run_dir = Path(run_dir)
223
+
224
+ @property
225
+ def config_path(self) -> Path:
226
+ return self.run_dir / _CONFIG_FILE
227
+
228
+ @property
229
+ def metrics_path(self) -> Path:
230
+ return self.run_dir / _METRICS_FILE
231
+
232
+ @property
233
+ def spend_path(self) -> Path:
234
+ return self.run_dir / _SPEND_FILE
235
+
236
+ @property
237
+ def warmup_path(self) -> Path:
238
+ return self.run_dir / _WARMUP_FILE
239
+
240
+ @property
241
+ def warmup_trials_path(self) -> Path:
242
+ return self.run_dir / _WARMUP_TRIALS_FILE
243
+
244
+ @property
245
+ def checkpoints_path(self) -> Path:
246
+ return self.run_dir / _CHECKPOINTS_FILE
247
+
248
+ @property
249
+ def gate_path(self) -> Path:
250
+ return self.run_dir / _GATE_FILE
251
+
252
+ @property
253
+ def model_card_path(self) -> Path:
254
+ return self.run_dir / _MODEL_CARD_FILE
255
+
256
+ @property
257
+ def handoff_path(self) -> Path:
258
+ return self.run_dir / _HANDOFF_FILE
259
+
260
+ @property
261
+ def evals_dir(self) -> Path:
262
+ return self.run_dir / _EVALS_DIR
263
+
264
+ @property
265
+ def samples_dir(self) -> Path:
266
+ return self.run_dir / _SAMPLES_DIR
267
+
268
+ # -- config snapshot -----------------------------------------------------------------------
269
+
270
+ def snapshot_config(self, cfg: DistillConfig) -> Path:
271
+ """Write the exact config the run started with to `config.toml`."""
272
+ self.run_dir.mkdir(parents=True, exist_ok=True)
273
+ write_text_atomic(self.config_path, snapshot_toml(cfg))
274
+ return self.config_path
275
+
276
+ def load_config(self) -> DistillConfig:
277
+ """Read the snapshotted config back (the resume path's source of truth)."""
278
+ return load_distill_config(self.config_path)
279
+
280
+ # -- metrics ---------------------------------------------------------------------------------
281
+
282
+ def append_metrics(self, step: int, row: BaseModel) -> None:
283
+ """Append one training step's metrics row to `metrics.jsonl`.
284
+
285
+ Args:
286
+ step: 0-based training step the row belongs to; written as the
287
+ row's `step` key.
288
+ row: The step's metrics as a pydantic model; its JSON-mode dump
289
+ becomes the rest of the row.
290
+
291
+ Raises:
292
+ ValueError: If `step` is negative, or the row itself carries a
293
+ conflicting `step` field.
294
+ """
295
+ if step < 0:
296
+ raise ValueError(f"metrics step must be >= 0, got {step}")
297
+ data = row.model_dump(mode="json")
298
+ if "step" in data and data["step"] != step:
299
+ raise ValueError(
300
+ f"metrics row carries step {data['step']!r} but was appended as step {step}; "
301
+ "drop the row's own step field or pass the matching step"
302
+ )
303
+ payload: JsonObject = {"step": step, **data}
304
+ self.run_dir.mkdir(parents=True, exist_ok=True)
305
+ with self.metrics_path.open("a", encoding="utf-8") as handle:
306
+ handle.write(json.dumps(payload) + "\n")
307
+
308
+ def read_metrics(self) -> list[JsonObject]:
309
+ """Read every metrics row back, in append order.
310
+
311
+ Returns:
312
+ One JSON object per non-empty line; an empty list when no metrics
313
+ were written yet.
314
+
315
+ Raises:
316
+ ValueError: If a line is not a JSON object; the message names the
317
+ line so the corrupt row can be inspected or removed.
318
+ """
319
+ try:
320
+ text = self.metrics_path.read_text(encoding="utf-8")
321
+ except FileNotFoundError:
322
+ return []
323
+ rows: list[JsonObject] = []
324
+ for line_number, line in enumerate(text.splitlines(), 1):
325
+ if not line.strip():
326
+ continue
327
+ try:
328
+ parsed = json.loads(line)
329
+ except json.JSONDecodeError as exc:
330
+ raise ValueError(
331
+ f"corrupt metrics row on line {line_number} of {self.metrics_path}: {exc}; "
332
+ "remove the broken line (each line must be one JSON object) and retry"
333
+ ) from exc
334
+ if not isinstance(parsed, dict):
335
+ raise ValueError(
336
+ f"corrupt metrics row on line {line_number} of {self.metrics_path}: "
337
+ "expected a JSON object; remove the broken line and retry"
338
+ )
339
+ rows.append(parsed)
340
+ return rows
341
+
342
+ def last_step(self) -> int | None:
343
+ """The highest step recorded in `metrics.jsonl`, or None when empty."""
344
+ steps = [row["step"] for row in self.read_metrics() if isinstance(row.get("step"), int)]
345
+ return max(steps) if steps else None
346
+
347
+ def budget_spent(self) -> float:
348
+ """Total USD recorded across metrics rows (their `usd` keys).
349
+
350
+ This is only the resume fallback for run dirs that predate the spend
351
+ ledger (`read_spend` returning None): metrics rows land once per
352
+ completed step, so spend since the last row (baseline evals before
353
+ step 0, an interim eval after its step's row, the finalize
354
+ student-after eval) is invisible here, and a resumed session that
355
+ re-appends rows for re-run steps double-counts them (conservative
356
+ direction). Rows without a numeric `usd` key contribute nothing; a
357
+ fresh run reports 0.0.
358
+ """
359
+ total = 0.0
360
+ for row in self.read_metrics():
361
+ usd = row.get("usd")
362
+ if isinstance(usd, int | float) and not isinstance(usd, bool):
363
+ total += float(usd)
364
+ return total
365
+
366
+ # -- spend ledger ----------------------------------------------------------------------------
367
+
368
+ def write_spend(self, total_usd: float) -> None:
369
+ """Persist the run's cumulative priced spend to `spend.json` (atomically).
370
+
371
+ The loop calls this on EVERY budget charge, so the ledger (not the
372
+ metrics rows, which only land when a training step completes) is the
373
+ resume path's source of truth for prior spend. Without it, everything
374
+ charged since the last metrics row (holdout baselines before step 0,
375
+ an interim eval after its step's row, the finalize student-after eval)
376
+ would be forgotten and a resumed run could spend `budget.max_usd`
377
+ again.
378
+
379
+ Args:
380
+ total_usd: Cumulative priced USD across every session of the run.
381
+
382
+ Raises:
383
+ ValueError: If `total_usd` is negative.
384
+ """
385
+ if total_usd < 0:
386
+ raise ValueError(f"cumulative spend must be >= 0, got {total_usd}")
387
+ self.run_dir.mkdir(parents=True, exist_ok=True)
388
+ payload = SpendLedger(total_usd=total_usd).model_dump_json(indent=2)
389
+ # The ledger is rewritten on every charge, and a torn write would
390
+ # block the next resume with a corrupt-ledger error.
391
+ write_text_atomic(self.spend_path, payload)
392
+
393
+ def read_spend(self) -> float | None:
394
+ """The ledger's cumulative spend, or None when no ledger exists yet.
395
+
396
+ None means either a fresh run dir or one from before the ledger
397
+ existed; resume falls back to `budget_spent()` in that case.
398
+
399
+ Raises:
400
+ ValueError: If the ledger file exists but does not parse; the
401
+ message says how to recover.
402
+ """
403
+ try:
404
+ text = self.spend_path.read_text(encoding="utf-8")
405
+ except FileNotFoundError:
406
+ return None
407
+ try:
408
+ return SpendLedger.model_validate_json(text).total_usd
409
+ except ValidationError as exc:
410
+ raise ValueError(
411
+ f"corrupt spend ledger at {self.spend_path}: {exc}; delete the file to "
412
+ "fall back to summing metrics rows (which may miss eval spend, so "
413
+ "consider lowering budget.max_usd accordingly) and resume"
414
+ ) from exc
415
+
416
+ # -- warmup ----------------------------------------------------------------------------------
417
+
418
+ def write_warmup(self, record: WarmupRecord) -> Path:
419
+ """Persist the warmup phase's terminal record to `warmup.json`.
420
+
421
+ Written exactly once per run, when the phase finished training or
422
+ decided to skip; a resumed session that finds the file never re-runs
423
+ warmup.
424
+
425
+ Args:
426
+ record: The phase's outcome.
427
+
428
+ Returns:
429
+ The written path.
430
+ """
431
+ self.run_dir.mkdir(parents=True, exist_ok=True)
432
+ write_text_atomic(self.warmup_path, record.model_dump_json(indent=2))
433
+ return self.warmup_path
434
+
435
+ def read_warmup(self) -> WarmupRecord | None:
436
+ """The recorded warmup outcome, or None when the phase never finished.
437
+
438
+ Raises:
439
+ ValueError: If the file exists but does not parse; the message
440
+ says how to recover.
441
+ """
442
+ try:
443
+ text = self.warmup_path.read_text(encoding="utf-8")
444
+ except FileNotFoundError:
445
+ return None
446
+ try:
447
+ return WarmupRecord.model_validate_json(text)
448
+ except ValidationError as exc:
449
+ raise ValueError(
450
+ f"corrupt warmup record at {self.warmup_path}: {exc}; delete the file "
451
+ "so the resumed run re-runs the warmup phase (already-collected teacher "
452
+ "trials under warmup-rollouts/ resume trial-level through harbor)"
453
+ ) from exc
454
+
455
+ def write_warmup_trials(self, manifest: WarmupTrialsManifest) -> Path:
456
+ """Persist the warmup collection's trial manifest to `warmup-trials.json`.
457
+
458
+ Written when the warmup teacher rollouts finish assembling (before the
459
+ keep filter), so another run can reuse the collection through
460
+ `warmup.trajectories_from` instead of paying for its own.
461
+
462
+ Args:
463
+ manifest: The collection's teacher identity and trial records.
464
+
465
+ Returns:
466
+ The written path.
467
+ """
468
+ self.run_dir.mkdir(parents=True, exist_ok=True)
469
+ write_text_atomic(self.warmup_trials_path, manifest.model_dump_json(indent=2))
470
+ return self.warmup_trials_path
471
+
472
+ def read_warmup_trials(self) -> WarmupTrialsManifest | None:
473
+ """The recorded warmup trial manifest, or None when none was written.
474
+
475
+ Raises:
476
+ ValueError: If the file exists but does not parse; the message
477
+ says how to recover.
478
+ """
479
+ try:
480
+ text = self.warmup_trials_path.read_text(encoding="utf-8")
481
+ except FileNotFoundError:
482
+ return None
483
+ try:
484
+ return WarmupTrialsManifest.model_validate_json(text)
485
+ except ValidationError as exc:
486
+ raise ValueError(
487
+ f"corrupt warmup trial manifest at {self.warmup_trials_path}: {exc}; "
488
+ "re-run the source run's warmup collection to rewrite it, or point "
489
+ "warmup.trajectories_from elsewhere"
490
+ ) from exc
491
+
492
+ # -- evals -----------------------------------------------------------------------------------
493
+
494
+ def write_eval(self, name: str, payload: BaseModel) -> Path:
495
+ """Write one eval payload to `evals/<name>.json`, replacing any prior one.
496
+
497
+ Args:
498
+ name: The eval's key (e.g. "baseline-teacher", "step-0010"); must
499
+ be a safe single path segment.
500
+ payload: The eval report model to persist.
501
+
502
+ Returns:
503
+ The written path.
504
+ """
505
+ self.evals_dir.mkdir(parents=True, exist_ok=True)
506
+ path = self.evals_dir / f"{validate_name(name)}.json"
507
+ write_text_atomic(path, payload.model_dump_json(indent=2))
508
+ return path
509
+
510
+ # -- sample rollouts -------------------------------------------------------------------------
511
+
512
+ def write_samples(self, name: str, text: str) -> Path:
513
+ """Write one batch's rendered sample rollouts to `samples/<name>.md`.
514
+
515
+ One file per batch, replacing any prior one (a resumed session that
516
+ re-runs a step rewrites its samples): `step-NNNN` for training
517
+ batches, `warmup` for the warmup collection, `eval-<name>` for eval
518
+ batches.
519
+
520
+ Args:
521
+ name: The batch's file stem; must be a safe single path segment.
522
+ text: The assembled document (`wmo.distill.samples.samples_markdown`).
523
+
524
+ Returns:
525
+ The written path.
526
+ """
527
+ self.samples_dir.mkdir(parents=True, exist_ok=True)
528
+ path = self.samples_dir / f"{validate_name(name)}.md"
529
+ write_text_atomic(path, text)
530
+ return path
531
+
532
+ # -- checkpoints -----------------------------------------------------------------------------
533
+
534
+ def record_checkpoint(self, step: int, state_path: str, sampler_path: str) -> CheckpointRecord:
535
+ """Record a saved checkpoint in `checkpoints.json`.
536
+
537
+ A record for the same step replaces the earlier one (a resumed run
538
+ re-saves its cadence checkpoints); distinct steps append.
539
+
540
+ Args:
541
+ step: 0-based training step the checkpoint was saved after.
542
+ state_path: The tinker:// path save_state returned.
543
+ sampler_path: The tinker:// sampler-weights path current at save.
544
+
545
+ Returns:
546
+ The recorded checkpoint.
547
+ """
548
+ record = CheckpointRecord(step=step, state_path=state_path, sampler_path=sampler_path)
549
+ manifest = self._read_manifest()
550
+ manifest.checkpoints = [c for c in manifest.checkpoints if c.step != step]
551
+ manifest.checkpoints.append(record)
552
+ self.run_dir.mkdir(parents=True, exist_ok=True)
553
+ write_text_atomic(self.checkpoints_path, manifest.model_dump_json(indent=2))
554
+ return record
555
+
556
+ def write_tripwire_baseline(self, baseline: TripwireBaseline) -> Path:
557
+ """Persist the degeneration tripwires' baseline into `checkpoints.json`.
558
+
559
+ Written the moment the baseline is measured (the run's first training
560
+ step), not at the next checkpoint cadence: a session that dies in
561
+ between must not lose it, or the resumed session would re-baseline
562
+ against whatever policy it restores. `record_checkpoint` reads the
563
+ manifest before rewriting it, so the two never clobber each other.
564
+
565
+ Args:
566
+ baseline: The measured baseline.
567
+
568
+ Returns:
569
+ The manifest path.
570
+ """
571
+ manifest = self._read_manifest()
572
+ manifest.tripwire_baseline = baseline
573
+ self.run_dir.mkdir(parents=True, exist_ok=True)
574
+ write_text_atomic(self.checkpoints_path, manifest.model_dump_json(indent=2))
575
+ return self.checkpoints_path
576
+
577
+ def read_tripwire_baseline(self) -> TripwireBaseline | None:
578
+ """The recorded tripwire baseline, or None when none was measured yet."""
579
+ return self._read_manifest().tripwire_baseline
580
+
581
+ def checkpoints(self) -> list[CheckpointRecord]:
582
+ """Every recorded checkpoint, in record order."""
583
+ return list(self._read_manifest().checkpoints)
584
+
585
+ def latest_checkpoint(self) -> CheckpointRecord | None:
586
+ """The highest-step checkpoint (the one `--resume` restores), or None."""
587
+ recorded = self._read_manifest().checkpoints
588
+ return max(recorded, key=lambda record: record.step) if recorded else None
589
+
590
+ def _read_manifest(self) -> CheckpointManifest:
591
+ try:
592
+ text = self.checkpoints_path.read_text(encoding="utf-8")
593
+ except FileNotFoundError:
594
+ return CheckpointManifest()
595
+ try:
596
+ return CheckpointManifest.model_validate_json(text)
597
+ except ValidationError as exc:
598
+ raise ValueError(
599
+ f"corrupt checkpoint manifest at {self.checkpoints_path}: {exc}; "
600
+ "restore it from the run's backup or delete it and re-save a checkpoint "
601
+ "(deleting loses resume points)"
602
+ ) from exc
603
+
604
+ # -- terminal artifacts ----------------------------------------------------------------------
605
+
606
+ def write_gate(self, record: DistillGateRecord) -> Path:
607
+ """Persist the promotion verdict to `gate.json`."""
608
+ self.run_dir.mkdir(parents=True, exist_ok=True)
609
+ write_text_atomic(self.gate_path, record.model_dump_json(indent=2))
610
+ return self.gate_path
611
+
612
+ def write_model_card(self, card: DistillModelCard) -> Path:
613
+ """Persist the run's model card to `model_card.json`."""
614
+ self.run_dir.mkdir(parents=True, exist_ok=True)
615
+ write_text_atomic(self.model_card_path, card.model_dump_json(indent=2))
616
+ return self.model_card_path
617
+
618
+ def write_handoff(self, toml_text: str) -> Path:
619
+ """Persist the serving handoff snippet to `handoff.toml`.
620
+
621
+ Args:
622
+ toml_text: The snippet, normally from `build_handoff_toml`.
623
+
624
+ Returns:
625
+ The written path.
626
+
627
+ Raises:
628
+ ValueError: If the text is not valid TOML (the snippet must paste
629
+ cleanly into a settings file); build it via `build_handoff_toml`.
630
+ """
631
+ try:
632
+ tomllib.loads(toml_text)
633
+ except tomllib.TOMLDecodeError as exc:
634
+ raise ValueError(
635
+ f"handoff snippet is not valid TOML ({exc}); build it with "
636
+ "build_handoff_toml so it pastes cleanly into a settings file"
637
+ ) from exc
638
+ self.run_dir.mkdir(parents=True, exist_ok=True)
639
+ write_text_atomic(self.handoff_path, toml_text)
640
+ return self.handoff_path
641
+
642
+
643
+ class AdapterStore:
644
+ """Named, versioned distilled adapters under `<root>/adapters/<name>/`.
645
+
646
+ Mirrors `HarnessStore`: versions are append-only `vN/` directories (each
647
+ holding one `model_card.json`), and deployment state lives in movable
648
+ aliases, so promotion and rollback never rewrite an artifact.
649
+ """
650
+
651
+ def __init__(self, root: str | Path = ".wmo") -> None:
652
+ self.root = Path(root)
653
+
654
+ @property
655
+ def adapters_dir(self) -> Path:
656
+ return self.root / ADAPTERS_DIR
657
+
658
+ def dir_for(self, name: str) -> Path:
659
+ return self.adapters_dir / validate_name(name)
660
+
661
+ # -- enumeration -----------------------------------------------------------------------------
662
+
663
+ def list_names(self) -> list[str]:
664
+ if not self.adapters_dir.exists():
665
+ return []
666
+ return sorted(
667
+ d.name for d in self.adapters_dir.iterdir() if d.is_dir() and self.versions(d.name)
668
+ )
669
+
670
+ def versions(self, name: str) -> list[int]:
671
+ directory = self.dir_for(name)
672
+ if not directory.exists():
673
+ return []
674
+ found: list[int] = []
675
+ for child in directory.iterdir():
676
+ if child.is_dir() and child.name.startswith("v") and child.name[1:].isdigit():
677
+ found.append(int(child.name[1:]))
678
+ return sorted(found)
679
+
680
+ def exists(self, name: str) -> bool:
681
+ return bool(self.versions(name))
682
+
683
+ # -- aliases ---------------------------------------------------------------------------------
684
+
685
+ def aliases(self, name: str) -> dict[str, int]:
686
+ path = self.dir_for(name) / _ALIASES_FILE
687
+ if not path.exists():
688
+ return {}
689
+ data = tomllib.loads(path.read_text(encoding="utf-8")).get("aliases", {})
690
+ return {k: v for k, v in data.items() if isinstance(v, int)}
691
+
692
+ def set_alias(self, name: str, alias: str, version: int) -> None:
693
+ """Point `alias` at `version` (moving it if it exists). Rollback is re-pointing."""
694
+ if version not in self.versions(name):
695
+ raise ValueError(f"adapter {name!r} has no version v{version}")
696
+ current = self.aliases(name)
697
+ current[alias] = version
698
+ path = self.dir_for(name) / _ALIASES_FILE
699
+ write_text_atomic(path, tomli_w.dumps({"aliases": current}))
700
+
701
+ # -- load / save -----------------------------------------------------------------------------
702
+
703
+ def resolve_version(self, name: str, ref: str | None = None) -> int:
704
+ """Resolve a version ref: `None` -> champion alias, else latest; `"vN"`/`"N"`; an alias."""
705
+ available = self.versions(name)
706
+ if not available:
707
+ raise FileNotFoundError(
708
+ f"no adapter named {name!r} under {self.adapters_dir} "
709
+ f"(have: {', '.join(self.list_names()) or 'none'})"
710
+ )
711
+ aliases = self.aliases(name)
712
+ if ref is None:
713
+ return aliases.get(CHAMPION_ALIAS, available[-1])
714
+ normalized = ref.removeprefix("v")
715
+ if normalized.isdigit():
716
+ version = int(normalized)
717
+ if version not in available:
718
+ raise ValueError(f"adapter {name!r} has no version v{version}")
719
+ return version
720
+ if ref in aliases:
721
+ return aliases[ref]
722
+ raise ValueError(f"adapter {name!r} has no version or alias {ref!r}")
723
+
724
+ def resolve(self, name: str, ref: str | None = None) -> DistillModelCard:
725
+ """Load the model card a ref resolves to (see `resolve_version` for refs).
726
+
727
+ Args:
728
+ name: The adapter name.
729
+ ref: A version (`"vN"`/`"N"`), an alias (e.g. "champion"), or None
730
+ for the champion alias falling back to latest.
731
+
732
+ Returns:
733
+ The version's model card, stamped with its name and version.
734
+ """
735
+ version = self.resolve_version(name, ref)
736
+ path = self.dir_for(name) / f"v{version}" / _CARD_FILE
737
+ try:
738
+ text = path.read_text(encoding="utf-8")
739
+ except FileNotFoundError as exc:
740
+ raise FileNotFoundError(
741
+ f"adapter {name!r} v{version} has no {_CARD_FILE} at {path}; the version "
742
+ "directory is corrupt, so re-save the adapter or remove the broken version"
743
+ ) from exc
744
+ card = DistillModelCard.model_validate_json(text)
745
+ return card.model_copy(update={"name": name, "version": version})
746
+
747
+ def save_version(
748
+ self, name: str, card: DistillModelCard, *, alias: str | None = CHAMPION_ALIAS
749
+ ) -> int:
750
+ """Write `card` as the next version of `name`; optionally point `alias` at it.
751
+
752
+ Versions are append-only: this never touches an existing version
753
+ directory.
754
+
755
+ Args:
756
+ name: The adapter name (a safe single path segment).
757
+ card: The model card to persist; its `name` and `version` fields
758
+ are stamped, not read.
759
+ alias: The alias to move to the new version; "champion" by
760
+ default, None to save without moving any alias.
761
+
762
+ Returns:
763
+ The new version number.
764
+ """
765
+ validate_name(name)
766
+ version = (self.versions(name)[-1] + 1) if self.exists(name) else 1
767
+ stamped = card.model_copy(update={"name": name, "version": version})
768
+ directory = self.dir_for(name) / f"v{version}"
769
+ directory.mkdir(parents=True, exist_ok=False) # append-only: collision is a bug
770
+ write_text_atomic(directory / _CARD_FILE, stamped.model_dump_json(indent=2))
771
+ if alias is not None:
772
+ self.set_alias(name, alias, version)
773
+ return version
774
+
775
+
776
+ def build_handoff_toml(sampler_path: str, *, base_model: str, endpoint: str | None = None) -> str:
777
+ """Build the `[models.agent]` snippet pointing an agent at the distilled student.
778
+
779
+ The snippet targets wmo's openai provider with a custom endpoint, which is
780
+ how any OpenAI-compatible server is addressed; Tinker's serving endpoint
781
+ authenticates with the Tinker API key via `WMO_ENDPOINT_API_KEY` (the
782
+ openai provider never sends `OPENAI_API_KEY` to a custom endpoint).
783
+
784
+ Args:
785
+ sampler_path: The final tinker:// sampler-weights path to serve.
786
+ base_model: The student's base model name, written as `model_type` so
787
+ capability resolution keys on the model family, not the raw
788
+ weights path (the same shape the CLI's `--promote` writes).
789
+ endpoint: The OpenAI-compatible base URL; defaults to
790
+ `DEFAULT_TINKER_OPENAI_ENDPOINT`.
791
+
792
+ Returns:
793
+ A valid TOML document: comment lines explaining auth, then the
794
+ `[models.agent]` table.
795
+
796
+ Raises:
797
+ ValueError: If the sampler path is not a tinker:// path, or a value
798
+ cannot be embedded in a basic TOML string.
799
+ """
800
+ if not sampler_path.startswith("tinker://"):
801
+ raise ValueError(
802
+ f"sampler path {sampler_path!r} is not a tinker:// weights path; pass the "
803
+ "path returned by save_weights_for_sampler (recorded in the run's "
804
+ "checkpoints.json and model card)"
805
+ )
806
+ resolved_endpoint = endpoint if endpoint is not None else DEFAULT_TINKER_OPENAI_ENDPOINT
807
+ values = (
808
+ ("sampler path", sampler_path),
809
+ ("base model", base_model),
810
+ ("endpoint", resolved_endpoint),
811
+ )
812
+ for label, value in values:
813
+ if any(ch in value for ch in ('"', "\\", "\n")):
814
+ raise ValueError(
815
+ f"{label} {value!r} contains a quote, backslash, or newline and cannot be "
816
+ "embedded in the handoff snippet; check the value for corruption"
817
+ )
818
+ return (
819
+ "# Distilled student handoff: point your agent at the trained adapter through\n"
820
+ "# Tinker's OpenAI-compatible serving endpoint.\n"
821
+ "# Auth: set WMO_ENDPOINT_API_KEY to your Tinker API key (the TINKER_API_KEY\n"
822
+ "# value); wmo sends WMO_ENDPOINT_API_KEY, never OPENAI_API_KEY, to custom\n"
823
+ "# endpoints.\n"
824
+ "[models.agent]\n"
825
+ 'provider = "openai"\n'
826
+ f'model = "{sampler_path}"\n'
827
+ f'model_type = "{base_model}"\n'
828
+ f'endpoint = "{resolved_endpoint}"\n'
829
+ )