world-model-optimizer 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (308) hide show
  1. llm_waterfall/LICENSE +21 -0
  2. llm_waterfall/__init__.py +53 -0
  3. llm_waterfall/adapters/__init__.py +36 -0
  4. llm_waterfall/adapters/anthropic.py +105 -0
  5. llm_waterfall/adapters/aws_mantle.py +47 -0
  6. llm_waterfall/adapters/azure_openai.py +71 -0
  7. llm_waterfall/adapters/base.py +51 -0
  8. llm_waterfall/adapters/bedrock.py +309 -0
  9. llm_waterfall/adapters/openai.py +130 -0
  10. llm_waterfall/classify.py +184 -0
  11. llm_waterfall/pricing.py +110 -0
  12. llm_waterfall/py.typed +0 -0
  13. llm_waterfall/types.py +295 -0
  14. llm_waterfall/waterfall.py +255 -0
  15. wmo/__init__.py +38 -0
  16. wmo/agents/__init__.py +7 -0
  17. wmo/agents/default.py +29 -0
  18. wmo/agents/meta.py +55 -0
  19. wmo/agents/optimizer.py +55 -0
  20. wmo/agents/project.py +928 -0
  21. wmo/cli/__init__.py +5 -0
  22. wmo/cli/agent_session.py +1123 -0
  23. wmo/cli/app.py +2489 -0
  24. wmo/cli/e2b_cmds.py +212 -0
  25. wmo/cli/eval_closed_loop.py +207 -0
  26. wmo/cli/harness_app.py +1147 -0
  27. wmo/cli/harness_distill.py +659 -0
  28. wmo/cli/hosted_session.py +880 -0
  29. wmo/cli/ingest_cmd.py +165 -0
  30. wmo/cli/model_roles.py +82 -0
  31. wmo/cli/platform_cmds.py +372 -0
  32. wmo/cli/route_app.py +274 -0
  33. wmo/cli/session_state.py +243 -0
  34. wmo/cli/ui.py +1107 -0
  35. wmo/cli/workspace_sync.py +504 -0
  36. wmo/config/__init__.py +60 -0
  37. wmo/config/card.py +129 -0
  38. wmo/config/config.py +367 -0
  39. wmo/config/dotenv.py +67 -0
  40. wmo/config/settings.py +128 -0
  41. wmo/config/store.py +177 -0
  42. wmo/conftest.py +19 -0
  43. wmo/connect/__init__.py +88 -0
  44. wmo/connect/apps.py +78 -0
  45. wmo/connect/brave.py +284 -0
  46. wmo/connect/connector.py +79 -0
  47. wmo/connect/credentials.py +164 -0
  48. wmo/connect/github.py +321 -0
  49. wmo/connect/google.py +627 -0
  50. wmo/connect/notion.py +790 -0
  51. wmo/connect/oauth.py +461 -0
  52. wmo/connect/slack.py +555 -0
  53. wmo/connect/store.py +199 -0
  54. wmo/connect/types.py +156 -0
  55. wmo/core/__init__.py +21 -0
  56. wmo/core/parsing.py +281 -0
  57. wmo/core/render.py +271 -0
  58. wmo/core/text.py +40 -0
  59. wmo/core/types.py +116 -0
  60. wmo/distill/__init__.py +14 -0
  61. wmo/distill/agents.py +140 -0
  62. wmo/distill/config.py +1006 -0
  63. wmo/distill/cost.py +437 -0
  64. wmo/distill/data.py +921 -0
  65. wmo/distill/deadlines.py +254 -0
  66. wmo/distill/fake_tinker.py +734 -0
  67. wmo/distill/gate.py +122 -0
  68. wmo/distill/loop.py +3499 -0
  69. wmo/distill/renderers.py +399 -0
  70. wmo/distill/rendering.py +620 -0
  71. wmo/distill/rollouts.py +726 -0
  72. wmo/distill/samples.py +195 -0
  73. wmo/distill/store.py +829 -0
  74. wmo/distill/teacher.py +714 -0
  75. wmo/distill/tokens.py +535 -0
  76. wmo/distill/tracking.py +552 -0
  77. wmo/distill/tripwire.py +411 -0
  78. wmo/distill/xtoken/byte_offsets.py +152 -0
  79. wmo/distill/xtoken/chunks.py +457 -0
  80. wmo/distill/xtoken/prompt_logprobs.py +475 -0
  81. wmo/distill/xtoken/teacher_render.py +346 -0
  82. wmo/engine/__init__.py +28 -0
  83. wmo/engine/autoconfig.py +367 -0
  84. wmo/engine/build.py +346 -0
  85. wmo/engine/demo.py +77 -0
  86. wmo/engine/eval_suites.py +245 -0
  87. wmo/engine/grounding.py +491 -0
  88. wmo/engine/knowledge.py +291 -0
  89. wmo/engine/loader.py +36 -0
  90. wmo/engine/play.py +92 -0
  91. wmo/engine/prompts.py +99 -0
  92. wmo/engine/replay.py +443 -0
  93. wmo/engine/reporting.py +58 -0
  94. wmo/engine/workspace.py +468 -0
  95. wmo/engine/world_model.py +568 -0
  96. wmo/env/__init__.py +22 -0
  97. wmo/env/base.py +121 -0
  98. wmo/env/closed_loop.py +229 -0
  99. wmo/env/episode.py +107 -0
  100. wmo/env/llm_agent.py +93 -0
  101. wmo/env/scenarios.py +73 -0
  102. wmo/evals/__init__.py +52 -0
  103. wmo/evals/agreement.py +110 -0
  104. wmo/evals/base.py +45 -0
  105. wmo/evals/closed_loop.py +480 -0
  106. wmo/evals/failover.py +96 -0
  107. wmo/evals/gold.py +127 -0
  108. wmo/evals/grid.py +394 -0
  109. wmo/evals/grid_plot.py +205 -0
  110. wmo/evals/harbor/__init__.py +27 -0
  111. wmo/evals/harbor/agent.py +573 -0
  112. wmo/evals/harbor/ctrf.py +171 -0
  113. wmo/evals/harbor/e2b_environment.py +587 -0
  114. wmo/evals/harbor/e2b_template_policy.py +144 -0
  115. wmo/evals/harbor/scorer.py +875 -0
  116. wmo/evals/harbor/tasks.py +140 -0
  117. wmo/evals/open_loop.py +194 -0
  118. wmo/evals/tasks.py +53 -0
  119. wmo/harness/__init__.py +51 -0
  120. wmo/harness/code_runtime.py +288 -0
  121. wmo/harness/create.py +1191 -0
  122. wmo/harness/delta.py +220 -0
  123. wmo/harness/doc.py +556 -0
  124. wmo/harness/e2b_ledger.py +342 -0
  125. wmo/harness/e2b_reap.py +476 -0
  126. wmo/harness/e2b_sandbox.py +350 -0
  127. wmo/harness/environment.py +35 -0
  128. wmo/harness/live_session.py +543 -0
  129. wmo/harness/mutate.py +343 -0
  130. wmo/harness/pi_e2b.py +1710 -0
  131. wmo/harness/pi_entry/entry.ts +268 -0
  132. wmo/harness/pi_entry/runner_frames.ts +92 -0
  133. wmo/harness/pi_entry/runner_live.ts +587 -0
  134. wmo/harness/pi_entry/runner_service.ts +270 -0
  135. wmo/harness/pi_entry/runner_stdio.ts +374 -0
  136. wmo/harness/pi_entry/runner_termination.ts +142 -0
  137. wmo/harness/pi_local.py +262 -0
  138. wmo/harness/pi_runtime.py +495 -0
  139. wmo/harness/pi_vendor.py +65 -0
  140. wmo/harness/population.py +509 -0
  141. wmo/harness/project_proposer.py +569 -0
  142. wmo/harness/proposer.py +977 -0
  143. wmo/harness/runner_link.py +619 -0
  144. wmo/harness/runtime.py +389 -0
  145. wmo/harness/scoring.py +247 -0
  146. wmo/harness/skills.py +116 -0
  147. wmo/harness/source_tree.py +319 -0
  148. wmo/harness/store.py +176 -0
  149. wmo/harness/tools.py +105 -0
  150. wmo/harness/vendor/manifest.sha256 +58 -0
  151. wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
  152. wmo/harness/vendor/pi-agent/LICENSE +21 -0
  153. wmo/harness/vendor/pi-agent/README.md +488 -0
  154. wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
  155. wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
  156. wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
  157. wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
  158. wmo/harness/vendor/pi-agent/docs/models.md +966 -0
  159. wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
  160. wmo/harness/vendor/pi-agent/package.json +60 -0
  161. wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
  162. wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
  163. wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
  164. wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
  165. wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
  166. wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
  167. wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
  168. wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
  169. wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
  170. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
  171. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
  172. wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
  173. wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
  174. wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
  175. wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
  176. wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
  177. wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
  178. wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
  179. wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
  180. wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
  181. wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
  182. wmo/harness/vendor/pi-agent/src/index.ts +44 -0
  183. wmo/harness/vendor/pi-agent/src/node.ts +2 -0
  184. wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
  185. wmo/harness/vendor/pi-agent/src/types.ts +428 -0
  186. wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
  187. wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
  188. wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
  189. wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
  190. wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
  191. wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
  192. wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
  193. wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
  194. wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
  195. wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
  196. wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
  197. wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
  198. wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
  199. wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
  200. wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
  201. wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
  202. wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
  203. wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
  204. wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
  205. wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
  206. wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
  207. wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
  208. wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
  209. wmo/harness/vendor/vendor_pi.sh +59 -0
  210. wmo/harness/workspace_patch.py +270 -0
  211. wmo/ingest/__init__.py +47 -0
  212. wmo/ingest/adapter.py +72 -0
  213. wmo/ingest/base.py +114 -0
  214. wmo/ingest/braintrust.py +339 -0
  215. wmo/ingest/detect.py +126 -0
  216. wmo/ingest/langfuse.py +291 -0
  217. wmo/ingest/langsmith.py +444 -0
  218. wmo/ingest/mastra.py +330 -0
  219. wmo/ingest/messages.py +170 -0
  220. wmo/ingest/normalize.py +679 -0
  221. wmo/ingest/otel_genai.py +69 -0
  222. wmo/ingest/otel_writer.py +100 -0
  223. wmo/ingest/phoenix.py +150 -0
  224. wmo/ingest/postgres.py +246 -0
  225. wmo/ingest/posthog.py +320 -0
  226. wmo/ingest/quality.py +28 -0
  227. wmo/ingest/stream.py +209 -0
  228. wmo/ingest/testdata/sample_otlp.json +60 -0
  229. wmo/ingest/testdata/sample_spans.jsonl +3 -0
  230. wmo/optimize/__init__.py +25 -0
  231. wmo/optimize/base.py +143 -0
  232. wmo/optimize/gepa.py +806 -0
  233. wmo/optimize/judge.py +262 -0
  234. wmo/optimize/judge_quality.py +359 -0
  235. wmo/optimize/knn.py +468 -0
  236. wmo/optimize/numeric.py +152 -0
  237. wmo/optimize/outcomes.py +103 -0
  238. wmo/optimize/policy.py +669 -0
  239. wmo/optimize/report.py +231 -0
  240. wmo/optimize/reward.py +129 -0
  241. wmo/optimize/routing.py +373 -0
  242. wmo/platform/__init__.py +6 -0
  243. wmo/platform/auth.py +115 -0
  244. wmo/platform/client.py +551 -0
  245. wmo/platform/credentials.py +126 -0
  246. wmo/platform/transfer.py +158 -0
  247. wmo/providers/__init__.py +40 -0
  248. wmo/providers/_bedrock_chat.py +155 -0
  249. wmo/providers/_openai_common.py +182 -0
  250. wmo/providers/_responses_common.py +472 -0
  251. wmo/providers/anthropic.py +134 -0
  252. wmo/providers/azure_openai.py +296 -0
  253. wmo/providers/base.py +300 -0
  254. wmo/providers/bedrock.py +312 -0
  255. wmo/providers/models.py +205 -0
  256. wmo/providers/openai.py +143 -0
  257. wmo/providers/openai_responses.py +240 -0
  258. wmo/providers/pool.py +170 -0
  259. wmo/providers/registry.py +73 -0
  260. wmo/providers/retry.py +151 -0
  261. wmo/providers/tinker.py +936 -0
  262. wmo/providers/waterfall.py +336 -0
  263. wmo/research/__init__.py +81 -0
  264. wmo/research/ablation.py +133 -0
  265. wmo/research/concurrency_plot.py +523 -0
  266. wmo/research/concurrency_run.py +240 -0
  267. wmo/research/concurrency_scaling.py +270 -0
  268. wmo/research/gepa_scaling.py +274 -0
  269. wmo/research/pipeline.py +198 -0
  270. wmo/research/scaling_split.py +82 -0
  271. wmo/research/scenario_fidelity.py +198 -0
  272. wmo/research/scenario_recovery.py +92 -0
  273. wmo/research/seed_stability.py +90 -0
  274. wmo/research/trace_scaling.py +348 -0
  275. wmo/retrieval/__init__.py +6 -0
  276. wmo/retrieval/embedders.py +105 -0
  277. wmo/retrieval/leakfree.py +52 -0
  278. wmo/retrieval/retriever.py +173 -0
  279. wmo/scenarios/__init__.py +58 -0
  280. wmo/scenarios/builder.py +152 -0
  281. wmo/scenarios/mining/__init__.py +27 -0
  282. wmo/scenarios/mining/clustering.py +171 -0
  283. wmo/scenarios/mining/facets.py +226 -0
  284. wmo/scenarios/mining/selection.py +220 -0
  285. wmo/scenarios/synthesis/__init__.py +6 -0
  286. wmo/scenarios/synthesis/scenario_set.py +63 -0
  287. wmo/scenarios/synthesis/synthesizer.py +85 -0
  288. wmo/scenarios/verification/__init__.py +17 -0
  289. wmo/scenarios/verification/judge.py +97 -0
  290. wmo/scenarios/verification/verify.py +135 -0
  291. wmo/serving/__init__.py +5 -0
  292. wmo/serving/builds.py +451 -0
  293. wmo/serving/chat.py +878 -0
  294. wmo/serving/endpoint_config.py +64 -0
  295. wmo/serving/savings.py +250 -0
  296. wmo/serving/server.py +553 -0
  297. wmo/serving/traces_source.py +206 -0
  298. wmo/telemetry.py +213 -0
  299. wmo/tracking/__init__.py +36 -0
  300. wmo/tracking/clock.py +24 -0
  301. wmo/tracking/metered.py +125 -0
  302. wmo/tracking/pricing.py +99 -0
  303. wmo/tracking/store.py +31 -0
  304. wmo/tracking/tracker.py +149 -0
  305. world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
  306. world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
  307. world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
  308. world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
