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,411 @@
1
+ """Degeneration tripwires: entropy and generation length, relative to baseline.
2
+
3
+ Reverse-KL distillation can degenerate in ways a KL curve cannot show, because
4
+ KL falls while the policy collapses. Two failures are on record in sibling
5
+ lanes:
6
+
7
+ - Length/mode collapse: generation length fell 50x (2,866 to about 50 tokens),
8
+ task accuracy fell 0.813 to 0.596, and entropy breached the lane's
9
+ pre-registered floor at steps 17 to 23; the run was killed at step 30/200.
10
+ - The mirror pathology, reported by a second lane: "pure KL gives EOS no
11
+ gradient, so the student never learns to stop", i.e. episodes that run to
12
+ the turn or token cap instead of submitting.
13
+
14
+ This module measures the two statistics that make both visible and decides
15
+ warn/kill against a baseline THIS run measured, from data the training step
16
+ already holds (the student's own recorded spans): no extra sampling, no extra
17
+ spend.
18
+
19
+ Why relative and never absolute. The sibling lane's rule was "entropy < 0.2
20
+ nats means collapse". Our healthy, untrained baseline is 0.181 nats/token
21
+ (`PROBE_BASELINE_ENTROPY_NATS`, pooled over 356,122 sampled tokens of a live
22
+ 48-episode TerminalBench-2 probe on Super-120B base weights at temperature
23
+ 0.7), so that rule would fire at step 0, before a single gradient step.
24
+ Terminal-command tokens are much more predictable than math reasoning, and a
25
+ sampled-token entropy estimate taken at temperature 0.7 is biased downward
26
+ besides. An always-firing tripwire gets muted, so an absolute threshold here
27
+ would be worse than no threshold at all: every bound in `TripwireConfig` is a
28
+ fraction of the measured baseline, and it must stay that way.
29
+
30
+ Why batch-pooled and never per-episode. On that same probe the healthiest
31
+ single episode already sits at entropy 0.082 nats/token and episode lengths
32
+ span 349 to 30,869 sampled tokens. Any per-episode rule (or a mean of
33
+ per-episode ratios) would fire on healthy runs constantly, so both statistics
34
+ pool over the whole batch: total sampled logprob over total sampled tokens, and
35
+ total sampled tokens over episodes.
36
+
37
+ What the defaults cost in false alarms. Resampling the probe's own 47 healthy
38
+ episodes into batches (200,000 draws per shape) gives the pooled ratio a
39
+ completely stationary policy would report:
40
+
41
+ episodes/batch length ratio p0.1 / min entropy ratio p0.1 / min
42
+ 32 (8 tasks x 4) 0.62 / 0.47 0.86 / 0.81
43
+ 16 0.50 / 0.36 0.80 / 0.70
44
+ 8 0.37 / 0.21 0.71 / 0.57
45
+
46
+ At the headline batch shape the length WARN (0.5) has probability 1e-5 per step
47
+ and the length KILL (0.25) never occurred in 200,000 draws; no draw at any shape
48
+ put the entropy ratio under 0.5, let alone the 0.3 kill bound. Requiring
49
+ `kill_consecutive_steps` in a row squares an already negligible number. The
50
+ tripwire tightens as the batch shrinks, though, so a step whose batch was
51
+ decimated by infrastructure failures is the one case worth reading beside its
52
+ `trials` and `empty_span_trials` counts before believing a length warning.
53
+
54
+ Both directions, since 2026-07-26. The bounds above are floors, and for a while
55
+ they were the only bounds, on the argument that a turn cap already bounds the
56
+ runaway direction. A measured run refuted that: under a 20-turn training cap the
57
+ pooled length still reached 5.33x baseline and entropy 1.68x, because the cap
58
+ bounds turns while each turn grew (172 turns pinned the 12,288-token output cap
59
+ in one step). Nothing fired, and the damage landed somewhere neither floor
60
+ watches — episodes over the context budget are dropped whole, so 22 of 64
61
+ episodes and a third of the trainable datums disappeared while every metric the
62
+ tripwire reads looked "fine, just larger". Each metric now answers to a ceiling
63
+ (`*_mult`) as well, and the ceilings are set from that run's own trajectory:
64
+ see `TripwireConfig` for which observation fixed each number.
65
+ """
66
+
67
+ from __future__ import annotations
68
+
69
+ import logging
70
+ from collections.abc import Sequence
71
+ from typing import Literal
72
+
73
+ from pydantic import BaseModel, ConfigDict, Field
74
+
75
+ from wmo.distill.config import TripwireConfig
76
+ from wmo.distill.tokens import TrialRecord
77
+
78
+ logger = logging.getLogger(__name__)
79
+
80
+ TripwireMetric = Literal["entropy_per_token", "mean_generation_tokens"]
81
+ """The two statistics under tripwire, named exactly as the metrics-row keys."""
82
+
83
+ TripwireLevel = Literal["warn", "kill"]
84
+
85
+ TripwireDirection = Literal["floor", "ceiling"]
86
+ """Which side of the baseline a breach is on.
87
+
88
+ `floor` is collapse (the ratio fell to a fraction); `ceiling` is runaway (it
89
+ rose to a multiple). Both are the same pathology seen from opposite ends, and a
90
+ run has been lost to each: a sibling lane's length fell 50x, and this lane's
91
+ length rose 5.33x until context overflow was discarding a third of every batch.
92
+ """
93
+
94
+
95
+ class PolicyHealth(BaseModel):
96
+ """One rollout batch's batch-pooled entropy proxy and generation length."""
97
+
98
+ model_config = ConfigDict(frozen=True, extra="forbid")
99
+
100
+ episodes: int = Field(ge=0)
101
+ """Episodes that recorded at least one sampled token.
102
+
103
+ Episodes with no spans are excluded from BOTH denominators on purpose: a
104
+ trial that died at sandbox creation or lost its transport generated nothing,
105
+ so counting it as a zero-length episode would drag the length statistic down
106
+ for an infrastructure outage and could kill a healthy policy. The
107
+ all-empty-batch abort (`MAX_CONSECUTIVE_EMPTY_STEPS`) owns that failure
108
+ mode, and `RolloutStats.empty_span_trials` counts it in the same row."""
109
+
110
+ sampled_tokens: int = Field(ge=0)
111
+ """Sampled (loss-position) tokens pooled over those episodes."""
112
+
113
+ entropy_per_token: float | None
114
+ """`-mean(sampled_logprobs)` over every sampled token in the batch.
115
+
116
+ The tokens were drawn from the policy that scored them, so `-log p(x)` with
117
+ `x ~ p` is an unbiased single-sample estimator of that policy's entropy
118
+ `H(p) = E_{x~p}[-log p(x)]`; pooling over the batch averages many such
119
+ samples. Two caveats to read it with:
120
+
121
+ - Temperature. Rollouts sample at `sampling.temperature` (0.7 for the
122
+ headline run), which concentrates the distribution the tokens come from,
123
+ so this sits BELOW the T=1 entropy: treat it as a lower bound and never
124
+ compare it against a number measured at another temperature.
125
+ - It is a policy-shape statistic, not a per-token uncertainty: a batch
126
+ dominated by long, highly predictable tool output reads low without
127
+ anything being wrong, which is exactly why the tripwire compares it only
128
+ against this run's own baseline.
129
+
130
+ None when the batch recorded no sampled token at all."""
131
+
132
+ mean_generation_tokens: float | None
133
+ """Sampled tokens per episode, pooled (`sampled_tokens / episodes`).
134
+
135
+ None when no episode recorded a sampled token."""
136
+
137
+
138
+ def policy_health(records: Sequence[TrialRecord]) -> PolicyHealth:
139
+ """Pool one rollout batch's recorded spans into the two tripwire statistics.
140
+
141
+ Measured from the raw `TrialRecord` spans rather than from the built
142
+ `TrainDatum`s, though the datum builder's loss positions are exactly these
143
+ sampled tokens (its loss mask is 1.0 on sampled tokens and 0.0 on context).
144
+ The reason is drops: `build_datums` discards whole episodes that overflow
145
+ the context budget or exceed `train.max_datum_tokens`, which are precisely
146
+ the LONGEST episodes, so measuring generation length after the drops would
147
+ deflate it in exactly the tail that matters and could kill a healthy run.
148
+
149
+ Args:
150
+ records: The batch's assembled trial records.
151
+
152
+ Returns:
153
+ The batch-pooled health; both statistics are None when no episode
154
+ recorded a sampled token (an all-empty batch, whose own abort path
155
+ handles it).
156
+ """
157
+ episodes = 0
158
+ sampled_tokens = 0
159
+ logprob_total = 0.0
160
+ for record in records:
161
+ episode_tokens = sum(len(span.sampled_token_ids) for span in record.spans)
162
+ if episode_tokens == 0:
163
+ continue
164
+ episodes += 1
165
+ sampled_tokens += episode_tokens
166
+ logprob_total += sum(sum(span.sampled_logprobs) for span in record.spans)
167
+ if episodes == 0 or sampled_tokens == 0:
168
+ return PolicyHealth(
169
+ episodes=episodes,
170
+ sampled_tokens=sampled_tokens,
171
+ entropy_per_token=None,
172
+ mean_generation_tokens=None,
173
+ )
174
+ return PolicyHealth(
175
+ episodes=episodes,
176
+ sampled_tokens=sampled_tokens,
177
+ entropy_per_token=-logprob_total / sampled_tokens,
178
+ mean_generation_tokens=sampled_tokens / episodes,
179
+ )
180
+
181
+
182
+ class TripwireBaseline(BaseModel):
183
+ """The reference the tripwires compare every later step against.
184
+
185
+ Captured from the run's FIRST training step and persisted in the run's
186
+ checkpoint manifest, because a resumed run that re-baselines against an
187
+ already-degenerated policy has a blind tripwire: the collapse becomes the
188
+ new normal and nothing fires again.
189
+ """
190
+
191
+ model_config = ConfigDict(frozen=True, extra="forbid")
192
+
193
+ step: int = Field(ge=0)
194
+ """The 0-based training step measured, so a resumed run can tell whether
195
+ the current step IS the baseline step (which never fires against itself)."""
196
+
197
+ entropy_per_token: float = Field(gt=0.0)
198
+ mean_generation_tokens: float = Field(gt=0.0)
199
+ episodes: int = Field(ge=1)
200
+ """Episodes behind the measurement, for reading the row's confidence."""
201
+
202
+ sampled_tokens: int = Field(ge=1)
203
+
204
+
205
+ def capture_baseline(step: int, health: PolicyHealth) -> TripwireBaseline | None:
206
+ """Turn a first step's measured health into the run's baseline.
207
+
208
+ Args:
209
+ step: The 0-based training step being measured.
210
+ health: That step's batch-pooled health.
211
+
212
+ Returns:
213
+ The baseline, or None when this step cannot serve as one: no episode
214
+ sampled anything, or a statistic came out non-positive (an entropy of
215
+ exactly 0.0 would make every later ratio undefined). The caller leaves
216
+ the tripwire unarmed and tries again at the next step.
217
+ """
218
+ entropy = health.entropy_per_token
219
+ length = health.mean_generation_tokens
220
+ if entropy is None or length is None or entropy <= 0.0 or length <= 0.0:
221
+ return None
222
+ return TripwireBaseline(
223
+ step=step,
224
+ entropy_per_token=entropy,
225
+ mean_generation_tokens=length,
226
+ episodes=health.episodes,
227
+ sampled_tokens=health.sampled_tokens,
228
+ )
229
+
230
+
231
+ class TripwireBreach(BaseModel):
232
+ """One metric under one threshold at one step."""
233
+
234
+ model_config = ConfigDict(frozen=True, extra="forbid")
235
+
236
+ metric: TripwireMetric
237
+ level: TripwireLevel
238
+ direction: TripwireDirection
239
+ baseline: float
240
+ value: float
241
+ ratio: float
242
+ """`value / baseline`; the tripwire is a bound on this, never on `value`."""
243
+
244
+ threshold_frac: float
245
+ """The configured bound the ratio reached: a fraction for a `floor` breach,
246
+ a multiple for a `ceiling` one."""
247
+
248
+ config_field: str
249
+ """The `[tripwire]` key that set `threshold_frac`, so a breach message says
250
+ exactly which knob to move."""
251
+
252
+ def describe(self) -> str:
253
+ """One line naming the metric, its baseline, the value, and the ratio."""
254
+ crossed = "at or under" if self.direction == "floor" else "at or above"
255
+ return (
256
+ f"{self.metric} {self.value:.4g} is {self.ratio:.2f}x this run's baseline "
257
+ f"{self.baseline:.4g} (measured at its first training step), {crossed} the "
258
+ f"{self.level} threshold {self.threshold_frac:.2f} ({self.config_field})"
259
+ )
260
+
261
+
262
+ def metric_ratio(value: float | None, baseline: float | None) -> float | None:
263
+ """`value / baseline`, or None when either side is missing or unusable."""
264
+ if value is None or baseline is None or baseline <= 0.0:
265
+ return None
266
+ return value / baseline
267
+
268
+
269
+ class _MetricReading(BaseModel):
270
+ """One metric's current value beside the baseline and bounds it answers to."""
271
+
272
+ model_config = ConfigDict(frozen=True, extra="forbid")
273
+
274
+ metric: TripwireMetric
275
+ value: float | None
276
+ baseline: float
277
+ warn_frac: float
278
+ kill_frac: float
279
+ warn_field: str
280
+ kill_field: str
281
+ warn_mult: float
282
+ kill_mult: float
283
+ warn_mult_field: str
284
+ kill_mult_field: str
285
+
286
+
287
+ def _readings(
288
+ cfg: TripwireConfig, baseline: TripwireBaseline, health: PolicyHealth
289
+ ) -> list[_MetricReading]:
290
+ """Both metrics wired to their configured bounds, in report order."""
291
+ return [
292
+ _MetricReading(
293
+ metric="entropy_per_token",
294
+ value=health.entropy_per_token,
295
+ baseline=baseline.entropy_per_token,
296
+ warn_frac=cfg.entropy_warn_frac,
297
+ kill_frac=cfg.entropy_kill_frac,
298
+ warn_field="tripwire.entropy_warn_frac",
299
+ kill_field="tripwire.entropy_kill_frac",
300
+ warn_mult=cfg.entropy_warn_mult,
301
+ kill_mult=cfg.entropy_kill_mult,
302
+ warn_mult_field="tripwire.entropy_warn_mult",
303
+ kill_mult_field="tripwire.entropy_kill_mult",
304
+ ),
305
+ _MetricReading(
306
+ metric="mean_generation_tokens",
307
+ value=health.mean_generation_tokens,
308
+ baseline=baseline.mean_generation_tokens,
309
+ warn_frac=cfg.length_warn_frac,
310
+ kill_frac=cfg.length_kill_frac,
311
+ warn_field="tripwire.length_warn_frac",
312
+ kill_field="tripwire.length_kill_frac",
313
+ warn_mult=cfg.length_warn_mult,
314
+ kill_mult=cfg.length_kill_mult,
315
+ warn_mult_field="tripwire.length_warn_mult",
316
+ kill_mult_field="tripwire.length_kill_mult",
317
+ ),
318
+ ]
319
+
320
+
321
+ def evaluate_breaches(
322
+ cfg: TripwireConfig, baseline: TripwireBaseline, health: PolicyHealth
323
+ ) -> list[TripwireBreach]:
324
+ """Compare one step's health against the baseline bounds, both sides.
325
+
326
+ A metric with no measurement (None) is skipped rather than treated as zero:
327
+ a batch that sampled nothing is an infrastructure failure, and the
328
+ all-empty-batch abort owns it. The boundary belongs to the breach side (a
329
+ ratio exactly at the fraction or the multiple fires), so a threshold set to a
330
+ value that was actually observed is never silently inert.
331
+
332
+ Kill is checked before warn on BOTH sides before either warn is considered,
333
+ so a metric that has blown through a kill bound is never reported as a mere
334
+ warning. A ratio cannot breach a floor and a ceiling at once (the validator
335
+ keeps every fraction <= 1 < every multiple), so the two directions cannot
336
+ collide.
337
+
338
+ Args:
339
+ cfg: The run's `[tripwire]` section (its `enabled` flag is the caller's
340
+ business; this function always reports what it sees).
341
+ baseline: The run's captured baseline.
342
+ health: The step's batch-pooled health.
343
+
344
+ Returns:
345
+ At most one breach per metric, the more severe level when both bounds
346
+ are crossed; an empty list when nothing breached.
347
+ """
348
+ breaches: list[TripwireBreach] = []
349
+ for reading in _readings(cfg, baseline, health):
350
+ ratio = metric_ratio(reading.value, reading.baseline)
351
+ if reading.value is None or ratio is None:
352
+ continue
353
+ level: TripwireLevel
354
+ direction: TripwireDirection
355
+ if ratio <= reading.kill_frac:
356
+ level, direction = "kill", "floor"
357
+ threshold, field = reading.kill_frac, reading.kill_field
358
+ elif ratio >= reading.kill_mult:
359
+ level, direction = "kill", "ceiling"
360
+ threshold, field = reading.kill_mult, reading.kill_mult_field
361
+ elif ratio <= reading.warn_frac:
362
+ level, direction = "warn", "floor"
363
+ threshold, field = reading.warn_frac, reading.warn_field
364
+ elif ratio >= reading.warn_mult:
365
+ level, direction = "warn", "ceiling"
366
+ threshold, field = reading.warn_mult, reading.warn_mult_field
367
+ else:
368
+ continue
369
+ breaches.append(
370
+ TripwireBreach(
371
+ metric=reading.metric,
372
+ level=level,
373
+ direction=direction,
374
+ baseline=reading.baseline,
375
+ value=reading.value,
376
+ ratio=ratio,
377
+ threshold_frac=threshold,
378
+ config_field=field,
379
+ )
380
+ )
381
+ return breaches
382
+
383
+
384
+ def health_summary(health: PolicyHealth, baseline: TripwireBaseline | None) -> str:
385
+ """The progress-line fragment for one step's health.
386
+
387
+ Args:
388
+ health: The step's batch-pooled health.
389
+ baseline: The run's baseline, when one is armed.
390
+
391
+ Returns:
392
+ A compact "entropy X (Rx baseline), gen Y tok/episode (Rx)" fragment,
393
+ with `n/a` in place of a statistic the batch could not measure and the
394
+ ratios omitted until a baseline exists.
395
+ """
396
+ parts: list[str] = []
397
+ entropy_ratio = metric_ratio(
398
+ health.entropy_per_token, baseline.entropy_per_token if baseline else None
399
+ )
400
+ length_ratio = metric_ratio(
401
+ health.mean_generation_tokens, baseline.mean_generation_tokens if baseline else None
402
+ )
403
+ entropy = health.entropy_per_token
404
+ length = health.mean_generation_tokens
405
+ parts.append("entropy/token n/a" if entropy is None else f"entropy/token {entropy:.4f}")
406
+ if entropy_ratio is not None:
407
+ parts[-1] += f" ({entropy_ratio:.2f}x baseline)"
408
+ parts.append("gen tokens/episode n/a" if length is None else f"gen tokens/episode {length:.0f}")
409
+ if length_ratio is not None:
410
+ parts[-1] += f" ({length_ratio:.2f}x)"
411
+ return ", ".join(parts)
@@ -0,0 +1,152 @@
1
+ """Exact per-token byte offsets for a token sequence, with no re-encoding.
2
+
3
+ Cross-tokenizer chunk alignment needs to know which BYTES of the decoded text
4
+ each token covers, on both the student side (the exact sampled ids, which
5
+ TITO forbids re-encoding) and the teacher side. Re-encoding is not an option
6
+ for the student: sampling routinely emits a NON-canonical BPE segmentation of
7
+ its own text (measured on the headline run's real sinks, `'S' + 'olver'` where
8
+ canonical BPE gives `'Solver'`), so `encode(decode(ids)) != ids` for 14% of
9
+ spans covering 42% of sampled tokens. Gating on that round trip would discard
10
+ those tokens as "unstable" when nothing about them is unstable.
11
+
12
+ Byte-level BPE vocabularies (the GPT-2 lineage, which both Qwen3.6 and
13
+ GLM-5.2 use) make the exact answer available directly: every ordinary token's
14
+ surface form is a reversible byte-level encoding of the bytes it covers, so
15
+ per-token byte lengths come from the vocabulary alone. `span_byte_ends`
16
+ reconstructs the span's bytes that way and VERIFIES the reconstruction against
17
+ the tokenizer's own decode before returning, so a vocabulary that does not
18
+ work this way (a SentencePiece tokenizer that normalizes text, say) is
19
+ reported as unusable instead of silently producing wrong offsets.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from typing import Protocol
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class ByteOffsetTokenizer(Protocol):
31
+ """The tokenizer slice byte-offset reconstruction needs.
32
+
33
+ HuggingFace fast tokenizers satisfy this structurally, as do the small
34
+ deterministic fakes in the tests (extra defaulted parameters do not
35
+ matter).
36
+ """
37
+
38
+ def convert_ids_to_tokens(self, ids: list[int]) -> list[str | None]:
39
+ """The surface form of each token id; None for ids outside the vocab."""
40
+ ...
41
+
42
+ def decode(self, token_ids: list[int]) -> str:
43
+ """Decode token ids back to text."""
44
+ ...
45
+
46
+
47
+ def _byte_decoder() -> dict[str, int]:
48
+ """The GPT-2 byte-level BPE surface-character to raw-byte map.
49
+
50
+ Byte-level BPE renders each of the 256 possible bytes as exactly one
51
+ printable character so a BPE vocabulary can be built over text. The map is
52
+ a fixed property of the scheme, not of any model, so it is derived here
53
+ rather than read from a tokenizer.
54
+ """
55
+ printable = list(range(33, 127)) + list(range(161, 173)) + list(range(174, 256))
56
+ surface = list(printable)
57
+ spare = 0
58
+ for value in range(256):
59
+ if value not in printable:
60
+ printable.append(value)
61
+ surface.append(256 + spare)
62
+ spare += 1
63
+ return {chr(code): value for value, code in zip(printable, surface, strict=True)}
64
+
65
+
66
+ BYTE_DECODER = _byte_decoder()
67
+ """Surface character to raw byte, for byte-level BPE token surface forms."""
68
+
69
+
70
+ def token_bytes(surface: str) -> bytes | None:
71
+ """The raw bytes one byte-level BPE token surface form stands for.
72
+
73
+ Args:
74
+ surface: A token's surface form from `convert_ids_to_tokens`.
75
+
76
+ Returns:
77
+ The bytes the token covers, or None when the surface form contains a
78
+ character outside the byte-level alphabet. That is the normal case for
79
+ special and added tokens (`<|im_end|>`), whose surface form is their
80
+ literal text; callers fall back to encoding that text directly.
81
+ """
82
+ try:
83
+ return bytes(BYTE_DECODER[char] for char in surface)
84
+ except KeyError:
85
+ return None
86
+
87
+
88
+ def span_byte_ends(
89
+ tokenizer: ByteOffsetTokenizer, token_ids: list[int]
90
+ ) -> tuple[list[int], bytes] | None:
91
+ """Cumulative byte-end offset per token, plus the span's decoded bytes.
92
+
93
+ Entry i is the byte offset just past token i, so token i covers
94
+ `bytes[ends[i - 1] : ends[i]]` (with `ends[-1] == 0` implied for i = 0).
95
+ The result is exact for the tokenizer's ACTUAL ids: nothing is re-encoded,
96
+ so a non-canonical sampled segmentation is handled like any other.
97
+
98
+ Args:
99
+ tokenizer: The tokenizer that produced `token_ids`.
100
+ token_ids: The span's token ids, in order.
101
+
102
+ Returns:
103
+ `(byte_ends, span_bytes)` when reconstruction agrees with the
104
+ tokenizer's own decode, else None. None means this tokenizer does not
105
+ expose reversible per-token bytes (it normalizes text, or a token id
106
+ is outside the vocabulary), so the caller must treat the span as
107
+ unscoreable rather than guess at offsets.
108
+ """
109
+ if not token_ids:
110
+ return [], b""
111
+ surfaces = tokenizer.convert_ids_to_tokens(list(token_ids))
112
+ if len(surfaces) != len(token_ids):
113
+ logger.warning(
114
+ "tokenizer returned %d surface form(s) for %d token id(s); cannot "
115
+ "reconstruct byte offsets for this span",
116
+ len(surfaces),
117
+ len(token_ids),
118
+ )
119
+ return None
120
+ pieces: list[bytes] = []
121
+ for index, surface in enumerate(surfaces):
122
+ if surface is None:
123
+ logger.warning(
124
+ "token id %d at position %d has no surface form (outside the "
125
+ "vocabulary); cannot reconstruct byte offsets for this span",
126
+ token_ids[index],
127
+ index,
128
+ )
129
+ return None
130
+ raw = token_bytes(surface)
131
+ pieces.append(surface.encode("utf-8") if raw is None else raw)
132
+ span_bytes = b"".join(pieces)
133
+ # The reconstruction is only trustworthy if it reproduces what the
134
+ # tokenizer itself decodes. A normalizing tokenizer (SentencePiece
135
+ # whitespace rewriting, NFKC) fails here, which is exactly the signal the
136
+ # caller needs: alignment by byte offset is invalid for that vocabulary.
137
+ decoded = tokenizer.decode(list(token_ids))
138
+ if span_bytes != decoded.encode("utf-8"):
139
+ logger.warning(
140
+ "per-token byte reconstruction (%d bytes) does not match the "
141
+ "tokenizer's decode (%d bytes); this vocabulary does not expose "
142
+ "reversible per-token bytes, so the span cannot be byte-aligned",
143
+ len(span_bytes),
144
+ len(decoded.encode("utf-8")),
145
+ )
146
+ return None
147
+ ends: list[int] = []
148
+ total = 0
149
+ for piece in pieces:
150
+ total += len(piece)
151
+ ends.append(total)
152
+ return ends, span_bytes