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,726 @@
1
+ """Collect one distillation training step's rollouts as real harbor trials.
2
+
3
+ `collect_rollouts` turns one train batch (task ids x group size) into scored
4
+ harbor trials of **harbor's own terminus-2 agent** sampling the Tinker
5
+ student, and joins each trial's exact token spans back into `TrialRecord`s.
6
+
7
+ Terminus-2, not the WMO pi bridge, because the scaffold is not what this run
8
+ is measuring: on the same TerminalBench-2 tasks pi took 2-3x the turns
9
+ terminus-2's published p50 of 10 (p50 21-30 across four models) with 0%
10
+ wasted turns, so the gap is scaffold efficiency, not a bug, and it drove
11
+ 39-59% of the harness loss. Terminus-2 supports this natively:
12
+ `llm_backend="tinker"` builds `harbor.llms.tinker.TinkerLLM`, and
13
+ `collect_rollout_details=True` records per-turn `prompt_token_ids`,
14
+ `completion_token_ids` and `logprobs` that harbor persists into each trial's
15
+ `result.json` (`agent_result.rollout_details`). Those ids ARE the training
16
+ targets; nothing is re-encoded from text.
17
+
18
+ Three consequences of that agent swap are load-bearing here:
19
+
20
+ - **No token sink directory.** Spans come from harbor's own per-trial
21
+ `result.json`, which the scorer's entry prune deletes together with the
22
+ trial it belongs to, so there is nothing left to keep outside the trial dir.
23
+ - **Terminus-2 runs IN-PROCESS**, driving the task environment over tmux, so
24
+ a trial holds one sandbox (the task environment), not two, and the Tinker
25
+ API key must be present in THIS process rather than forwarded into a
26
+ harness sandbox.
27
+ - **Terminus-2 has no wall clock of its own**, so `rollout.episode_timeout_s`
28
+ is applied as harbor's agent-phase timeout
29
+ (`AgentConfig.override_timeout_sec`) instead of an agent kwarg.
30
+
31
+ Two directory choices are unchanged and still load-bearing:
32
+
33
+ - The harbor jobs dir is FRESH per training step (`run_dir/harbor/step-NNNN`):
34
+ harbor job dirs are keyed by the candidate doc hash only, so reusing one
35
+ jobs dir across steps would resume step N's completed trials as step N+1's
36
+ "results", carrying tokens sampled from the previous weights.
37
+ - A job dir left by a PREVIOUS SESSION under different sampler weights is
38
+ wiped before scoring (`_wipe_stale_policy_dir`): sampler paths carry a
39
+ per-session nonce, so such a dir can never satisfy the scorer's strict
40
+ job-config resume check, and its trials sampled a policy this session did
41
+ not restore. Wiping makes a crash-resumed step re-run whole from the
42
+ current weights. A dir whose recorded provider matches (the teacher's
43
+ stable identity) is kept for harbor's native trial-level resume.
44
+
45
+ The harbor SDK is an optional extra imported lazily here, the same contract
46
+ as the CLI's harbor commands; `import wmo.distill.rollouts` succeeds without
47
+ it.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import asyncio
53
+ import json
54
+ import logging
55
+ import shutil
56
+ from collections.abc import Callable, Sequence
57
+ from pathlib import Path
58
+ from typing import TYPE_CHECKING
59
+
60
+ from pydantic import BaseModel, ConfigDict, Field
61
+
62
+ from wmo.core.types import JsonObject
63
+ from wmo.distill.config import DistillConfig
64
+ from wmo.distill.tokens import TrialRecord, assemble_harbor_trial_records
65
+ from wmo.harness.doc import HarnessDoc
66
+ from wmo.harness.runtime import StopReason
67
+ from wmo.providers.base import ProviderConfig, ProviderKind
68
+
69
+ if TYPE_CHECKING:
70
+ from harbor.models.job.config import JobConfig
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+ MISSING_HARBOR_EXTRA = (
75
+ "the harbor SDK is not installed; distillation rollouts run as real harbor trials. "
76
+ "Run `uv sync --extra harbor` (or `pip install 'world-model-optimizer[harbor]'`) and retry"
77
+ )
78
+
79
+ HARBOR_TERMINUS_2_AGENT_IMPORT_PATH = "harbor.agents.terminus_2.terminus_2:Terminus2"
80
+ """Harbor's own terminus-2 agent, the rollout agent for distillation.
81
+
82
+ Imported by string through harbor's agent factory (never at module scope here), so
83
+ `import wmo.distill.rollouts` keeps working without the harbor extra."""
84
+
85
+ E2B_SANDBOXES_PER_TRIAL = 1
86
+ """Concurrent E2B sandboxes one `harbor.backend = "e2b"` trial holds at once.
87
+
88
+ One, not two: terminus-2 runs inside the harbor process and drives the task environment over
89
+ tmux, so the only sandbox a trial holds is harbor's task environment. (The pi bridge needed a
90
+ second, pooled sandbox to host the harness process; that path is no longer used for
91
+ distillation rollouts.) Capacity planning (`wmo.cli.harness_distill`) multiplies by this."""
92
+
93
+
94
+ def _recorded_provider_config(config_path: Path) -> JsonObject | None:
95
+ """The provider config a persisted harbor job config ran with, or None.
96
+
97
+ None means the file is unreadable or not shaped like a scorer-produced
98
+ JobConfig dump; callers leave those dirs alone so the scorer can raise its
99
+ own actionable error instead of evidence being destroyed silently.
100
+ """
101
+ try:
102
+ payload = json.loads(config_path.read_text(encoding="utf-8"))
103
+ except (OSError, json.JSONDecodeError):
104
+ return None
105
+ if not isinstance(payload, dict):
106
+ return None
107
+ agents = payload.get("agents")
108
+ if not isinstance(agents, list) or not agents or not isinstance(agents[0], dict):
109
+ return None
110
+ kwargs = agents[0].get("kwargs")
111
+ if not isinstance(kwargs, dict):
112
+ return None
113
+ recorded = kwargs.get("provider_config")
114
+ return recorded if isinstance(recorded, dict) else None
115
+
116
+
117
+ def _wipe_stale_policy_dir(candidate_dir: Path, provider_config: ProviderConfig) -> bool:
118
+ """Wipe a candidate job dir recorded under different provider weights.
119
+
120
+ Sampler paths carry a per-session nonce, so a job dir left by a previous
121
+ session (a crash mid-batch, then `--resume`) can never satisfy the
122
+ scorer's strict job-config resume check, and its completed trials sampled
123
+ a policy this session did not restore, so resuming them would mix
124
+ policies. Deleting the dir makes the batch re-run whole from the current
125
+ weights: correct, at the price of re-running that batch's completed
126
+ trials. The trials' recorded token spans go with it, which is right -
127
+ they were sampled from the policy being discarded.
128
+
129
+ A recorded provider that MATCHES the current one (the teacher's stable
130
+ identity, whose baseline eval legitimately resumes across sessions) is
131
+ left for harbor's native trial-level resume, as is an unreadable config
132
+ (the scorer raises its own actionable error for those).
133
+
134
+ Args:
135
+ candidate_dir: The scorer's deterministic per-candidate job dir.
136
+ provider_config: The provider the batch is about to sample.
137
+
138
+ Returns:
139
+ True when the directory was wiped.
140
+ """
141
+ recorded = _recorded_provider_config(candidate_dir / "config.json")
142
+ if recorded is None or recorded == provider_config.model_dump(mode="json"):
143
+ return False
144
+ logger.warning(
145
+ "harbor job dir %s was produced under provider %r, not the current %r; "
146
+ "wiping it so the batch re-runs from the current weights instead of "
147
+ "resuming another policy's trials",
148
+ candidate_dir,
149
+ recorded.get("model"),
150
+ provider_config.model,
151
+ )
152
+ shutil.rmtree(candidate_dir)
153
+ return True
154
+
155
+
156
+ def terminus_2_agent_kwargs(cfg: DistillConfig, provider_config: ProviderConfig) -> JsonObject:
157
+ """The harbor `AgentConfig.kwargs` that make terminus-2 sample the Tinker student.
158
+
159
+ Every value here is either a WMO config knob or an invariant the
160
+ tokens-in-tokens-out contract depends on:
161
+
162
+ - `llm_backend="tinker"` selects `harbor.llms.tinker.TinkerLLM`, and
163
+ `llm_kwargs.model_path` points it at the EXACT weights being sampled
164
+ (`provider_config.model`, a `tinker://.../sampler_weights/...` path).
165
+ The base model that names the renderer and tokenizer travels separately
166
+ as harbor's `AgentConfig.model_name` (`provider_config.model_type`),
167
+ because `TinkerLLM(model_name=...)` means the base model, not the
168
+ checkpoint. A provider whose `model` IS its `model_type` (the teacher
169
+ sampling a base model directly) sends no `model_path` at all.
170
+ - `collect_rollout_details=True` is what records `prompt_token_ids`,
171
+ `completion_token_ids` and `logprobs` per turn. Without it a trial
172
+ produces rewards and no training data at all.
173
+ - `enable_summarize` follows `rollout.compaction`, which defaults False and
174
+ which `RolloutConfig` only allows to be true under a history-editing
175
+ renderer. Off, a context overflow raises and the trial FAILS on a
176
+ recorded `ContextLengthExceededError` -- it does not score zero, it
177
+ leaves the denominator. On, terminus-2 compacts and the episode
178
+ continues; under a strip-history renderer that costs nothing structural,
179
+ because every real turn is still recorded (the summarization subagents
180
+ bypass the `Chat` entirely) and the episode was already one datum per
181
+ turn. See `RolloutConfig.compaction` for why this is not the blanket
182
+ rejection it used to be.
183
+ - `max_turns` is terminus-2's `max_episodes`, and
184
+ `llm_kwargs.context_limit` / `max_tokens` are the context and output
185
+ budgets the agent measures itself against.
186
+ - `llm_kwargs.renderer_name` is sent only when `rollout.renderers` names
187
+ one for THIS provider's base model, so a run whose teacher rollouts use
188
+ a different base model than the student still gets each model's own
189
+ renderer. The wmo verbatim renderers are registered with the cookbook
190
+ here, because this is the one call site every rollout path shares, and
191
+ terminus-2 resolves the name from that registry in this process.
192
+
193
+ Args:
194
+ cfg: The validated run config.
195
+ provider_config: The provider the batch samples; must be the tinker
196
+ kind, since only Tinker sampling records token ids and logprobs.
197
+
198
+ Returns:
199
+ The kwargs dict, safe to pass as the scorer's `extra_agent_kwargs`
200
+ (it collides with none of the scorer-owned keys).
201
+
202
+ Raises:
203
+ ValueError: If the provider is not the tinker kind.
204
+ """
205
+ if provider_config.kind is not ProviderKind.TINKER:
206
+ raise ValueError(
207
+ "distillation rollouts must sample through Tinker so the student's exact token "
208
+ f"spans are recorded, got provider kind {provider_config.kind.value!r}; configure "
209
+ "the worker provider with kind 'tinker'"
210
+ )
211
+ llm_kwargs: JsonObject = {
212
+ "max_tokens": cfg.sampling.max_tokens,
213
+ "context_limit": cfg.rollout.context_budget_tokens,
214
+ "output_limit": cfg.sampling.max_tokens,
215
+ }
216
+ if provider_config.model != provider_config.model_type:
217
+ llm_kwargs["model_path"] = provider_config.model
218
+ renderer_name = cfg.rollout.renderers.get(provider_config.model_type)
219
+ if renderer_name is not None:
220
+ llm_kwargs["renderer_name"] = renderer_name
221
+ # Terminus-2 resolves `renderer_name` through the cookbook's GLOBAL registry, inside the
222
+ # TinkerLLM it builds in this process. This is the single place every rollout path (training,
223
+ # warmup collection, eval waves) passes through, so registering the wmo verbatim renderers
224
+ # here is what makes a `[rollout.renderers]` entry naming one resolvable. The import is
225
+ # deferred because wmo.distill.renderers subclasses cookbook classes at module scope, and
226
+ # `import wmo.distill.rollouts` must keep working without the distill extra (the CLI imports
227
+ # it eagerly). Registration is idempotent.
228
+ from wmo.distill.renderers import register_wmo_renderers
229
+
230
+ register_wmo_renderers()
231
+ _share_harbor_tinker_service_client()
232
+ return {
233
+ "llm_backend": "tinker",
234
+ "llm_kwargs": llm_kwargs,
235
+ "collect_rollout_details": True,
236
+ "temperature": cfg.sampling.temperature,
237
+ "max_turns": cfg.rollout.max_turns,
238
+ "enable_summarize": cfg.rollout.compaction,
239
+ # The cap is deliberate here, so terminus-2's "consider removing this limit" warning is
240
+ # noise on every trial.
241
+ "suppress_max_turns_warning": True,
242
+ }
243
+
244
+
245
+ _HARBOR_TINKER_CLIENT_SHARED = False
246
+
247
+
248
+ def _share_harbor_tinker_service_client() -> None:
249
+ """Make harbor's `TinkerLLM` reuse wmo's process-wide `ServiceClient`.
250
+
251
+ Harbor's `TinkerLLM._ensure_client` does `tinker.ServiceClient()` per
252
+ instance and never closes it, and terminus-2 builds one `TinkerLLM` per
253
+ TRIAL. The SDK's service client starts a heartbeat task that strongly
254
+ references its holder, so each one pins a live server-side session for the
255
+ rest of the process -- the exact leak `wmo.providers.tinker.
256
+ shared_service_client` exists to prevent for wmo's own call paths. Harbor's
257
+ class was the one path that still bypassed it.
258
+
259
+ Measured cost of not doing this, on a 64-episode-per-step run: step 0 clean,
260
+ step 1 three failures, step 2 THIRTY-ONE trials rejected with
261
+ `400 Too many active sessions. Please ensure you are not creating extra
262
+ ServiceClient objects`, halving that step's datums. The cap is ~240
263
+ sessions and each step burns 64, so the failure arrives on schedule and
264
+ worsens every step.
265
+
266
+ Only the client construction changes; the sampling-client selection below
267
+ it is harbor's own logic, reproduced exactly. Idempotent, and a no-op if
268
+ harbor's internals move -- a rollout that samples successfully matters more
269
+ than this optimization, so a shape mismatch leaves the original in place.
270
+ """
271
+ global _HARBOR_TINKER_CLIENT_SHARED
272
+ if _HARBOR_TINKER_CLIENT_SHARED:
273
+ return
274
+ from harbor.llms.tinker import TinkerLLM
275
+
276
+ from wmo.providers import tinker as tinker_provider
277
+
278
+ if not hasattr(TinkerLLM, "_ensure_client"): # pragma: no cover - upstream moved
279
+ logger.warning(
280
+ "harbor's TinkerLLM has no _ensure_client; leaving its service-client "
281
+ "construction alone. Watch for '400 Too many active sessions' errors"
282
+ )
283
+ _HARBOR_TINKER_CLIENT_SHARED = True
284
+ return
285
+
286
+ async def _ensure_client_shared(self: object) -> object:
287
+ """Harbor's `_ensure_client`, but on the shared service client."""
288
+ existing = getattr(self, "_sampling_client", None)
289
+ if existing is not None:
290
+ return existing
291
+ # Resolved through the MODULE, not captured at patch time, so
292
+ # `rebuild_shared_service_client()` (the wedge-recovery path) is honoured.
293
+ service = tinker_provider.shared_service_client()
294
+ self._service_client = service # ty: ignore[unresolved-attribute]
295
+ model_path = getattr(self, "_model_path", None)
296
+ if model_path:
297
+ client = await service.create_sampling_client_async(model_path=model_path)
298
+ else:
299
+ client = await service.create_sampling_client_async(
300
+ base_model=self._model_name # ty: ignore[unresolved-attribute]
301
+ )
302
+ self._sampling_client = client # ty: ignore[unresolved-attribute]
303
+ return client
304
+
305
+ TinkerLLM._ensure_client = _ensure_client_shared # ty: ignore[invalid-assignment]
306
+ _HARBOR_TINKER_CLIENT_SHARED = True
307
+ logger.info("harbor TinkerLLM patched to share wmo's process-wide tinker ServiceClient")
308
+
309
+
310
+ class RolloutStats(BaseModel):
311
+ """Aggregate health metrics for one collected rollout batch."""
312
+
313
+ model_config = ConfigDict(frozen=True, extra="forbid")
314
+
315
+ trials: int = Field(ge=0)
316
+ trials_with_spans: int = Field(ge=0)
317
+ solve_rate: float = Field(ge=0.0, le=1.0)
318
+ """Fraction of EXECUTED trials whose verifier reward passed (metrics/gating signal).
319
+
320
+ Infrastructure failures are excluded from the denominator: a trial with no verifier evidence
321
+ behind its stand-in 0.0 is an UNKNOWN outcome, and counting it as a task failure biases this
322
+ rate in whichever direction the failures fell. Both directions have been measured: three Super
323
+ `student-before` baselines were reported as 0.0% from 51/51 rate-limited trials, and 2 of 48
324
+ TerminalBench-2 probe trials whose verifier timed out on submitted work held a probe at 20.8%
325
+ when its gradeable denominator was 46. 0.0 when nothing executed (`executed_trials == 0`),
326
+ which callers must treat as a null measurement rather than a score."""
327
+
328
+ graded_solve_rate: float = Field(default=0.0, ge=0.0, le=1.0)
329
+ """Mean GRADED test-pass score over the trials that have one: the power metric beside
330
+ `solve_rate`.
331
+
332
+ Same trials, read at test resolution instead of the benchmark's one bit (see
333
+ `wmo.harness.scoring.GradedTests`). `solve_rate` stays the headline because binary IS the
334
+ benchmark's definition of success; this exists because a small binary holdout cannot resolve a
335
+ real effect. On the 48-episode TerminalBench-2 probe it reads 0.319 against a 0.217 binary solve
336
+ rate, and it moves 2 of 12 tasks off a flat 0.00 where no improvement could ever have shown up.
337
+
338
+ Denominator: trials that are NOT `infra_failed` AND carry a readable test report
339
+ (`graded_trials`). A verifier that timed out wrote no report either, so such a trial is excluded
340
+ here exactly as it is from `solve_rate`, never averaged in as 0.0. 0.0 when there are none,
341
+ which callers must read as a null measurement, not a score.
342
+
343
+ Coarse, not continuous: the probe's tasks carried 1 to 6 tests, so most trial scores are 0, 1/2,
344
+ or 1, and a single-test task is exactly as binary as its reward."""
345
+
346
+ graded_trials: int = Field(default=0, ge=0)
347
+ """Gradeable trials that carried a readable test report: the `graded_solve_rate` denominator.
348
+
349
+ Below `executed_trials` means some graded trials produced a reward but no parseable test report,
350
+ so the two rates ran over different trial sets; the gap is what says so instead of a fabricated
351
+ 0.0 hiding it."""
352
+
353
+ raw_solve_rate: float = Field(ge=0.0, le=1.0)
354
+ """Passing trials over ALL trials, infra failures included.
355
+
356
+ What the training path actually optimizes against (`missing_reward="zero"` makes a dead trial a
357
+ failed trial for advantage estimation), kept alongside `solve_rate` so the two can be compared
358
+ instead of one silently standing in for the other."""
359
+
360
+ executed_trials: int = Field(ge=0)
361
+ """Trials that produced verifier evidence (`trials` minus `infra_failed_trials`)."""
362
+
363
+ infra_failed_trials: int = Field(ge=0)
364
+ """Trials with no verifier evidence, so no measured outcome.
365
+
366
+ One count over two causes, which share this denominator treatment but not their diagnosis:
367
+ the agent never ran (sandbox creation, rate limit, transport death) or the agent ran and was
368
+ never graded (verifier timeout, unwritten/unparseable reward file). The per-cell note the
369
+ scorer writes (`infra-failure: <exception type>; ...`) is what tells them apart."""
370
+
371
+ empty_span_trials: int = Field(ge=0)
372
+ """Trials that recorded no token span (died before the first completion)."""
373
+
374
+ truncated_spans: int = Field(default=0, ge=0)
375
+ """Turns that sampled the full output cap, so the model was cut off mid-answer.
376
+
377
+ Nothing upstream reports this. Harbor's `TinkerLLM` means to
378
+ (`harbor/llms/tinker.py:243` raises `OutputLengthExceededError` when a
379
+ response hit `max_tokens`), but it gates on `not parse_success`, and
380
+ `parse_success` is a `ParseTermination` StrEnum whose members are ALL
381
+ non-empty strings and therefore ALL truthy. The guard can never fire, so a
382
+ turn cut off at the cap flows on as an ordinary turn: the agent reads a
383
+ half-written action, the parser reports an error, and the episode continues
384
+ as if the model had simply answered badly.
385
+
386
+ A truncated turn also has no stop token, so it cannot be replayed verbatim
387
+ (`wmo.distill.renderers`) and shows up downstream as a prefix break, which
388
+ means a fragmented episode and multiplied teacher-scoring cost. Counted per
389
+ SPAN, since one bad turn does not spoil the episode's other turns; a rising
390
+ count means `sampling.max_tokens` is too low for the reasoning these tasks
391
+ provoke, not that the model got worse.
392
+
393
+ 0 on records assembled before this field existed."""
394
+
395
+ truncated_span_trials: int = Field(default=0, ge=0)
396
+ """Trials carrying at least one `truncated_spans` turn: the episode-level view."""
397
+
398
+ stop_reason_counts: dict[str, int] = Field(default_factory=dict)
399
+ """How many trials ended on each recorded stop reason (`"unknown"` when no trace was readable).
400
+
401
+ The per-reason breakdown behind `scaffold_loss_rate`: `max_turns` (turn cap), `budget` (wall
402
+ clock), `no_tool_call`, `output_truncated`, `unparsed_tool_call`, `provider_error`, and
403
+ `submitted`."""
404
+
405
+ scaffold_loss_rate: float = Field(ge=0.0, le=1.0)
406
+ """Share of trials WITH A READABLE STOP REASON that never reached an explicit `submit`.
407
+
408
+ The headline number of the pi/Nemotron-3 scaffold audit: it was 88.8% for Super and 92.2% for
409
+ Ultra, every one of those trials scored reward 0, and nothing in the metrics surfaced it. An
410
+ episode the harness cut off measures where the guillotine fell, not what the model can do, so
411
+ this belongs beside every solve rate. 0.0 when no trial reported a stop reason.
412
+
413
+ Deliberately NOT the `executed` denominator that `solve_rate` uses, because the two answer
414
+ different questions. "Did the harness cut this episode off?" is answered by the stop reason
415
+ alone and needs no verifier; "did the model solve it?" needs a grade. An episode that reached
416
+ `submit` and then had its VERIFIER time out is a scaffold SUCCESS whose task outcome is
417
+ unknown, so it belongs in this denominator (as a non-loss) while being excluded from
418
+ `solve_rate`. Sharing one denominator conflated them and inflated this rate: on the 48-episode
419
+ probe it read 15.22% over 46 gradeable trials when the true scaffold loss was 14.58% (7 of 48
420
+ episodes reported a non-submit stop reason). A trial with no readable trace at all has no
421
+ stop reason and is excluded from both."""
422
+
423
+ # TODO: surface TokenRecorder.fallback_count (incremental-prompt re-renders) here;
424
+ # it needs the per-trial span sink format to carry the counter across the
425
+ # agent-process boundary, so it is not trivially wireable today.
426
+
427
+
428
+ UNKNOWN_STOP_REASON = "unknown"
429
+ """Stop-reason bucket for a trial whose run trace was missing or unreadable."""
430
+
431
+
432
+ def rollout_stats(records: Sequence[TrialRecord], *, max_tokens: int) -> RolloutStats:
433
+ """The batch health stats over a set of assembled trial records.
434
+
435
+ A pure function of the records and the output cap they sampled under, so a
436
+ batch loaded back from persisted trial records (the warmup-trials manifest)
437
+ reports the same stats its original collection did.
438
+
439
+ Args:
440
+ records: The batch's trial records.
441
+ max_tokens: The per-turn output cap the batch sampled under
442
+ (`sampling.max_tokens`); a span that reached it was cut off, which
443
+ nothing upstream reports (see `RolloutStats.truncated_spans`). Pass
444
+ THIS run's cap when re-reading another run's records: the count
445
+ then answers "would these turns be truncated here", which is what
446
+ the reader of this run's metrics needs.
447
+
448
+ Returns:
449
+ The aggregate stats. An empty batch, and a batch where every trial was
450
+ an infrastructure failure, both report a 0.0 solve rate over zero
451
+ executed trials: those are null measurements, and the counts are what
452
+ distinguish them. `scaffold_loss_rate` runs over its own denominator
453
+ (trials that reported a stop reason), so a batch whose agents all ran
454
+ but whose verifiers all failed still reports a real scaffold rate.
455
+ `graded_solve_rate` runs over `graded_trials` (gradeable trials with a
456
+ readable test report) and is 0.0 over zero of them, likewise a null
457
+ measurement rather than a score.
458
+ """
459
+ with_spans = sum(1 for record in records if record.spans)
460
+ truncated_per_record = [
461
+ sum(1 for span in record.spans if len(span.sampled_token_ids) >= max_tokens)
462
+ for record in records
463
+ ]
464
+ executed = [record for record in records if not record.infra_failed]
465
+ # The graded rate's own denominator: gradeable trials whose verifier also left a readable test
466
+ # report. A missing report is an absent measurement, so it is excluded rather than scored 0.0,
467
+ # the same rule that keeps an ungradeable trial out of `solve_rate`.
468
+ graded = [record.graded_score for record in executed if record.graded_score is not None]
469
+ counts: dict[str, int] = {}
470
+ for record in records:
471
+ key = record.stop_reason or UNKNOWN_STOP_REASON
472
+ counts[key] = counts.get(key, 0) + 1
473
+ # The scaffold question ("did the harness cut this off?") is answered by the stop reason and
474
+ # needs no verifier, so it gets its own denominator: every trial that reported one. Sharing
475
+ # `executed` with solve_rate dropped submit-then-ungradeable episodes out of a rate they
476
+ # belong in as successes, which inflated it.
477
+ with_stop_reason = [record for record in records if record.stop_reason]
478
+ submitted = sum(
479
+ 1 for record in with_stop_reason if record.stop_reason == StopReason.SUBMITTED.value
480
+ )
481
+ return RolloutStats(
482
+ trials=len(records),
483
+ trials_with_spans=with_spans,
484
+ solve_rate=(
485
+ sum(1 for record in executed if record.passed) / len(executed) if executed else 0.0
486
+ ),
487
+ graded_solve_rate=(sum(graded) / len(graded) if graded else 0.0),
488
+ graded_trials=len(graded),
489
+ raw_solve_rate=(
490
+ sum(1 for record in records if record.passed) / len(records) if records else 0.0
491
+ ),
492
+ executed_trials=len(executed),
493
+ infra_failed_trials=len(records) - len(executed),
494
+ empty_span_trials=len(records) - with_spans,
495
+ truncated_spans=sum(truncated_per_record),
496
+ truncated_span_trials=sum(1 for count in truncated_per_record if count),
497
+ stop_reason_counts=dict(sorted(counts.items())),
498
+ scaffold_loss_rate=(
499
+ (len(with_stop_reason) - submitted) / len(with_stop_reason) if with_stop_reason else 0.0
500
+ ),
501
+ )
502
+
503
+
504
+ def _with_agent_wall_budget(template: JobConfig, episode_timeout_s: float) -> JobConfig:
505
+ """Return the job template with the episode wall budget as harbor's agent timeout.
506
+
507
+ Terminus-2 has no wall clock of its own (the WMO pi bridge enforced
508
+ `episode_timeout_sec` itself), so the budget has to be harbor's: harbor
509
+ runs `agent.run()` under `asyncio.wait_for(..., agent_timeout_sec)`,
510
+ derived from `AgentConfig.override_timeout_sec` when set and the task's
511
+ own declared agent timeout otherwise. Overriding it here makes every
512
+ rollout and eval wave honor `rollout.episode_timeout_s` instead of
513
+ whatever each task happens to declare.
514
+
515
+ `max_timeout_sec` is cleared alongside it, because harbor resolves the
516
+ effective budget as `min(base, max) * multiplier` and a template-provided
517
+ ceiling would silently shorten a deliberately raised budget.
518
+
519
+ The scorer's template validation permits both fields (it owns agent
520
+ identity, model, skills, env and kwargs, not the timeouts), so this is a
521
+ supported use of the seam rather than a bypass.
522
+
523
+ Args:
524
+ template: The validated harbor `JobConfig`.
525
+ episode_timeout_s: The per-episode wall budget in seconds.
526
+
527
+ Returns:
528
+ A revalidated `JobConfig` whose template agents carry the budget.
529
+ """
530
+ from harbor.models.job.config import JobConfig
531
+ from harbor.models.trial.config import AgentConfig
532
+
533
+ agents = []
534
+ for agent in template.agents:
535
+ agent_fields = {name: getattr(agent, name) for name in AgentConfig.model_fields}
536
+ agent_fields["override_timeout_sec"] = episode_timeout_s
537
+ agent_fields["max_timeout_sec"] = None
538
+ agents.append(AgentConfig(**agent_fields))
539
+ job_fields = {name: getattr(template, name) for name in JobConfig.model_fields}
540
+ job_fields["agents"] = agents
541
+ return JobConfig(**job_fields)
542
+
543
+
544
+ def collect_rollouts(
545
+ step_index: int,
546
+ task_ids: Sequence[str],
547
+ cfg: DistillConfig,
548
+ harness: HarnessDoc,
549
+ provider_config: ProviderConfig,
550
+ run_dir: Path,
551
+ *,
552
+ should_cancel: Callable[[], bool] | None = None,
553
+ ) -> tuple[list[TrialRecord], RolloutStats]:
554
+ """Run one train batch of harbor trials and join rewards with token spans.
555
+
556
+ Each task in `task_ids` runs `cfg.train.group_size` attempts (the
557
+ on-policy group), every trial running harbor's terminus-2 agent against
558
+ the Tinker student, with the per-turn token ids harbor records in the
559
+ trial's `result.json` joined back as the trial's spans.
560
+
561
+ This is a synchronous entry point, mirroring how the CLI drives the
562
+ scorer: the async `HarborScorer.create` runs on its own loop and the
563
+ (task x attempts) batch runs inside the blocking `score` call.
564
+
565
+ Args:
566
+ step_index: 0-based training step; keys the fresh jobs dir.
567
+ task_ids: Exact task ids for this batch (a subset of the train split).
568
+ cfg: The validated run config (harbor template/backend/reward key,
569
+ group size, trial concurrency).
570
+ harness: The document whose hash keys this candidate's harbor job dir.
571
+ Terminus-2 runs its own scaffold, so the document no longer
572
+ steers the agent; the rollout knobs pinned into it
573
+ (`pin_rollout_params`) still change the job identity, which is
574
+ what keeps a config change from resuming another config's trials.
575
+ provider_config: The student provider config; `model` must point at
576
+ the CURRENT sampler weights (`tinker://` path) and `model_type`
577
+ at the base model naming the renderer/tokenizer.
578
+ run_dir: The distillation run directory.
579
+ should_cancel: Optional cooperative cancellation poll, forwarded to
580
+ the harbor runner.
581
+
582
+ Returns:
583
+ The `TrialRecord`s (one per task x attempt, in report order) and the
584
+ batch stats. A trial whose `result.json` records no rollout details
585
+ is kept with empty spans and counted in `empty_span_trials`, never
586
+ dropped: its reward is real batch signal and dropping it would
587
+ silently bias the solve rate.
588
+
589
+ Raises:
590
+ ValueError: If `step_index` is negative, the harbor job template
591
+ cannot be loaded/validated, or the provider is not the tinker kind.
592
+ ImportError: If the harbor extra is not installed.
593
+ """
594
+ if step_index < 0:
595
+ raise ValueError(f"step_index must be >= 0, got {step_index}")
596
+ try:
597
+ import yaml
598
+ from harbor.models.job.config import JobConfig
599
+
600
+ from wmo.evals.harbor.scorer import HarborScorer
601
+ except ImportError as error:
602
+ raise ImportError(MISSING_HARBOR_EXTRA) from error
603
+
604
+ step_name = f"step-{step_index:04d}"
605
+ jobs_dir = run_dir / "harbor" / step_name
606
+
607
+ template_path = Path(cfg.harbor.job_template)
608
+ try:
609
+ raw = yaml.safe_load(template_path.read_text(encoding="utf-8"))
610
+ except (OSError, yaml.YAMLError) as error:
611
+ raise ValueError(
612
+ f"cannot load the harbor job template from {template_path}: {error}; point "
613
+ "harbor.job_template at a harbor JobConfig YAML/JSON file"
614
+ ) from error
615
+ if not isinstance(raw, dict):
616
+ raise ValueError(f"the harbor job template in {template_path} must be a mapping")
617
+ try:
618
+ template = JobConfig.model_validate({**raw, "jobs_dir": str(jobs_dir)})
619
+ except ValueError as error:
620
+ raise ValueError(
621
+ f"invalid harbor job template {template_path}: {error}; fix the template "
622
+ "so it validates as a harbor JobConfig"
623
+ ) from error
624
+ template = _with_agent_wall_budget(template, cfg.rollout.episode_timeout_s)
625
+
626
+ backend = cfg.harbor.backend
627
+ scorer = asyncio.run(
628
+ HarborScorer.create(
629
+ template,
630
+ task_ids,
631
+ provider_config=provider_config,
632
+ reward_key=cfg.harbor.reward_key,
633
+ attempts=cfg.train.group_size,
634
+ task_environment="e2b" if backend == "e2b" else "docker",
635
+ harness_backend=backend,
636
+ # Terminus-2 runs in this process, but the scorer's local backend still pins
637
+ # concurrency to 1 (that guard belongs to the pi runner's shared runner dir);
638
+ # e2b parallelizes up to the configured trial concurrency.
639
+ agent_concurrency=1 if backend == "local" else cfg.train.trial_concurrency,
640
+ harbor_retries=cfg.harbor.retries,
641
+ agent_import_path=HARBOR_TERMINUS_2_AGENT_IMPORT_PATH,
642
+ extra_agent_kwargs=terminus_2_agent_kwargs(cfg, provider_config),
643
+ # TinkerLLM reads the base model, not the checkpoint, to pick its renderer and
644
+ # tokenizer; the checkpoint travels as llm_kwargs.model_path.
645
+ agent_model_name=provider_config.model_type,
646
+ # Kept for the scorer's own bookkeeping and the pi bridge; terminus-2 takes its
647
+ # wall budget from harbor's agent-phase timeout (set on the template above) and
648
+ # its context budget from llm_kwargs.context_limit.
649
+ episode_timeout_s=cfg.rollout.episode_timeout_s,
650
+ context_window=cfg.rollout.context_budget_tokens,
651
+ # A trial with no verifier evidence (the agent never ran, or the verifier never
652
+ # graded it) keeps a stand-in 0.0 so advantage estimation stays defined, and is
653
+ # flagged `infra_failed` so it never enters a reported solve rate. Never a reason
654
+ # to abort the run.
655
+ missing_reward="zero",
656
+ )
657
+ )
658
+ _wipe_stale_policy_dir(scorer.candidate_job_dir(harness), provider_config)
659
+ logger.info(
660
+ "collecting rollouts for %s: %d task(s) x %d attempt(s), backend %s -> %s",
661
+ step_name,
662
+ len(task_ids),
663
+ cfg.train.group_size,
664
+ backend,
665
+ jobs_dir,
666
+ )
667
+ report = scorer.score(harness, should_cancel=should_cancel)
668
+ records = assemble_harbor_trial_records(report.cells, max_turns=cfg.rollout.max_turns)
669
+ stats = rollout_stats(records, max_tokens=cfg.sampling.max_tokens)
670
+ if stats.truncated_spans:
671
+ logger.warning(
672
+ "%d turn(s) across %d/%d trial(s) in %s sampled the full sampling.max_tokens = %d "
673
+ "and were cut off mid-answer; harbor cannot report this (its OutputLengthExceededError "
674
+ "guard is unreachable), so the agent read a half-written action and the episode "
675
+ "continued as if the model had answered badly. A truncated turn also cannot be "
676
+ "replayed verbatim, so it fragments the episode; raise sampling.max_tokens",
677
+ stats.truncated_spans,
678
+ stats.truncated_span_trials,
679
+ stats.trials,
680
+ step_name,
681
+ cfg.sampling.max_tokens,
682
+ )
683
+ if stats.empty_span_trials:
684
+ logger.warning(
685
+ "%d/%d trial(s) in %s recorded no token spans in their result.json (the agent "
686
+ "died before its first student completion, or harbor logged no rollout details); "
687
+ "they carry reward signal but no training data",
688
+ stats.empty_span_trials,
689
+ stats.trials,
690
+ step_name,
691
+ )
692
+ if stats.infra_failed_trials:
693
+ logger.warning(
694
+ "%d/%d trial(s) in %s never produced verifier evidence (no sandbox, a rate limit, a "
695
+ "dead transport, or a VERIFIER that never graded the work); they are EXCLUDED from "
696
+ "the reported solve rate (%d executed) and carry a stand-in 0.0 reward for advantage "
697
+ "estimation only; grep the cells' `infra-failure:` notes for the exact causes",
698
+ stats.infra_failed_trials,
699
+ stats.trials,
700
+ step_name,
701
+ stats.executed_trials,
702
+ )
703
+ if stats.graded_trials < stats.executed_trials:
704
+ logger.warning(
705
+ "%d/%d gradeable trial(s) in %s carry a verifier reward but no readable CTRF test "
706
+ "report, so they are EXCLUDED from the graded solve rate (%.3f over %d trial(s)) "
707
+ "instead of counted as 0.0; the binary solve rate still covers all %d",
708
+ stats.executed_trials - stats.graded_trials,
709
+ stats.executed_trials,
710
+ step_name,
711
+ stats.graded_solve_rate,
712
+ stats.graded_trials,
713
+ stats.executed_trials,
714
+ )
715
+ if stats.scaffold_loss_rate > 0:
716
+ logger.warning(
717
+ "scaffold loss rate %.1f%% in %s: %d/%d executed trial(s) never reached an "
718
+ "explicit submit (stop reasons %s); those rewards measure where the harness cut "
719
+ "the episode off, not model capability",
720
+ 100.0 * stats.scaffold_loss_rate,
721
+ step_name,
722
+ round(stats.scaffold_loss_rate * stats.executed_trials),
723
+ stats.executed_trials,
724
+ stats.stop_reason_counts,
725
+ )
726
+ return records, stats