wmo/distill/tokens.py ADDED
@@ -0,0 +1,535 @@
1
+ """Token-span records tying harbor trials to the student tokens they sampled.
2
+
3
+ Two span sources feed the same `TrialRecord` shape, one per rollout agent:
4
+
5
+ - **harbor's own terminus-2** (`load_trial_rollout_spans`, the distillation
6
+ path): the agent runs `harbor.llms.tinker.TinkerLLM` with
7
+ `collect_rollout_details=True`, harbor persists the per-turn
8
+ `prompt_token_ids` / `completion_token_ids` / `logprobs` into the trial's
9
+ `result.json` under `agent_result.rollout_details`, and this module reads
10
+ them straight back out. Stop reasons come from
11
+ `read_terminus_stop_reason`, which reconstructs them from the trial's
12
+ recorded exception, its episode count, and the ATIF trajectory's final
13
+ `mark_task_complete`.
14
+ - **the WMO pi bridge** (`load_trial_spans`, the harness-optimization path):
15
+ the distill agent's `TokenRecorder` appends every sampled `TokenSpan` to a
16
+ per-trial JSONL sink named after the harbor trial
17
+ (`{sink_dir}/{trial_name}.jsonl`, one span JSON per line), and the WMO run
18
+ trace (`wmo-run.json`, written into harbor's per-trial agent logs dir)
19
+ supplies the stop reason.
20
+
21
+ Either way `assemble_trial_records` joins the spans with the scorer's reward
22
+ cells into `TrialRecord`s, the unit the datum builder consumes.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ from collections.abc import Callable, Sequence
30
+ from pathlib import Path
31
+
32
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
33
+
34
+ from wmo.core.types import JsonObject
35
+ from wmo.harness.runtime import StopReason
36
+ from wmo.harness.scoring import GradedTests, ScoreCell
37
+ from wmo.providers.tinker import TokenSpan
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ WMO_RUN_TRACE_FILENAME = "wmo-run.json"
42
+
43
+ HARBOR_TRIAL_RESULT_FILENAME = "result.json"
44
+ """Harbor's per-trial `TrialResult` dump (`harbor.models.trial.paths.TrialPaths.result_path`)."""
45
+
46
+ HARBOR_TRAJECTORY_FILENAME = "trajectory.json"
47
+ """Terminus-2's ATIF trajectory, dumped into its logs dir (`{trial_dir}/agent/`)."""
48
+
49
+ TERMINUS_COMPLETE_TOOL = "mark_task_complete"
50
+ """The ATIF tool call terminus-2 records when it declares the task finished."""
51
+
52
+ StopReasonReader = Callable[[Path], str | None]
53
+ """Reads one trial's stop reason from its artifact dir; None when unknown."""
54
+
55
+ SpanLoader = Callable[[Path], list[TokenSpan]]
56
+ """Reads one trial's sampled token spans from its artifact dir."""
57
+
58
+
59
+ class TrialRecord(BaseModel):
60
+ """One harbor trial joined with the exact token spans it sampled.
61
+
62
+ `spans` is the tokens-in-tokens-out training evidence (empty when the
63
+ trial died before its first successful student completion); `reward` and
64
+ `passed` come from the harbor verifier via the scorer's cells and are
65
+ metrics/gating signal, never part of the distillation loss.
66
+ """
67
+
68
+ model_config = ConfigDict(extra="forbid")
69
+
70
+ task_id: str = Field(min_length=1)
71
+ attempt: int = Field(ge=1)
72
+ trial_name: str = Field(min_length=1)
73
+ reward: float = Field(ge=0.0, le=1.0, allow_inf_nan=False)
74
+ passed: bool
75
+ spans: list[TokenSpan] = Field(default_factory=list)
76
+ stop_reason: str | None = None
77
+ """The WMO run trace's stop reason; None when no trace was readable."""
78
+
79
+ infra_failed: bool = False
80
+ """True when the trial never produced verifier evidence.
81
+
82
+ Two causes, one measurement status: the agent never ran (sandbox/transport death) or its work
83
+ was never graded (the verifier timed out or wrote nothing parseable). Carried from the scorer's
84
+ cell. Its `reward` is a stand-in, so metrics must exclude it from solve-rate denominators and
85
+ count it separately (see `rollout_stats`)."""
86
+
87
+ tests: GradedTests | None = None
88
+ """The verifier's per-test counts, carried from the scorer's cell; None when it wrote no
89
+ readable report.
90
+
91
+ `graded_score` is the pass fraction over resolved tests: the same trials at test resolution,
92
+ beside (never instead of) the binary `passed`. None is not a 0.0, so `rollout_stats` averages
93
+ only the trials that have one. Absent on records written before this field existed."""
94
+
95
+ artifact_dir: str
96
+ """The harbor trial directory holding this trial's raw evidence."""
97
+
98
+ @property
99
+ def graded_score(self) -> float | None:
100
+ """This trial's graded test-pass score in [0, 1]; None when no test report exists."""
101
+ return None if self.tests is None else self.tests.score
102
+
103
+
104
+ def load_trial_spans(sink_dir: Path, trial_name: str) -> list[TokenSpan]:
105
+ """Read the token spans one trial's recorder sink captured.
106
+
107
+ The sink is the JSONL file the distill agent's `TokenRecorder` writes:
108
+ `{trial_name}.jsonl` under `sink_dir`, one `TokenSpan` JSON per line,
109
+ flushed after every successful completion.
110
+
111
+ Args:
112
+ sink_dir: The per-trial token sink directory for one rollout batch.
113
+ trial_name: The harbor trial name the sink file is keyed by.
114
+
115
+ Returns:
116
+ The recorded spans in call order. A missing sink file yields an empty
117
+ list: the trial made no successful student completion (it died before
118
+ the first sample), which callers count in their stats rather than
119
+ raise on.
120
+
121
+ Raises:
122
+ ValueError: If a line is not a valid `TokenSpan`, or the call_index
123
+ sequence is not exactly 0..n-1 (the sink was appended by more than
124
+ one recorder); delete the step's token sink directory and re-run
125
+ the step to rebuild it.
126
+ """
127
+ sink_path = sink_dir / f"{trial_name}.jsonl"
128
+ try:
129
+ text = sink_path.read_text(encoding="utf-8")
130
+ except FileNotFoundError:
131
+ return []
132
+ spans: list[TokenSpan] = []
133
+ for line_number, line in enumerate(text.splitlines(), 1):
134
+ if not line.strip():
135
+ continue
136
+ try:
137
+ spans.append(TokenSpan.model_validate_json(line))
138
+ except ValidationError as exc:
139
+ raise ValueError(
140
+ f"invalid token span on line {line_number} of {sink_path}: {exc}; "
141
+ "the sink is corrupt, so delete this step's token sink directory "
142
+ "and re-run the step"
143
+ ) from exc
144
+ observed = [span.call_index for span in spans]
145
+ if observed != list(range(len(spans))):
146
+ raise ValueError(
147
+ f"token sink {sink_path} has call_index sequence {observed}, expected "
148
+ f"0..{len(spans) - 1}; more than one recorder appended to it (e.g. a "
149
+ "re-run trial reused the sink), so delete this step's token sink "
150
+ "directory and re-run the step"
151
+ )
152
+ return spans
153
+
154
+
155
+ def _read_json_object(path: Path) -> JsonObject | None:
156
+ """One JSON object from disk, or None when it is missing or unreadable.
157
+
158
+ Never raises: assembly must survive the trials that died hardest, where a
159
+ truncated or absent artifact IS the evidence.
160
+ """
161
+ try:
162
+ payload = json.loads(path.read_text(encoding="utf-8"))
163
+ except FileNotFoundError:
164
+ return None
165
+ except (OSError, json.JSONDecodeError):
166
+ logger.warning("unreadable harbor artifact at %s", path)
167
+ return None
168
+ return payload if isinstance(payload, dict) else None
169
+
170
+
171
+ def _int_lists(value: object) -> list[list[int]] | None:
172
+ """`value` as a list of int lists, or None when it is not shaped like one."""
173
+ if not isinstance(value, list):
174
+ return None
175
+ rows: list[list[int]] = []
176
+ for row in value:
177
+ if not isinstance(row, list):
178
+ return None
179
+ ints: list[int] = []
180
+ for item in row:
181
+ if isinstance(item, bool) or not isinstance(item, int):
182
+ return None
183
+ ints.append(item)
184
+ rows.append(ints)
185
+ return rows
186
+
187
+
188
+ def _float_lists(value: object) -> list[list[float]] | None:
189
+ """`value` as a list of float lists, or None when it is not shaped like one."""
190
+ if not isinstance(value, list):
191
+ return None
192
+ rows: list[list[float]] = []
193
+ for row in value:
194
+ if not isinstance(row, list):
195
+ return None
196
+ floats: list[float] = []
197
+ for item in row:
198
+ if isinstance(item, bool) or not isinstance(item, (int, float)):
199
+ return None
200
+ floats.append(float(item))
201
+ rows.append(floats)
202
+ return rows
203
+
204
+
205
+ def load_trial_rollout_spans(artifact_dir: Path) -> list[TokenSpan]:
206
+ """Read the token spans harbor recorded for one terminus-2 trial.
207
+
208
+ The source is harbor's own persisted evidence, not a WMO sink: the trial's
209
+ `result.json` carries `agent_result.rollout_details`, a list of
210
+ `harbor.models.agent.rollout_detail.RolloutDetail`, and by harbor's
211
+ convention its FIRST entry is the main agent's linear chat history. Each
212
+ entry holds three per-turn lists (`prompt_token_ids`,
213
+ `completion_token_ids`, `logprobs`) that terminus-2's `TinkerLLM`
214
+ populates with the exact ids the sampler consumed and issued, so the
215
+ tokens-in-tokens-out contract is preserved verbatim through the file.
216
+
217
+ Later entries are SUBAGENT segments (terminus-2 appends one per
218
+ summarization handoff); they are separate conversations whose prompts do
219
+ not extend the main chat, so they are skipped and warned about. The
220
+ distill collector disables summarization precisely so none exist.
221
+
222
+ Args:
223
+ artifact_dir: The harbor trial directory (one cell's `artifact_dir`).
224
+
225
+ Returns:
226
+ The spans in turn order, `call_index` 0..n-1. Empty when the trial
227
+ never reached a completion, when harbor recorded no rollout details
228
+ (the agent ran without `collect_rollout_details`, or died before its
229
+ first sample), or when the recorded lists cannot be aligned. Every
230
+ non-trivial empty case is logged at WARNING and surfaces in
231
+ `RolloutStats.empty_span_trials`: a trial that produced no training
232
+ evidence must be counted, never silently dropped and never allowed to
233
+ abort the batch around it.
234
+ """
235
+ payload = _read_json_object(artifact_dir / HARBOR_TRIAL_RESULT_FILENAME)
236
+ if payload is None:
237
+ return []
238
+ agent_result = payload.get("agent_result")
239
+ if not isinstance(agent_result, dict):
240
+ if payload.get("step_results"):
241
+ logger.warning(
242
+ "harbor trial %s is a MULTI-STEP trial: its per-step chats are separate "
243
+ "conversations, so no single prefix-chained episode exists to train on",
244
+ artifact_dir.name,
245
+ )
246
+ return []
247
+ details = agent_result.get("rollout_details")
248
+ if not isinstance(details, list) or not details:
249
+ return []
250
+ if len(details) > 1:
251
+ logger.warning(
252
+ "harbor trial %s recorded %d rollout detail segments; only the first (the main "
253
+ "agent chat) is trainable, the rest are subagent conversations whose prompts do "
254
+ "not extend it. Summarization must stay OFF for distillation rollouts",
255
+ artifact_dir.name,
256
+ len(details),
257
+ )
258
+ detail = details[0]
259
+ if not isinstance(detail, dict):
260
+ logger.warning("harbor trial %s recorded a malformed rollout detail", artifact_dir.name)
261
+ return []
262
+ prompts = _int_lists(detail.get("prompt_token_ids"))
263
+ completions = _int_lists(detail.get("completion_token_ids"))
264
+ logprobs = _float_lists(detail.get("logprobs"))
265
+ if prompts is None or completions is None or logprobs is None:
266
+ logger.warning(
267
+ "harbor trial %s recorded rollout details without well-formed per-turn token id "
268
+ "lists (prompt=%s completion=%s logprobs=%s); it contributes no training data",
269
+ artifact_dir.name,
270
+ type(detail.get("prompt_token_ids")).__name__,
271
+ type(detail.get("completion_token_ids")).__name__,
272
+ type(detail.get("logprobs")).__name__,
273
+ )
274
+ return []
275
+ if not (len(prompts) == len(completions) == len(logprobs)):
276
+ # harbor's Chat appends to the three lists independently, so a turn that returned no
277
+ # completion (or no logprobs) desynchronizes them for every later turn. Nothing can
278
+ # realign them after the fact, and pairing them anyway would train the wrong prompts.
279
+ logger.warning(
280
+ "harbor trial %s recorded %d prompt turn(s), %d completion turn(s) and %d logprob "
281
+ "turn(s); the per-turn lists cannot be aligned, so the trial contributes no "
282
+ "training data",
283
+ artifact_dir.name,
284
+ len(prompts),
285
+ len(completions),
286
+ len(logprobs),
287
+ )
288
+ return []
289
+ spans: list[TokenSpan] = []
290
+ for index, (prompt, completion, row) in enumerate(
291
+ zip(prompts, completions, logprobs, strict=True)
292
+ ):
293
+ try:
294
+ spans.append(
295
+ TokenSpan(
296
+ call_index=index,
297
+ prompt_token_ids=prompt,
298
+ sampled_token_ids=completion,
299
+ sampled_logprobs=row,
300
+ )
301
+ )
302
+ except ValidationError as exc:
303
+ logger.warning(
304
+ "harbor trial %s turn %d records %d sampled token(s) against %d logprob(s) "
305
+ "(%s); the trial contributes no training data",
306
+ artifact_dir.name,
307
+ index,
308
+ len(completion),
309
+ len(row),
310
+ exc,
311
+ )
312
+ return []
313
+ return spans
314
+
315
+
316
+ _TERMINUS_EXCEPTION_STOP_REASONS = {
317
+ # Harbor's own agent-phase wall clock (`AgentConfig.override_timeout_sec`, which is where
318
+ # `rollout.episode_timeout_s` lands): harbor swallows it and still verifies the work.
319
+ "AgentTimeoutError": StopReason.BUDGET,
320
+ # Terminus-2 re-raises this when summarization is off, which is how distillation runs it:
321
+ # the next prompt outgrew `rollout.context_budget_tokens` and nothing was sampled from it.
322
+ "ContextLengthExceededError": StopReason.PROVIDER_ERROR,
323
+ }
324
+ """Terminus-2 trial exceptions that name their own stop reason; anything else is `ERROR`."""
325
+
326
+
327
+ def _terminus_declared_complete(artifact_dir: Path) -> bool:
328
+ """True when terminus-2's final agent step declared the task complete.
329
+
330
+ Read from the ATIF trajectory terminus-2 dumps after every episode, so it
331
+ survives a trial that died later. `mark_task_complete` is the tool call it
332
+ records for its own completion claim, which is the closest analogue of
333
+ pi's explicit `submit`.
334
+ """
335
+ payload = _read_json_object(artifact_dir / "agent" / HARBOR_TRAJECTORY_FILENAME)
336
+ steps = payload.get("steps") if payload is not None else None
337
+ if not isinstance(steps, list):
338
+ return False
339
+ for step in reversed(steps):
340
+ if not isinstance(step, dict) or step.get("source") != "agent":
341
+ continue
342
+ calls = step.get("tool_calls")
343
+ if not isinstance(calls, list):
344
+ return False
345
+ return any(
346
+ isinstance(call, dict) and call.get("function_name") == TERMINUS_COMPLETE_TOOL
347
+ for call in calls
348
+ )
349
+ return False
350
+
351
+
352
+ def read_terminus_stop_reason(artifact_dir: Path, *, max_turns: int) -> str | None:
353
+ """Why one terminus-2 episode ended, in WMO's `StopReason` vocabulary.
354
+
355
+ Terminus-2 writes no WMO run trace, so the reason is reconstructed from
356
+ the three artifacts harbor and the agent do leave behind, in the order
357
+ that makes each one decisive:
358
+
359
+ 1. the trial's recorded exception (`AgentTimeoutError` is the wall clock,
360
+ `ContextLengthExceededError` is the context budget, anything else is a
361
+ harness/runtime error);
362
+ 2. the episode count terminus-2 records in
363
+ `agent_result.metadata.n_episodes`: reaching `max_turns` means the loop
364
+ was cut off by the cap;
365
+ 3. the ATIF trajectory's final `mark_task_complete`, terminus-2's own
366
+ completion claim.
367
+
368
+ Deliberately conservative at the boundary: an episode that declared
369
+ completion on exactly its last allowed turn reads as `max_turns`, i.e. as
370
+ a scaffold loss, rather than as a submission the cap happened to permit.
371
+
372
+ Args:
373
+ artifact_dir: The harbor trial directory (one cell's `artifact_dir`).
374
+ max_turns: The turn cap the agent ran under (`rollout.max_turns`).
375
+
376
+ Returns:
377
+ A `StopReason` value, or None when no trial result was readable at all
378
+ (which `rollout_stats` counts as `"unknown"` and excludes from the
379
+ scaffold-loss denominator rather than guessing).
380
+ """
381
+ payload = _read_json_object(artifact_dir / HARBOR_TRIAL_RESULT_FILENAME)
382
+ if payload is None:
383
+ return None
384
+ exception = payload.get("exception_info")
385
+ if isinstance(exception, dict):
386
+ raised = exception.get("exception_type")
387
+ if isinstance(raised, str):
388
+ return _TERMINUS_EXCEPTION_STOP_REASONS.get(raised, StopReason.ERROR).value
389
+ agent_result = payload.get("agent_result")
390
+ metadata = agent_result.get("metadata") if isinstance(agent_result, dict) else None
391
+ episodes = metadata.get("n_episodes") if isinstance(metadata, dict) else None
392
+ if isinstance(episodes, bool) or not isinstance(episodes, int):
393
+ return None
394
+ if episodes >= max_turns:
395
+ return StopReason.MAX_TURNS.value
396
+ if _terminus_declared_complete(artifact_dir):
397
+ return StopReason.SUBMITTED.value
398
+ # The loop returned early without claiming completion: terminus-2 does that only when its
399
+ # tmux session died under it, which is a harness failure, not a task verdict.
400
+ return StopReason.ERROR.value
401
+
402
+
403
+ def read_trial_stop_reason(artifact_dir: Path) -> str | None:
404
+ """The WMO run trace's stop reason for one trial, when a trace exists.
405
+
406
+ The agent bridge writes `wmo-run.json` into harbor's per-trial agent logs
407
+ dir (`{trial_dir}/agent/` for single-step tasks); both full `RunResult`
408
+ dumps and partial cancellation traces carry a string `stop_reason`. The
409
+ trial-dir root is also checked for robustness against layout drift.
410
+
411
+ Args:
412
+ artifact_dir: The harbor trial directory (one cell's `artifact_dir`).
413
+
414
+ Returns:
415
+ The stop reason string, or None when no trace is present or readable.
416
+ Assembly must not fail on the trials that died hardest; a missing
417
+ stop reason is itself the signal.
418
+ """
419
+ candidates = (
420
+ artifact_dir / "agent" / WMO_RUN_TRACE_FILENAME,
421
+ artifact_dir / WMO_RUN_TRACE_FILENAME,
422
+ )
423
+ for candidate in candidates:
424
+ if not candidate.is_file():
425
+ continue
426
+ try:
427
+ payload = json.loads(candidate.read_text(encoding="utf-8"))
428
+ except (OSError, json.JSONDecodeError):
429
+ logger.warning("unreadable WMO run trace at %s; recording no stop reason", candidate)
430
+ return None
431
+ stop_reason = payload.get("stop_reason") if isinstance(payload, dict) else None
432
+ return stop_reason if isinstance(stop_reason, str) else None
433
+ return None
434
+
435
+
436
+ def assemble_harbor_trial_records(
437
+ cells: Sequence[ScoreCell], *, max_turns: int
438
+ ) -> list[TrialRecord]:
439
+ """Join scorer cells with the spans HARBOR recorded for its terminus-2 agent.
440
+
441
+ The distillation path's assembler: spans come from each trial's
442
+ `result.json` (`load_trial_rollout_spans`) and stop reasons are
443
+ reconstructed from harbor's own artifacts (`read_terminus_stop_reason`).
444
+ No WMO-side sink directory is involved, so nothing has to survive the
445
+ scorer's destructive entry prune.
446
+
447
+ Args:
448
+ cells: The scored trial cells (one per task x attempt).
449
+ max_turns: The turn cap the agent ran under, needed to tell a
450
+ cap-terminated episode from a completed one.
451
+
452
+ Returns:
453
+ One `TrialRecord` per cell, in the cells' order.
454
+
455
+ Raises:
456
+ ValueError: If a cell carries no artifact dir.
457
+ """
458
+ return _assemble_trial_records(
459
+ cells,
460
+ load_spans=load_trial_rollout_spans,
461
+ read_stop_reason=lambda artifact_dir: read_terminus_stop_reason(
462
+ artifact_dir, max_turns=max_turns
463
+ ),
464
+ )
465
+
466
+
467
+ def assemble_trial_records(
468
+ cells: Sequence[ScoreCell],
469
+ sink_dir: Path,
470
+ *,
471
+ read_stop_reason: StopReasonReader | None = None,
472
+ ) -> list[TrialRecord]:
473
+ """Join scorer cells with their token sinks and run traces.
474
+
475
+ The WMO pi-bridge path's assembler (see `assemble_harbor_trial_records`
476
+ for the terminus-2 one).
477
+
478
+ Args:
479
+ cells: The scored trial cells (one per task x attempt); each cell's
480
+ `artifact_dir` must be the harbor trial directory, whose basename
481
+ is the trial name the token sinks are keyed by.
482
+ sink_dir: The per-trial token sink directory for this rollout batch.
483
+ read_stop_reason: Reads one trial's stop reason from its artifact dir;
484
+ defaults to `read_trial_stop_reason`.
485
+
486
+ Returns:
487
+ One `TrialRecord` per cell, in the cells' order. A trial without a
488
+ sink file gets empty spans, never dropped: its reward is still real
489
+ batch signal and callers count span-less trials explicitly.
490
+
491
+ Raises:
492
+ ValueError: If a cell carries no artifact dir (the trial name cannot
493
+ be derived), or a sink file is corrupt (see `load_trial_spans`).
494
+ """
495
+ return _assemble_trial_records(
496
+ cells,
497
+ load_spans=lambda artifact_dir: load_trial_spans(sink_dir, artifact_dir.name),
498
+ read_stop_reason=read_stop_reason or read_trial_stop_reason,
499
+ )
500
+
501
+
502
+ def _assemble_trial_records(
503
+ cells: Sequence[ScoreCell],
504
+ *,
505
+ load_spans: SpanLoader,
506
+ read_stop_reason: StopReasonReader,
507
+ ) -> list[TrialRecord]:
508
+ """Join cells with whichever span source and stop-reason reader the caller uses."""
509
+ reader = read_stop_reason
510
+ records: list[TrialRecord] = []
511
+ for cell in cells:
512
+ artifact_dir = Path(cell.artifact_dir)
513
+ trial_name = artifact_dir.name
514
+ if not cell.artifact_dir or not trial_name:
515
+ raise ValueError(
516
+ f"score cell for task {cell.task_id!r} attempt {cell.attempt} carries no "
517
+ "artifact dir, so its trial name (the token-sink key) cannot be derived; "
518
+ "collect rollouts through a scorer that records per-trial directories "
519
+ "(HarborScorer does)"
520
+ )
521
+ records.append(
522
+ TrialRecord(
523
+ task_id=cell.task_id,
524
+ attempt=cell.attempt,
525
+ trial_name=trial_name,
526
+ reward=cell.reward,
527
+ passed=cell.passed,
528
+ spans=load_spans(artifact_dir),
529
+ stop_reason=reader(artifact_dir),
530
+ infra_failed=cell.infra_failed,
531
+ tests=cell.tests,
532
+ artifact_dir=cell.artifact_dir,
533
+ )
534
+ )
535
+ return records