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
@@ -0,0 +1,552 @@
1
+ """Optional Weights & Biases tracking for distillation runs.
2
+
3
+ `build_tracker` turns the run config's `[wandb]` section into a
4
+ `DistillTracker`: the no-op `NullTracker` when tracking is disabled (the
5
+ default), or a `WandbTracker` streaming step metrics, eval solve rates,
6
+ sample rollout tables, and the final gate summary to a wandb run. The wandb
7
+ SDK stays an optional extra
8
+ (lazy import, mirroring the tinker SDK in `wmo.providers.tinker`), and
9
+ credentials are checked at init so a misconfigured run fails fast BEFORE any
10
+ paid rollout. After a successful init the contract inverts: a wandb failure
11
+ mid-run (network blip, service outage) logs one warning and every later
12
+ tracker call degrades to a no-op, because a dead dashboard must never abort a
13
+ paid training run. A restarted run continues its dashboard run: the wandb run
14
+ id persists in `<run_dir>/wandb-run.json` and later inits resume it (see
15
+ `WandbTracker` for the id/resume and step-monotonicity reasoning).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import netrc
22
+ import os
23
+ from collections.abc import Callable, Mapping
24
+ from pathlib import Path
25
+ from typing import TYPE_CHECKING, Literal, Protocol, cast
26
+
27
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
28
+
29
+ from wmo.core.types import JsonObject, JsonValue
30
+ from wmo.distill.config import DistillConfig
31
+ from wmo.distill.samples import SampleRollout
32
+ from wmo.distill.store import write_text_atomic
33
+
34
+ if TYPE_CHECKING:
35
+ from wmo.distill.loop import StepMetrics, WarmupMetrics
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ WANDB_API_KEY_ENV = "WANDB_API_KEY"
40
+ WANDB_NETRC_MACHINE = "api.wandb.ai"
41
+
42
+ WANDB_RUN_FILE = "wandb-run.json"
43
+ """Run-dir file persisting the wandb run id so a restart resumes the same run."""
44
+
45
+ _MISSING_WANDB_EXTRA = (
46
+ "the wandb SDK is not installed; run `uv sync --extra distill` to enable "
47
+ "[wandb] run tracking, or set wandb.enabled = false in the distill config"
48
+ )
49
+
50
+
51
+ class DistillTracker(Protocol):
52
+ """The tracking slice the distillation loop emits to.
53
+
54
+ Implementations must never raise from `log_step`, `log_warmup_step`,
55
+ `log_eval`, `log_samples`, `log_summary`, or `finish` once constructed:
56
+ the loop calls them inline with paid training work.
57
+ """
58
+
59
+ def log_step(self, step: int, metrics: StepMetrics) -> None:
60
+ """Record one training step's metrics row."""
61
+ ...
62
+
63
+ def log_warmup_step(self, warmup_step: int, metrics: WarmupMetrics) -> None:
64
+ """Record one warmup step's metrics row (keys under `warmup/`)."""
65
+ ...
66
+
67
+ def log_eval(
68
+ self,
69
+ name: str,
70
+ solve_rate: float,
71
+ step: int | None,
72
+ *,
73
+ graded_solve_rate: float | None = None,
74
+ ) -> None:
75
+ """Record one eval batch's solve rate (None step means pre-training).
76
+
77
+ `graded_solve_rate` is the graded test-pass companion, and None means the batch measured
78
+ none (no readable test report, or a baseline imported from a run predating the metric):
79
+ nothing is charted rather than a fabricated 0.0.
80
+ """
81
+ ...
82
+
83
+ def log_samples(self, kind: str, step: int | None, samples: list[SampleRollout]) -> None:
84
+ """Record one batch's rendered sample rollouts (None step means pre-training)."""
85
+ ...
86
+
87
+ def log_summary(
88
+ self,
89
+ *,
90
+ gate_accepted: bool,
91
+ gate_reason: str,
92
+ teacher_solve_rate: float,
93
+ student_before_solve_rate: float,
94
+ student_after_solve_rate: float,
95
+ total_usd: float,
96
+ steps_completed: int,
97
+ ) -> None:
98
+ """Record the run's terminal outcome (the gate verdict and totals)."""
99
+ ...
100
+
101
+ def finish(self) -> None:
102
+ """Flush and close the tracking run (idempotent best effort)."""
103
+ ...
104
+
105
+
106
+ class NullTracker:
107
+ """The disabled tracker: every call is a no-op."""
108
+
109
+ def log_step(self, step: int, metrics: StepMetrics) -> None:
110
+ """No-op."""
111
+
112
+ def log_warmup_step(self, warmup_step: int, metrics: WarmupMetrics) -> None:
113
+ """No-op."""
114
+
115
+ def log_eval(
116
+ self,
117
+ name: str,
118
+ solve_rate: float,
119
+ step: int | None,
120
+ *,
121
+ graded_solve_rate: float | None = None,
122
+ ) -> None:
123
+ """No-op."""
124
+
125
+ def log_samples(self, kind: str, step: int | None, samples: list[SampleRollout]) -> None:
126
+ """No-op."""
127
+
128
+ def log_summary(
129
+ self,
130
+ *,
131
+ gate_accepted: bool,
132
+ gate_reason: str,
133
+ teacher_solve_rate: float,
134
+ student_before_solve_rate: float,
135
+ student_after_solve_rate: float,
136
+ total_usd: float,
137
+ steps_completed: int,
138
+ ) -> None:
139
+ """No-op."""
140
+
141
+ def finish(self) -> None:
142
+ """No-op."""
143
+
144
+
145
+ # -- the wandb SDK slice (typed so the lazy import stays checkable) -------------------------------
146
+
147
+
148
+ class WandbSummaryLike(Protocol):
149
+ """The run-summary slice: `update` with plain JSON values."""
150
+
151
+ def update(self, values: Mapping[str, JsonValue]) -> None:
152
+ """Merge values into the run's summary."""
153
+ ...
154
+
155
+
156
+ class WandbRunLike(Protocol):
157
+ """The active-run slice: the id (persisted for resume) and the summary."""
158
+
159
+ @property
160
+ def id(self) -> str:
161
+ """The run's wandb id (what a restart passes back to `init`)."""
162
+ ...
163
+
164
+ @property
165
+ def summary(self) -> WandbSummaryLike:
166
+ """The run's summary mapping."""
167
+ ...
168
+
169
+
170
+ class WandbTableLike(Protocol):
171
+ """An opaque wandb Table instance: constructed, logged, never read back."""
172
+
173
+
174
+ WandbLogValue = JsonValue | WandbTableLike
175
+ """What one wandb.log payload value may be: a plain scalar or a table."""
176
+
177
+
178
+ class WandbModuleLike(Protocol):
179
+ """The module-level wandb surface the tracker drives."""
180
+
181
+ def init(
182
+ self,
183
+ *,
184
+ project: str,
185
+ entity: str | None,
186
+ name: str,
187
+ tags: list[str],
188
+ # `dir` and `id` shadow builtins, but they are the wandb SDK's own
189
+ # keyword names.
190
+ dir: str,
191
+ config: JsonObject,
192
+ id: str | None,
193
+ resume: Literal["allow"] | None,
194
+ ) -> WandbRunLike:
195
+ """Start (or, with an id and resume mode, continue) a wandb run."""
196
+ ...
197
+
198
+ # The SDK's own class name; structurally just a callable attribute.
199
+ def Table(self, *, columns: list[str], data: list[list[JsonValue]]) -> WandbTableLike:
200
+ """Build one wandb Table (a fresh one per `log_samples` call)."""
201
+ ...
202
+
203
+ def log(self, data: Mapping[str, WandbLogValue], *, step: int) -> None:
204
+ """Log one row of metrics (or tables) at a step."""
205
+ ...
206
+
207
+ def finish(self) -> None:
208
+ """Flush and close the active run."""
209
+ ...
210
+
211
+
212
+ def _import_wandb() -> WandbModuleLike:
213
+ """Lazily import the optional wandb SDK (the distill extra).
214
+
215
+ Raises:
216
+ ImportError: If the SDK is not installed; the message names the fix.
217
+ """
218
+ try:
219
+ import wandb
220
+ except ImportError as exc:
221
+ raise ImportError(_MISSING_WANDB_EXTRA) from exc
222
+ return cast("WandbModuleLike", wandb)
223
+
224
+
225
+ def _netrc_has_wandb_login() -> bool:
226
+ """Whether ~/.netrc carries an api.wandb.ai entry (a prior `wandb login`)."""
227
+ try:
228
+ entry = netrc.netrc().authenticators(WANDB_NETRC_MACHINE)
229
+ except (FileNotFoundError, netrc.NetrcParseError):
230
+ return False
231
+ return entry is not None
232
+
233
+
234
+ def _require_wandb_credentials() -> None:
235
+ """Fail fast when no wandb credentials exist (before any paid work).
236
+
237
+ Raises:
238
+ ValueError: When WANDB_API_KEY is unset AND no api.wandb.ai entry
239
+ exists in ~/.netrc; the message names both fixes.
240
+ """
241
+ if os.environ.get(WANDB_API_KEY_ENV):
242
+ return
243
+ if _netrc_has_wandb_login():
244
+ return
245
+ raise ValueError(
246
+ f"[wandb] tracking is enabled but no credentials were found: "
247
+ f"{WANDB_API_KEY_ENV} is not set in the environment and ~/.netrc has no "
248
+ f"{WANDB_NETRC_MACHINE} entry. Set {WANDB_API_KEY_ENV} to your API key, or "
249
+ "run `wandb login` once to store it in ~/.netrc"
250
+ )
251
+
252
+
253
+ class WandbRunRecord(BaseModel):
254
+ """The `wandb-run.json` shape: the run id a restarted session resumes."""
255
+
256
+ model_config = ConfigDict(extra="forbid")
257
+
258
+ run_id: str = Field(min_length=1)
259
+
260
+
261
+ def _read_wandb_run_id(path: Path) -> str | None:
262
+ """The persisted wandb run id, or None when absent or unreadable.
263
+
264
+ A corrupt record is downgraded to a fresh run (with a warning) rather
265
+ than raised: losing dashboard continuity must never block a paid run,
266
+ and the caller rewrites the file after init either way.
267
+ """
268
+ try:
269
+ text = path.read_text(encoding="utf-8")
270
+ except FileNotFoundError:
271
+ return None
272
+ try:
273
+ return WandbRunRecord.model_validate_json(text).run_id
274
+ except ValidationError:
275
+ logger.warning(
276
+ "corrupt wandb run record at %s; starting a fresh wandb run and rewriting the file",
277
+ path,
278
+ exc_info=True,
279
+ )
280
+ return None
281
+
282
+
283
+ def _flatten_step_metrics(metrics: StepMetrics) -> dict[str, float | int | str]:
284
+ """One step's metrics row as flat, namespaced wandb keys.
285
+
286
+ Per-meter token counts land under `tokens/`, the step's priced spend
287
+ under `cost/usd` (with the run's all-session total as `cost/usd_cum`),
288
+ the per-stop-reason trial counts under `stop/<reason>`, and every other
289
+ number under `train/`. The one non-numeric key kept is the objective
290
+ (`train/loss`, e.g. `"ppo"`), because a chart of `train/advantage_mean`
291
+ or `train/clip_fraction` means different things per mode and the row must
292
+ say which one produced it. Other non-numeric fields (the sampler path)
293
+ and unreported values (`reverse_kl_per_token`, `reward_mean`, the
294
+ advantage stats, `pg_loss`, and `grad_norm` when None) are dropped:
295
+ wandb charts numbers, and absent backend metrics are never fabricated.
296
+
297
+ `train/scaffold_loss_rate` plus the `stop/` series is the pair that makes a
298
+ harness-induced floor visible on the dashboard: the pi/Nemotron-3 runs sat
299
+ at 88.8% scaffold loss with nothing to chart it against.
300
+
301
+ `train/solve_rate` and `train/graded_solve_rate` are charted side by side:
302
+ binary is the benchmark's own verdict and the gate's number, graded is the
303
+ same trials at test resolution and the series with enough resolution to move
304
+ on a 12-task batch. Read `train/graded_trials` beside it, since a graded rate
305
+ over zero graded trials is a null measurement carrying 0.0.
306
+
307
+ `train/entropy_per_token` and `train/mean_generation_tokens`, beside their
308
+ `train/*_baseline` and `train/*_ratio` companions, are the degeneration
309
+ pair: the ratio series is the one to chart, since only it is comparable
310
+ across runs (each run measures its own baseline). A step that sampled
311
+ nothing charts no entropy or length point rather than a fabricated zero.
312
+ """
313
+ explicit = {
314
+ "student_prefill_tokens": "tokens/student_prefill",
315
+ "student_cached_prefill_tokens": "tokens/student_cached_prefill",
316
+ "student_sample_tokens": "tokens/student_sample",
317
+ "student_train_tokens": "tokens/student_train",
318
+ "teacher_prefill_tokens": "tokens/teacher_prefill",
319
+ "teacher_cached_prefill_tokens": "tokens/teacher_cached_prefill",
320
+ "teacher_sample_tokens": "tokens/teacher_sample",
321
+ "usd": "cost/usd",
322
+ "cumulative_usd": "cost/usd_cum",
323
+ }
324
+ payload: dict[str, float | int | str] = {}
325
+ for key, value in metrics.model_dump(mode="json").items():
326
+ if key == "loss" and isinstance(value, str):
327
+ payload["train/loss"] = value
328
+ continue
329
+ if key == "stop_reason_counts":
330
+ if isinstance(value, dict):
331
+ payload.update(
332
+ {
333
+ f"stop/{reason}": count
334
+ for reason, count in value.items()
335
+ if isinstance(count, int)
336
+ }
337
+ )
338
+ continue
339
+ if not isinstance(value, int | float) or isinstance(value, bool):
340
+ continue
341
+ payload[explicit.get(key, f"train/{key}")] = value
342
+ return payload
343
+
344
+
345
+ class WandbTracker:
346
+ """Streams a distillation run to Weights & Biases.
347
+
348
+ Construction is strict (missing SDK or credentials raise, so a
349
+ misconfigured run fails before spending anything); logging is forgiving
350
+ (after a successful init, the first wandb failure logs one warning and
351
+ every later call becomes a no-op, because a dead dashboard must never
352
+ abort a paid training run).
353
+
354
+ Restart continuity: the wandb run id is persisted to
355
+ `<run_dir>/wandb-run.json` on first init, and when that file exists a
356
+ later construction passes `id=<persisted>` with `resume="allow"` so a
357
+ restarted run CONTINUES the same dashboard run ("allow" resumes the run
358
+ when the id exists and starts one under that id otherwise). A missing or
359
+ corrupt record starts fresh and rewrites the file. Resumed logging stays
360
+ step-monotonic: `log_step` logs at the training step number, which only
361
+ moves forward across sessions (a resume continues from the last
362
+ checkpoint; the rare re-run of an un-checkpointed step re-logs at its own
363
+ index, which wandb treats as an update to or drop at that step, never a
364
+ decrease), and warmup rows always log at wandb step 0 under `warmup/`
365
+ keys before any training row exists, so wandb never sees a step go
366
+ backwards.
367
+
368
+ Args:
369
+ cfg: The validated run config; its `[wandb]` section names the run
370
+ and its snapshot dump becomes the wandb run config.
371
+ run_dir: The run's artifact directory; wandb files land under it.
372
+ agent_name: The agent being distilled; names the run when
373
+ `wandb.run_name` is unset.
374
+
375
+ Raises:
376
+ ImportError: If the wandb SDK is not installed (the distill extra).
377
+ ValueError: If no credentials exist (see `_require_wandb_credentials`).
378
+ """
379
+
380
+ def __init__(self, cfg: DistillConfig, run_dir: Path, agent_name: str) -> None:
381
+ wandb = _import_wandb()
382
+ _require_wandb_credentials()
383
+ run_name = cfg.wandb.run_name or f"{agent_name}-{run_dir.name}"
384
+ run_dir.mkdir(parents=True, exist_ok=True)
385
+ run_record_path = run_dir / WANDB_RUN_FILE
386
+ persisted_id = _read_wandb_run_id(run_record_path)
387
+ self._wandb = wandb
388
+ self._run = wandb.init(
389
+ project=cfg.wandb.project,
390
+ entity=cfg.wandb.entity,
391
+ name=run_name,
392
+ tags=list(cfg.wandb.tags),
393
+ dir=str(run_dir),
394
+ # The same plain dict snapshot_toml renders, so the wandb run
395
+ # config matches the run dir's config.toml exactly.
396
+ config=cfg.model_dump(mode="json", exclude_none=True),
397
+ id=persisted_id,
398
+ resume="allow" if persisted_id is not None else None,
399
+ )
400
+ # Atomic like every other durable run-dir file: a crash between
401
+ # wandb.init() and a torn write would leave the id absent or partial,
402
+ # and the resumed session would silently start a SECOND dashboard run
403
+ # instead of continuing this one.
404
+ write_text_atomic(
405
+ run_record_path, WandbRunRecord(run_id=self._run.id).model_dump_json(indent=2)
406
+ )
407
+ self._dead = False
408
+ if persisted_id is not None:
409
+ logger.info(
410
+ "wandb tracking resumed: project %s, run %s (id %s)",
411
+ cfg.wandb.project,
412
+ run_name,
413
+ persisted_id,
414
+ )
415
+ else:
416
+ logger.info("wandb tracking started: project %s, run %s", cfg.wandb.project, run_name)
417
+
418
+ def _guarded(self, action: Callable[[], None]) -> None:
419
+ """Run one wandb call, degrading to a no-op if the dashboard dies.
420
+
421
+ The first failure logs one warning and marks the tracker dead; every
422
+ later call (including `finish`) is skipped silently. Training must
423
+ keep going: the run's own artifacts (metrics.jsonl, eval reports) are
424
+ unaffected by a lost dashboard.
425
+ """
426
+ if self._dead:
427
+ return
428
+ try:
429
+ action()
430
+ except Exception: # noqa: BLE001 - any wandb failure degrades, never aborts the run
431
+ self._dead = True
432
+ logger.warning(
433
+ "wandb logging failed; tracking is disabled for the rest of the run "
434
+ "(training continues; the run dir keeps every artifact)",
435
+ exc_info=True,
436
+ )
437
+
438
+ def log_step(self, step: int, metrics: StepMetrics) -> None:
439
+ """Log one training step's flattened metrics row."""
440
+ payload = _flatten_step_metrics(metrics)
441
+ self._guarded(lambda: self._wandb.log(cast("Mapping[str, JsonValue]", payload), step=step))
442
+
443
+ def log_warmup_step(self, warmup_step: int, metrics: WarmupMetrics) -> None:
444
+ """Log one warmup step's numeric fields under `warmup/` keys.
445
+
446
+ Warmup precedes training step 0 and wandb steps must never decrease,
447
+ so every warmup row logs at wandb step 0 with the warmup index carried
448
+ as `warmup/step` (later warmup rows overwrite same-step keys on the
449
+ dashboard; the run dir's metrics.jsonl keeps every row).
450
+ """
451
+ payload: dict[str, float | int] = {"warmup/step": warmup_step}
452
+ for key, value in metrics.model_dump(mode="json").items():
453
+ if not isinstance(value, int | float) or isinstance(value, bool):
454
+ continue
455
+ payload[f"warmup/{key}"] = value
456
+ self._guarded(lambda: self._wandb.log(cast("Mapping[str, JsonValue]", payload), step=0))
457
+
458
+ def log_eval(
459
+ self,
460
+ name: str,
461
+ solve_rate: float,
462
+ step: int | None,
463
+ *,
464
+ graded_solve_rate: float | None = None,
465
+ ) -> None:
466
+ """Log one eval batch's solve rate under `eval/<name>`, graded under `eval/<name>-graded`.
467
+
468
+ The binary rate is always charted (it is the benchmark's own verdict and what the promotion
469
+ gate reads); the graded companion is charted only when the batch measured one, so a batch
470
+ with no readable test report leaves a gap in that series instead of a 0.0 point.
471
+ """
472
+ at_step = step if step is not None else 0
473
+ payload: dict[str, JsonValue] = {f"eval/{name}": solve_rate}
474
+ if graded_solve_rate is not None:
475
+ payload[f"eval/{name}-graded"] = graded_solve_rate
476
+ self._guarded(lambda: self._wandb.log(payload, step=at_step))
477
+
478
+ def log_samples(self, kind: str, step: int | None, samples: list[SampleRollout]) -> None:
479
+ """Log one batch's rendered rollouts as a fresh wandb Table per call.
480
+
481
+ A wandb Table is immutable once logged (current SDKs reject
482
+ re-logging a mutated Table object, and incremental appends need
483
+ artifact round-trips), so the tracker keeps it simple: each call
484
+ builds a fresh table with columns kind/step/trial/reward/text and
485
+ logs it under a step-qualified key, `samples/<kind>-<step>`
486
+ (`samples/<kind>` when step is None), so every batch's sample set
487
+ stays addressable on the dashboard. Pre-training batches (step None)
488
+ chart at wandb step 0, matching `log_eval`.
489
+ """
490
+ if not samples:
491
+ return
492
+ at_step = step if step is not None else 0
493
+ key = f"samples/{kind}" if step is None else f"samples/{kind}-{step:04d}"
494
+
495
+ def _log() -> None:
496
+ table = self._wandb.Table(
497
+ columns=["kind", "step", "trial", "reward", "text"],
498
+ data=[
499
+ [kind, step, sample.trial_name, sample.reward, sample.text]
500
+ for sample in samples
501
+ ],
502
+ )
503
+ self._wandb.log({key: table}, step=at_step)
504
+
505
+ self._guarded(_log)
506
+
507
+ def log_summary(
508
+ self,
509
+ *,
510
+ gate_accepted: bool,
511
+ gate_reason: str,
512
+ teacher_solve_rate: float,
513
+ student_before_solve_rate: float,
514
+ student_after_solve_rate: float,
515
+ total_usd: float,
516
+ steps_completed: int,
517
+ ) -> None:
518
+ """Record the run's terminal outcome in the wandb run summary."""
519
+ values: dict[str, JsonValue] = {
520
+ "gate_accepted": gate_accepted,
521
+ "gate_reason": gate_reason,
522
+ "teacher_solve_rate": teacher_solve_rate,
523
+ "student_before_solve_rate": student_before_solve_rate,
524
+ "student_after_solve_rate": student_after_solve_rate,
525
+ "total_usd": total_usd,
526
+ "steps_completed": steps_completed,
527
+ }
528
+ self._guarded(lambda: self._run.summary.update(values))
529
+
530
+ def finish(self) -> None:
531
+ """Flush and close the wandb run (skipped once the tracker is dead)."""
532
+ self._guarded(self._wandb.finish)
533
+
534
+
535
+ def build_tracker(cfg: DistillConfig, run_dir: Path, agent_name: str) -> DistillTracker:
536
+ """The tracker for one run: `WandbTracker` when enabled, else `NullTracker`.
537
+
538
+ Args:
539
+ cfg: The validated run config (`cfg.wandb.enabled` decides).
540
+ run_dir: The run's artifact directory.
541
+ agent_name: The agent being distilled (names the default wandb run).
542
+
543
+ Returns:
544
+ The tracker the loop should emit to.
545
+
546
+ Raises:
547
+ ImportError: Tracking enabled but the wandb SDK is not installed.
548
+ ValueError: Tracking enabled but no wandb credentials exist.
549
+ """
550
+ if not cfg.wandb.enabled:
551
+ return NullTracker()
552
+ return WandbTracker(cfg, run_dir, agent_name)