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/evals/agreement.py ADDED
@@ -0,0 +1,110 @@
1
+ """Agreement between two closed-loop reports: does one environment's verdict match another's?
2
+
3
+ The canonical use is sim vs real (docs/reference/closed_loop.md's "outcome agreement... the headline
4
+ closed-loop validity number"): score the same tasks against the world model and against a real
5
+ environment, then ask how often the per-task pass/fail verdicts match. It works over any two
6
+ `ClosedLoopReport`s — however the second one was produced — so nothing here depends on an execution
7
+ backend existing in this repo.
8
+
9
+ Pure over its inputs; the confusion is tallied on (task) cells present in BOTH reports, binarized at
10
+ `pass_threshold` on each task's k-pass success rate. `outcome_agreement` is None (not 0.0) when
11
+ there are no overlapping cells — 0.0 would read as "total disagreement" when the truth is "no data".
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+ from wmo.evals.closed_loop import ClosedLoopReport
19
+
20
+ DEFAULT_PASS_THRESHOLD = 0.5 # a task "passes" when >= this fraction of its k passes do
21
+
22
+
23
+ class Confusion(BaseModel):
24
+ """2x2 counts of task cells by report-A vs report-B pass/fail."""
25
+
26
+ a_pass_b_pass: int = 0
27
+ a_pass_b_fail: int = 0 # A over-optimistic (for A=sim: the mirage a search would chase)
28
+ a_fail_b_pass: int = 0 # A over-pessimistic
29
+ a_fail_b_fail: int = 0
30
+
31
+ @property
32
+ def total(self) -> int:
33
+ return self.a_pass_b_pass + self.a_pass_b_fail + self.a_fail_b_pass + self.a_fail_b_fail
34
+
35
+ @property
36
+ def agree(self) -> int:
37
+ return self.a_pass_b_pass + self.a_fail_b_fail
38
+
39
+
40
+ class AgreementReport(BaseModel):
41
+ """How well two closed-loop reports agree, task by task.
42
+
43
+ Every number here is computed over the SHARED tasks only (present in both reports), including
44
+ `success_gap` — mixing each report's full aggregate would conflate coverage differences with
45
+ calibration. `k_a`/`k_b` are recorded separately; reports with different k are comparable (the
46
+ threshold binarizes each side's own pass rate) but the reader should know.
47
+ """
48
+
49
+ label_a: str = ""
50
+ label_b: str = ""
51
+ k_a: int = 0
52
+ k_b: int = 0
53
+ pass_threshold: float = DEFAULT_PASS_THRESHOLD
54
+ confusion: Confusion = Field(default_factory=Confusion)
55
+ outcome_agreement: float | None = None # fraction of shared cells where verdicts match
56
+ success_gap: float = 0.0 # A minus B, mean success over the SHARED tasks
57
+
58
+ def summary(self) -> str:
59
+ oa = "n/a" if self.outcome_agreement is None else f"{self.outcome_agreement:.3f}"
60
+ k_note = f"k={self.k_a}" if self.k_a == self.k_b else f"k_a={self.k_a} k_b={self.k_b}"
61
+ return (
62
+ f"outcome_agreement={oa} over {self.confusion.total} shared task(s) ({k_note}); "
63
+ f"success gap ({self.label_a} - {self.label_b}) = {self.success_gap:+.3f}"
64
+ )
65
+
66
+
67
+ def compute_agreement(
68
+ report_a: ClosedLoopReport,
69
+ report_b: ClosedLoopReport,
70
+ *,
71
+ pass_threshold: float = DEFAULT_PASS_THRESHOLD,
72
+ ) -> AgreementReport:
73
+ """Compare two closed-loop reports over their shared tasks (matched by task_id)."""
74
+ confusion = Confusion()
75
+ rates_a: list[float] = []
76
+ rates_b: list[float] = []
77
+ for task_id, outcome_a in report_a.per_task.items():
78
+ outcome_b = report_b.per_task.get(task_id)
79
+ if outcome_b is None:
80
+ continue
81
+ rates_a.append(outcome_a.success_rate)
82
+ rates_b.append(outcome_b.success_rate)
83
+ _tally(
84
+ confusion,
85
+ outcome_a.success_rate >= pass_threshold,
86
+ outcome_b.success_rate >= pass_threshold,
87
+ )
88
+ total = confusion.total
89
+ gap = (sum(rates_a) - sum(rates_b)) / total if total else 0.0
90
+ return AgreementReport(
91
+ label_a=report_a.label,
92
+ label_b=report_b.label,
93
+ k_a=report_a.k,
94
+ k_b=report_b.k,
95
+ pass_threshold=pass_threshold,
96
+ confusion=confusion,
97
+ outcome_agreement=confusion.agree / total if total else None,
98
+ success_gap=gap,
99
+ )
100
+
101
+
102
+ def _tally(confusion: Confusion, a_pass: bool, b_pass: bool) -> None:
103
+ if a_pass and b_pass:
104
+ confusion.a_pass_b_pass += 1
105
+ elif a_pass and not b_pass:
106
+ confusion.a_pass_b_fail += 1
107
+ elif not a_pass and b_pass:
108
+ confusion.a_fail_b_pass += 1
109
+ else:
110
+ confusion.a_fail_b_fail += 1
wmo/evals/base.py ADDED
@@ -0,0 +1,45 @@
1
+ """The general evaluation interface: configured at construction, one `run()` to a result.
2
+
3
+ Open-loop and closed-loop evaluation answer the same question — "how good is this world model?" —
4
+ through genuinely different inputs and metrics: open-loop replays recorded trace steps
5
+ teacher-forced and scores per-step reconstruction fidelity; closed-loop runs a live agent with the
6
+ world model as its environment and scores end-to-end task success. Forcing those inputs through
7
+ one signature would produce a union-typed blob, so the interface deliberately unifies only what is
8
+ truly common:
9
+
10
+ - an `Evaluation` is a fully configured measurement — every mode-specific input (trace files,
11
+ task specs, the agent runtime, k) is bound at construction;
12
+ - `run()` executes it and returns an `EvalResult`: a pydantic report that can print a one-line
13
+ `summary()` and expose a single `headline` score in [0, 1] (fidelity or task success).
14
+
15
+ `wmo eval --mode open-loop|closed-loop` selects the implementation; any future backend (e.g. a
16
+ real-environment closed loop) plugs in as one more implementation of the same protocol.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Protocol, runtime_checkable
22
+
23
+
24
+ @runtime_checkable
25
+ class EvalResult(Protocol):
26
+ """What every evaluation returns: a serializable report with a comparable headline."""
27
+
28
+ @property
29
+ def headline(self) -> float:
30
+ """The one-number score in [0, 1] (open-loop: fidelity; closed-loop: success rate)."""
31
+ ...
32
+
33
+ def summary(self) -> str:
34
+ """One human-readable line describing the result."""
35
+ ...
36
+
37
+ def model_dump_json(self, *, indent: int | None = None) -> str: # satisfied by pydantic
38
+ ...
39
+
40
+
41
+ @runtime_checkable
42
+ class Evaluation(Protocol):
43
+ """A configured evaluation of a world model. Construct with mode-specific inputs, then run."""
44
+
45
+ def run(self) -> EvalResult: ...
@@ -0,0 +1,480 @@
1
+ """Closed-loop scoring: run a live agent on tasks against an environment, judge task success.
2
+
3
+ Open-loop eval (`wmo/engine/eval.py`, the default `wmo eval` mode) replays recorded steps
4
+ teacher-forced and scores per-step fidelity. This module is the closed-loop counterpart
5
+ (`wmo eval --mode closed-loop`): for each task, the agent loop runs to completion (submit or turn
6
+ cap) and the `GoldJudge` scores the transcript against the task's gold assertions. Per the repo's
7
+ eval convention, every task runs **k=3 passes** and metrics are means over the passes — never
8
+ single-pass.
9
+
10
+ The environment is a factory parameter: `evaluate_closed_loop` binds it to the world model
11
+ (`WorldModelEnvironment`), and any real execution backend can bind the same core through the
12
+ `AgentEnvironment` protocol, producing a directly comparable report (see
13
+ `wmo.evals.agreement`).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
20
+ from statistics import fmean, pstdev
21
+
22
+ from pydantic import BaseModel, Field
23
+
24
+ from wmo.core.text import normalize_durable_text
25
+ from wmo.core.types import Action, Observation
26
+ from wmo.engine.world_model import WorldModel
27
+ from wmo.evals.gold import GoldJudge, GoldVerdict
28
+ from wmo.evals.tasks import TaskSpec
29
+ from wmo.harness.environment import AgentEnvironment
30
+ from wmo.harness.runtime import (
31
+ AgentRuntime,
32
+ RunResult,
33
+ Runtime,
34
+ RuntimeCancelled,
35
+ StopReason,
36
+ TokenUsage,
37
+ combine_usage,
38
+ )
39
+ from wmo.providers.base import Provider
40
+
41
+ DEFAULT_K = 3 # eval-reporting convention: every metric is the mean of k passes, never single-pass
42
+ _ROLLOUT_EVIDENCE_CHARS = 12_000
43
+ _ROLLOUT_ANSWER_CHARS = 4_000
44
+
45
+ # Opens a fresh environment for one task. The world-model backend and any real backend both fit
46
+ # this shape, which is what lets the SAME scoring core measure simulation and reality.
47
+ EnvFactory = Callable[[TaskSpec], AgentEnvironment]
48
+
49
+
50
+ class WorldModelEnvironment:
51
+ """A simulated environment: actions are answered by the world model, not a real shell.
52
+
53
+ Wraps one `WorldModel` session, so the agent loop drives closed-loop eval exactly as it would
54
+ drive a real environment. Sessions are explicitly ended on `close` so batch rollouts don't
55
+ accumulate resident session state in the model.
56
+ """
57
+
58
+ def __init__(self, world_model: WorldModel, task: str) -> None:
59
+ self._wm = world_model
60
+ # enrich=False: this rollout's PREDICTED steps must not enter the retrieval buffer, or
61
+ # k=2 retrieves k=1's hallucinations as demos and scores become order-dependent.
62
+ self._session = world_model.new_session(task=task, enrich=False)
63
+ self._closed = False
64
+
65
+ @property
66
+ def session_id(self) -> str:
67
+ return self._session.id
68
+
69
+ def execute(self, action: Action) -> Observation:
70
+ return self._wm.step(self._session.id, action)
71
+
72
+ def close(self) -> None:
73
+ if not self._closed:
74
+ self._wm.end_session(self._session.id)
75
+ self._closed = True
76
+
77
+
78
+ class RolloutEvidence(BaseModel):
79
+ """The execution evidence behind one judged pass.
80
+
81
+ Aggregate scores are sufficient for ranking, but not for improving a harness: the proposer
82
+ needs to see what the agent actually tried, what the environment returned, and why the run
83
+ stopped. Keeping this alongside each verdict lets every optimizer backend expose the same
84
+ trace-level feedback without reaching into a platform-specific rollout store.
85
+ """
86
+
87
+ answer: str = ""
88
+ transcript: str = ""
89
+ stop_reason: StopReason
90
+ turns: int = 0
91
+
92
+
93
+ class TaskOutcome(BaseModel):
94
+ """One task's closed-loop result across k passes."""
95
+
96
+ task_id: str
97
+ success_rate: float = 0.0 # fraction of k passes that fully passed gold
98
+ mean_fraction: float = 0.0 # mean fraction-of-assertions across passes (partial credit)
99
+ passes: int = 0
100
+ verdicts: list[GoldVerdict] = Field(default_factory=list)
101
+ attempts: list[RolloutEvidence] = Field(default_factory=list)
102
+
103
+
104
+ class ClosedLoopReport(BaseModel):
105
+ """A closed-loop scorecard over a task suite.
106
+
107
+ `label` names what produced the report (a world model name, or a real environment) so two
108
+ reports compared by `compute_agreement` stay identifiable.
109
+ """
110
+
111
+ label: str = ""
112
+ success_rate: float = 0.0 # mean over tasks of per-task pass rate
113
+ mean_fraction: float = 0.0 # mean over tasks of mean assertion-fraction (denser signal)
114
+ success_std: float = 0.0 # spread of per-task success rates
115
+ k: int = DEFAULT_K
116
+ per_task: dict[str, TaskOutcome] = Field(default_factory=dict)
117
+ # Aggregate worker-LLM spend from runtimes that meter it themselves (the pi worker path);
118
+ # None when every rollout came from a provider-wrapped runtime (metered upstream).
119
+ worker_usage: TokenUsage | None = None
120
+
121
+ @property
122
+ def headline(self) -> float:
123
+ """The `EvalResult` headline: end-to-end task success."""
124
+ return self.success_rate
125
+
126
+ def summary(self) -> str:
127
+ return (
128
+ f"success_rate={self.success_rate:.3f}±{self.success_std:.3f} "
129
+ f"assertion_fraction={self.mean_fraction:.3f} "
130
+ f"({len(self.per_task)} tasks, k={self.k})"
131
+ )
132
+
133
+
134
+ def evaluate_with_env(
135
+ tasks: list[TaskSpec],
136
+ make_env: EnvFactory,
137
+ runtime: Runtime,
138
+ judge: GoldJudge,
139
+ *,
140
+ label: str = "",
141
+ k: int = DEFAULT_K,
142
+ concurrency: int = 1,
143
+ on_progress: Callable[[str, int, GoldVerdict], None] | None = None,
144
+ should_cancel: Callable[[], bool] | None = None,
145
+ ) -> ClosedLoopReport:
146
+ """Score the agent on `tasks` against whatever env `make_env` opens, k passes per task.
147
+
148
+ `concurrency` is how many (task, attempt) cells run at once: the default 1 keeps the
149
+ sequential loop (world-model behavior unchanged), 0 runs every cell simultaneously (the E2B
150
+ one-sandbox-per-rollout backend), and N>1 caps the pool at N. The report is identical either
151
+ way: verdicts are collected by cell index and aggregated per task in attempt order, and
152
+ `on_progress` always fires from the calling thread so UI callbacks see a serial stream.
153
+ """
154
+ if k < 1:
155
+ raise ValueError("k must be >= 1 (metrics are means over k passes)")
156
+ per_task: dict[str, TaskOutcome] = {}
157
+ usages: list[TokenUsage | None] = []
158
+ if concurrency != 0 and concurrency <= 1:
159
+ try:
160
+ for task in tasks:
161
+ verdicts: list[GoldVerdict] = []
162
+ attempts: list[RolloutEvidence] = []
163
+ for attempt in range(k):
164
+ _check_cancelled(should_cancel)
165
+ result = _run_once(task, make_env, runtime)
166
+ # Record the episode before the next cancellation boundary. If
167
+ # cancellation landed during the bounded worker call, RunnerLink
168
+ # raises with that episode's partial usage instead.
169
+ usages.append(result.worker_usage)
170
+ attempts.append(_rollout_evidence(result))
171
+ _check_cancelled(should_cancel)
172
+ verdict = judge.score(
173
+ task.instruction, result.answer, result.transcript(), task.gold
174
+ )
175
+ _check_cancelled(should_cancel)
176
+ verdicts.append(verdict)
177
+ if on_progress is not None:
178
+ on_progress(task.task_id, attempt + 1, verdict)
179
+ successes = [1.0 if v.passed else 0.0 for v in verdicts]
180
+ per_task[task.task_id] = TaskOutcome(
181
+ task_id=task.task_id,
182
+ success_rate=fmean(successes),
183
+ mean_fraction=fmean(v.fraction for v in verdicts),
184
+ passes=k,
185
+ verdicts=verdicts,
186
+ attempts=attempts,
187
+ )
188
+ except RuntimeCancelled as error:
189
+ error.worker_usage = combine_usage([*usages, error.worker_usage])
190
+ raise
191
+ else:
192
+ by_cell, usages, evidence_by_cell = _run_cells_concurrently(
193
+ tasks,
194
+ make_env,
195
+ runtime,
196
+ judge,
197
+ k=k,
198
+ concurrency=concurrency,
199
+ on_progress=on_progress,
200
+ should_cancel=should_cancel,
201
+ )
202
+ for index, task in enumerate(tasks):
203
+ verdicts = by_cell[index * k : (index + 1) * k] # cells are task-major, attempt-minor
204
+ attempts = evidence_by_cell[index * k : (index + 1) * k]
205
+ successes = [1.0 if v.passed else 0.0 for v in verdicts]
206
+ per_task[task.task_id] = TaskOutcome(
207
+ task_id=task.task_id,
208
+ success_rate=fmean(successes),
209
+ mean_fraction=fmean(v.fraction for v in verdicts),
210
+ passes=k,
211
+ verdicts=verdicts,
212
+ attempts=attempts,
213
+ )
214
+
215
+ task_rates = [o.success_rate for o in per_task.values()]
216
+ return ClosedLoopReport(
217
+ label=label,
218
+ success_rate=fmean(task_rates) if task_rates else 0.0,
219
+ mean_fraction=fmean(o.mean_fraction for o in per_task.values()) if per_task else 0.0,
220
+ success_std=pstdev(task_rates) if len(task_rates) > 1 else 0.0,
221
+ k=k,
222
+ per_task=per_task,
223
+ worker_usage=combine_usage(usages),
224
+ )
225
+
226
+
227
+ def evaluate_closed_loop(
228
+ tasks: list[TaskSpec],
229
+ world_model: WorldModel,
230
+ agent_provider: Provider,
231
+ judge: GoldJudge,
232
+ *,
233
+ label: str = "world-model",
234
+ k: int = DEFAULT_K,
235
+ concurrency: int = 1,
236
+ runtime: Runtime | None = None,
237
+ on_progress: Callable[[str, int, GoldVerdict], None] | None = None,
238
+ should_cancel: Callable[[], bool] | None = None,
239
+ ) -> ClosedLoopReport:
240
+ """Score the fixed agent on `tasks` against `world_model` (`wmo eval --mode closed-loop`).
241
+
242
+ With `concurrency != 1` the world model steps for many rollouts at once, so the whole eval
243
+ runs under `world_model.frozen()` (the `scenario_fidelity.score_matrix` precedent): sessions
244
+ are already independent (`enrich=False`), and freezing keeps parallel stepping from mutating
245
+ the shared retrieval index mid-eval. Sequential behavior is unchanged.
246
+ """
247
+
248
+ def _evaluate() -> ClosedLoopReport:
249
+ return evaluate_with_env(
250
+ tasks,
251
+ lambda task: WorldModelEnvironment(world_model, task=task.instruction),
252
+ runtime if runtime is not None else AgentRuntime(agent_provider),
253
+ judge,
254
+ label=label,
255
+ k=k,
256
+ concurrency=concurrency,
257
+ on_progress=on_progress,
258
+ should_cancel=should_cancel,
259
+ )
260
+
261
+ if concurrency == 1:
262
+ return _evaluate()
263
+ with world_model.frozen():
264
+ return _evaluate()
265
+
266
+
267
+ class ClosedLoopEval:
268
+ """The closed-loop `Evaluation`: a live agent runs tasks with the world model as its env."""
269
+
270
+ def __init__(
271
+ self,
272
+ tasks: list[TaskSpec],
273
+ world_model: WorldModel,
274
+ agent_provider: Provider,
275
+ judge: GoldJudge,
276
+ *,
277
+ label: str = "world-model",
278
+ k: int = DEFAULT_K,
279
+ concurrency: int = 1,
280
+ runtime: Runtime | None = None,
281
+ on_progress: Callable[[str, int, GoldVerdict], None] | None = None,
282
+ ) -> None:
283
+ self._tasks = tasks
284
+ self._world_model = world_model
285
+ self._agent_provider = agent_provider
286
+ self._judge = judge
287
+ self._label = label
288
+ self._k = k
289
+ self._concurrency = concurrency
290
+ self._runtime = runtime
291
+ self._on_progress = on_progress
292
+
293
+ def run(self) -> ClosedLoopReport:
294
+ return evaluate_closed_loop(
295
+ self._tasks,
296
+ self._world_model,
297
+ self._agent_provider,
298
+ self._judge,
299
+ label=self._label,
300
+ k=self._k,
301
+ concurrency=self._concurrency,
302
+ runtime=self._runtime,
303
+ on_progress=self._on_progress,
304
+ )
305
+
306
+
307
+ def _run_once(task: TaskSpec, make_env: EnvFactory, runtime: Runtime) -> RunResult:
308
+ """One rollout: a fresh environment per attempt, always closed."""
309
+ env = make_env(task)
310
+ try:
311
+ return runtime.run(task.task_id, task.instruction, env)
312
+ finally:
313
+ env.close()
314
+
315
+
316
+ def _rollout_evidence(result: RunResult) -> RolloutEvidence:
317
+ """Freeze the proposer-facing parts of a run before its environment is gone."""
318
+ return RolloutEvidence(
319
+ answer=_bounded_evidence_text(
320
+ normalize_durable_text(result.answer),
321
+ _ROLLOUT_ANSWER_CHARS,
322
+ label="answer",
323
+ ),
324
+ transcript=_bounded_evidence_text(
325
+ normalize_durable_text(result.transcript()),
326
+ _ROLLOUT_EVIDENCE_CHARS,
327
+ label="trace",
328
+ ),
329
+ stop_reason=result.stop_reason,
330
+ turns=result.turns,
331
+ )
332
+
333
+
334
+ def _bounded_evidence_text(content: str, limit: int, *, label: str) -> str:
335
+ """Retain the beginning and terminal behavior without unbounded report memory."""
336
+ if len(content) <= limit:
337
+ return content
338
+ head = limit // 2
339
+ tail = limit - head
340
+ omitted = len(content) - limit
341
+ return f"{content[:head]}\n... ({omitted} {label} characters omitted) ...\n{content[-tail:]}"
342
+
343
+
344
+ def _run_cells_concurrently(
345
+ tasks: list[TaskSpec],
346
+ make_env: EnvFactory,
347
+ runtime: Runtime,
348
+ judge: GoldJudge,
349
+ *,
350
+ k: int,
351
+ concurrency: int,
352
+ on_progress: Callable[[str, int, GoldVerdict], None] | None,
353
+ should_cancel: Callable[[], bool] | None,
354
+ ) -> tuple[list[GoldVerdict], list[TokenUsage | None], list[RolloutEvidence]]:
355
+ """Run every (task, attempt) cell on a thread pool; verdicts return in cell order.
356
+
357
+ Cell order is task-major, attempt-minor — the exact order the sequential loop visits — so the
358
+ caller can slice per task and aggregate deterministically. `on_progress` fires from THIS
359
+ thread as futures land (gepa.py precedent: UI callbacks must be a serial stream). A rollout or
360
+ judge call that raises is a real failure: pending cells are cancelled and the exception
361
+ propagates after owned work drains — never swallowed into a verdict. Any failure drains
362
+ already-started cells before returning while cancelling cells
363
+ that have not started: those threads can still own billable provider calls, rollout
364
+ persistence, and sandbox leases, so the caller must not terminalize the enclosing run while
365
+ they remain active. Every cell releases its environment through ``_run_once``'s ``finally``.
366
+ """
367
+ cells = [(task, attempt) for task in tasks for attempt in range(k)]
368
+ if not cells:
369
+ return [], [], []
370
+ max_workers = len(cells) if concurrency == 0 else min(concurrency, len(cells))
371
+ slots: list[GoldVerdict | None] = [None] * len(cells)
372
+ usage_slots: list[TokenUsage | None] = [None] * len(cells)
373
+ evidence_slots: list[RolloutEvidence | None] = [None] * len(cells)
374
+
375
+ def run_cell(
376
+ task: TaskSpec,
377
+ ) -> tuple[GoldVerdict, TokenUsage | None, RolloutEvidence]:
378
+ _check_cancelled(should_cancel)
379
+ result = _run_once(task, make_env, runtime)
380
+ try:
381
+ _check_cancelled(should_cancel)
382
+ verdict = judge.score(task.instruction, result.answer, result.transcript(), task.gold)
383
+ _check_cancelled(should_cancel)
384
+ except RuntimeCancelled as error:
385
+ # The rollout finished before cancellation, even though its verdict
386
+ # did not. Preserve that worker spend for the coordinator's drain.
387
+ error.worker_usage = combine_usage([result.worker_usage, error.worker_usage])
388
+ raise
389
+ return verdict, result.worker_usage, _rollout_evidence(result)
390
+
391
+ pool = ThreadPoolExecutor(max_workers=max_workers)
392
+ try:
393
+ futures = {pool.submit(run_cell, task): i for i, (task, _attempt) in enumerate(cells)}
394
+ pending = set(futures)
395
+ while pending:
396
+ done, pending = wait(pending, timeout=0.25, return_when=FIRST_COMPLETED)
397
+ # Do not rely on a cell reaching RunnerLink before noticing API cancellation: all
398
+ # cells may still be inside cold E2B startup. This caller-side poll aborts the shared
399
+ # runtime below, which closes registered sandboxes before the ownership drain.
400
+ _check_cancelled(should_cancel)
401
+ for future in sorted(done, key=futures.__getitem__):
402
+ index = futures[future]
403
+ verdict, usage, evidence = (
404
+ future.result()
405
+ ) # a rollout/judge exception propagates here
406
+ slots[index] = verdict
407
+ usage_slots[index] = usage
408
+ evidence_slots[index] = evidence
409
+ if on_progress is not None:
410
+ task, attempt = cells[index]
411
+ on_progress(task.task_id, attempt + 1, verdict)
412
+ except BaseException as error:
413
+ cancelled = isinstance(error, RuntimeCancelled)
414
+ if not cancelled and isinstance(error, Exception) and should_cancel is not None:
415
+ try:
416
+ cancelled = should_cancel()
417
+ except Exception: # noqa: BLE001 - preserve the cell's original failure
418
+ pass
419
+ abort_error: BaseException | None = None
420
+ abort = getattr(runtime, "abort", None)
421
+ if callable(abort):
422
+ try:
423
+ abort()
424
+ except BaseException as candidate: # noqa: BLE001 - cleanup must survive the drain
425
+ abort_error = candidate
426
+ pool.shutdown(wait=True, cancel_futures=True)
427
+ if abort_error is not None and callable(abort):
428
+ # A worker's release() during the drain may have retried and proved the exact lease
429
+ # whose first abort failed. Re-run the idempotent close before declaring a leak.
430
+ try:
431
+ abort()
432
+ except BaseException as candidate: # noqa: BLE001 - this is the final cleanup proof
433
+ abort_error = candidate
434
+ else:
435
+ abort_error = None
436
+ cancelled_error: RuntimeCancelled | None = None
437
+ if cancelled:
438
+ # Every started future is done after shutdown(wait=True). Collect
439
+ # successful episode usage and partial usage carried by cancelled
440
+ # episodes exactly once, including futures the coordinator had not
441
+ # observed before the cancellation boundary.
442
+ partial_usages: list[TokenUsage | None] = []
443
+ for future in futures:
444
+ if future.cancelled():
445
+ continue
446
+ try:
447
+ _verdict, usage, _evidence = future.result()
448
+ except RuntimeCancelled as candidate:
449
+ partial_usages.append(candidate.worker_usage)
450
+ except BaseException: # noqa: BLE001 - original error remains authoritative
451
+ continue
452
+ else:
453
+ partial_usages.append(usage)
454
+ if isinstance(error, RuntimeCancelled):
455
+ cancelled_error = error
456
+ else:
457
+ cancelled_error = RuntimeCancelled("runtime evaluation cancelled")
458
+ cancelled_error.worker_usage = combine_usage(partial_usages)
459
+ if abort_error is not None:
460
+ raise abort_error from (cancelled_error or error)
461
+ if cancelled_error is not None and cancelled_error is not error:
462
+ raise cancelled_error from error
463
+ raise
464
+ else:
465
+ pool.shutdown(wait=True)
466
+ verdicts: list[GoldVerdict] = []
467
+ attempts: list[RolloutEvidence] = []
468
+ for slot, evidence in zip(slots, evidence_slots, strict=True):
469
+ if slot is None: # pragma: no cover - every future completed, or we raised above
470
+ raise RuntimeError("a cell completed without producing a verdict")
471
+ if evidence is None: # pragma: no cover - same future produced both values atomically
472
+ raise RuntimeError("a cell completed without preserving rollout evidence")
473
+ verdicts.append(slot)
474
+ attempts.append(evidence)
475
+ return verdicts, usage_slots, attempts
476
+
477
+
478
+ def _check_cancelled(should_cancel: Callable[[], bool] | None) -> None:
479
+ if should_cancel is not None and should_cancel():
480
+ raise RuntimeCancelled("runtime evaluation cancelled")