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/teacher.py ADDED
@@ -0,0 +1,714 @@
1
+ """Teacher-side logprob scoring for on-policy distillation.
2
+
3
+ The teacher never generates during training; it scores the student's exact
4
+ sampled tokens. `TinkerTeacher` sends each `TrainDatum`'s full (unshifted)
5
+ token sequence through `tinker.SamplingClient.compute_logprobs` and keeps the
6
+ loss positions. For the `topk_ce` loss, `score_topk` instead makes one
7
+ prefill-only `sample` call per datum (`max_tokens=1`,
8
+ `include_prompt_logprobs=True`, `topk_prompt_logprobs=k`; verified live on the
9
+ pinned SDK, k <= 1000) whose response carries BOTH the per-position top-k
10
+ candidates and the realized per-position logprobs, so one request feeds the
11
+ candidate targets and the unchanged reverse-KL metric.
12
+
13
+ Indexing convention (verified against both the real SDK and the fakes):
14
+ `compute_logprobs(tokens)` returns one entry per input position, where entry p
15
+ is the logprob of token p given tokens < p, and entry 0 is None because the
16
+ first token has no context. The prefill response's `prompt_logprobs` and
17
+ `topk_prompt_logprobs` follow the same convention. The datum's
18
+ `model_input_tokens` IS that full sequence and its `loss_mask` names the
19
+ sampled positions, so the returned rows align index for index with the datum
20
+ (`teacher_test.py` pins the alignment against the fake sampler's echoed
21
+ logprobs to guard any off-by-one).
22
+
23
+ The tinker SDK is an optional extra imported lazily (`uv sync --extra
24
+ distill`), mirroring `wmo.providers.tinker`; injecting a sampling client
25
+ (tests use `wmo.distill.fake_tinker.FakeSamplingClient`) avoids the SDK
26
+ entirely. The lazily built real client comes from `wmo.providers.tinker`'s
27
+ process-wide shared cache (one `tinker.ServiceClient` and one
28
+ `SamplingClient` per model string for the whole process), so teacher
29
+ construction never adds server-side sessions of its own.
30
+
31
+ Every SDK call is deadline-bounded (`wmo.distill.deadlines`): a wedged
32
+ session raises a retryable `TinkerDeadlineError` instead of hanging, and the
33
+ teacher drops its lazily built client on expiry, evicting the shared cache
34
+ entry, so the next score() call rebuilds a fresh session (an injected client
35
+ is never dropped).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import logging
41
+ from collections.abc import Sequence
42
+ from concurrent.futures import ThreadPoolExecutor
43
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
44
+
45
+ from pydantic import BaseModel, ConfigDict
46
+
47
+ from wmo.distill.config import TeacherConfig
48
+ from wmo.distill.data import TopkCandidates, TrainDatum
49
+ from wmo.distill.deadlines import TinkerDeadlineError, wait_with_deadline
50
+ from wmo.providers.base import ProviderKind, VerifyResult
51
+ from wmo.providers.tinker import evict_shared_sampling_client, shared_sampling_client
52
+
53
+ if TYPE_CHECKING:
54
+ import tinker
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+ _SCORE_CONCURRENCY = 8
59
+ """Upper bound on concurrent compute_logprobs calls in one score() batch."""
60
+
61
+ _VERIFY_PROBE_TOKEN_IDS: tuple[int, ...] = (1, 2, 3, 4)
62
+ """A tiny fixed sequence for verify(); small ids are valid in any real vocab."""
63
+
64
+ TOKENIZER_PROBE_TEXTS: tuple[str, ...] = (
65
+ "Hello, world! How are you today?",
66
+ "naïve café: déjà vu, übermäßig, œuvre, 你好世界, こんにちは, 🚀✨",
67
+ "def solve(xs: list[int]) -> int:\n return sum(x * 2 for x in xs)\n",
68
+ '{"tool": "bash", "arguments": {"cmd": "ls -la | wc -l", "timeout": 30}}',
69
+ "tail -n 100 /var/log/syslog | grep -E 'error|warn' >> /tmp/out.txt",
70
+ "Mixing scripts: Ω ≈ 3.14, привет, مرحبا, 한국어, \t tabs\nand newlines.",
71
+ )
72
+ """Default fingerprint probe corpus: prose, unicode, code, JSON, shell."""
73
+
74
+
75
+ class TeacherTopkScores(BaseModel):
76
+ """One `score_topk` batch: top-k candidate rows plus realized logprobs.
77
+
78
+ Both lists align one to one with the scored datums and, per datum, index
79
+ for index with its `model_input_tokens` (the compute_logprobs
80
+ convention). They come from the same prefill response, so the realized
81
+ logprobs cost no extra teacher request.
82
+ """
83
+
84
+ model_config = ConfigDict(frozen=True, extra="forbid")
85
+
86
+ topk: list[list[TopkCandidates | None]]
87
+ """Per datum, per position: the teacher's top-k (token_id, logprob)
88
+ candidates when the position is a scoreable loss position (mask 1.0,
89
+ p >= 1), None everywhere else."""
90
+
91
+ realized: list[list[float | None]]
92
+ """Per datum, per position: the teacher's logprob of the REALIZED token
93
+ at scoreable loss positions, None everywhere else; identical in meaning
94
+ to a `score()` row, so the reverse-KL metric stays comparable across
95
+ loss modes."""
96
+
97
+
98
+ class TeacherClient(Protocol):
99
+ """What the distillation loop needs from a teacher backend."""
100
+
101
+ def score(self, datums: Sequence[TrainDatum]) -> list[list[float | None]]:
102
+ """Per-position teacher logprob rows, aligned one to one with `datums`.
103
+
104
+ Row entry p is the teacher's logprob of `model_input_tokens[p]` when p
105
+ is a scoreable loss position (mask 1.0, p >= 1) and None everywhere
106
+ else (context positions, and position 0, which has no context).
107
+ """
108
+ ...
109
+
110
+ def score_topk(self, datums: Sequence[TrainDatum], k: int) -> TeacherTopkScores:
111
+ """Per-position top-k candidate rows plus realized logprobs.
112
+
113
+ Row entry p carries the teacher's top-k (token_id, logprob)
114
+ candidates for position p when p is a scoreable loss position and
115
+ None everywhere else, following the same convention as `score`.
116
+ """
117
+ ...
118
+
119
+ def verify(self) -> VerifyResult:
120
+ """One cheap preflight probe; reports failure, never raises."""
121
+ ...
122
+
123
+ def usage(self) -> int:
124
+ """Total tokens submitted for scoring so far (the teacher_prefill meter)."""
125
+ ...
126
+
127
+
128
+ @runtime_checkable
129
+ class CachedUsageTeacher(Protocol):
130
+ """A teacher that can report how much of its prefill the service cached.
131
+
132
+ Deliberately NOT part of `TeacherClient`: every existing teacher and test
133
+ fake satisfies that seam today, and widening it would force each of them to
134
+ grow a method whose only honest answer is 0. A caller narrows to this at
135
+ runtime and falls back to billing everything uncached.
136
+ """
137
+
138
+ def cached_usage(self) -> int:
139
+ """Of `usage()`, the tokens served from the service's prefix cache."""
140
+ ...
141
+
142
+
143
+ class EncodingTokenizer(Protocol):
144
+ """The tokenizer slice the fingerprint check needs: encoding only.
145
+
146
+ HuggingFace tokenizers and the deterministic test fakes both satisfy it
147
+ structurally (extra defaulted parameters do not matter).
148
+ """
149
+
150
+ def encode(self, text: str) -> list[int]:
151
+ """Encode text to token ids."""
152
+ ...
153
+
154
+
155
+ class LogprobScorer(Protocol):
156
+ """The one call the teacher makes, in token-id terms.
157
+
158
+ `wmo.distill.fake_tinker.FakeSamplingClient` satisfies this directly;
159
+ real `tinker.SamplingClient`s are adapted via `SdkLogprobScorer`.
160
+ """
161
+
162
+ def compute_logprobs(self, token_ids: list[int]) -> list[float | None]:
163
+ """Per-position logprobs for the sequence; entry 0 is None."""
164
+ ...
165
+
166
+
167
+ @runtime_checkable
168
+ class CacheReportingScorer(Protocol):
169
+ """A scorer that can report the service's own prefix-cache hit count.
170
+
171
+ Kept a separate, runtime-checked protocol rather than folded into
172
+ `LogprobScorer` so the deterministic fakes and any externally injected
173
+ scorer keep satisfying the base seam unchanged; the caller treats a scorer
174
+ without this as "cache unknown", which bills every token at the uncached
175
+ rate exactly as before.
176
+ """
177
+
178
+ @property
179
+ def cache_hit_tokens(self) -> int:
180
+ """Prompt tokens the service reported serving from its prefix cache."""
181
+ ...
182
+
183
+
184
+ @runtime_checkable
185
+ class TopkLogprobScorer(Protocol):
186
+ """The prefill-only top-k call `score_topk` additionally needs.
187
+
188
+ `FakeSamplingClient` and `SdkLogprobScorer` (and the loop's
189
+ `SdkSamplingClient` wrapper) all provide it; `score_topk` narrows its
190
+ `LogprobScorer` to this at runtime so plain injected scorers keep
191
+ working for `score`.
192
+ """
193
+
194
+ def topk_prompt_logprobs(
195
+ self, token_ids: list[int], k: int
196
+ ) -> tuple[list[float | None], list[TopkCandidates | None]]:
197
+ """(realized logprobs, top-k rows), one entry per position each."""
198
+ ...
199
+
200
+
201
+ class SdkLogprobScorer:
202
+ """Adapts a real `tinker.SamplingClient` to the `LogprobScorer` seam."""
203
+
204
+ def __init__(self, client: tinker.SamplingClient) -> None:
205
+ self._client = client
206
+ self._cache_hit_tokens = 0
207
+
208
+ @property
209
+ def sdk_client(self) -> tinker.SamplingClient:
210
+ """The wrapped SDK client (shared-cache eviction compares its identity)."""
211
+ return self._client
212
+
213
+ @property
214
+ def cache_hit_tokens(self) -> int:
215
+ """Prompt tokens the service reported serving from its prefix cache.
216
+
217
+ Accumulated across every scoring call this scorer has made, so the
218
+ caller can bill the cached portion at the cached rate instead of
219
+ assuming zero.
220
+ """
221
+ return self._cache_hit_tokens
222
+
223
+ def _record_cache_hits(self, response: object) -> None:
224
+ """Add one response's reported cache hits, tolerating their absence.
225
+
226
+ Read defensively on purpose. This is COST ACCOUNTING on the hot path of
227
+ a paid run: if a future SDK renames or drops `prompt_cache_hit_tokens`,
228
+ the correct outcome is a ledger that quietly reverts to billing
229
+ everything uncached (an overstatement, the behaviour we already lived
230
+ with), not an AttributeError that kills a run mid-batch after the
231
+ service has already been paid for the scoring. Logged at debug once per
232
+ call rather than warned, because a scorer that cannot report is the
233
+ normal case for injected fakes.
234
+
235
+ Args:
236
+ response: The SDK sample response.
237
+ """
238
+ hits = getattr(response, "prompt_cache_hit_tokens", None)
239
+ if isinstance(hits, int):
240
+ self._cache_hit_tokens += hits
241
+ return
242
+ logger.debug(
243
+ "teacher scoring response carries no usable prompt_cache_hit_tokens (%r); "
244
+ "billing this call's prefill as uncached",
245
+ hits,
246
+ )
247
+
248
+ def compute_logprobs(self, token_ids: list[int]) -> list[float | None]:
249
+ """One deadline-bounded prefill call on the full sequence.
250
+
251
+ Issued as `sample` rather than the SDK's `compute_logprobs`, which is
252
+ not a different request: the SDK's own implementation is exactly this
253
+ call (`num_samples=1`, `max_tokens=1`, `include_prompt_logprobs=True`)
254
+ followed by `return sample_res.prompt_logprobs`. It throws the rest of
255
+ the response away, including `prompt_cache_hit_tokens` -- which is the
256
+ service's own measurement of how much of this prefill it did not have to
257
+ recompute, and the single number that decides whether our cost figures
258
+ are a bill or a ceiling. Tinker's prefix cache demonstrably works (a
259
+ repeated prompt measured 16,000 of 16,000 tokens cached) while every
260
+ `*_cached_prefill` meter read 0 by construction, so the run ledger has
261
+ been overstating spend by an unknown factor. Same request, same
262
+ `prompt_logprobs`, one more field kept.
263
+
264
+ The deadline keeps the `compute_logprobs` label deliberately: the
265
+ workload is byte-for-byte the same prefill, and relabelling it `sample`
266
+ would silently swap in a deadline tuned for generation instead.
267
+
268
+ Raises:
269
+ TinkerDeadlineError: If the deadline expires (the session is
270
+ likely wedged; the caller should retry with a fresh one).
271
+ RuntimeError: If the response carries no prompt logprobs even
272
+ though the request asked for them (SDK drift).
273
+ """
274
+ import tinker
275
+
276
+ future = self._client.sample(
277
+ prompt=tinker.ModelInput.from_ints(token_ids),
278
+ num_samples=1,
279
+ sampling_params=tinker.SamplingParams(max_tokens=1),
280
+ include_prompt_logprobs=True,
281
+ )
282
+ response = wait_with_deadline("compute_logprobs", future)
283
+ self._record_cache_hits(response)
284
+ realized = response.prompt_logprobs
285
+ if realized is None:
286
+ raise RuntimeError(
287
+ "the teacher's prefill sample response carries no prompt_logprobs even "
288
+ "though include_prompt_logprobs was set; check the pinned tinker SDK "
289
+ "version (0.23.3 populates it on request)"
290
+ )
291
+ return list(realized)
292
+
293
+ def topk_prompt_logprobs(
294
+ self, token_ids: list[int], k: int
295
+ ) -> tuple[list[float | None], list[TopkCandidates | None]]:
296
+ """One deadline-bounded prefill-only sample returning prompt logprobs.
297
+
298
+ The whole sequence rides as the PROMPT of a `max_tokens=1` sample
299
+ request with `include_prompt_logprobs=True` and
300
+ `topk_prompt_logprobs=k` (the pinned SDK's only top-k surface,
301
+ verified live: k <= 1000). The response's `prompt_logprobs` and
302
+ `topk_prompt_logprobs` both follow the compute_logprobs convention
303
+ (entry p scores token p given tokens < p; entry 0 is None), so one
304
+ request yields the candidates AND the realized logprobs. The single
305
+ sampled token is discarded.
306
+
307
+ Raises:
308
+ TinkerDeadlineError: If the deadline expires (the session is
309
+ likely wedged; the caller should retry with a fresh one).
310
+ RuntimeError: If the response omits either logprob field (SDK
311
+ drift; the pinned tinker 0.23.3 populates both on request).
312
+ """
313
+ import tinker
314
+
315
+ future = self._client.sample(
316
+ prompt=tinker.ModelInput.from_ints(token_ids),
317
+ num_samples=1,
318
+ sampling_params=tinker.SamplingParams(max_tokens=1),
319
+ include_prompt_logprobs=True,
320
+ topk_prompt_logprobs=k,
321
+ )
322
+ response = wait_with_deadline("sample", future)
323
+ self._record_cache_hits(response)
324
+ realized = response.prompt_logprobs
325
+ rows = response.topk_prompt_logprobs
326
+ if realized is None or rows is None:
327
+ missing = "prompt_logprobs" if realized is None else "topk_prompt_logprobs"
328
+ raise RuntimeError(
329
+ f"the teacher's prefill sample response carries no {missing} even "
330
+ "though the request asked for it; check the pinned tinker SDK "
331
+ "version (0.23.3 populates both when include_prompt_logprobs and "
332
+ "topk_prompt_logprobs are set)"
333
+ )
334
+ return list(realized), [None if row is None else list(row) for row in rows]
335
+
336
+
337
+ class TinkerTeacher:
338
+ """`TeacherClient` backed by a Tinker sampling client.
339
+
340
+ Args:
341
+ spec: The `[teacher]` section of the run config; `model` is the base
342
+ model name and `checkpoint` optionally pins a `tinker://` weights
343
+ path to serve the teacher from.
344
+ sampling_client: Optional injected scorer (tests use the fakes in
345
+ `wmo.distill.fake_tinker`; wrap a real `tinker.SamplingClient` in
346
+ `SdkLogprobScorer`). When None, a real client is fetched lazily
347
+ on first use from `wmo.providers.tinker`'s process-wide shared
348
+ cache, keyed by the teacher's model identity (checkpoint or base
349
+ model name); an injected client bypasses the cache entirely.
350
+ """
351
+
352
+ def __init__(
353
+ self, spec: TeacherConfig, *, sampling_client: LogprobScorer | None = None
354
+ ) -> None:
355
+ self._spec = spec
356
+ self._scorer = sampling_client
357
+ # Only a client the teacher built itself may be dropped and rebuilt
358
+ # after a deadline expiry; an injected one cannot be reconstructed.
359
+ self._owns_scorer = sampling_client is None
360
+ self._usage_tokens = 0
361
+
362
+ def _model_identity(self) -> str:
363
+ return self._spec.checkpoint or self._spec.model
364
+
365
+ def _get_scorer(self) -> LogprobScorer:
366
+ if self._scorer is None:
367
+ self._scorer = self._build_sdk_scorer()
368
+ return self._scorer
369
+
370
+ def _build_sdk_scorer(self) -> LogprobScorer:
371
+ # Lazy: the SDK import and the API-key check happen inside the shared
372
+ # cache path (`wmo.providers.tinker.shared_sampling_client`). One
373
+ # process-wide ServiceClient and one SamplingClient per model string
374
+ # are shared with the rollout providers, so building the teacher
375
+ # never adds server-side sessions of its own.
376
+ return SdkLogprobScorer(shared_sampling_client(self._model_identity()))
377
+
378
+ def _drop_wedged_scorer(self) -> None:
379
+ """Forget (and evict from the shared cache) a wedged scoring client.
380
+
381
+ A wedged session keeps timing out while a freshly built one heals, so
382
+ the next score() call rebuilds through `_get_scorer`. The shared
383
+ cache entry is evicted too, not just this teacher's reference, so no
384
+ other user of the same model string inherits the wedged session. An
385
+ injected client is never dropped: the teacher cannot rebuild what it
386
+ did not build.
387
+ """
388
+ if self._owns_scorer and self._scorer is not None:
389
+ logger.warning(
390
+ "dropping the tinker teacher client after a deadline expiry; "
391
+ "the next score() call builds a fresh session"
392
+ )
393
+ if isinstance(self._scorer, SdkLogprobScorer):
394
+ evict_shared_sampling_client(self._model_identity(), self._scorer.sdk_client)
395
+ self._scorer = None
396
+
397
+ def score(self, datums: Sequence[TrainDatum]) -> list[list[float | None]]:
398
+ """Score each datum's full token sequence into a per-position row.
399
+
400
+ Datums are scored with bounded concurrency (the SDK call blocks on a
401
+ per-sequence future, so a small thread pool batches them) and results
402
+ keep datum order. Row entry p carries the teacher logprob of the
403
+ datum's token p exactly when p is a loss position (mask 1.0) with
404
+ context (p >= 1); every other entry is None. A loss token at position
405
+ 0 cannot be conditioned on anything, so its None survives and
406
+ `attach_advantages` drops that datum loudly.
407
+
408
+ Args:
409
+ datums: The batch to score, in the loop's unshifted layout.
410
+
411
+ Returns:
412
+ One per-position row per datum, aligned with its
413
+ `model_input_tokens`.
414
+
415
+ Raises:
416
+ ImportError: If no client was injected and the tinker extra is
417
+ not installed.
418
+ RuntimeError: If the API key is missing, the teacher returns a
419
+ result of the wrong length (tokenizer or SDK drift), or a
420
+ scoreable loss position comes back None.
421
+ TinkerDeadlineError: If a compute_logprobs deadline expires; the
422
+ lazily built client is dropped first so retrying score()
423
+ rebuilds a fresh session.
424
+ """
425
+ datum_list = list(datums)
426
+ if not datum_list:
427
+ return []
428
+ scorer = self._get_scorer()
429
+ workers = min(_SCORE_CONCURRENCY, len(datum_list))
430
+ try:
431
+ with ThreadPoolExecutor(max_workers=workers) as pool:
432
+ per_datum = list(
433
+ pool.map(
434
+ lambda datum: scorer.compute_logprobs(datum.model_input_tokens), datum_list
435
+ )
436
+ )
437
+ except TinkerDeadlineError:
438
+ # The teacher session is likely wedged; drop the lazily built
439
+ # client so the caller's retry scores through a fresh session.
440
+ # Every in-flight worker is itself deadline-bounded, so the
441
+ # pool's shutdown join above stays bounded too.
442
+ self._drop_wedged_scorer()
443
+ raise
444
+ finally:
445
+ # Counted whether or not scoring succeeded: pool.map submits every
446
+ # datum up front and the executor's shutdown join runs them all, so
447
+ # the service bills the whole batch even when one call raises. The
448
+ # caller's finally-charge turns this into ledgered spend.
449
+ self._usage_tokens += sum(len(datum.model_input_tokens) for datum in datum_list)
450
+ results: list[list[float | None]] = []
451
+ for index, (datum, logprobs) in enumerate(zip(datum_list, per_datum, strict=True)):
452
+ results.append(self._loss_position_row(index, datum, logprobs))
453
+ logger.debug(
454
+ "teacher scored %d datum(s), %d tokens total so far",
455
+ len(datum_list),
456
+ self._usage_tokens,
457
+ )
458
+ return results
459
+
460
+ def score_topk(self, datums: Sequence[TrainDatum], k: int) -> TeacherTopkScores:
461
+ """Top-k candidates plus realized logprobs for each datum's loss positions.
462
+
463
+ One prefill-only sample request per datum (bounded concurrency, datum
464
+ order preserved) returns the teacher's per-position top-k candidates
465
+ AND the realized per-position logprobs together, so the reverse-KL
466
+ metric costs no second pass. Rows follow `score`'s convention: entry
467
+ p is populated exactly when p is a loss position (mask 1.0) with
468
+ context (p >= 1); a loss token at position 0 stays None in BOTH rows
469
+ and `build_topk_ce_datums` drops that datum loudly, mirroring
470
+ `attach_advantages`.
471
+
472
+ Usage accounting matches `score`: the full sequence tokens count
473
+ toward the teacher_prefill meter. The one discarded sampled token per
474
+ request is deliberately not counted (it is noise against the
475
+ sequence volume and has no meter of its own).
476
+
477
+ Args:
478
+ datums: The batch to score, in the loop's unshifted layout.
479
+ k: Candidates per position (`train.topk`).
480
+
481
+ Returns:
482
+ The batch's `TeacherTopkScores`, aligned one to one with `datums`.
483
+
484
+ Raises:
485
+ ValueError: If `k < 1`.
486
+ ImportError: If no client was injected and the tinker extra is
487
+ not installed.
488
+ RuntimeError: If the API key is missing, the scoring client has
489
+ no top-k surface, the teacher returns rows of the wrong
490
+ length, or a scoreable loss position comes back without
491
+ candidates or without a realized logprob.
492
+ TinkerDeadlineError: If a request deadline expires; the lazily
493
+ built client is dropped first so retrying score_topk
494
+ rebuilds a fresh session.
495
+ """
496
+ if k < 1:
497
+ raise ValueError(f"k must be >= 1, got {k}")
498
+ datum_list = list(datums)
499
+ if not datum_list:
500
+ return TeacherTopkScores(topk=[], realized=[])
501
+ scorer = self._get_scorer()
502
+ if not isinstance(scorer, TopkLogprobScorer):
503
+ raise RuntimeError(
504
+ f"the teacher's scoring client {type(scorer).__name__} has no "
505
+ "topk_prompt_logprobs surface, so it cannot serve the topk_ce "
506
+ "loss; inject a client with the prefill top-k call (the SDK "
507
+ "adapters and the fakes both have it) or use "
508
+ 'train.loss = "importance_sampling" or "ppo"'
509
+ )
510
+ workers = min(_SCORE_CONCURRENCY, len(datum_list))
511
+ try:
512
+ with ThreadPoolExecutor(max_workers=workers) as pool:
513
+ per_datum = list(
514
+ pool.map(
515
+ lambda datum: scorer.topk_prompt_logprobs(datum.model_input_tokens, k),
516
+ datum_list,
517
+ )
518
+ )
519
+ except TinkerDeadlineError:
520
+ # Same rationale as score(): the session is likely wedged, so a
521
+ # lazily built client is dropped for the caller's retry.
522
+ self._drop_wedged_scorer()
523
+ raise
524
+ finally:
525
+ # Counted whether or not scoring succeeded, same contract as
526
+ # score(): every submitted call runs (and bills) server-side
527
+ # before the pool join propagates an error.
528
+ self._usage_tokens += sum(len(datum.model_input_tokens) for datum in datum_list)
529
+ topk_rows: list[list[TopkCandidates | None]] = []
530
+ realized_rows: list[list[float | None]] = []
531
+ for index, (datum, (realized, candidates)) in enumerate(
532
+ zip(datum_list, per_datum, strict=True)
533
+ ):
534
+ realized_rows.append(self._loss_position_row(index, datum, realized))
535
+ topk_rows.append(self._loss_position_topk_row(index, datum, candidates))
536
+ logger.debug(
537
+ "teacher top-%d scored %d datum(s), %d tokens total so far",
538
+ k,
539
+ len(datum_list),
540
+ self._usage_tokens,
541
+ )
542
+ return TeacherTopkScores(topk=topk_rows, realized=realized_rows)
543
+
544
+ def _loss_position_topk_row(
545
+ self,
546
+ datum_index: int,
547
+ datum: TrainDatum,
548
+ candidates: list[TopkCandidates | None],
549
+ ) -> list[TopkCandidates | None]:
550
+ """Keep the prefill top-k entries at the datum's loss positions.
551
+
552
+ Mirrors `_loss_position_row` index for index: `candidates[p]` holds
553
+ the top-k for token p given tokens < p, so the result aligns with
554
+ `model_input_tokens`. Position 0's None survives (no context), and
555
+ `build_topk_ce_datums` drops that datum loudly.
556
+ """
557
+ tokens = datum.model_input_tokens
558
+ if len(candidates) != len(tokens):
559
+ raise RuntimeError(
560
+ f"teacher returned {len(candidates)} top-k entrie(s) for the "
561
+ f"{len(tokens)}-token sequence of datum {datum_index}; the prefill "
562
+ "top-k must return one entry per input position. Check that the "
563
+ "teacher model matches the student's tokenizer (run "
564
+ "tokenizer_fingerprint_check) and that the pinned tinker SDK "
565
+ "version is unchanged"
566
+ )
567
+ row: list[TopkCandidates | None] = [None] * len(tokens)
568
+ for position, weight in enumerate(datum.loss_mask):
569
+ if weight != 1.0 or position == 0:
570
+ continue
571
+ value = candidates[position]
572
+ if not value:
573
+ raise RuntimeError(
574
+ f"teacher returned no top-k candidates for loss position "
575
+ f"{position} of datum {datum_index}; only position 0 may be "
576
+ f"empty. Re-run the step; if it persists, the teacher "
577
+ f"{self._model_identity()!r} could not score the sequence, so "
578
+ "check the model/checkpoint and the tokenizer fingerprint"
579
+ )
580
+ row[position] = list(value)
581
+ return row
582
+
583
+ def _loss_position_row(
584
+ self,
585
+ datum_index: int,
586
+ datum: TrainDatum,
587
+ logprobs: list[float | None],
588
+ ) -> list[float | None]:
589
+ """Keep the compute_logprobs entries at the datum's loss positions.
590
+
591
+ `logprobs[p]` is the logprob of token p given tokens < p, so the
592
+ result aligns index for index with `model_input_tokens`.
593
+ """
594
+ tokens = datum.model_input_tokens
595
+ if len(logprobs) != len(tokens):
596
+ raise RuntimeError(
597
+ f"teacher returned {len(logprobs)} logprobs for the {len(tokens)}-token "
598
+ f"sequence of datum {datum_index}; compute_logprobs must return one "
599
+ "entry per input position. Check that the teacher model matches the "
600
+ "student's tokenizer (run tokenizer_fingerprint_check) and that the "
601
+ "pinned tinker SDK version is unchanged"
602
+ )
603
+ row: list[float | None] = [None] * len(tokens)
604
+ for position, weight in enumerate(datum.loss_mask):
605
+ if weight != 1.0 or position == 0:
606
+ continue
607
+ value = logprobs[position]
608
+ if value is None:
609
+ raise RuntimeError(
610
+ f"teacher returned no logprob for loss position {position} of "
611
+ f"datum {datum_index}; only position 0 may be None. Re-run the "
612
+ f"step; if it persists, the teacher {self._model_identity()!r} "
613
+ "could not score the sequence, so check the model/checkpoint "
614
+ "and the tokenizer fingerprint"
615
+ )
616
+ row[position] = value
617
+ return row
618
+
619
+ def verify(self) -> VerifyResult:
620
+ """One tiny compute_logprobs probe, reporting failure as ok=False.
621
+
622
+ Mirrors `wmo.providers.base.verify_via_ping`: never raises, so
623
+ preflight can report every misconfigured client at once.
624
+ """
625
+ model = self._model_identity()
626
+ try:
627
+ result = self._get_scorer().compute_logprobs(list(_VERIFY_PROBE_TOKEN_IDS))
628
+ if len(result) != len(_VERIFY_PROBE_TOKEN_IDS):
629
+ raise RuntimeError(
630
+ f"probe returned {len(result)} logprobs for "
631
+ f"{len(_VERIFY_PROBE_TOKEN_IDS)} tokens"
632
+ )
633
+ except Exception as exc: # noqa: BLE001 - verify reports failure, never raises
634
+ if isinstance(exc, TinkerDeadlineError):
635
+ self._drop_wedged_scorer()
636
+ return VerifyResult(ok=False, kind=ProviderKind.TINKER, model=model, detail=str(exc))
637
+ return VerifyResult(ok=True, kind=ProviderKind.TINKER, model=model)
638
+
639
+ def usage(self) -> int:
640
+ """Total tokens submitted through score() so far (verify probes excluded)."""
641
+ return self._usage_tokens
642
+
643
+ def cached_usage(self) -> int:
644
+ """Of `usage()`, the tokens the SERVICE reported serving from its cache.
645
+
646
+ Zero when the active scorer cannot report it (an injected fake, or a
647
+ future SDK that drops the field), which bills everything at the uncached
648
+ rate — the same conservative behaviour the ledger had before this was
649
+ readable. The count is cumulative and monotonic like `usage()`, so a
650
+ caller takes deltas around a step.
651
+ """
652
+ scorer = self._scorer
653
+ if isinstance(scorer, CacheReportingScorer):
654
+ return scorer.cache_hit_tokens
655
+ return 0
656
+
657
+
658
+ def tokenizer_fingerprint_check(
659
+ student_model: str,
660
+ teacher_model: str,
661
+ student_tokenizer: EncodingTokenizer,
662
+ teacher_tokenizer: EncodingTokenizer,
663
+ probe_texts: Sequence[str] = TOKENIZER_PROBE_TEXTS,
664
+ ) -> None:
665
+ """Require the student and teacher tokenizers to be interchangeable.
666
+
667
+ Tinker distillation scores the student's exact token ids with the teacher,
668
+ which is only meaningful when both models tokenize identically. Every
669
+ probe text must encode to the same token ids under both tokenizers.
670
+
671
+ Args:
672
+ student_model: Student base model name, for the error message.
673
+ teacher_model: Teacher model name, for the error message.
674
+ student_tokenizer: The student's tokenizer.
675
+ teacher_tokenizer: The teacher's tokenizer.
676
+ probe_texts: The probe corpus; defaults to `TOKENIZER_PROBE_TEXTS`
677
+ (prose, mixed unicode, code, JSON, shell).
678
+
679
+ Raises:
680
+ ValueError: If `probe_texts` is empty, or any probe encodes
681
+ differently; the message names both models and the fix.
682
+ """
683
+ if not probe_texts:
684
+ raise ValueError(
685
+ "probe_texts is empty, so the tokenizer fingerprint check would prove "
686
+ "nothing; pass at least one probe text (or use the default corpus)"
687
+ )
688
+ for text in probe_texts:
689
+ student_ids = student_tokenizer.encode(text)
690
+ teacher_ids = teacher_tokenizer.encode(text)
691
+ if student_ids == teacher_ids:
692
+ continue
693
+ mismatch = next(
694
+ (
695
+ index
696
+ for index, (a, b) in enumerate(zip(student_ids, teacher_ids, strict=False))
697
+ if a != b
698
+ ),
699
+ min(len(student_ids), len(teacher_ids)),
700
+ )
701
+ raise ValueError(
702
+ f"tokenizer fingerprint mismatch between student {student_model!r} and "
703
+ f"teacher {teacher_model!r}: probe {text!r} encodes to {len(student_ids)} "
704
+ f"student token(s) vs {len(teacher_ids)} teacher token(s), first "
705
+ f"difference at position {mismatch}. Tinker distillation requires "
706
+ "identical tokenizers, so pick a same-family teacher that shares the "
707
+ "student's tokenizer, or choose a different student/teacher pair"
708
+ )
709
+ logger.debug(
710
+ "tokenizer fingerprint ok: %s and %s agree on %d probe(s)",
711
+ student_model,
712
+ teacher_model,
713
+ len(probe_texts),
714
+ )