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,734 @@
1
+ """Deterministic in-memory fakes of the Tinker SDK surface the distill loop uses.
2
+
3
+ These fakes mirror the shapes of the real SDK (tinker.ServiceClient,
4
+ tinker.TrainingClient, tinker.SamplingClient, tinker.types.Datum and
5
+ tinker.types.SampledSequence) structurally, without importing tinker at all,
6
+ so the test suite runs without the `distill` extra installed. Method names and
7
+ call shapes match the real clients closely enough that the distill loop can be
8
+ exercised end to end against them; the notable simplifications are documented
9
+ on each method (token ids are plain `list[int]` rather than ModelInput chunks,
10
+ results are returned directly rather than through futures).
11
+
12
+ Everything is deterministic: sampled tokens and logprobs are derived from
13
+ SHA-256 hashes of (seed, prompt ids, position), never from time or global
14
+ randomness, so a run replayed with the same inputs produces identical outputs.
15
+
16
+ The fakes also mirror the live service's model-initialization ordering rule:
17
+ tinker accepts LoadWeights only on an uninitialized model, so
18
+ `FakeTrainingClient.load_state` raises once any weight-affecting call has run
19
+ on that client (see `MODEL_INITIALIZING_CALLS`), and every client keeps an
20
+ ordered `calls` log so tests can assert what ran before what. Saved states
21
+ therefore live on the `FakeServiceClient`, not the client that wrote them,
22
+ exactly as real tinker:// state paths outlive their session: a later session's
23
+ freshly created training client can restore them.
24
+
25
+ The fakes also enforce the tokens-in-tokens-out (TITO) invariant that on-policy
26
+ distillation depends on: every sampled span trained on must be byte-identical
27
+ to a span some sampling client actually issued. The issuer set is the training
28
+ client's own linked samplers (its refreshed student weights) plus every
29
+ sampling client its owning FakeServiceClient created (the teacher client the
30
+ warmup phase trains on is created through the service, not linked to the
31
+ student); fabricated or corrupted token ids were issued by nobody and still
32
+ fail. FakeTrainingClient raises AssertionError from forward_backward when a
33
+ datum violates it. Datums flagged `topk=True` (topk-CE replicas) get the
34
+ input-side variant of the check: their TARGETS are intentionally
35
+ teacher-proposed candidate tokens no sampler issued, so the invariant binds
36
+ the model INPUT under the loss-weighted positions instead, which must still
37
+ be the student's exact sampled tokens (see FakeTrainingClient.forward_backward).
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import hashlib
43
+ from dataclasses import dataclass, field
44
+ from typing import Literal
45
+
46
+ _SAMPLED_TOKEN_BASE = 32
47
+ _SAMPLED_TOKEN_RANGE = 95
48
+ """Sampled token ids stay in the printable ASCII range so decode() is total."""
49
+
50
+ MODEL_INITIALIZING_CALLS = frozenset(
51
+ {"load_state", "save_state", "save_weights_for_sampler", "forward_backward", "optim_step"}
52
+ )
53
+ """Training-client calls after which the live service refuses LoadWeights.
54
+
55
+ Tinker's error is "LoadWeights can only be called on uninitialized models
56
+ (before any weights have been loaded or training has started)", so
57
+ `FakeTrainingClient.load_state` refuses once any of these ran on the same
58
+ client. `get_tokenizer` is deliberately absent: the SDK serves it from a
59
+ metadata-only GetInfo request that never touches weights, so it is safe
60
+ before a restore.
61
+ """
62
+
63
+
64
+ def _digest(*parts: str) -> bytes:
65
+ """A 32-byte SHA-256 digest of the given parts joined unambiguously."""
66
+ return hashlib.sha256("\x1f".join(parts).encode("utf-8")).digest()
67
+
68
+
69
+ def _ids_key(token_ids: list[int]) -> str:
70
+ return ",".join(str(t) for t in token_ids)
71
+
72
+
73
+ def _contains_run(haystack: tuple[int, ...], needle: tuple[int, ...]) -> bool:
74
+ """Whether `needle` appears as a contiguous run inside `haystack`."""
75
+ if not needle:
76
+ return True
77
+ span = len(needle)
78
+ return any(
79
+ haystack[start : start + span] == needle for start in range(len(haystack) - span + 1)
80
+ )
81
+
82
+
83
+ def _derived_token(seed: str, prompt_ids: list[int], sample_index: int, position: int) -> int:
84
+ digest = _digest("token", seed, _ids_key(prompt_ids), str(sample_index), str(position))
85
+ value = int.from_bytes(digest[:4], "big")
86
+ return _SAMPLED_TOKEN_BASE + (value % _SAMPLED_TOKEN_RANGE)
87
+
88
+
89
+ def _derived_logprob(*parts: str) -> float:
90
+ """A deterministic pseudo-logprob in [-4.05, -0.05), from the given parts."""
91
+ digest = _digest("logprob", *parts)
92
+ value = int.from_bytes(digest[:4], "big")
93
+ return -0.05 - (value % 4000) / 1000.0
94
+
95
+
96
+ class FakeTokenizer:
97
+ """A tiny deterministic char-level tokenizer: token id = code point."""
98
+
99
+ def encode(self, text: str) -> list[int]:
100
+ """Encode text to token ids (one token per character)."""
101
+ return [ord(ch) for ch in text]
102
+
103
+ def decode(self, token_ids: list[int]) -> str:
104
+ """Decode token ids back to text."""
105
+ return "".join(chr(t) for t in token_ids)
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class FakeSampledSequence:
110
+ """One sampled sequence, mirroring tinker.types.SampledSequence.
111
+
112
+ `tokens` are the generated token ids and `logprobs[j]` is the logprob the
113
+ sampler assigned to `tokens[j]`, aligned one to one.
114
+ """
115
+
116
+ tokens: list[int]
117
+ logprobs: list[float]
118
+ stop_reason: Literal["length", "stop"]
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class FakeForwardBackwardOutput:
123
+ """One forward/backward result, mirroring tinker.types.ForwardBackwardOutput.
124
+
125
+ The real output carries per-datum `loss_fn_outputs` tensors (the cookbook
126
+ reads a per-datum "logprobs" TensorData) plus a server-populated
127
+ `metrics: dict[str, float]` whose keys carry a ":reduction" suffix for
128
+ the SDK's chunk combiner. The fake keeps the per-datum shape (one empty
129
+ dict per datum) and reports a deterministic batch loss under
130
+ "total_loss:sum" (the cookbook's "total_loss" name plus the combiner
131
+ suffix), so adapters exercise the same suffix-tolerant metric extraction
132
+ they run against the real SDK.
133
+ """
134
+
135
+ loss_fn_output_type: str
136
+ loss_fn_outputs: list[dict[str, list[float]]]
137
+ metrics: dict[str, float]
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class FakeOptimStepResponse:
142
+ """One optimizer-step result, mirroring tinker.types.OptimStepResponse.
143
+
144
+ The real response carries only an untyped optional metrics mapping; the
145
+ fake reports a deterministic "grad_norm:mean" so adapters can prove their
146
+ extraction plumbing on a stable value.
147
+ """
148
+
149
+ metrics: dict[str, float] | None
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class IssuedSample:
154
+ """A record of one span a sampling client issued: the TITO ground truth."""
155
+
156
+ prompt_ids: tuple[int, ...]
157
+ sampled_ids: tuple[int, ...]
158
+ logprobs: tuple[float, ...]
159
+
160
+
161
+ @dataclass
162
+ class _SpanLedger:
163
+ """Issued-span records shared by a training client and its samplers."""
164
+
165
+ records: list[IssuedSample] = field(default_factory=list)
166
+
167
+
168
+ class FakeDatum:
169
+ """A training datum, mirroring tinker.types.Datum structurally.
170
+
171
+ The real Datum carries a ModelInput plus tensor-valued loss_fn_inputs;
172
+ here everything is plain lists. `model_input_tokens` is the full input
173
+ sequence and the loss inputs are aligned with `target_tokens`.
174
+
175
+ Args:
176
+ model_input_tokens: The full input token sequence (all but the final
177
+ target position, in the usual shifted-by-one layout; the fakes do
178
+ not enforce that layout).
179
+ target_tokens: Tokens the loss is computed over.
180
+ weights: Per-target-token loss weights; positions with weight 0 are
181
+ prompt/tool tokens outside the sampled spans. Defaults to all 1.0.
182
+ advantages: Optional per-target-token advantages (importance_sampling).
183
+ logprobs: Optional per-target-token behavior-policy logprobs.
184
+ topk: Marks a topk-CE replica: its targets are teacher-proposed
185
+ candidates (fractional weights), so the TITO check binds the
186
+ model INPUT under the weighted positions instead of the targets.
187
+ """
188
+
189
+ def __init__(
190
+ self,
191
+ model_input_tokens: list[int],
192
+ target_tokens: list[int],
193
+ weights: list[float] | None = None,
194
+ advantages: list[float] | None = None,
195
+ logprobs: list[float] | None = None,
196
+ topk: bool = False,
197
+ ) -> None:
198
+ self.model_input_tokens = list(model_input_tokens)
199
+ self.target_tokens = list(target_tokens)
200
+ self.weights = list(weights) if weights is not None else [1.0] * len(target_tokens)
201
+ self.advantages = list(advantages) if advantages is not None else []
202
+ self.logprobs = list(logprobs) if logprobs is not None else []
203
+ self.topk = topk
204
+ if len(self.weights) != len(self.target_tokens):
205
+ raise ValueError(
206
+ f"weights length {len(self.weights)} does not match "
207
+ f"target_tokens length {len(self.target_tokens)}"
208
+ )
209
+
210
+ def sampled_spans(self) -> list[tuple[int, ...]]:
211
+ """Maximal contiguous runs of target tokens with nonzero weight."""
212
+ spans: list[tuple[int, ...]] = []
213
+ current: list[int] = []
214
+ for token, weight in zip(self.target_tokens, self.weights, strict=True):
215
+ if weight != 0.0:
216
+ current.append(token)
217
+ elif current:
218
+ spans.append(tuple(current))
219
+ current = []
220
+ if current:
221
+ spans.append(tuple(current))
222
+ return spans
223
+
224
+ def input_loss_spans(self) -> list[tuple[int, ...]]:
225
+ """Model-INPUT token runs under the nonzero-weight target positions.
226
+
227
+ Target index j scores unshifted position j + 1, whose token sits at
228
+ model_input index j + 1 when that index exists (the final target's
229
+ token was shifted out of the input, so a loss run reaching the
230
+ sequence end contributes one token fewer here). This is what the
231
+ TITO check inspects for topk-CE replicas: the targets are candidate
232
+ tokens by design, but the input context at the loss positions must
233
+ still be tokens a sampler actually issued.
234
+ """
235
+ spans: list[tuple[int, ...]] = []
236
+ current: list[int] = []
237
+ for index, weight in enumerate(self.weights):
238
+ in_input = index + 1 < len(self.model_input_tokens)
239
+ if weight != 0.0 and in_input:
240
+ current.append(self.model_input_tokens[index + 1])
241
+ continue
242
+ if current:
243
+ spans.append(tuple(current))
244
+ current = []
245
+ if current:
246
+ spans.append(tuple(current))
247
+ return spans
248
+
249
+
250
+ class FakeSamplingClient:
251
+ """Deterministic stand-in for tinker.SamplingClient.
252
+
253
+ Simplifications vs the real client: prompts are `list[int]` (the real
254
+ client takes ModelInput), results are returned directly (no futures), and
255
+ one call returns one sequence.
256
+
257
+ Args:
258
+ seed: Seed string, by convention the fake sampler weights path; all
259
+ sampled tokens and logprobs derive from it.
260
+ ledger: Shared issued-span ledger; samplers refreshed from the same
261
+ training client share one ledger so TITO checks see all of them.
262
+ """
263
+
264
+ def __init__(self, seed: str, ledger: _SpanLedger | None = None) -> None:
265
+ self.seed = seed
266
+ self._ledger = ledger if ledger is not None else _SpanLedger()
267
+ self.issued: list[IssuedSample] = []
268
+
269
+ def sample(
270
+ self,
271
+ prompt_token_ids: list[int],
272
+ max_tokens: int,
273
+ temperature: float,
274
+ stop: list[int] | list[str] | None = None,
275
+ sample_index: int = 0,
276
+ ) -> FakeSampledSequence:
277
+ """Sample a deterministic sequence for the prompt and record it.
278
+
279
+ Tokens and logprobs derive purely from a hash of (seed, prompt ids,
280
+ sample_index, position): calling again with identical arguments
281
+ returns an identical sequence. Pass distinct `sample_index` values to
282
+ get distinct group members deterministically (the real SDK's
283
+ num_samples analogue). `temperature` is accepted for signature parity
284
+ and does not affect the fake's output.
285
+
286
+ Args:
287
+ prompt_token_ids: Prompt tokens the sample conditions on.
288
+ max_tokens: Maximum number of tokens to generate.
289
+ temperature: Accepted for parity; unused by the fake.
290
+ stop: Optional stop token ids or stop strings (the real
291
+ SamplingParams accepts both). A generated stop token is
292
+ included in the output and ends generation with stop_reason
293
+ "stop"; a stop string fires when the generation so far, decoded
294
+ with the char-level FakeTokenizer convention, ends with it.
295
+ sample_index: Deterministic nonce distinguishing group members.
296
+
297
+ Returns:
298
+ The sampled sequence with aligned per-token logprobs.
299
+ """
300
+ del temperature
301
+ int_stops = {item for item in (stop or ()) if isinstance(item, int)}
302
+ str_stops = [item for item in (stop or ()) if isinstance(item, str)]
303
+ tokens: list[int] = []
304
+ logprobs: list[float] = []
305
+ stop_reason: Literal["length", "stop"] = "length"
306
+ for position in range(max_tokens):
307
+ token = _derived_token(self.seed, prompt_token_ids, sample_index, position)
308
+ logprob = _derived_logprob(
309
+ "issued", self.seed, _ids_key(prompt_token_ids), str(sample_index), str(position)
310
+ )
311
+ tokens.append(token)
312
+ logprobs.append(logprob)
313
+ if token in int_stops:
314
+ stop_reason = "stop"
315
+ break
316
+ if str_stops:
317
+ text = "".join(chr(t) for t in tokens)
318
+ if any(text.endswith(item) for item in str_stops):
319
+ stop_reason = "stop"
320
+ break
321
+ record = IssuedSample(
322
+ prompt_ids=tuple(prompt_token_ids),
323
+ sampled_ids=tuple(tokens),
324
+ logprobs=tuple(logprobs),
325
+ )
326
+ self.issued.append(record)
327
+ self._ledger.records.append(record)
328
+ return FakeSampledSequence(tokens=tokens, logprobs=logprobs, stop_reason=stop_reason)
329
+
330
+ def compute_logprobs(self, token_ids: list[int]) -> list[float | None]:
331
+ """Per-position logprobs for a full token sequence.
332
+
333
+ Indexing convention (mirroring the real SDK's prompt_logprobs): index
334
+ i holds the logprob of token i given tokens < i, and index 0 is None
335
+ because the first token has no context (the real SDK likewise returns
336
+ None where a logprob cannot be computed; we chose None over a
337
+ placeholder to match it exactly).
338
+
339
+ Positions covered by a previously issued sampled span, meaning the
340
+ span's sampled ids appear in `token_ids` immediately after that span's
341
+ exact prompt ids, echo the exact logprobs issued at sampling time
342
+ (first matching record wins for overlaps). Every other position gets
343
+ a deterministic hash-derived value from (seed, tokens <= i).
344
+
345
+ Args:
346
+ token_ids: The full sequence to score.
347
+
348
+ Returns:
349
+ One entry per input position; entry 0 is None.
350
+ """
351
+ n = len(token_ids)
352
+ result: list[float | None] = [None] * n
353
+ filled = [False] * n
354
+ if n > 0:
355
+ filled[0] = True # position 0 stays None by convention
356
+ for record in self._ledger.records:
357
+ span_len = len(record.sampled_ids)
358
+ prompt_len = len(record.prompt_ids)
359
+ if span_len == 0:
360
+ continue
361
+ for start in range(prompt_len, n - span_len + 1):
362
+ if tuple(token_ids[start : start + span_len]) != record.sampled_ids:
363
+ continue
364
+ if tuple(token_ids[start - prompt_len : start]) != record.prompt_ids:
365
+ continue
366
+ for offset in range(span_len):
367
+ position = start + offset
368
+ if not filled[position]:
369
+ result[position] = record.logprobs[offset]
370
+ filled[position] = True
371
+ for position in range(1, n):
372
+ if not filled[position]:
373
+ result[position] = _derived_logprob(
374
+ "context", self.seed, _ids_key(token_ids[: position + 1])
375
+ )
376
+ return result
377
+
378
+ def topk_prompt_logprobs(
379
+ self, token_ids: list[int], k: int
380
+ ) -> tuple[list[float | None], list[list[tuple[int, float]] | None]]:
381
+ """Deterministic echo of the SDK's prefill-only top-k prompt logprobs.
382
+
383
+ Mirrors `sample(prompt=token_ids, max_tokens=1,
384
+ include_prompt_logprobs=True, topk_prompt_logprobs=k)` on the real
385
+ client: the first return value is the realized per-position logprobs
386
+ (exactly `compute_logprobs(token_ids)`, so issued spans echo their
387
+ sampling-time logprobs), and the second is one top-k candidate list
388
+ per position (None at position 0, which has no context). At each
389
+ scoreable position the top-1 candidate is the sequence's own token
390
+ with its realized logprob; the remaining k - 1 candidates carry
391
+ hash-derived token ids and strictly decreasing hash-derived logprobs
392
+ below it, so ranks are unambiguous and the whole result is a pure
393
+ function of (seed, ledger echoes, token_ids, k): replaying the same
394
+ call always returns the identical value.
395
+
396
+ Args:
397
+ token_ids: The full sequence to score, prompt-style.
398
+ k: Candidates per position (>= 1).
399
+
400
+ Returns:
401
+ The (realized logprobs, top-k rows) pair, both with one entry
402
+ per input position.
403
+
404
+ Raises:
405
+ ValueError: If `k` is not positive.
406
+ """
407
+ if k < 1:
408
+ raise ValueError(f"k must be >= 1, got {k}")
409
+ if k > _SAMPLED_TOKEN_RANGE:
410
+ raise ValueError(
411
+ f"the fake vocabulary has only {_SAMPLED_TOKEN_RANGE} derivable "
412
+ f"candidate ids per position, got k = {k} (the config caps "
413
+ "train.topk at 64, well inside it)"
414
+ )
415
+ realized = self.compute_logprobs(token_ids)
416
+ rows: list[list[tuple[int, float]] | None] = [None] * len(token_ids)
417
+ for position in range(1, len(token_ids)):
418
+ top_logprob = realized[position]
419
+ assert top_logprob is not None # compute_logprobs fills every p >= 1
420
+ entries: list[tuple[int, float]] = [(token_ids[position], top_logprob)]
421
+ seen = {token_ids[position]}
422
+ logprob = top_logprob
423
+ rank = 1
424
+ while len(entries) < k:
425
+ token = _derived_token(f"topk:{self.seed}", token_ids[:position], rank, position)
426
+ while token in seen:
427
+ token = _SAMPLED_TOKEN_BASE + (
428
+ (token - _SAMPLED_TOKEN_BASE + 1) % _SAMPLED_TOKEN_RANGE
429
+ )
430
+ seen.add(token)
431
+ logprob += _derived_logprob(
432
+ "topk-gap", self.seed, _ids_key(token_ids[: position + 1]), str(rank)
433
+ )
434
+ entries.append((token, logprob))
435
+ rank += 1
436
+ rows[position] = entries
437
+ return realized, rows
438
+
439
+
440
+ class FakeTrainingClient:
441
+ """Deterministic stand-in for tinker.TrainingClient.
442
+
443
+ Create via FakeServiceClient.create_lora_training_client. Simplifications
444
+ vs the real client: results return directly (no APIFuture), datums are
445
+ FakeDatum (plain lists rather than tensors), and optim_step takes a bare
446
+ learning rate rather than AdamParams.
447
+
448
+ Like the real client this one is UNINITIALIZED until a weight-affecting
449
+ call runs on it, and `load_state` is legal only while it stays that way
450
+ (see `MODEL_INITIALIZING_CALLS` and `load_state`).
451
+ """
452
+
453
+ def __init__(self, service: FakeServiceClient, base_model: str, rank: int) -> None:
454
+ self.base_model = base_model
455
+ self.rank = rank
456
+ self.step_count = 0
457
+ self.forward_backward_calls: list[tuple[list[FakeDatum], str]] = []
458
+ self.optim_step_lrs: list[float] = []
459
+ self.calls: list[str] = []
460
+ """Ordered log of every method called on this client, oldest first."""
461
+
462
+ self._service = service
463
+ self._ledger = _SpanLedger()
464
+ self._sampler_counter = 0
465
+
466
+ def get_tokenizer(self) -> FakeTokenizer:
467
+ """The deterministic char-level tokenizer for this fake model.
468
+
469
+ Logged like every other call, but absent from
470
+ `MODEL_INITIALIZING_CALLS`: the real SDK answers it from a
471
+ metadata-only GetInfo request, so it never blocks a later restore.
472
+ """
473
+ self.calls.append("get_tokenizer")
474
+ return FakeTokenizer()
475
+
476
+ def forward_backward(self, datums: list[FakeDatum], loss_fn: str) -> FakeForwardBackwardOutput:
477
+ """Record a training batch after asserting the TITO invariant.
478
+
479
+ Every sampled span in every datum (maximal nonzero-weight run of
480
+ target tokens) must exactly equal the sampled ids of some span an
481
+ eligible issuer previously issued: a linked sampling client (this
482
+ training client's refreshed student weights) or any sampling client
483
+ the owning FakeServiceClient created (the teacher client the warmup
484
+ phase trains on). Fabricated ids fail either way.
485
+
486
+ Datums flagged `topk=True` (topk-CE replicas) are checked on the
487
+ model INPUT instead of the targets: their targets are intentionally
488
+ teacher-proposed candidate tokens that no sampler ever issued (that
489
+ is the whole point of the loss), while the input context must remain
490
+ the student's exact sampled tokens. Each input-side loss span
491
+ (`FakeDatum.input_loss_spans`) must appear as a contiguous run inside
492
+ some issued span (a contiguous run rather than the whole span, since
493
+ the next-token shift truncates a sequence-final span by one token and
494
+ rank padding can split a run). Topk replicas are additionally pinned
495
+ to the cross_entropy loss.
496
+
497
+ Args:
498
+ datums: The batch to train on.
499
+ loss_fn: Loss function name (e.g. "importance_sampling", "ppo",
500
+ or "cross_entropy"); recorded but not interpreted beyond the
501
+ topk-replica pin above.
502
+
503
+ Returns:
504
+ A deterministic SDK-shaped output: the metrics dict carries a
505
+ "total_loss:sum" value derived purely from (loss_fn, batch
506
+ target tokens), so the same batch always reports the same loss.
507
+
508
+ Raises:
509
+ AssertionError: If a sampled span was never issued by an eligible
510
+ issuer (the message names the datum, the span, and the first
511
+ mismatching token position against the closest issued span),
512
+ if a topk replica's input-side loss span appears in no issued
513
+ span, or if a topk replica arrives under a loss other than
514
+ cross_entropy.
515
+ """
516
+ records = self._issuer_records()
517
+ issued = {record.sampled_ids for record in records}
518
+ for datum_index, datum in enumerate(datums):
519
+ if datum.topk:
520
+ if loss_fn != "cross_entropy":
521
+ raise AssertionError(
522
+ f"topk-CE replica datum {datum_index} was trained under "
523
+ f"loss_fn {loss_fn!r}; candidate targets are only valid "
524
+ "under cross_entropy"
525
+ )
526
+ for span in datum.input_loss_spans():
527
+ if any(_contains_run(record.sampled_ids, span) for record in records):
528
+ continue
529
+ raise AssertionError(
530
+ f"TITO violation in topk datum {datum_index}: the model input "
531
+ f"under a loss-weighted span (length {len(span)}) matches no "
532
+ "issued span; topk-CE may propose candidate TARGETS, but the "
533
+ "input context must stay the student's exact sampled tokens"
534
+ )
535
+ continue
536
+ for span in datum.sampled_spans():
537
+ if span in issued:
538
+ continue
539
+ raise AssertionError(self._tito_message(datum_index, span, records))
540
+ self.calls.append("forward_backward")
541
+ self.forward_backward_calls.append((list(datums), loss_fn))
542
+ loss = -_derived_logprob(
543
+ "batch-loss", loss_fn, *(_ids_key(datum.target_tokens) for datum in datums)
544
+ )
545
+ return FakeForwardBackwardOutput(
546
+ loss_fn_output_type="FakeLossReturn",
547
+ loss_fn_outputs=[{} for _ in datums],
548
+ metrics={"total_loss:sum": loss},
549
+ )
550
+
551
+ def _issuer_records(self) -> list[IssuedSample]:
552
+ """Every span an eligible issuer recorded: linked ledger + service clients."""
553
+ records = list(self._ledger.records)
554
+ records.extend(self._service.issued_records())
555
+ return records
556
+
557
+ def _tito_message(
558
+ self, datum_index: int, span: tuple[int, ...], records: list[IssuedSample]
559
+ ) -> str:
560
+ """Build the TITO failure message naming the first mismatch position."""
561
+ best: IssuedSample | None = None
562
+ best_prefix = -1
563
+ for record in records:
564
+ prefix = 0
565
+ for a, b in zip(span, record.sampled_ids, strict=False):
566
+ if a != b:
567
+ break
568
+ prefix += 1
569
+ if prefix > best_prefix:
570
+ best_prefix = prefix
571
+ best = record
572
+ if best is None:
573
+ return (
574
+ f"TITO violation in datum {datum_index}: sampled span of length "
575
+ f"{len(span)} trained on, but no eligible sampler issued any span"
576
+ )
577
+ mismatch = best_prefix
578
+ if mismatch < len(span) and mismatch < len(best.sampled_ids):
579
+ detail = (
580
+ f"first mismatch at position {mismatch}: datum has {span[mismatch]}, "
581
+ f"closest issued span has {best.sampled_ids[mismatch]}"
582
+ )
583
+ else:
584
+ detail = (
585
+ f"first mismatch at position {mismatch}: datum span length {len(span)} "
586
+ f"vs closest issued span length {len(best.sampled_ids)}"
587
+ )
588
+ return f"TITO violation in datum {datum_index}: {detail}"
589
+
590
+ def optim_step(self, learning_rate: float) -> FakeOptimStepResponse:
591
+ """Apply one optimizer step: increments the step counter.
592
+
593
+ Returns:
594
+ A deterministic SDK-shaped response whose metrics carry a
595
+ "grad_norm:mean" derived purely from (step count, learning rate).
596
+ """
597
+ self.calls.append("optim_step")
598
+ self.optim_step_lrs.append(learning_rate)
599
+ self.step_count += 1
600
+ grad_norm = -_derived_logprob("grad-norm", str(self.step_count), str(learning_rate))
601
+ return FakeOptimStepResponse(metrics={"grad_norm:mean": grad_norm})
602
+
603
+ def save_state(self) -> str:
604
+ """Save training state, returning a fake tinker:// state path.
605
+
606
+ The state lands in the owning FakeServiceClient's artifact store, not
607
+ on this client: real tinker:// state paths outlive the session that
608
+ wrote them, which is what lets a later session restore them on a
609
+ freshly created training client.
610
+ """
611
+ self.calls.append("save_state")
612
+ return self._service._register_state(self.step_count)
613
+
614
+ def load_state(self, path: str) -> None:
615
+ """Restore a previously saved state; must be this client's FIRST call.
616
+
617
+ Mirrors the live service's rule that LoadWeights is accepted only on
618
+ an uninitialized model: once any call in `MODEL_INITIALIZING_CALLS`
619
+ has run here, restoring is refused. That is the exact failure a
620
+ resumed distillation run hits when anything (a sampler-weights save,
621
+ a forward pass, or an earlier abandoned restore) touches the training
622
+ client before the checkpoint is loaded.
623
+
624
+ Args:
625
+ path: A path returned by save_state on any training client of the
626
+ owning service.
627
+
628
+ Raises:
629
+ RuntimeError: If a weight-affecting call already ran on this
630
+ client; the message names them in order.
631
+ ValueError: If the path was never returned by save_state.
632
+ """
633
+ initialized = [call for call in self.calls if call in MODEL_INITIALIZING_CALLS]
634
+ if initialized:
635
+ raise RuntimeError(
636
+ "LoadWeights can only be called on uninitialized models: load_state "
637
+ f"came after {initialized} on this training client. Restore the "
638
+ "checkpoint as the first call on a freshly created training client"
639
+ )
640
+ step_count = self._service._saved_state_step(path)
641
+ self.calls.append("load_state")
642
+ self.step_count = step_count
643
+
644
+ def save_weights_for_sampler(self, name: str) -> str:
645
+ """Save current weights for sampling, returning a fake sampler path.
646
+
647
+ The path can be exchanged for a linked FakeSamplingClient via
648
+ FakeServiceClient.create_sampling_client.
649
+ """
650
+ self.calls.append("save_weights_for_sampler")
651
+ path = f"tinker://fake/sampler/{name}/{self._sampler_counter}"
652
+ self._sampler_counter += 1
653
+ self._service._register_sampler_path(path, self)
654
+ return path
655
+
656
+ def save_weights_and_get_sampling_client(self, name: str) -> FakeSamplingClient:
657
+ """Save current weights and return a fresh linked sampling client.
658
+
659
+ Each call yields a distinct sampler path (so a refreshed sampler
660
+ samples different tokens) whose client shares this training client's
661
+ span ledger (so TITO checks see every sampler's issued spans).
662
+ """
663
+ path = self.save_weights_for_sampler(name)
664
+ return FakeSamplingClient(seed=path, ledger=self._ledger)
665
+
666
+
667
+ class FakeServiceClient:
668
+ """Deterministic stand-in for tinker.ServiceClient.
669
+
670
+ Every sampling client it creates is remembered as a potential TITO issuer
671
+ (see `issued_records`): the real service serves the teacher and the
672
+ student through the same account, so tokens the teacher client genuinely
673
+ sampled are legitimate training targets for the warmup phase, while ids no
674
+ client ever issued remain violations.
675
+
676
+ The service also owns the saved-state artifact store, so a state one
677
+ training client wrote can be restored by another (what a resumed run
678
+ does): every `create_lora_training_client` call yields a fresh,
679
+ uninitialized client, just like the real service.
680
+ """
681
+
682
+ def __init__(self) -> None:
683
+ self._sampler_paths: dict[str, FakeTrainingClient] = {}
684
+ self._sampling_clients: list[FakeSamplingClient] = []
685
+ self._states: dict[str, int] = {}
686
+ self._state_counter = 0
687
+
688
+ def create_lora_training_client(self, base_model: str, rank: int = 32) -> FakeTrainingClient:
689
+ """Create a fresh, uninitialized fake LoRA training client."""
690
+ return FakeTrainingClient(service=self, base_model=base_model, rank=rank)
691
+
692
+ def create_sampling_client(self, model_path: str) -> FakeSamplingClient:
693
+ """Create (and track as a TITO issuer) a sampling client for a path.
694
+
695
+ Paths produced by a linked training client's save_weights_for_sampler
696
+ yield clients that share that training client's span ledger; any other
697
+ path (e.g. a base model name for a standalone teacher) yields an
698
+ unlinked client seeded with the path. Either way the client is tracked
699
+ so its issued spans satisfy the training clients' TITO checks.
700
+ """
701
+ training = self._sampler_paths.get(model_path)
702
+ if training is not None:
703
+ client = FakeSamplingClient(seed=model_path, ledger=training._ledger)
704
+ else:
705
+ client = FakeSamplingClient(seed=model_path)
706
+ self._sampling_clients.append(client)
707
+ return client
708
+
709
+ def issued_records(self) -> list[IssuedSample]:
710
+ """Every span any sampling client created by this service issued."""
711
+ return [record for client in self._sampling_clients for record in client.issued]
712
+
713
+ def _register_sampler_path(self, path: str, training: FakeTrainingClient) -> None:
714
+ self._sampler_paths[path] = training
715
+
716
+ def _register_state(self, step_count: int) -> str:
717
+ """Store one saved state, returning its fresh service-wide path."""
718
+ path = f"tinker://fake/state/{self._state_counter}"
719
+ self._state_counter += 1
720
+ self._states[path] = step_count
721
+ return path
722
+
723
+ def _saved_state_step(self, path: str) -> int:
724
+ """The step count a saved state carries.
725
+
726
+ Raises:
727
+ ValueError: If no training client of this service saved `path`.
728
+ """
729
+ if path not in self._states:
730
+ raise ValueError(
731
+ f"unknown state path {path!r}: it was never returned by save_state "
732
+ "on a training client of this service"
733
+ )
734
+ return self._states[path]