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,875 @@
1
+ """Score harness candidates on real benchmark tasks through harbor.
2
+
3
+ `HarborScorer` implements the `wmo.harness.scoring.Scorer` protocol: one exact `HarnessDoc`
4
+ candidate becomes one harbor job (the WMO agent bridge + a pinned task list), harbor owns the
5
+ task environments and the verifier lifecycle, and the verifier rewards project into a
6
+ `ScoreReport`. Harbor's own job directory is the artifact record, and each cell points at its
7
+ trial directory; nothing is re-read, re-hashed, or copied.
8
+
9
+ Two operational behaviors matter most here:
10
+
11
+ - **Trial-level resume.** Each candidate gets a deterministic job directory
12
+ (`jobs_dir/wmo-<doc_hash12>`), and before running, trial directories whose result.json is
13
+ missing/unparseable or that failed without a verifier reward are pruned. Harbor's native
14
+ resume then keeps completed trials and re-runs only what is missing, so a crashed or
15
+ interrupted boundary re-pays a handful of trials instead of the whole matrix.
16
+ - **Candidate outcomes vs infra failures.** A trial that raised (e.g. AgentTimeoutError) but
17
+ still carries a written verifier reward is a CANDIDATE outcome: it becomes a scored cell with
18
+ a note. Absent that reward there is nothing to score, so search mode raises
19
+ (`HarborRewardMissingError`) and evaluation-tolerant mode records the cell as an
20
+ infrastructure failure whose 0.0 is an explicit stand-in. The rule that decides it is a
21
+ single measurement question, not the shape of the exception: in `missing_reward="zero"` mode a
22
+ cell is `infra_failed` exactly when the verifier wrote no reward for the configured key, so a
23
+ verifier that timed out on work the agent really submitted can never be reported as a definite
24
+ task failure (see `_trial_outcome`).
25
+
26
+ Each cell also carries the trial's CTRF test breakdown when the verifier wrote one
27
+ (`wmo.evals.harbor.ctrf`), so a caller can read a graded test-pass score BESIDE the binary reward.
28
+ The reward is unchanged and remains the benchmark's own verdict; the breakdown is None (never 0.0)
29
+ whenever no report exists, so a graded rate can exclude it the way a solve rate excludes an
30
+ ungradeable trial.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import logging
37
+ import math
38
+ import shutil
39
+ import threading
40
+ from collections import defaultdict
41
+ from collections.abc import Callable, Sequence
42
+ from concurrent.futures import ThreadPoolExecutor
43
+ from dataclasses import dataclass
44
+ from pathlib import Path
45
+ from typing import Literal, Protocol, Self
46
+
47
+ from harbor import Job
48
+ from harbor.models.agent.name import AgentName
49
+ from harbor.models.environment_type import EnvironmentType
50
+ from harbor.models.job.config import JobConfig, RetryConfig
51
+ from harbor.models.job.result import JobResult
52
+ from harbor.models.trial.config import AgentConfig, TaskConfig
53
+ from harbor.models.trial.paths import TrialPaths
54
+ from harbor.models.trial.result import TrialResult
55
+
56
+ from wmo.core.types import JsonObject
57
+ from wmo.evals.harbor.agent import (
58
+ DEFAULT_EPISODE_WORKERS,
59
+ MAX_ENVIRONMENT_COMMAND_TIMEOUT_SEC,
60
+ WMO_HARBOR_AGENT_IMPORT_PATH,
61
+ )
62
+ from wmo.evals.harbor.ctrf import read_trial_graded_tests
63
+ from wmo.evals.harbor.e2b_template_policy import WMO_HARBOR_E2B_ENVIRONMENT_IMPORT_PATH
64
+ from wmo.evals.harbor.tasks import resolve_harbor_tasks
65
+ from wmo.harness.doc import HarnessDoc
66
+ from wmo.harness.e2b_sandbox import resolve_e2b_template
67
+ from wmo.harness.runtime import (
68
+ DEFAULT_EVAL_EPISODE_TIMEOUT_S,
69
+ HarnessSearchCancelled,
70
+ validate_episode_timeout_s,
71
+ )
72
+ from wmo.harness.scoring import RewardMode, ScoreCell, ScoreReport, ScoreRequest, reward_passed
73
+ from wmo.providers.base import ProviderConfig
74
+
75
+ logger = logging.getLogger(__name__)
76
+
77
+ TaskEnvironment = Literal["docker", "e2b"]
78
+ HarnessBackend = Literal["local", "e2b"]
79
+ MissingRewardMode = Literal["raise", "zero"]
80
+ """How a trial with no verifier reward scores: "raise" aborts (candidate search
81
+ semantics: an unscoreable candidate is an infra failure), "zero" records a
82
+ stand-in 0.0 flagged `infra_failed` with an auditable note (distillation evals:
83
+ an ungradeable trial is an UNKNOWN outcome, kept out of every solve rate)."""
84
+
85
+ # In-process registry of job dirs with a score() in flight: the entry prune is destructive, so
86
+ # concurrent scores of the same candidate must be rejected, not interleaved.
87
+ _ACTIVE_GUARD = threading.Lock()
88
+ _ACTIVE_JOB_DIRS: set[Path] = set()
89
+
90
+ # Agent kwargs the scorer computes and owns; `extra_agent_kwargs` may extend the kwargs dict but
91
+ # never silently override these, or a custom agent would run a different candidate/provider than
92
+ # the one this scorer reports on.
93
+ _SCORER_OWNED_AGENT_KWARGS = frozenset(
94
+ {
95
+ "harness",
96
+ "provider_config",
97
+ "harness_backend",
98
+ "e2b_template",
99
+ "command_timeout_sec",
100
+ "episode_timeout_sec",
101
+ "episode_workers",
102
+ "context_window",
103
+ }
104
+ )
105
+
106
+
107
+ class HarborRewardMissingError(RuntimeError):
108
+ """Verifier evidence or the configured reward key is absent from a finished trial.
109
+
110
+ This is the one condition the scorer refuses to score around: a missing reward is an
111
+ infrastructure failure (verifier never ran, reward file lost), not a candidate outcome.
112
+ """
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class HarborRun:
117
+ """One completed harbor job and its output directory."""
118
+
119
+ result: JobResult
120
+ job_dir: Path
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class _TrialOutcome:
125
+ """How one finished trial projects into a cell: its reward, note, and measurement status."""
126
+
127
+ reward: float
128
+ infra_failed: bool
129
+ note: str
130
+
131
+
132
+ class HarborRunner(Protocol):
133
+ """Synchronous execution seam for one harbor job (fakes replace it in tests)."""
134
+
135
+ def run(
136
+ self,
137
+ config: JobConfig,
138
+ *,
139
+ should_cancel: Callable[[], bool] | None = None,
140
+ ) -> HarborRun: ...
141
+
142
+
143
+ class HarborJobRunner:
144
+ """Run harbor's async Python API from synchronous optimizer code.
145
+
146
+ `should_cancel` is polled every `poll_interval_s` while the job runs; observing it cancels
147
+ the harbor job task (harbor cancels its in-flight trials and persists what it can; the
148
+ scorer's entry prune makes the interrupted boundary resumable) and raises
149
+ `HarnessSearchCancelled`, so a multi-hour boundary stays cancellable through the Scorer
150
+ contract instead of only at its edges.
151
+ """
152
+
153
+ def __init__(self, *, poll_interval_s: float = 2.0) -> None:
154
+ if not poll_interval_s > 0:
155
+ raise ValueError("poll_interval_s must be positive")
156
+ self._poll_interval_s = poll_interval_s
157
+
158
+ def run(
159
+ self,
160
+ config: JobConfig,
161
+ *,
162
+ should_cancel: Callable[[], bool] | None = None,
163
+ ) -> HarborRun:
164
+ async def run_job() -> HarborRun:
165
+ job = await Job.create(config)
166
+ job_task = asyncio.ensure_future(job.run())
167
+ if should_cancel is None:
168
+ return HarborRun(result=await job_task, job_dir=job.job_dir)
169
+ while True:
170
+ done, _pending = await asyncio.wait({job_task}, timeout=self._poll_interval_s)
171
+ if done:
172
+ return HarborRun(result=job_task.result(), job_dir=job.job_dir)
173
+ if should_cancel():
174
+ job_task.cancel()
175
+ await asyncio.wait({job_task})
176
+ if not job_task.cancelled():
177
+ job_task.exception() # consume; cancellation is the outcome
178
+ raise HarnessSearchCancelled("harbor scoring cancelled mid-job")
179
+
180
+ try:
181
+ asyncio.get_running_loop()
182
+ except RuntimeError:
183
+ return asyncio.run(run_job())
184
+ # Called from inside an event loop (e.g. an async CLI): run the job on its own loop in
185
+ # one worker thread instead of failing on nested asyncio.run.
186
+ with ThreadPoolExecutor(max_workers=1) as executor:
187
+ return executor.submit(lambda: asyncio.run(run_job())).result()
188
+
189
+
190
+ class HarborScorer:
191
+ """Evaluate exact harness candidates through harbor's verifier lifecycle."""
192
+
193
+ def __init__(
194
+ self,
195
+ *,
196
+ job_template: JobConfig,
197
+ tasks: Sequence[TaskConfig],
198
+ provider_config: ProviderConfig,
199
+ reward_key: str = "reward",
200
+ reward_mode: RewardMode = "raw",
201
+ attempts: int = 1,
202
+ task_environment: TaskEnvironment = "docker",
203
+ harness_backend: HarnessBackend = "local",
204
+ e2b_template: str | None = None,
205
+ episode_timeout_s: float = DEFAULT_EVAL_EPISODE_TIMEOUT_S,
206
+ command_timeout_sec: int = MAX_ENVIRONMENT_COMMAND_TIMEOUT_SEC,
207
+ agent_concurrency: int | None = None,
208
+ harbor_retries: int = 0,
209
+ agent_import_path: str = WMO_HARBOR_AGENT_IMPORT_PATH,
210
+ extra_agent_kwargs: JsonObject | None = None,
211
+ agent_model_name: str | None = None,
212
+ missing_reward: MissingRewardMode = "raise",
213
+ context_window: int | None = None,
214
+ runner: HarborRunner | None = None,
215
+ ) -> None:
216
+ if not tasks:
217
+ raise ValueError("HarborScorer requires at least one resolved task")
218
+ if missing_reward not in ("raise", "zero"):
219
+ raise ValueError("missing_reward must be raise or zero")
220
+ task_ids = [task.get_task_id().get_name() for task in tasks]
221
+ if len(task_ids) != len(set(task_ids)):
222
+ raise ValueError("HarborScorer requires unique task ids")
223
+ if isinstance(attempts, bool) or not isinstance(attempts, int) or attempts < 1:
224
+ raise ValueError("attempts must be a positive integer")
225
+ if not reward_key:
226
+ raise ValueError("reward_key must be nonempty")
227
+ if reward_mode not in ("raw", "positive-binary"):
228
+ raise ValueError("reward_mode must be raw or positive-binary")
229
+ if harness_backend not in ("local", "e2b"):
230
+ raise ValueError("harness_backend must be local or e2b")
231
+ if harness_backend == "local" and e2b_template is not None:
232
+ raise ValueError("e2b_template requires harness_backend='e2b'")
233
+ # Both harness backends honor the wall budget now: the local SSH transport applies it as
234
+ # the remote node timeout instead of the fixed 300s it used to hardcode.
235
+ episode_timeout_s = validate_episode_timeout_s(episode_timeout_s)
236
+ if isinstance(harbor_retries, bool) or not isinstance(harbor_retries, int):
237
+ raise ValueError("harbor_retries must be a nonnegative integer")
238
+ if harbor_retries < 0:
239
+ raise ValueError("harbor_retries must be a nonnegative integer")
240
+ if not agent_import_path or ":" not in agent_import_path:
241
+ raise ValueError(
242
+ f"agent_import_path must be a 'module:Class' import path, got "
243
+ f"{agent_import_path!r}; use the exported constant of the agent bridge "
244
+ "(e.g. WMO_HARBOR_AGENT_IMPORT_PATH)"
245
+ )
246
+ if agent_model_name is not None and not agent_model_name:
247
+ raise ValueError("agent_model_name must be a nonempty string when given")
248
+ overridden = _SCORER_OWNED_AGENT_KWARGS & set(extra_agent_kwargs or {})
249
+ if overridden:
250
+ raise ValueError(
251
+ f"extra_agent_kwargs may not override the scorer-owned agent kwargs "
252
+ f"{sorted(overridden)}; configure those through the scorer's own parameters"
253
+ )
254
+ if agent_concurrency is not None and (
255
+ isinstance(agent_concurrency, bool)
256
+ or not isinstance(agent_concurrency, int)
257
+ or agent_concurrency < 1
258
+ ):
259
+ raise ValueError("agent_concurrency must be a positive integer")
260
+ if (
261
+ isinstance(command_timeout_sec, bool)
262
+ or not isinstance(command_timeout_sec, int)
263
+ or not 1 <= command_timeout_sec <= MAX_ENVIRONMENT_COMMAND_TIMEOUT_SEC
264
+ ):
265
+ raise ValueError(
266
+ "command_timeout_sec must be an integer in "
267
+ f"[1, {MAX_ENVIRONMENT_COMMAND_TIMEOUT_SEC}]"
268
+ )
269
+ _validate_job_template(job_template)
270
+ environment = _route_task_environment(job_template, task_environment)
271
+ effective_concurrency = agent_concurrency or job_template.n_concurrent_trials
272
+ if harness_backend == "local" and effective_concurrency > 1:
273
+ raise ValueError(
274
+ "local harness execution requires agent concurrency 1 (the local pi runner "
275
+ "shares one runner dir); use harness_backend='e2b' for parallel trials"
276
+ )
277
+ # model_copy(update=) skips validation, so the copied config is re-validated as a whole.
278
+ self._job_template = _revalidated_job_config(
279
+ job_template.model_copy(
280
+ update={
281
+ "environment": environment,
282
+ "datasets": [],
283
+ "tasks": [],
284
+ "n_concurrent_trials": effective_concurrency,
285
+ "quiet": True,
286
+ "retry": RetryConfig(max_retries=harbor_retries),
287
+ },
288
+ deep=True,
289
+ )
290
+ )
291
+ self._tasks = [TaskConfig.model_validate(task.model_dump(mode="python")) for task in tasks]
292
+ self._task_ids = tuple(task_ids)
293
+ self._provider_config = ProviderConfig.model_validate(
294
+ provider_config.model_dump(mode="python")
295
+ )
296
+ self._reward_key = reward_key
297
+ self._reward_mode: RewardMode = reward_mode
298
+ self._attempts = attempts
299
+ self._agent_import_path = agent_import_path
300
+ self._extra_agent_kwargs: JsonObject = dict(extra_agent_kwargs or {})
301
+ self._agent_model_name = agent_model_name
302
+ self._missing_reward: MissingRewardMode = missing_reward
303
+ self._harness_backend: HarnessBackend = harness_backend
304
+ self._episode_timeout_s = episode_timeout_s
305
+ self._context_window = context_window
306
+ self._command_timeout_sec = command_timeout_sec
307
+ # The dedicated episode executor must never be smaller than agent concurrency (episodes
308
+ # + uncancellable cleanup share it), or queued episodes burn harbor timeout budget.
309
+ self._episode_workers = max(DEFAULT_EPISODE_WORKERS, 2 * effective_concurrency)
310
+ if harness_backend == "e2b":
311
+ resolved_template = resolve_e2b_template(e2b_template)
312
+ # "" pins "no template" so the agent process cannot drift onto an ambient
313
+ # $WMO_E2B_TEMPLATE that differs from what this scorer resolved.
314
+ self._e2b_template: str | None = (
315
+ resolved_template if resolved_template is not None else ""
316
+ )
317
+ else:
318
+ self._e2b_template = None
319
+ self._runner = runner or HarborJobRunner()
320
+
321
+ @classmethod
322
+ async def create(
323
+ cls,
324
+ job_template: JobConfig,
325
+ task_ids: Sequence[str],
326
+ *,
327
+ provider_config: ProviderConfig,
328
+ reward_key: str = "reward",
329
+ reward_mode: RewardMode = "raw",
330
+ attempts: int = 1,
331
+ task_environment: TaskEnvironment = "docker",
332
+ harness_backend: HarnessBackend = "local",
333
+ e2b_template: str | None = None,
334
+ episode_timeout_s: float = DEFAULT_EVAL_EPISODE_TIMEOUT_S,
335
+ command_timeout_sec: int = MAX_ENVIRONMENT_COMMAND_TIMEOUT_SEC,
336
+ agent_concurrency: int | None = None,
337
+ harbor_retries: int = 0,
338
+ agent_import_path: str = WMO_HARBOR_AGENT_IMPORT_PATH,
339
+ extra_agent_kwargs: JsonObject | None = None,
340
+ agent_model_name: str | None = None,
341
+ missing_reward: MissingRewardMode = "raise",
342
+ context_window: int | None = None,
343
+ runner: HarborRunner | None = None,
344
+ ) -> Self:
345
+ """Resolve the exact tasks and construct a scorer that can incur spend.
346
+
347
+ `job_template` supplies the run directory (`jobs_dir`), the task environment config,
348
+ and harbor tuning (timeouts, concurrency); it must carry exactly one dataset, no direct
349
+ tasks, and an untouched default agent + retry config (the scorer owns those).
350
+ `agent_import_path` plus `extra_agent_kwargs` route trials through a custom agent
351
+ bridge (e.g. the distill collector's token-recording subclass); the defaults preserve
352
+ the standard WMO agent, and extra kwargs may never shadow the scorer-owned ones.
353
+ `agent_model_name` overrides the harbor `AgentConfig.model_name` for agents that read
354
+ it as a real model identity (harbor's own terminus_2 derives its renderer and tokenizer
355
+ from it) instead of as the provenance label the WMO bridge treats it as.
356
+ """
357
+ if len(job_template.datasets) != 1 or job_template.tasks:
358
+ raise ValueError("HarborScorer requires exactly one dataset and no direct tasks")
359
+ tasks = await resolve_harbor_tasks(job_template.datasets[0], task_ids)
360
+ return cls(
361
+ job_template=job_template,
362
+ tasks=tasks,
363
+ provider_config=provider_config,
364
+ reward_key=reward_key,
365
+ reward_mode=reward_mode,
366
+ attempts=attempts,
367
+ task_environment=task_environment,
368
+ harness_backend=harness_backend,
369
+ e2b_template=e2b_template,
370
+ episode_timeout_s=episode_timeout_s,
371
+ command_timeout_sec=command_timeout_sec,
372
+ agent_concurrency=agent_concurrency,
373
+ harbor_retries=harbor_retries,
374
+ agent_import_path=agent_import_path,
375
+ extra_agent_kwargs=extra_agent_kwargs,
376
+ agent_model_name=agent_model_name,
377
+ missing_reward=missing_reward,
378
+ context_window=context_window,
379
+ runner=runner,
380
+ )
381
+
382
+ @property
383
+ def request(self) -> ScoreRequest:
384
+ """The exact task-by-attempt matrix every `score` call evaluates."""
385
+ return ScoreRequest(task_ids=self._task_ids, attempts=self._attempts)
386
+
387
+ @property
388
+ def reward_mode(self) -> RewardMode:
389
+ """The frozen reward interpretation this scorer applies."""
390
+ return self._reward_mode
391
+
392
+ @property
393
+ def task_pins(self) -> dict[str, str]:
394
+ """One stable provenance pin per resolved task, keyed by task id.
395
+
396
+ Git tasks pin their resolved commit and package tasks their name@ref, so a caller can
397
+ record the exact task identity a run was scored against and detect a dataset that
398
+ re-resolves differently on resume. Local-path tasks pin only their resolved path (the
399
+ weaker identity is deliberate: hashing arbitrary task dirs is not this scorer's job).
400
+ """
401
+ pins: dict[str, str] = {}
402
+ for task in self._tasks:
403
+ task_id = task.get_task_id().get_name()
404
+ if task.is_package_task():
405
+ pins[task_id] = f"package:{task.name}@{task.ref or 'latest'}"
406
+ elif task.is_git_task():
407
+ pins[task_id] = f"git:{task.git_url}@{task.git_commit_id}"
408
+ else:
409
+ pins[task_id] = f"path:{task.path}"
410
+ return pins
411
+
412
+ def candidate_job_dir(self, doc: HarnessDoc) -> Path:
413
+ """The deterministic job directory one candidate's trials live in (resume key)."""
414
+ return self._job_template.jobs_dir / f"wmo-{doc.doc_hash[:12]}"
415
+
416
+ def score(
417
+ self,
418
+ doc: HarnessDoc,
419
+ *,
420
+ should_cancel: Callable[[], bool] | None = None,
421
+ ) -> ScoreReport:
422
+ """Run one candidate through harbor and project its verifier rewards."""
423
+ if should_cancel is not None and should_cancel():
424
+ raise HarnessSearchCancelled("harbor scoring cancelled before job start")
425
+ config = self._candidate_job(doc)
426
+ job_dir = (config.jobs_dir / config.job_name).resolve()
427
+ # The entry prune is destructive; two concurrent scores of one candidate in this
428
+ # process would delete each other's in-flight trials through the shared job dir.
429
+ with _ACTIVE_GUARD:
430
+ if job_dir in _ACTIVE_JOB_DIRS:
431
+ raise RuntimeError(
432
+ f"candidate {doc.doc_hash[:12]} is already being scored (job dir {job_dir}); "
433
+ "wait for the in-flight score to finish"
434
+ )
435
+ _ACTIVE_JOB_DIRS.add(job_dir)
436
+ try:
437
+ _assert_job_dir_resumable(job_dir, config)
438
+ pruned = _prune_invalid_trial_dirs(job_dir, reward_key=self._reward_key)
439
+ if pruned:
440
+ logger.info(
441
+ "pruned %d invalid trial dir(s) under %s; harbor resume re-runs only those",
442
+ pruned,
443
+ job_dir,
444
+ )
445
+ run = self._runner.run(config, should_cancel=should_cancel)
446
+ finally:
447
+ with _ACTIVE_GUARD:
448
+ _ACTIVE_JOB_DIRS.discard(job_dir)
449
+ return self._project(doc, run)
450
+
451
+ def _candidate_job(self, doc: HarnessDoc) -> JobConfig:
452
+ template_agent = self._job_template.agents[0]
453
+ # Constructor, not model_copy(update=): construction runs AgentConfig's validators.
454
+ agent_fields = {name: getattr(template_agent, name) for name in AgentConfig.model_fields}
455
+ agent_fields.update(
456
+ {
457
+ "name": None,
458
+ "import_path": self._agent_import_path,
459
+ "model_name": (
460
+ self._agent_model_name
461
+ if self._agent_model_name is not None
462
+ else f"{self._provider_config.kind.value}/{self._provider_config.model}"
463
+ ),
464
+ "skills": [],
465
+ "env": {},
466
+ "mcp_servers": [],
467
+ "kwargs": {
468
+ "harness": doc.model_dump(mode="json"),
469
+ "provider_config": self._provider_config.model_dump(mode="json"),
470
+ "harness_backend": self._harness_backend,
471
+ "e2b_template": self._e2b_template,
472
+ "command_timeout_sec": self._command_timeout_sec,
473
+ "episode_timeout_sec": self._episode_timeout_s,
474
+ "episode_workers": self._episode_workers,
475
+ "context_window": self._context_window,
476
+ # Custom-bridge kwargs; collisions with the scorer-owned keys above
477
+ # were rejected at construction, so this merge cannot shadow them.
478
+ **self._extra_agent_kwargs,
479
+ },
480
+ }
481
+ )
482
+ agent = AgentConfig(**agent_fields)
483
+ config = self._job_template.model_copy(
484
+ update={
485
+ # Deterministic (NOT uuid-suffixed): rescoring the same candidate resumes its
486
+ # completed trials through harbor's native trial resume.
487
+ "job_name": f"wmo-{doc.doc_hash[:12]}",
488
+ "n_attempts": self._attempts,
489
+ # source names the dataset a task came from, but candidate jobs carry no
490
+ # datasets: harbor's Job._refresh_metrics_for_eval indexes its metrics
491
+ # defaultdict by source, creating an empty entry for the unknown name that
492
+ # its display hook later crashes on with IndexError. Adhoc tasks (source
493
+ # None) use the always-present "adhoc" metrics entry.
494
+ "tasks": [
495
+ task.model_copy(deep=True, update={"source": None}) for task in self._tasks
496
+ ],
497
+ "agents": [agent],
498
+ },
499
+ deep=True,
500
+ )
501
+ # model_copy(update=) skips validation: re-validate the exact config harbor will run.
502
+ return _revalidated_job_config(config)
503
+
504
+ def _project(self, doc: HarnessDoc, run: HarborRun) -> ScoreReport:
505
+ result = run.result
506
+ if result.finished_at is None:
507
+ raise ValueError("harbor job did not finish")
508
+ expected = len(self._task_ids) * self._attempts
509
+ if len(result.trial_results) != expected:
510
+ raise ValueError(
511
+ f"harbor returned {len(result.trial_results)} trials; expected {expected}"
512
+ )
513
+ grouped: defaultdict[str, list[TrialResult]] = defaultdict(list)
514
+ for trial in result.trial_results:
515
+ task_id = trial.task_id.get_name()
516
+ if task_id not in self._task_ids:
517
+ raise ValueError(f"harbor returned an unexpected task {task_id!r}")
518
+ if trial.finished_at is None:
519
+ raise ValueError(f"harbor trial {trial.trial_name!r} did not finish")
520
+ grouped[task_id].append(trial)
521
+ wrong_counts = {
522
+ task_id: len(grouped[task_id])
523
+ for task_id in self._task_ids
524
+ if len(grouped[task_id]) != self._attempts
525
+ }
526
+ if wrong_counts:
527
+ raise ValueError(f"harbor task matrix is incomplete: counts={wrong_counts}")
528
+
529
+ cells: list[ScoreCell] = []
530
+ for task_id in self._task_ids:
531
+ trials = sorted(grouped[task_id], key=lambda trial: trial.trial_name)
532
+ for attempt, trial in enumerate(trials, 1):
533
+ outcome = self._trial_outcome(trial)
534
+ trial_dir = run.job_dir / trial.trial_name
535
+ cells.append(
536
+ ScoreCell(
537
+ task_id=task_id,
538
+ attempt=attempt,
539
+ reward=outcome.reward,
540
+ passed=reward_passed(outcome.reward, self._reward_mode),
541
+ artifact_dir=str(trial_dir),
542
+ note=outcome.note,
543
+ infra_failed=outcome.infra_failed,
544
+ # Read for every trial, including infra failures: the report is evidence,
545
+ # and whether it counts is an aggregation decision (a graded rate excludes
546
+ # `infra_failed` cells exactly as a solve rate does). A trial whose verifier
547
+ # died wrote no report anyway, so this is None there.
548
+ tests=read_trial_graded_tests(trial_dir),
549
+ )
550
+ )
551
+ return ScoreReport(
552
+ doc_hash=doc.doc_hash,
553
+ request=self.request,
554
+ reward_mode=self._reward_mode,
555
+ cells=tuple(cells),
556
+ )
557
+
558
+ def _trial_outcome(self, trial: TrialResult) -> _TrialOutcome:
559
+ """Classify one finished trial: a measured candidate outcome or an ungradeable failure.
560
+
561
+ Evaluation-tolerant mode (`missing_reward="zero"`, the distillation evals) reports a
562
+ cell as `infra_failed` exactly when the verifier wrote no reward for the configured key,
563
+ which reaches this method in two shapes that must be treated identically:
564
+
565
+ - Nothing was written and nothing explains it: `_official_reward` raises. The runner
566
+ died before verification (no sandbox, dead transport, rate limit).
567
+ - Nothing was written and a terminal verifier failure explains it
568
+ (`_UNGRADEABLE_VERIFIER_EXCEPTIONS`): `_official_reward` substitutes a stand-in 0.0.
569
+ The live case was two of 48 TerminalBench-2 probe trials whose E2B command stream
570
+ stalled until `VerifierTimeoutError` after the agent had submitted real work (5,039
571
+ and 12,459 sampled tokens); recorded as definite failures they held the measured
572
+ solve rate at 20.8% (10/48) when the gradeable denominator was 46.
573
+
574
+ Search mode is deliberately unchanged: it raises on the first shape and scores the
575
+ substituted 0.0 on the second, so a deterministic verifier failure cannot wedge a
576
+ boundary in a raise -> prune -> identical re-run loop.
577
+ """
578
+ try:
579
+ reward = _official_reward(trial, reward_key=self._reward_key)
580
+ except HarborRewardMissingError:
581
+ if self._missing_reward == "raise":
582
+ raise
583
+ # The 0.0 keeps advantage estimation defined; `infra_failed` keeps it out of every
584
+ # reported solve rate, and the note keeps the cause auditable per cell.
585
+ return self._ungradeable_outcome(trial, reward=0.0)
586
+ if (
587
+ self._missing_reward == "zero"
588
+ and _ungradeable_verifier_cause(trial, reward_key=self._reward_key) is not None
589
+ ):
590
+ return self._ungradeable_outcome(trial, reward=reward)
591
+ return _TrialOutcome(reward=reward, infra_failed=False, note=_trial_note(trial))
592
+
593
+ def _ungradeable_outcome(self, trial: TrialResult, *, reward: float) -> _TrialOutcome:
594
+ """One trial the verifier never graded, as an auditable stand-in cell."""
595
+ exception = trial.exception_info
596
+ cause = exception.exception_type if exception else "no exception info"
597
+ logger.warning(
598
+ "harbor trial %s: the VERIFIER produced no evidence for reward key %r (%s), so this "
599
+ "trial's reward is a stand-in; counting it as an infrastructure failure EXCLUDED "
600
+ "from the solve rate, not as a task failure",
601
+ trial.trial_name,
602
+ self._reward_key,
603
+ cause,
604
+ )
605
+ return _TrialOutcome(
606
+ reward=reward,
607
+ infra_failed=True,
608
+ note=f"infra-failure: {cause}; no verifier evidence, excluded from solve rate",
609
+ )
610
+
611
+
612
+ def _revalidated_job_config(config: JobConfig) -> JobConfig:
613
+ """Re-run JobConfig validation without serializing env-bearing sections.
614
+
615
+ model_copy(update=) skips validation, and a model_dump round-trip is NOT safe here:
616
+ harbor's EnvironmentConfig/VerifierConfig env field serializers templatize and redact
617
+ sensitive-named values on every dump (harbor.utils.env.templatize_sensitive_env) with no
618
+ disabling context, so dumping would silently corrupt a literal secret whose value differs
619
+ from os.environ. Reconstructing from field values re-runs every JobConfig validator while
620
+ nested models pass through by reference, never through their serializers.
621
+ """
622
+ return JobConfig(**{name: getattr(config, name) for name in JobConfig.model_fields})
623
+
624
+
625
+ def _validate_job_template(job_template: JobConfig) -> None:
626
+ """Reject template shapes the scorer would otherwise have to silently rewrite."""
627
+ if len(job_template.agents) != 1:
628
+ raise ValueError("HarborScorer requires exactly one agent template")
629
+ template = job_template.agents[0]
630
+ if any(
631
+ (
632
+ template.name not in (None, AgentName.ORACLE.value),
633
+ template.import_path is not None,
634
+ template.model_name is not None,
635
+ bool(template.skills),
636
+ bool(template.env),
637
+ bool(template.mcp_servers),
638
+ bool(template.kwargs),
639
+ )
640
+ ):
641
+ raise ValueError(
642
+ "HarborScorer owns agent identity, model, skills, environment, and kwargs; "
643
+ "leave the template agent unset"
644
+ )
645
+ if job_template.install_only:
646
+ raise ValueError("HarborScorer cannot use an install-only harbor job")
647
+ if job_template.verifier.disable:
648
+ raise ValueError("HarborScorer requires harbor verification")
649
+ if job_template.retry != RetryConfig():
650
+ raise ValueError(
651
+ "HarborScorer owns the retry policy; configure retries through harbor_retries"
652
+ )
653
+
654
+
655
+ def _route_task_environment(job_template: JobConfig, task_environment: TaskEnvironment) -> object:
656
+ """Validate the template/backend combination; rewrite type -> import_path only for E2B.
657
+
658
+ Conflicts are REJECTED, never rewritten: a docker template with a requested e2b task
659
+ environment (or vice versa) means the caller's config and intent disagree.
660
+ """
661
+ environment = job_template.environment
662
+ if task_environment not in ("docker", "e2b"):
663
+ raise ValueError("task_environment must be docker or e2b")
664
+ if environment.import_path is not None:
665
+ if environment.type is not None:
666
+ raise ValueError("harbor environment cannot set both type and import_path")
667
+ if (
668
+ task_environment == "e2b"
669
+ and environment.import_path == WMO_HARBOR_E2B_ENVIRONMENT_IMPORT_PATH
670
+ ):
671
+ return environment.model_copy(deep=True)
672
+ raise ValueError(
673
+ "HarborScorer owns task-environment routing; set environment.type and pass "
674
+ "task_environment instead of import_path"
675
+ )
676
+ if task_environment == "docker":
677
+ if environment.type is not EnvironmentType.DOCKER:
678
+ raise ValueError(
679
+ f"job template declares a {environment.type} task environment but "
680
+ "task_environment='docker' was requested; make them agree"
681
+ )
682
+ return environment.model_copy(deep=True)
683
+ if environment.type is not EnvironmentType.E2B:
684
+ raise ValueError(
685
+ f"job template declares a {environment.type} task environment but "
686
+ "task_environment='e2b' was requested; make them agree"
687
+ )
688
+ # The consistent combination: route harbor's built-in E2B type through WMO's paced
689
+ # subclass (qualified aliases, single-submit builds, create pacing). Options survive.
690
+ return environment.model_copy(
691
+ update={"type": None, "import_path": WMO_HARBOR_E2B_ENVIRONMENT_IMPORT_PATH},
692
+ deep=True,
693
+ )
694
+
695
+
696
+ def _assert_job_dir_resumable(job_dir: Path, config: JobConfig) -> None:
697
+ """Refuse to touch a job dir whose recorded config differs from the one about to run.
698
+
699
+ Harbor itself refuses to resume such a dir (FileExistsError), so pruning it first would
700
+ only destroy transcripts of a run that can never be resumed by this config anyway.
701
+ JobConfig equality ignores job_name/debug, matching harbor's own resume check.
702
+ """
703
+ config_path = job_dir / "config.json"
704
+ if not config_path.exists():
705
+ return
706
+ try:
707
+ existing = JobConfig.model_validate_json(config_path.read_text(encoding="utf-8"))
708
+ except (OSError, ValueError) as error:
709
+ raise ValueError(
710
+ f"candidate job dir {job_dir} has an unreadable config.json; refusing to prune or "
711
+ "resume it. Move or delete the directory to rerun this candidate"
712
+ ) from error
713
+ if existing != config:
714
+ raise ValueError(
715
+ f"candidate job dir {job_dir} was produced by a different job config; harbor would "
716
+ "refuse to resume it. Move or delete the directory to rerun this candidate"
717
+ )
718
+
719
+
720
+ def _prune_invalid_trial_dirs(job_dir: Path, *, reward_key: str) -> int:
721
+ """Delete trial dirs harbor's resume would either crash on or wrongly keep.
722
+
723
+ Harbor keeps any trial whose result.json parses; a trial that died with exception_info and
724
+ no verifier reward would therefore be "kept" as an unscoreable cell forever. Pruning it (and
725
+ unreadable ones) makes harbor re-run exactly those trials: cheap trial-level resume of a
726
+ crashed or interrupted boundary.
727
+ """
728
+ if not job_dir.is_dir():
729
+ return 0
730
+ pruned = 0
731
+ for trial_dir in sorted(job_dir.iterdir()):
732
+ if not trial_dir.is_dir():
733
+ continue
734
+ if _trial_dir_is_scoreable(trial_dir, reward_key=reward_key):
735
+ continue
736
+ shutil.rmtree(trial_dir)
737
+ pruned += 1
738
+ return pruned
739
+
740
+
741
+ def _trial_dir_is_scoreable(trial_dir: Path, *, reward_key: str) -> bool:
742
+ result_path = TrialPaths(trial_dir).result_path
743
+ try:
744
+ trial = TrialResult.model_validate_json(result_path.read_text(encoding="utf-8"))
745
+ except (OSError, ValueError):
746
+ return False
747
+ if trial.exception_info is None:
748
+ return True
749
+ if trial.exception_info.exception_type in _UNGRADEABLE_VERIFIER_EXCEPTIONS:
750
+ # `_official_reward` substitutes a 0.0 for these, so the trial projects into a cell
751
+ # (an `infra_failed` one under evaluation-tolerant scoring); re-running a deterministic
752
+ # verifier failure would loop forever without changing the outcome.
753
+ return True
754
+ return _trial_reward(trial, reward_key=reward_key) is not None
755
+
756
+
757
+ def _trial_reward(trial: TrialResult, *, reward_key: str) -> float | None:
758
+ verifier = trial.verifier_result
759
+ rewards = None if verifier is None else verifier.rewards
760
+ if rewards is None or reward_key not in rewards:
761
+ return None
762
+ value = rewards[reward_key]
763
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
764
+ return None
765
+ reward = float(value)
766
+ if not math.isfinite(reward) or not 0.0 <= reward <= 1.0:
767
+ return None
768
+ return reward
769
+
770
+
771
+ _UNGRADEABLE_VERIFIER_EXCEPTIONS = frozenset(
772
+ {
773
+ # The verifier's own wall clock expired before it wrote a reward. Live cause on
774
+ # TerminalBench-2: an E2B command-stream stall (asyncio.CancelledError inside harbor's
775
+ # `harbor/environments/e2b.py exec` -> `command_handle.wait`) on trials whose agent had
776
+ # already submitted, so nothing about the agent's work was ever measured.
777
+ "VerifierTimeoutError",
778
+ # The test script exited without writing /verifier/reward.{txt,json}: no grade exists,
779
+ # and nothing in the trial says whether the submitted work was right or wrong.
780
+ "RewardFileNotFoundError",
781
+ # The reward file is there but empty (a write cut off mid-flight): same, no grade.
782
+ "RewardFileEmptyError",
783
+ # The reward file's contents do not parse as a float/JSON: the grader's output is
784
+ # corrupt, which is a statement about the grader, not about the agent.
785
+ "VerifierOutputParseError",
786
+ }
787
+ )
788
+ """Terminal verifier failures that leave the trial with NO grade for anyone to read.
789
+
790
+ Three roles, all of them following from that one fact:
791
+
792
+ - `_official_reward` substitutes a stand-in 0.0 instead of raising, so a deterministic verifier
793
+ failure cannot wedge a candidate boundary in a raise -> prune -> identical re-run loop.
794
+ - `_trial_dir_is_scoreable` keeps such a trial on resume, for the same reason.
795
+ - Evaluation-tolerant scoring flags the cell `infra_failed`, because "the grader never spoke" is
796
+ an UNKNOWN outcome and reporting it as a task failure biases every solve rate downward.
797
+
798
+ Deaths BEFORE the verifier are deliberately absent (`AgentSetupTimeoutError`,
799
+ `EnvironmentStartTimeoutError`, a failed sandbox build, an upload/download failure, anything the
800
+ WMO bridge itself raises): they leave no reward at all, so the missing-reward branch of
801
+ `_trial_outcome` already classifies them from the evidence, while listing them here would make the
802
+ entry prune KEEP a transient environment failure instead of re-running it.
803
+
804
+ Pinned against harbor's own retry-exclude vocabulary in `scorer_test.py`, together with
805
+ `_GRADED_AGENT_EXCEPTIONS`, so a new harbor error type cannot silently join the wrong side."""
806
+
807
+ _GRADED_AGENT_EXCEPTIONS = frozenset(
808
+ {
809
+ # The agent ran out of ITS wall budget. harbor swallows this inside `_run_agent` and
810
+ # still verifies, so the trial carries a real grade of the work the agent managed: a
811
+ # legitimate benchmark outcome that STAYS in the solve-rate denominator, and one already
812
+ # visible as the `cancelled-by-harbor-timeout` stop reason behind `scaffold_loss_rate`.
813
+ "AgentTimeoutError",
814
+ # The remaining four are `NonZeroAgentExitCodeError` subclasses harbor also swallows in
815
+ # `_run_agent` before verifying, all raised by harbor's own installed CLI agents (WMO's
816
+ # Python bridge never raises them). Classified anyway so a harbor upgrade cannot move one
817
+ # onto the ungradeable side unnoticed.
818
+ "ApiUsageLimitError",
819
+ "AgentSafetyRefusalError",
820
+ "AgentAuthenticationError",
821
+ "ModelNotFoundError",
822
+ }
823
+ )
824
+ """Terminal agent-side failures that still reach the verifier, so a written grade is real.
825
+
826
+ These are never reclassified as infrastructure: an agent that exhausted its budget produced a
827
+ measurable outcome. When one of them leaves no reward at all the verifier still failed to grade
828
+ the trial, and the missing-reward branch of `_trial_outcome` flags that on the evidence itself."""
829
+
830
+
831
+ def _official_reward(trial: TrialResult, *, reward_key: str) -> float:
832
+ """The verifier's reward; a terminal verifier failure substitutes 0.0, anything else raises.
833
+
834
+ A substituted 0.0 is NOT a measurement (`_ungradeable_verifier_cause` is how a caller tells
835
+ the two apart); it exists so search-mode scoring stays terminating.
836
+ """
837
+ verifier = trial.verifier_result
838
+ rewards = None if verifier is None else verifier.rewards
839
+ if rewards is None or reward_key not in rewards:
840
+ exception = trial.exception_info
841
+ if exception is not None and exception.exception_type in _UNGRADEABLE_VERIFIER_EXCEPTIONS:
842
+ return 0.0
843
+ available = sorted(rewards or {})
844
+ raise HarborRewardMissingError(
845
+ f"harbor trial {trial.trial_name!r} has no verifier reward {reward_key!r} "
846
+ f"(available reward keys: {available or 'none'}); either the verifier never "
847
+ "produced evidence or reward_key is misconfigured for this task set"
848
+ )
849
+ value = rewards[reward_key]
850
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
851
+ raise ValueError(f"harbor reward {reward_key!r} must be numeric")
852
+ reward = float(value)
853
+ if not math.isfinite(reward) or not 0.0 <= reward <= 1.0:
854
+ raise ValueError(f"harbor reward {reward_key!r} must be finite and in [0, 1]")
855
+ return reward
856
+
857
+
858
+ def _ungradeable_verifier_cause(trial: TrialResult, *, reward_key: str) -> str | None:
859
+ """The exception type behind a reward the verifier never wrote, or None if it wrote one.
860
+
861
+ `_official_reward` substitutes a stand-in 0.0 for every cause in
862
+ `_UNGRADEABLE_VERIFIER_EXCEPTIONS`, so a cell can otherwise carry a reward no grader ever
863
+ produced. This is what separates that stand-in from a measured 0.0.
864
+ """
865
+ if _trial_reward(trial, reward_key=reward_key) is not None:
866
+ return None
867
+ exception = trial.exception_info
868
+ if exception is None or exception.exception_type not in _UNGRADEABLE_VERIFIER_EXCEPTIONS:
869
+ return None
870
+ return exception.exception_type
871
+
872
+
873
+ def _trial_note(trial: TrialResult) -> str:
874
+ exception = trial.exception_info
875
+ return "completed" if exception is None else f"completed with {exception.exception_type}"