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/data.py ADDED
@@ -0,0 +1,921 @@
1
+ """Prefix-merge datum builder: harbor trials to advantage-weighted training data.
2
+
3
+ `build_datums` turns each trial's recorded `TokenSpan`s into `TrainDatum`s
4
+ with the tinker-cookbook's `trajectory_to_data` merge semantics: as long as
5
+ each call's prompt extends the accumulated episode tokens (previous prompt
6
+ plus previous sampled tokens) verbatim as a list prefix, the whole episode is
7
+ ONE datum, so every context token is prefilled exactly once. A call whose
8
+ prompt is not such an extension starts a new datum (a fragment), which is
9
+ correct but re-prefills shared context; the fragmentation rate is therefore a
10
+ first-class cost metric, not a curiosity.
11
+
12
+ Alignment is sacred: episodes are never truncated (that would desynchronize
13
+ tokens from their sampled logprobs), only dropped whole and counted, both for
14
+ context overflow (any call measured over `rollout.context_budget_tokens`, or
15
+ a run trace marked with the overflow stop reason) and for merged lengths over
16
+ `train.max_datum_tokens`.
17
+
18
+ `attach_advantages` fills the reverse-KL advantages from teacher logprobs,
19
+ and `to_tinker_datums` converts to real `tinker.Datum`s in the shifted
20
+ next-token layout the cookbook uses (one wire format for both
21
+ advantage-weighted losses, `importance_sampling` and `ppo`);
22
+ `to_tinker_sft_datums` is the
23
+ cross_entropy sibling for the supervised warmup phase (teacher-sampled
24
+ trajectories need no advantages, only the loss-mask weights). For the
25
+ `topk_ce` loss, `build_topk_ce_datums` turns teacher top-k candidate rows
26
+ into rank-aligned replica datums (see its docstring for why replication is
27
+ per RANK, not per position) and `to_tinker_topk_ce_datums` is its one-call
28
+ wire conversion through the same pinned cross_entropy keyset. The tinker SDK
29
+ is an optional extra and is imported lazily inside those converters only,
30
+ mirroring the provider's contract; everything else here runs without it.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ import math
37
+ from collections.abc import Sequence
38
+ from typing import TYPE_CHECKING
39
+
40
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
41
+
42
+ from wmo.distill.config import DistillConfig
43
+ from wmo.distill.tokens import TrialRecord
44
+ from wmo.providers.tinker import TokenSpan
45
+
46
+ if TYPE_CHECKING:
47
+ import tinker
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ MISSING_TINKER_EXTRA = (
52
+ "the tinker SDK is not installed; run `uv sync --extra distill` to convert "
53
+ "training datums for a real Tinker training client"
54
+ )
55
+
56
+ TopkCandidates = list[tuple[int, float]]
57
+ """One position's teacher top-k: (token_id, logprob) pairs, best first."""
58
+
59
+ CONTEXT_OVERFLOW_STOP_REASON = "context_overflow"
60
+ """A run-trace stop reason marking an episode that outgrew the context budget.
61
+
62
+ Overflowed episodes are dropped from training: their final turns were shaped
63
+ by the budget cutoff rather than the policy, and their cost profile is exactly
64
+ the blowup the prefix property exists to prevent. The enforcement itself is
65
+ measured from the recorded spans (any call whose prompt plus sampled tokens
66
+ exceeds `rollout.context_budget_tokens`); the stop-reason marker is honored
67
+ additionally for runtimes that end episodes at the budget themselves.
68
+ """
69
+
70
+
71
+ class TrainDatum(BaseModel):
72
+ """One merged training sequence in the UNshifted layout, fully aligned.
73
+
74
+ `model_input_tokens` is the complete episode-fragment token sequence:
75
+ context deltas (prompt tokens the agent appended between completions)
76
+ interleaved with sampled spans, in order. The three per-token lists are
77
+ aligned one to one with it; `to_tinker_datums` produces the shifted
78
+ next-token layout, so this model stays trivially inspectable.
79
+ """
80
+
81
+ model_config = ConfigDict(frozen=True, extra="forbid")
82
+
83
+ trial_name: str = Field(min_length=1)
84
+ """The harbor trial this datum came from, for loud per-datum diagnostics."""
85
+
86
+ fragment_index: int = Field(ge=0)
87
+ """0-based index of this datum within its episode; > 0 means a prefix break."""
88
+
89
+ model_input_tokens: list[int]
90
+ """The full unshifted token sequence (context deltas plus sampled spans)."""
91
+
92
+ loss_mask: list[float]
93
+ """Per-token loss mask: 1.0 on sampled tokens, 0.0 on context tokens."""
94
+
95
+ sampled_logprobs: list[float]
96
+ """Sampler-assigned logprob per token; 0.0 at mask-0 (context) positions."""
97
+
98
+ advantages: list[float] = Field(default_factory=list)
99
+ """Per-token advantages; empty until `attach_advantages` fills them."""
100
+
101
+ target_tokens: list[int] = Field(default_factory=list)
102
+ """Per-position training targets when they differ from the sequence's own
103
+ tokens; empty for ordinary datums (targets are the next tokens of
104
+ `model_input_tokens`). Set only on topk-CE replicas, where each loss
105
+ position's target is a teacher candidate rather than the realized token
106
+ (`build_topk_ce_datums`); non-loss positions keep the realized token."""
107
+
108
+ target_weights: list[float] = Field(default_factory=list)
109
+ """Per-position cross_entropy weights replacing the loss mask on the wire;
110
+ empty for ordinary datums (the loss mask is the weight). Set only on
111
+ topk-CE replicas: the renormalized teacher probability of this replica's
112
+ candidate at each loss position, 0.0 everywhere else."""
113
+
114
+ @model_validator(mode="after")
115
+ def _check_alignment(self) -> TrainDatum:
116
+ """Reject any misalignment between the token sequence and its per-token lists."""
117
+ n = len(self.model_input_tokens)
118
+ if len(self.loss_mask) != n or len(self.sampled_logprobs) != n:
119
+ raise ValueError(
120
+ f"loss_mask length {len(self.loss_mask)} and sampled_logprobs length "
121
+ f"{len(self.sampled_logprobs)} must both match model_input_tokens "
122
+ f"length {n}"
123
+ )
124
+ if any(value not in (0.0, 1.0) for value in self.loss_mask):
125
+ raise ValueError("loss_mask values must be exactly 0.0 or 1.0")
126
+ if self.advantages and len(self.advantages) != n:
127
+ raise ValueError(
128
+ f"advantages length {len(self.advantages)} must match "
129
+ f"model_input_tokens length {n} (or be empty before attach_advantages)"
130
+ )
131
+ if bool(self.target_tokens) != bool(self.target_weights):
132
+ raise ValueError(
133
+ "target_tokens and target_weights must be set together (a topk-CE "
134
+ "replica carries both) or both left empty"
135
+ )
136
+ if self.target_tokens and (len(self.target_tokens) != n or len(self.target_weights) != n):
137
+ raise ValueError(
138
+ f"target_tokens length {len(self.target_tokens)} and target_weights "
139
+ f"length {len(self.target_weights)} must both match "
140
+ f"model_input_tokens length {n}"
141
+ )
142
+ if self.target_weights and any(
143
+ weight != 0.0 and mask != 1.0
144
+ for weight, mask in zip(self.target_weights, self.loss_mask, strict=True)
145
+ ):
146
+ raise ValueError(
147
+ "target_weights may be nonzero only at loss positions (mask 1.0); a "
148
+ "weighted context position would train on tokens the student never "
149
+ "sampled"
150
+ )
151
+ return self
152
+
153
+ @property
154
+ def is_topk_replica(self) -> bool:
155
+ """Whether this datum is a topk-CE replica (candidate targets attached)."""
156
+ return bool(self.target_tokens)
157
+
158
+ @property
159
+ def loss_token_count(self) -> int:
160
+ """How many positions carry loss (mask 1.0)."""
161
+ return sum(1 for value in self.loss_mask if value == 1.0)
162
+
163
+ def sampled_token_ids(self) -> list[int]:
164
+ """The sampled (mask 1.0) token ids, in sequence order."""
165
+ return [
166
+ token
167
+ for token, mask in zip(self.model_input_tokens, self.loss_mask, strict=True)
168
+ if mask == 1.0
169
+ ]
170
+
171
+
172
+ class DatumStats(BaseModel):
173
+ """Accounting for one `build_datums` call (drops are episodes, not datums)."""
174
+
175
+ model_config = ConfigDict(frozen=True, extra="forbid")
176
+
177
+ datums: int = Field(ge=0)
178
+ fragments: int = Field(ge=0)
179
+ """Datums beyond the first of their episode (each one is a prefix break)."""
180
+
181
+ fragmentation_rate: float = Field(ge=0.0, le=1.0)
182
+ """fragments / datums; every fragment re-prefills context, so this is cost."""
183
+
184
+ overflow_drops: int = Field(ge=0)
185
+ """Trials dropped for context overflow (measured or stop-reason marked)."""
186
+
187
+ overlong_drops: int = Field(ge=0)
188
+ """Episodes dropped whole because a merged datum exceeded max_datum_tokens."""
189
+
190
+ loss_tokens: int = Field(ge=0)
191
+ context_tokens: int = Field(ge=0)
192
+
193
+
194
+ class AdvantageStats(BaseModel):
195
+ """Accounting for one `attach_advantages` call.
196
+
197
+ Token-level counters cover only the ATTACHED datums: a datum dropped for
198
+ a teacher mismatch is never trained on, so its tokens are not signal and
199
+ contribute to no counter here.
200
+ """
201
+
202
+ model_config = ConfigDict(frozen=True, extra="forbid")
203
+
204
+ datums: int = Field(ge=0)
205
+ """Datums that came out with advantages attached."""
206
+
207
+ mismatch_drops: int = Field(ge=0)
208
+ """Datums dropped because their teacher logprobs did not align."""
209
+
210
+ clipped_tokens: int = Field(ge=0)
211
+ """Loss tokens whose raw advantage hit the clip bound (attached datums
212
+ only); always 0 when `train.advantage_clip` is None (clipping off)."""
213
+
214
+ loss_tokens: int = Field(ge=0)
215
+ """Loss tokens across the attached datums (the clip-fraction denominator)."""
216
+
217
+ context_tokens: int = Field(ge=0)
218
+ """Non-loss (mask 0.0) tokens across the attached datums.
219
+
220
+ Reported beside `loss_tokens` so a caller can describe the TRAINED batch's
221
+ token volume without falling back to the pre-drop `DatumStats`, whose
222
+ counts include datums this call dropped.
223
+ """
224
+
225
+ advantage_mean: float | None
226
+ """Mean advantage over the attached datums' loss tokens, exactly as
227
+ trained (after any clipping and any centering); None when no loss token
228
+ survived.
229
+
230
+ Under the default objective (no clipping, no centering) this is the mean
231
+ teacher-minus-student logprob gap, i.e. the negated reverse KL over the
232
+ trained tokens: the direct read of how far the student still is from the
233
+ teacher, and the number that should shrink as the run works. Under
234
+ `train.center_advantages` it is ~0.0 by construction and says nothing."""
235
+
236
+ advantage_std: float | None
237
+ """Population standard deviation over the same loss tokens; None when none."""
238
+
239
+
240
+ def _is_prefix(prefix: list[int], sequence: list[int]) -> bool:
241
+ """Whether `prefix` equals the start of `sequence` (cookbook semantics)."""
242
+ return len(prefix) <= len(sequence) and sequence[: len(prefix)] == prefix
243
+
244
+
245
+ def _merge_trial_spans(trial_name: str, spans: Sequence[TokenSpan]) -> list[TrainDatum]:
246
+ """Merge one trial's spans into datums, breaking on every non-prefix prompt.
247
+
248
+ Args:
249
+ trial_name: The trial identity stamped onto each produced datum.
250
+ spans: The trial's recorded spans, in any order (sorted by call_index).
251
+
252
+ Returns:
253
+ The episode's datums in order: one when every prompt extended the
254
+ accumulated tokens as a prefix, more when the history was edited. A
255
+ fragment containing no sampled tokens (possible only when a span
256
+ sampled nothing) carries no trainable signal and is skipped.
257
+ """
258
+ datums: list[TrainDatum] = []
259
+ tokens: list[int] = []
260
+ mask: list[float] = []
261
+ logprobs: list[float] = []
262
+
263
+ def flush() -> None:
264
+ if not tokens:
265
+ return
266
+ if not any(value == 1.0 for value in mask):
267
+ logger.debug(
268
+ "skipping a fragment of trial %s with no sampled tokens (%d context tokens)",
269
+ trial_name,
270
+ len(tokens),
271
+ )
272
+ else:
273
+ datums.append(
274
+ TrainDatum(
275
+ trial_name=trial_name,
276
+ fragment_index=len(datums),
277
+ model_input_tokens=list(tokens),
278
+ loss_mask=list(mask),
279
+ sampled_logprobs=list(logprobs),
280
+ )
281
+ )
282
+ tokens.clear()
283
+ mask.clear()
284
+ logprobs.clear()
285
+
286
+ for span in sorted(spans, key=lambda item: item.call_index):
287
+ prompt = span.prompt_token_ids
288
+ if tokens and _is_prefix(tokens, prompt):
289
+ delta = prompt[len(tokens) :]
290
+ else:
291
+ flush()
292
+ delta = prompt
293
+ tokens.extend(delta)
294
+ mask.extend([0.0] * len(delta))
295
+ logprobs.extend([0.0] * len(delta))
296
+ tokens.extend(span.sampled_token_ids)
297
+ mask.extend([1.0] * len(span.sampled_token_ids))
298
+ logprobs.extend(span.sampled_logprobs)
299
+ flush()
300
+ return datums
301
+
302
+
303
+ def build_datums(
304
+ records: Sequence[TrialRecord], cfg: DistillConfig
305
+ ) -> tuple[list[TrainDatum], DatumStats]:
306
+ """Build training datums from scored trials via the prefix merge.
307
+
308
+ Per trial: spans are sorted by call_index and merged into one datum while
309
+ each span's prompt extends the accumulated tokens (previous prompt plus
310
+ previous sampled tokens) exactly as a list prefix; the appended context
311
+ delta gets loss mask 0.0 and sampled tokens get 1.0. A non-prefix span
312
+ starts a new datum (fragment). Two whole-episode drops protect alignment
313
+ and cost, never truncation:
314
+
315
+ - context overflow: any recorded call whose prompt plus sampled tokens
316
+ exceeds `cfg.rollout.context_budget_tokens` (measured from the spans),
317
+ or a trial whose stop reason is `CONTEXT_OVERFLOW_STOP_REASON`;
318
+ - episodes where any merged datum exceeds `cfg.train.max_datum_tokens`.
319
+
320
+ Trials with no spans contribute nothing here; the rollout collector
321
+ already counts them (`RolloutStats.empty_span_trials`).
322
+
323
+ Args:
324
+ records: The scored trials for one training step.
325
+ cfg: The run config; reads `rollout.context_budget_tokens` and
326
+ `train.max_datum_tokens`.
327
+
328
+ Returns:
329
+ The kept datums (trial order, then fragment order) and the stats.
330
+ """
331
+ datums: list[TrainDatum] = []
332
+ fragments = 0
333
+ overflow_drops = 0
334
+ overlong_drops = 0
335
+ context_budget = cfg.rollout.context_budget_tokens
336
+ for record in records:
337
+ if record.stop_reason == CONTEXT_OVERFLOW_STOP_REASON:
338
+ overflow_drops += 1
339
+ logger.warning(
340
+ "dropping trial %s from training: the episode overflowed the rollout "
341
+ "context budget (stop reason %r)",
342
+ record.trial_name,
343
+ record.stop_reason,
344
+ )
345
+ continue
346
+ if not record.spans:
347
+ continue
348
+ peak_context = max(
349
+ len(span.prompt_token_ids) + len(span.sampled_token_ids) for span in record.spans
350
+ )
351
+ if peak_context > context_budget:
352
+ overflow_drops += 1
353
+ logger.warning(
354
+ "dropping trial %s from training: its largest call consumed %d tokens, "
355
+ "over rollout.context_budget_tokens = %d (episodes are dropped whole, "
356
+ "never truncated, so tokens stay aligned with their sampled logprobs)",
357
+ record.trial_name,
358
+ peak_context,
359
+ context_budget,
360
+ )
361
+ continue
362
+ episode = _merge_trial_spans(record.trial_name, record.spans)
363
+ longest = max((len(datum.model_input_tokens) for datum in episode), default=0)
364
+ if longest > cfg.train.max_datum_tokens:
365
+ overlong_drops += 1
366
+ logger.warning(
367
+ "dropping trial %s from training: a merged datum has %d tokens, over "
368
+ "train.max_datum_tokens = %d (episodes are dropped whole, never "
369
+ "truncated, so tokens stay aligned with their sampled logprobs)",
370
+ record.trial_name,
371
+ longest,
372
+ cfg.train.max_datum_tokens,
373
+ )
374
+ continue
375
+ datums.extend(episode)
376
+ fragments += max(0, len(episode) - 1)
377
+ loss_tokens = sum(datum.loss_token_count for datum in datums)
378
+ context_tokens = sum(len(datum.model_input_tokens) for datum in datums) - loss_tokens
379
+ stats = DatumStats(
380
+ datums=len(datums),
381
+ fragments=fragments,
382
+ fragmentation_rate=fragments / len(datums) if datums else 0.0,
383
+ overflow_drops=overflow_drops,
384
+ overlong_drops=overlong_drops,
385
+ loss_tokens=loss_tokens,
386
+ context_tokens=context_tokens,
387
+ )
388
+ if stats.fragments:
389
+ # Not always a fault. A history-editing renderer (wmo/qwen3_5_strip_history) drops prior
390
+ # turns' reasoning by design, which breaks the prefix property on purpose and makes every
391
+ # turn its own datum -- a near-1.0 rate is the EXPECTED reading there, not a regression.
392
+ # The message says what it costs and lets the reader judge, rather than asserting a bug:
393
+ # the old wording ("check the harness prefix pins") sent a reader hunting for a break that
394
+ # the configuration had deliberately chosen.
395
+ logger.warning(
396
+ "%d of %d datum(s) are fragments (fragmentation rate %.2f): turns are scored "
397
+ "separately rather than merged, so shared context is re-prefilled per turn and "
398
+ "teacher scoring cost rises with turn count. Expected under a history-editing "
399
+ "renderer; unexpected under a verbatim one, where it means the prefix property "
400
+ "broke and the harness pins need checking",
401
+ stats.fragments,
402
+ stats.datums,
403
+ stats.fragmentation_rate,
404
+ )
405
+ return datums, stats
406
+
407
+
408
+ def attach_advantages(
409
+ datums: Sequence[TrainDatum],
410
+ teacher_logprobs: Sequence[Sequence[float | None]],
411
+ cfg: DistillConfig,
412
+ ) -> tuple[list[TrainDatum], AdvantageStats]:
413
+ """Fill per-token reverse-KL advantages from teacher logprobs.
414
+
415
+ Each loss token gets `teacher_lp - sampled_lp`, bounded to
416
+ `+-train.advantage_clip` when that bound is set (None, the default,
417
+ trains the raw gap and clips nothing, which is what `train.loss = "ppo"`
418
+ expects: the ratio clip inside the loss is the regularizer); context
419
+ (mask 0.0) tokens get 0.0. With `train.center_advantages` the mean over
420
+ ALL loss tokens in the batch is then subtracted from every loss token, so
421
+ the batch-mean advantage is zero; without it (the default) the raw gaps
422
+ ride through and `AdvantageStats.advantage_mean` reads the objective.
423
+
424
+ `teacher_logprobs[i]` scores `datums[i].model_input_tokens` in the
425
+ compute_logprobs convention: entry p is the logprob of token p given the
426
+ tokens before it, and entry 0 is None (no context). A datum whose teacher
427
+ entry has the wrong length, or a None at a loss position, is dropped
428
+ loudly and counted; a silently misaligned advantage would train on noise.
429
+
430
+ Args:
431
+ datums: Datums from `build_datums` (advantages not yet attached).
432
+ teacher_logprobs: One per-position logprob list per datum, aligned
433
+ one to one with `datums`.
434
+ cfg: The run config; reads `train.advantage_clip` (None = no
435
+ clipping) and `train.center_advantages`.
436
+
437
+ Returns:
438
+ New datums with advantages attached (drops removed, order preserved)
439
+ and the stats, including the trained advantage distribution (mean and
440
+ population std over the attached datums' loss tokens) and the kept
441
+ clip/loss token counts the loop derives `clip_fraction` from (no
442
+ token counts as clipped when clipping is off).
443
+
444
+ Raises:
445
+ ValueError: If `teacher_logprobs` does not have one entry per datum;
446
+ that is a caller bug, not per-datum evidence, so nothing is
447
+ dropped for it.
448
+ """
449
+ if len(teacher_logprobs) != len(datums):
450
+ raise ValueError(
451
+ f"got {len(teacher_logprobs)} teacher logprob list(s) for {len(datums)} "
452
+ "datum(s); pass exactly one per-position list per datum, in datum order"
453
+ )
454
+ clip = cfg.train.advantage_clip
455
+ kept: list[TrainDatum] = []
456
+ per_datum_advantages: list[list[float]] = []
457
+ mismatch_drops = 0
458
+ clipped_tokens = 0
459
+ for datum, teacher in zip(datums, teacher_logprobs, strict=True):
460
+ n = len(datum.model_input_tokens)
461
+ if len(teacher) != n:
462
+ mismatch_drops += 1
463
+ logger.warning(
464
+ "dropping datum (trial %s, fragment %d) from training: teacher "
465
+ "returned %d logprob(s) for %d token(s); the teacher must score the "
466
+ "datum's exact token sequence with one entry per position",
467
+ datum.trial_name,
468
+ datum.fragment_index,
469
+ len(teacher),
470
+ n,
471
+ )
472
+ continue
473
+ advantages = [0.0] * n
474
+ datum_clipped = 0
475
+ missing_position: int | None = None
476
+ for position in range(n):
477
+ if datum.loss_mask[position] != 1.0:
478
+ continue
479
+ teacher_lp = teacher[position]
480
+ if teacher_lp is None:
481
+ missing_position = position
482
+ break
483
+ raw = teacher_lp - datum.sampled_logprobs[position]
484
+ if clip is None:
485
+ advantages[position] = raw
486
+ continue
487
+ clipped = min(max(raw, -clip), clip)
488
+ if clipped != raw:
489
+ datum_clipped += 1
490
+ advantages[position] = clipped
491
+ if missing_position is not None:
492
+ mismatch_drops += 1
493
+ logger.warning(
494
+ "dropping datum (trial %s, fragment %d) from training: teacher "
495
+ "logprob at loss position %d is None; the teacher must return a "
496
+ "logprob for every sampled position",
497
+ datum.trial_name,
498
+ datum.fragment_index,
499
+ missing_position,
500
+ )
501
+ continue
502
+ kept.append(datum)
503
+ per_datum_advantages.append(advantages)
504
+ clipped_tokens += datum_clipped
505
+ if cfg.train.center_advantages:
506
+ loss_count = sum(datum.loss_token_count for datum in kept)
507
+ if loss_count:
508
+ total = sum(
509
+ advantages[position]
510
+ for datum, advantages in zip(kept, per_datum_advantages, strict=True)
511
+ for position in range(len(advantages))
512
+ if datum.loss_mask[position] == 1.0
513
+ )
514
+ mean = total / loss_count
515
+ for datum, advantages in zip(kept, per_datum_advantages, strict=True):
516
+ for position in range(len(advantages)):
517
+ if datum.loss_mask[position] == 1.0:
518
+ advantages[position] -= mean
519
+ attached = [
520
+ TrainDatum(
521
+ trial_name=datum.trial_name,
522
+ fragment_index=datum.fragment_index,
523
+ model_input_tokens=datum.model_input_tokens,
524
+ loss_mask=datum.loss_mask,
525
+ sampled_logprobs=datum.sampled_logprobs,
526
+ advantages=advantages,
527
+ )
528
+ for datum, advantages in zip(kept, per_datum_advantages, strict=True)
529
+ ]
530
+ # The advantage distribution actually trained on: loss tokens of the kept
531
+ # datums, after clipping and any centering.
532
+ loss_values = [
533
+ advantages[position]
534
+ for datum, advantages in zip(kept, per_datum_advantages, strict=True)
535
+ for position in range(len(advantages))
536
+ if datum.loss_mask[position] == 1.0
537
+ ]
538
+ advantage_mean: float | None = None
539
+ advantage_std: float | None = None
540
+ if loss_values:
541
+ advantage_mean = sum(loss_values) / len(loss_values)
542
+ variance = sum((value - advantage_mean) ** 2 for value in loss_values) / len(loss_values)
543
+ advantage_std = math.sqrt(variance)
544
+ stats = AdvantageStats(
545
+ datums=len(attached),
546
+ mismatch_drops=mismatch_drops,
547
+ clipped_tokens=clipped_tokens,
548
+ loss_tokens=len(loss_values),
549
+ context_tokens=sum(len(datum.model_input_tokens) for datum in attached) - len(loss_values),
550
+ advantage_mean=advantage_mean,
551
+ advantage_std=advantage_std,
552
+ )
553
+ return attached, stats
554
+
555
+
556
+ class TopkCeStats(BaseModel):
557
+ """Accounting for one `build_topk_ce_datums` call.
558
+
559
+ Token counters cover only the KEPT source datums: a source dropped for a
560
+ misaligned or unscoreable top-k row is never trained on.
561
+ """
562
+
563
+ model_config = ConfigDict(frozen=True, extra="forbid")
564
+
565
+ source_datums: int = Field(ge=0)
566
+ """Source datums that survived and were replicated."""
567
+
568
+ datums: int = Field(ge=0)
569
+ """Replica datums emitted (`source_datums x k`)."""
570
+
571
+ mismatch_drops: int = Field(ge=0)
572
+ """Source datums dropped because their top-k row did not align (wrong
573
+ length, or no candidates at a loss position)."""
574
+
575
+ loss_tokens: int = Field(ge=0)
576
+ """Loss positions across the kept source datums (per copy, not x k)."""
577
+
578
+ context_tokens: int = Field(ge=0)
579
+ """Non-loss positions across the kept source datums (per copy, not x k).
580
+
581
+ Same role as `AdvantageStats.context_tokens`: the TRAINED batch's context
582
+ volume, so the loop never has to reach back to the pre-drop `DatumStats`.
583
+ """
584
+
585
+
586
+ def _renormalized_probs(candidates: TopkCandidates) -> list[float]:
587
+ """Softmax the candidates' logprobs over just the top-k support.
588
+
589
+ The teacher's top-k logprobs are from the FULL vocabulary distribution,
590
+ so they sum to less than 1; renormalizing over the k candidates makes the
591
+ per-position replica weights a proper distribution (they sum to 1 across
592
+ the k copies), keeping the weighted-CE objective's per-position scale
593
+ independent of how much mass the tail held.
594
+ """
595
+ peak = max(logprob for _, logprob in candidates)
596
+ exps = [math.exp(logprob - peak) for _, logprob in candidates]
597
+ total = sum(exps)
598
+ return [value / total for value in exps]
599
+
600
+
601
+ def build_topk_ce_datums(
602
+ datums: Sequence[TrainDatum],
603
+ topk_rows: Sequence[Sequence[TopkCandidates | None]],
604
+ k: int,
605
+ ) -> tuple[list[TrainDatum], TopkCeStats]:
606
+ """Build rank-aligned topk-CE replica datums from teacher top-k rows.
607
+
608
+ The weighted-CE objective is per POSITION: at each loss position p the
609
+ loss is `sum_j w_j(p) * CE(target=candidate_j(p))` with `w_j(p)` the
610
+ renormalized teacher probability of candidate j at p. Emitting one
611
+ single-target datum per (position, candidate) would compute exactly that
612
+ but explode the datum count (loss_tokens x k datums, each re-prefilling
613
+ the whole sequence). Instead each source datum becomes k RANK-ALIGNED
614
+ replicas sharing the model input: replica j uses, at EVERY loss position,
615
+ the j-th ranked candidate as target with its renormalized probability as
616
+ weight (replica 0 is the teacher's top choice everywhere, and so on).
617
+ Because cross_entropy is a per-position sum of `weight * -logprob(target)`
618
+ with no cross-position coupling, summing the k replicas reproduces the
619
+ identical per-position weighted-CE objective with k forward passes
620
+ instead of k per position; the weights at each loss position sum to 1
621
+ across the replicas.
622
+
623
+ Positions where the teacher returned fewer than k candidates pad the
624
+ missing ranks with the realized token at weight 0.0 (no loss, and the
625
+ remaining ranks' weights still renormalize over the AVAILABLE candidates,
626
+ so the position keeps unit total weight). Non-loss positions carry the
627
+ realized next token at weight 0.0 in every replica. Candidates are
628
+ sorted best-first defensively (logprob descending, token id ascending on
629
+ ties) so rank is well defined regardless of backend ordering.
630
+
631
+ Alignment failures drop the SOURCE datum loudly and are counted, exactly
632
+ like `attach_advantages`: a wrong-length row, a None at a loss position
633
+ (including an unscoreable loss token at position 0), or an empty
634
+ candidate list at a loss position.
635
+
636
+ Args:
637
+ datums: Datums from `build_datums` (advantages not needed).
638
+ topk_rows: One per-position row per datum, aligned one to one with
639
+ `datums`; entry p is the teacher's top-k candidates for token p
640
+ when p is a scoreable loss position and None everywhere else
641
+ (`TeacherClient.score_topk`).
642
+ k: The replica count (`train.topk`); rows may carry at most k
643
+ candidates per position.
644
+
645
+ Returns:
646
+ The replica datums (source order, then rank order) and the stats.
647
+
648
+ Raises:
649
+ ValueError: If `k < 1`, `topk_rows` does not have one entry per
650
+ datum, or a position carries more than k candidates; those are
651
+ caller bugs, not per-datum evidence, so nothing is dropped.
652
+ """
653
+ if k < 1:
654
+ raise ValueError(f"k must be >= 1, got {k}")
655
+ if len(topk_rows) != len(datums):
656
+ raise ValueError(
657
+ f"got {len(topk_rows)} top-k row(s) for {len(datums)} datum(s); pass "
658
+ "exactly one per-position row per datum, in datum order"
659
+ )
660
+ replicas: list[TrainDatum] = []
661
+ mismatch_drops = 0
662
+ kept_sources = 0
663
+ loss_tokens = 0
664
+ context_tokens = 0
665
+ for datum, row in zip(datums, topk_rows, strict=True):
666
+ n = len(datum.model_input_tokens)
667
+ if len(row) != n:
668
+ mismatch_drops += 1
669
+ logger.warning(
670
+ "dropping datum (trial %s, fragment %d) from training: teacher "
671
+ "returned %d top-k entrie(s) for %d token(s); the teacher must "
672
+ "score the datum's exact token sequence with one entry per position",
673
+ datum.trial_name,
674
+ datum.fragment_index,
675
+ len(row),
676
+ n,
677
+ )
678
+ continue
679
+ bad_position = next(
680
+ (
681
+ position
682
+ for position in range(n)
683
+ if datum.loss_mask[position] == 1.0 and not row[position]
684
+ ),
685
+ None,
686
+ )
687
+ if bad_position is not None:
688
+ mismatch_drops += 1
689
+ logger.warning(
690
+ "dropping datum (trial %s, fragment %d) from training: the teacher "
691
+ "top-k row has no candidates at loss position %d (position 0 can "
692
+ "never be scored; elsewhere the teacher must return candidates for "
693
+ "every sampled position)",
694
+ datum.trial_name,
695
+ datum.fragment_index,
696
+ bad_position,
697
+ )
698
+ continue
699
+ oversized = next((position for position in range(n) if len(row[position] or []) > k), None)
700
+ if oversized is not None:
701
+ raise ValueError(
702
+ f"top-k row for datum (trial {datum.trial_name}, fragment "
703
+ f"{datum.fragment_index}) carries {len(row[oversized] or [])} "
704
+ f"candidates at position {oversized}, more than k = {k}; score with "
705
+ "the same k the replication uses"
706
+ )
707
+ per_position: list[tuple[list[int], list[float]]] = []
708
+ for position in range(n):
709
+ candidates = row[position]
710
+ if datum.loss_mask[position] != 1.0 or not candidates:
711
+ per_position.append(([datum.model_input_tokens[position]] * k, [0.0] * k))
712
+ continue
713
+ ranked = sorted(candidates, key=lambda entry: (-entry[1], entry[0]))
714
+ weights = _renormalized_probs(ranked)
715
+ tokens = [token for token, _ in ranked]
716
+ pad = k - len(ranked)
717
+ per_position.append(
718
+ (
719
+ tokens + [datum.model_input_tokens[position]] * pad,
720
+ weights + [0.0] * pad,
721
+ )
722
+ )
723
+ kept_sources += 1
724
+ loss_tokens += datum.loss_token_count
725
+ context_tokens += n - datum.loss_token_count
726
+ for rank in range(k):
727
+ replicas.append(
728
+ TrainDatum(
729
+ trial_name=datum.trial_name,
730
+ fragment_index=datum.fragment_index,
731
+ model_input_tokens=datum.model_input_tokens,
732
+ loss_mask=datum.loss_mask,
733
+ sampled_logprobs=datum.sampled_logprobs,
734
+ target_tokens=[tokens[rank] for tokens, _ in per_position],
735
+ target_weights=[weights[rank] for _, weights in per_position],
736
+ )
737
+ )
738
+ stats = TopkCeStats(
739
+ source_datums=kept_sources,
740
+ datums=len(replicas),
741
+ mismatch_drops=mismatch_drops,
742
+ loss_tokens=loss_tokens,
743
+ context_tokens=context_tokens,
744
+ )
745
+ return replicas, stats
746
+
747
+
748
+ def to_tinker_datums(train_datums: Sequence[TrainDatum]) -> list[tinker.Datum]:
749
+ """Convert attached datums to real `tinker.Datum`s for the advantage losses.
750
+
751
+ One wire format serves both `importance_sampling` and `ppo`: the SDK
752
+ (tinker 0.23.3) lists both in `types.LossFnType`, its
753
+ `forward_backward(data, loss_fn, loss_fn_config)` takes the same
754
+ `Datum`s for either, and the cookbook's RL path submits exactly this
755
+ keyset under both (`tinker_cookbook/rl/train.py:train_step` strips
756
+ "mask" and passes `loss_fn` straight through, and its own docstring
757
+ names `"importance_sampling"` and `"ppo"` as the values). `ppo` differs
758
+ only server-side, in what the loss does with the advantages: it bounds
759
+ the update by clipping the policy ratio (Tinker's default epsilon; this
760
+ code sends no `loss_fn_config`) rather than trusting a bounded advantage.
761
+
762
+ Produces the cookbook's shifted next-token layout: model input is every
763
+ token but the last, and target_tokens, logprobs, and advantages are the
764
+ per-token lists with position 0 dropped, so index j scores predicting
765
+ token j+1 from tokens <= j. The loss mask is folded into the advantages
766
+ (0.0 at context positions); the live loss rejects a separate "mask" key.
767
+
768
+ Args:
769
+ train_datums: Datums with advantages attached (`attach_advantages`).
770
+
771
+ Returns:
772
+ One `tinker.Datum` per input datum, in order, with loss_fn_inputs
773
+ exactly target_tokens (int64), logprobs, and advantages (float32).
774
+
775
+ Raises:
776
+ ImportError: If the tinker SDK is not installed (distill extra).
777
+ ValueError: If a datum has no advantages attached or is too short for
778
+ the input/target shift (fewer than 2 tokens).
779
+ """
780
+ try:
781
+ import tinker
782
+ except ImportError as exc:
783
+ raise ImportError(MISSING_TINKER_EXTRA) from exc
784
+
785
+ out: list[tinker.Datum] = []
786
+ for datum in train_datums:
787
+ if not datum.advantages:
788
+ raise ValueError(
789
+ f"datum (trial {datum.trial_name}, fragment {datum.fragment_index}) has "
790
+ "no advantages attached; run attach_advantages on the batch before "
791
+ "converting to tinker datums"
792
+ )
793
+ tokens = datum.model_input_tokens
794
+ if len(tokens) < 2:
795
+ raise ValueError(
796
+ f"datum (trial {datum.trial_name}, fragment {datum.fragment_index}) has "
797
+ f"{len(tokens)} token(s); at least 2 are needed for the next-token "
798
+ "input/target shift"
799
+ )
800
+ length = len(tokens) - 1
801
+ out.append(
802
+ tinker.Datum(
803
+ model_input=tinker.ModelInput.from_ints(tokens[:-1]),
804
+ loss_fn_inputs={
805
+ "target_tokens": tinker.TensorData(
806
+ data=tokens[1:], dtype="int64", shape=[length]
807
+ ),
808
+ "logprobs": tinker.TensorData(
809
+ data=datum.sampled_logprobs[1:], dtype="float32", shape=[length]
810
+ ),
811
+ # No "mask" key: the live importance_sampling and ppo losses accept
812
+ # exactly target_tokens, logprobs, and advantages (a "mask" kwarg is
813
+ # rejected server-side, observed 2026-07-23; the cookbook strips the
814
+ # same key for both losses). Masking is expressed through the
815
+ # advantages instead: attach_advantages leaves context positions at
816
+ # 0.0, and a zero-advantage position contributes zero loss.
817
+ "advantages": tinker.TensorData(
818
+ data=datum.advantages[1:], dtype="float32", shape=[length]
819
+ ),
820
+ },
821
+ )
822
+ )
823
+ return out
824
+
825
+
826
+ def to_tinker_sft_datums(train_datums: Sequence[TrainDatum]) -> list[tinker.Datum]:
827
+ """Convert datums to real `tinker.Datum`s for the cross_entropy loss.
828
+
829
+ Same shifted next-token layout as `to_tinker_datums`, but for the two
830
+ cross_entropy consumers: supervised warmup on teacher-sampled
831
+ trajectories (no advantages are needed or read; the loss mask rides as
832
+ the `weights` input so context tokens carry no loss, the backend CE loss
833
+ being `sum(-logprobs * weights)`), and topk-CE replicas from
834
+ `build_topk_ce_datums` (their attached `target_tokens` replace the
835
+ next-token targets at loss positions and their `target_weights`, the
836
+ renormalized teacher probabilities, replace the loss mask).
837
+
838
+ The loss_fn_inputs keyset is exactly {"target_tokens", "weights"}: the
839
+ cross_entropy backend of the pinned SDK (tinker 0.23.3) accepts only those
840
+ two keys (its own custom-loss path in
841
+ `tinker/lib/public_interfaces/training_client.py` rejects any other key,
842
+ and the cookbook's supervised datum builder emits exactly this pair).
843
+ Verified against the installed SDK the same way importance_sampling's
844
+ keyset was: the live service already rejected an unexpected "mask" key
845
+ once, so the keyset is pinned by `data_test.py` rather than trusted.
846
+
847
+ Args:
848
+ train_datums: Datums from `build_datums` (advantages may be empty)
849
+ or replicas from `build_topk_ce_datums`.
850
+
851
+ Returns:
852
+ One `tinker.Datum` per input datum, in order, with loss_fn_inputs
853
+ exactly target_tokens (int64) and weights (float32).
854
+
855
+ Raises:
856
+ ImportError: If the tinker SDK is not installed (distill extra).
857
+ ValueError: If a datum is too short for the input/target shift
858
+ (fewer than 2 tokens).
859
+ """
860
+ try:
861
+ import tinker
862
+ except ImportError as exc:
863
+ raise ImportError(MISSING_TINKER_EXTRA) from exc
864
+
865
+ out: list[tinker.Datum] = []
866
+ for datum in train_datums:
867
+ tokens = datum.model_input_tokens
868
+ if len(tokens) < 2:
869
+ raise ValueError(
870
+ f"datum (trial {datum.trial_name}, fragment {datum.fragment_index}) has "
871
+ f"{len(tokens)} token(s); at least 2 are needed for the next-token "
872
+ "input/target shift"
873
+ )
874
+ length = len(tokens) - 1
875
+ targets = datum.target_tokens if datum.is_topk_replica else tokens
876
+ weights = datum.target_weights if datum.is_topk_replica else datum.loss_mask
877
+ out.append(
878
+ tinker.Datum(
879
+ model_input=tinker.ModelInput.from_ints(tokens[:-1]),
880
+ loss_fn_inputs={
881
+ "target_tokens": tinker.TensorData(
882
+ data=targets[1:], dtype="int64", shape=[length]
883
+ ),
884
+ "weights": tinker.TensorData(data=weights[1:], dtype="float32", shape=[length]),
885
+ },
886
+ )
887
+ )
888
+ return out
889
+
890
+
891
+ def to_tinker_topk_ce_datums(
892
+ train_datums: Sequence[TrainDatum],
893
+ topk_scores: Sequence[Sequence[TopkCandidates | None]],
894
+ k: int,
895
+ ) -> list[tinker.Datum]:
896
+ """Convert source datums plus teacher top-k rows to cross_entropy wire datums.
897
+
898
+ The one-call conversion for the `topk_ce` loss: `build_topk_ce_datums`
899
+ does the rank-aligned replication and weight renormalization (see its
900
+ docstring for the objective-equivalence argument), then the replicas ride
901
+ the pinned cross_entropy wire format (`to_tinker_sft_datums`: exactly
902
+ {"target_tokens", "weights"}). Misaligned rows drop their source datum
903
+ with a warning, mirroring the loop's own two-step path.
904
+
905
+ Args:
906
+ train_datums: Datums from `build_datums`.
907
+ topk_scores: One per-position top-k row per datum
908
+ (`TeacherClient.score_topk`).
909
+ k: The replica count (`train.topk`).
910
+
911
+ Returns:
912
+ `k` `tinker.Datum`s per kept source datum, source order then rank
913
+ order, sharing each source's model input.
914
+
915
+ Raises:
916
+ ImportError: If the tinker SDK is not installed (distill extra).
917
+ ValueError: On caller bugs (`build_topk_ce_datums`) or datums too
918
+ short for the shift (`to_tinker_sft_datums`).
919
+ """
920
+ replicas, _ = build_topk_ce_datums(train_datums, topk_scores, k)
921
+ return to_tinker_sft_datums(replicas)