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/cli/app.py ADDED
@@ -0,0 +1,2489 @@
1
+ """`wmo` CLI — ingestion UI and operator console for the harness.
2
+
3
+ Deliberately small. The lifecycle is:
4
+ providers verify -> build -> list -> serve / demo / play
5
+ `build` creates the project artifact directory itself, so there is no separate init step. World
6
+ models are named (`--name`), stored under `<root>/models/<name>/`, and listed with `wmo list`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import random
14
+ import subprocess
15
+ import time
16
+ import urllib.error
17
+ import uuid
18
+ from dataclasses import dataclass
19
+ from datetime import UTC, datetime
20
+ from pathlib import Path
21
+ from uuid import uuid4
22
+
23
+ import typer
24
+ import uvicorn
25
+ from environment_capture.hub import (
26
+ CORPORA,
27
+ corpus_path,
28
+ fetch_corpus,
29
+ published_corpora,
30
+ )
31
+ from llm_waterfall import is_capacity_error
32
+ from rich.console import Console
33
+ from rich.markup import escape
34
+ from rich.panel import Panel
35
+ from rich.progress import BarColumn, DownloadColumn, Progress, TextColumn
36
+ from rich.table import Table
37
+
38
+ import wmo.providers as providers
39
+ from wmo.cli.agent_session import register as register_agent_session_commands
40
+ from wmo.cli.e2b_cmds import register as register_e2b_commands
41
+ from wmo.cli.eval_closed_loop import run_agreement, run_closed_loop
42
+ from wmo.cli.harness_app import harness_app, optimize_app
43
+ from wmo.cli.ingest_cmd import ingest as _ingest_command
44
+ from wmo.cli.platform_cmds import register as register_platform_commands
45
+ from wmo.cli.ui import (
46
+ BuildParams,
47
+ RichBuildReporter,
48
+ build_summary_panel,
49
+ judge_model_default,
50
+ models_table,
51
+ run_build_wizard,
52
+ run_play_repl,
53
+ select_model,
54
+ select_option,
55
+ select_provider_and_model,
56
+ )
57
+ from wmo.config import (
58
+ ARTIFACT_DIR,
59
+ DEFAULT_MODEL_NAME,
60
+ FIDELITY_TIERS,
61
+ PROVIDER_ENV_VARS,
62
+ ArtifactPaths,
63
+ FidelityTier,
64
+ HarnessConfig,
65
+ ModelRole,
66
+ WorldModelStore,
67
+ load_config,
68
+ load_env_file,
69
+ load_settings,
70
+ normalize_name,
71
+ save_settings,
72
+ set_telemetry_enabled,
73
+ settings_path,
74
+ validate_name,
75
+ )
76
+ from wmo.config.card import make_build_card, save_card
77
+ from wmo.core.types import JsonObject
78
+ from wmo.engine.build import build as run_build
79
+ from wmo.engine.build import ingest, split_traces
80
+ from wmo.engine.demo import run_demo
81
+ from wmo.engine.eval_suites import (
82
+ discover_eval_suites,
83
+ list_eval_results,
84
+ resolve_eval_suite,
85
+ result_path,
86
+ )
87
+ from wmo.engine.grounding import GROUNDER_KINDS
88
+ from wmo.engine.knowledge import KnowledgeBase
89
+ from wmo.engine.prompts import BASE_ENV_PROMPT
90
+ from wmo.engine.world_model import WorldModel
91
+ from wmo.env.llm_agent import LLMAgent
92
+ from wmo.evals.grid import GridResult, ModelSpec, merge_results, run_grid
93
+ from wmo.evals.grid_plot import plot_grid, plot_grid_heatmap
94
+ from wmo.evals.open_loop import EvalReport, OpenLoopEval
95
+ from wmo.ingest import VendorPull, get_adapter, list_adapters
96
+ from wmo.optimize.judge import JUDGE_VERSION, RubricJudge
97
+ from wmo.providers import ProviderConfig, ProviderKind, verify_all, verify_embedder
98
+ from wmo.providers.base import Embedder, EmbedderKind, Provider
99
+ from wmo.providers.models import resolve_provider_model
100
+ from wmo.providers.retry import wrap_provider_with_retries
101
+ from wmo.research import Side, run_concurrency_scaling
102
+ from wmo.research.concurrency_run import build_real_runner, build_world_runner
103
+ from wmo.research.concurrency_scaling import ConcurrencyPoint
104
+ from wmo.retrieval import EmbeddingRetriever, HashingEmbedder, get_embedder
105
+ from wmo.retrieval.leakfree import DemoRetriever
106
+ from wmo.scenarios import (
107
+ ChecklistJudge,
108
+ FacetExtractor,
109
+ ScenarioBuildConfig,
110
+ ScenarioSet,
111
+ build_scenario_set,
112
+ verify_scenarios,
113
+ )
114
+ from wmo.serving.server import create_app
115
+ from wmo.telemetry import (
116
+ BuildTelemetryStats,
117
+ TelemetryBuildReporter,
118
+ capture_build_completed,
119
+ capture_eval_completed,
120
+ settings_root_from_results_root,
121
+ )
122
+ from wmo.tracking import MeteredProvider, Phase, RunTracker, classify_build_call, save_run
123
+
124
+ app = typer.Typer(
125
+ help="Run agents, build world models from traces, and optimize agent harnesses.",
126
+ no_args_is_help=True,
127
+ )
128
+
129
+
130
+ providers_app = typer.Typer(help="Manage and verify LLM providers.", no_args_is_help=True)
131
+ examples_app = typer.Typer(
132
+ help="List and launch self-contained task examples.", no_args_is_help=True
133
+ )
134
+ config_app = typer.Typer(help="Manage local harness config.", no_args_is_help=True)
135
+ research_app = typer.Typer(
136
+ help="Research experiments over the harness (scaling laws, ablations).", no_args_is_help=True
137
+ )
138
+ scenarios_app = typer.Typer(
139
+ help="Construct and verify representative eval scenario sets from traces.",
140
+ no_args_is_help=True,
141
+ )
142
+ app.add_typer(providers_app, name="providers")
143
+ app.add_typer(examples_app, name="examples")
144
+ app.add_typer(config_app, name="config")
145
+ app.add_typer(research_app, name="research")
146
+ app.add_typer(scenarios_app, name="scenarios")
147
+ app.add_typer(harness_app, name="harness")
148
+ app.add_typer(optimize_app, name="optimize")
149
+ app.command("ingest")(_ingest_command)
150
+ register_platform_commands(app)
151
+ register_agent_session_commands(app)
152
+ register_e2b_commands(app)
153
+ _console = Console()
154
+ _CHECK = "[green]✓[/green]"
155
+
156
+ # Module-level singleton: a typer.Argument call can't be a default inline (ruff B008).
157
+ _EVAL_TOKENS = typer.Argument(
158
+ None,
159
+ help="Trace files to score, or eval flow: list | run <suite> | results optional-suite.",
160
+ )
161
+ # Repeatable option default hoisted out of the signature (ruff B008 forbids the call inline).
162
+ _RESEARCH_REAL_ARG = typer.Option(
163
+ None, "--real-arg", help="Extra arg forwarded to the real sandbox run.sh; repeat for several."
164
+ )
165
+ _RESEARCH_PLOT_REPORT = typer.Argument(..., help="ConcurrencyScalingReport JSON file to plot.")
166
+ _RESEARCH_PLOT_COMBINED = typer.Argument(
167
+ ..., help="ConcurrencyScalingReport JSONs to overlay (one per benchmark)."
168
+ )
169
+ # Flags each real runner needs so concurrent scenarios stay COLD/FRESH (as if on separate machines)
170
+ # and measure the TRUE standup cost. swe-bench: `--mode build` forces the honest from-source standup
171
+ # (base image + conda/pip env install + repo clone/checkout/install, minutes/env) instead of pulling
172
+ # SWE-bench's prebuilt image (~16s, which under-counts the real environment cost ~15-30x); plus
173
+ # `--no-family-purge` keeps each instance's own build cold without deleting sibling runs' images.
174
+ # terminal-tasks needs nothing — it already builds a per-run unique image tag under concurrency.
175
+ # tau-bench's real side is in-process (no docker), so it is always isolated.
176
+ _CONCURRENCY_ISOLATION_FLAGS: dict[str, tuple[str, ...]] = {
177
+ # --cache-shared: build the shared base+env images once, but cold-build the per-instance image
178
+ # at every level (the marginal per-scenario standup). Correct for this fixed-N sweep, where the
179
+ # same scenarios repeat across levels — plain --no-family-purge would rebuild the 660MB base for
180
+ # every scenario and let concurrent workers clobber each other's base image. (--cache-shared
181
+ # implies no family purge.)
182
+ "swe-bench": ("--mode", "build", "--cache-shared"),
183
+ }
184
+
185
+ _DOWNLOAD_BENCHMARKS = typer.Argument(
186
+ None, help="Benchmark bundles to download, or 'all'. Omit for a picker."
187
+ )
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class _EvalOptions:
192
+ prompt_file: str | None
193
+ train_split: float
194
+ embed_dim: int
195
+ use_rag: bool
196
+ sample_turns: str
197
+ seed: int
198
+ top_k: int
199
+ knowledge: bool
200
+ reasoning: bool
201
+
202
+
203
+ @config_app.command("telemetry")
204
+ def config_telemetry(
205
+ action: str = typer.Argument("status", help="status | enable | disable"),
206
+ root: str = typer.Option(ARTIFACT_DIR, help="Project dir holding local settings."),
207
+ ) -> None:
208
+ """View or change project-local usage telemetry settings."""
209
+ normalized = action.lower()
210
+ if normalized == "status":
211
+ settings = load_settings(root)
212
+ elif normalized == "enable":
213
+ settings = set_telemetry_enabled(True, root)
214
+ elif normalized == "disable":
215
+ settings = set_telemetry_enabled(False, root)
216
+ else:
217
+ raise typer.BadParameter("action must be one of: status, enable, disable")
218
+ state = "enabled" if settings.telemetry.enabled else "disabled"
219
+ _console.print(f"telemetry {state} ({settings_path(root)})")
220
+
221
+
222
+ def _worker_provider_config(
223
+ provider: str,
224
+ model: str,
225
+ region: str | None,
226
+ *,
227
+ endpoint: str | None = None,
228
+ deployment: str | None = None,
229
+ api_version: str | None = None,
230
+ ) -> ProviderConfig:
231
+ """Resolve the provider settings used by the built-in worker agent."""
232
+ config = _provider_config(provider, model, region)
233
+ if endpoint is not None:
234
+ config = config.model_copy(update={"endpoint": endpoint})
235
+ if config.kind is ProviderKind.AZURE_OPENAI:
236
+ config = config.model_copy(
237
+ update={
238
+ "deployment": deployment or config.model_type or config.model,
239
+ "api_version": api_version or "2024-05-01-preview",
240
+ }
241
+ )
242
+ return config
243
+
244
+
245
+ @providers_app.command("set")
246
+ def providers_set(
247
+ provider: str = typer.Option(None, "--provider", help="Provider for the local worker agent."),
248
+ model: str = typer.Option(None, "--model", help="Canonical model type for the worker."),
249
+ region: str = typer.Option(None, "--region", help="AWS region for Bedrock."),
250
+ endpoint: str = typer.Option(None, "--endpoint", help="OpenAI-compatible API endpoint."),
251
+ deployment: str = typer.Option(None, "--deployment", help="Azure deployment name."),
252
+ api_version: str = typer.Option(None, "--api-version", help="Azure API version."),
253
+ root: str = typer.Option(ARTIFACT_DIR, help="Project dir holding local settings."),
254
+ ) -> None:
255
+ """Choose and verify the model provider used by local worker-agent runs."""
256
+ existing = load_settings(root).models.worker
257
+ used_picker = _console.is_terminal and (provider is None or model is None)
258
+ if used_picker:
259
+ provider, model, region = select_provider_and_model(
260
+ _console,
261
+ lambda text: _console.input(text),
262
+ lambda text: _console.input(text, password=True),
263
+ default_provider=provider or (existing.provider if existing else None),
264
+ default_model=model or (existing.model if existing else None),
265
+ default_region=region or (existing.region if existing else None),
266
+ interactive=True,
267
+ check=lambda cfg: verify_all(
268
+ [
269
+ _worker_provider_config(
270
+ cfg.kind.value,
271
+ cfg.model_type or cfg.model,
272
+ cfg.region,
273
+ endpoint=endpoint,
274
+ deployment=deployment,
275
+ api_version=api_version,
276
+ )
277
+ ]
278
+ )[0],
279
+ )
280
+ if provider is None or model is None:
281
+ raise typer.BadParameter(
282
+ "provide --provider and --model, or run `wmo providers set` in a terminal"
283
+ )
284
+ config = _worker_provider_config(
285
+ provider,
286
+ model,
287
+ region,
288
+ endpoint=endpoint,
289
+ deployment=deployment,
290
+ api_version=api_version,
291
+ )
292
+ if not used_picker:
293
+ result = verify_all([config])[0]
294
+ if not result.ok:
295
+ detail = escape(result.detail or "unknown error")
296
+ _console.print(f"[red]provider verification failed[/red]: {detail}")
297
+ raise typer.Exit(1)
298
+
299
+ settings = load_settings(root)
300
+ settings.models.worker = ModelRole(
301
+ provider=config.kind.value,
302
+ model=config.model_type or model,
303
+ region=config.region,
304
+ endpoint=config.endpoint,
305
+ deployment=config.deployment,
306
+ api_version=config.api_version,
307
+ )
308
+ save_settings(settings, root)
309
+ _console.print(
310
+ f"[green]set[/green] local worker provider to {config.kind.value} "
311
+ f"({config.model_type or model}) in {settings_path(root)}"
312
+ )
313
+
314
+
315
+ @providers_app.command("verify")
316
+ def providers_verify(
317
+ name: str = typer.Option(None, "--name", help="Verify one model's providers (default: all)."),
318
+ root: str = typer.Option(ARTIFACT_DIR, help="Project dir."),
319
+ ) -> None:
320
+ """Ping every configured provider (completion + embed path) and report status.
321
+
322
+ Gathers provider configs from the built world models (one `--name`, or all of them, deduped by
323
+ kind+model), so a brand-new project with nothing built yet has nothing to verify. The phi embed
324
+ path of each model is checked too, unless it is the offline (creds-free) hashing embedder.
325
+ """
326
+ store = WorldModelStore(root)
327
+ names = [name] if name is not None else store.list_names()
328
+ if not names:
329
+ _console.print("[yellow]no world models built yet[/yellow]; run `wmo build --name <name>`")
330
+ return
331
+ configs: list[HarnessConfig] = []
332
+ for model_name in names:
333
+ try:
334
+ model_dir = str(store.resolve(model_name))
335
+ except (FileNotFoundError, ValueError) as exc:
336
+ raise typer.BadParameter(str(exc)) from exc
337
+ configs.append(load_config(model_dir))
338
+
339
+ # Dedup completion providers by kind+model across all selected models.
340
+ seen: set[tuple[str, str]] = set()
341
+ providers: list[ProviderConfig] = []
342
+ for config in configs:
343
+ for pc in config.providers:
344
+ key = (pc.kind.value, pc.model)
345
+ if key not in seen:
346
+ seen.add(key)
347
+ providers.append(pc)
348
+ for result in verify_all(providers):
349
+ mark = "[green]ok[/green]" if result.ok else "[red]fail[/red]"
350
+ _console.print(f"{mark} {result.kind.value} ({result.model}) {result.detail}")
351
+
352
+ # Verify each distinct provider-backed embed path (skip the offline hashing embedder).
353
+ embed_seen: set[tuple[str, str]] = set()
354
+ for config in configs:
355
+ if config.embed_provider is EmbedderKind.HASHING:
356
+ continue
357
+ embed_config = config.embed_provider_config()
358
+ key = (embed_config.kind.value, embed_config.model)
359
+ if key in embed_seen:
360
+ continue
361
+ embed_seen.add(key)
362
+ result = verify_embedder(embed_config)
363
+ mark = "[green]ok[/green]" if result.ok else "[red]fail[/red]"
364
+ _console.print(f"{mark} embed:{result.kind.value} ({result.model}) {result.detail}")
365
+
366
+
367
+ @examples_app.command("list")
368
+ def examples_list() -> None:
369
+ """List self-contained example tasks."""
370
+ examples = _discover_examples()
371
+ if not examples:
372
+ _console.print("[yellow]no examples found[/yellow]")
373
+ return
374
+ for example in examples:
375
+ _console.print(example.name)
376
+
377
+
378
+ @examples_app.command(
379
+ "run",
380
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
381
+ )
382
+ def examples_run(
383
+ ctx: typer.Context,
384
+ name: str = typer.Argument(..., help="Example task name."),
385
+ ) -> None:
386
+ """Run an example's local launcher, forwarding any extra args after `--`."""
387
+ example_dir = _resolve_example(name)
388
+ runner = example_dir / "run.sh"
389
+ if not runner.exists():
390
+ raise typer.BadParameter(f"example {name!r} has no run.sh launcher")
391
+ result = subprocess.run([str(runner), *ctx.args], cwd=example_dir, check=False)
392
+ raise typer.Exit(result.returncode)
393
+
394
+
395
+ @app.command("build")
396
+ def build(
397
+ name: str = typer.Option(None, "--name", help="Name for this world model."),
398
+ source: str = typer.Option(
399
+ "otel-genai",
400
+ "--source",
401
+ help="Trace source adapter: otel-genai, chat-json, braintrust, phoenix, langfuse, "
402
+ "langsmith, posthog, mastra.",
403
+ ),
404
+ file: str = typer.Option(None, "--file", help="Path to an exported traces file for --source."),
405
+ pull: bool = typer.Option(
406
+ False, "--pull", help="Pull traces live from the source's vendor API (instead of --file)."
407
+ ),
408
+ project: str = typer.Option(None, "--project", help="Vendor project/workspace id (--pull)."),
409
+ api_key: str = typer.Option(None, "--api-key", help="Vendor API key (else env var)."),
410
+ since: str = typer.Option(None, "--since", help="Only pull traces since this ISO timestamp."),
411
+ limit: int = typer.Option(None, "--limit", help="Max number of traces to pull."),
412
+ vendor: str = typer.Option(
413
+ None, "--vendor", help="[deprecated] alias for --source <name> --pull."
414
+ ),
415
+ root: str = typer.Option(ARTIFACT_DIR, help="Project dir holding all world models."),
416
+ provider: str = typer.Option(
417
+ None, "--provider", help="Provider that serves the model (default: bedrock)."
418
+ ),
419
+ model: str = typer.Option(None, help="Canonical serve model type."),
420
+ judge_model: str = typer.Option(
421
+ None, "--judge-model", help="Canonical GEPA judge model type (default: cheap per provider)."
422
+ ),
423
+ region: str = typer.Option(None, help="AWS region (Bedrock)."),
424
+ fidelity: str = typer.Option(
425
+ "medium",
426
+ help="Build effort (all searching tiers are floored at low's estimate — more effort "
427
+ "never ships worse than low): low (free; the estimated-best config, no search) | "
428
+ "medium (+light GEPA + cheap-lever search) | high (+GEPA + config search) | max (deep "
429
+ "GEPA + full config search). The chosen config serves under `--max-fidelity`.",
430
+ ),
431
+ chain: str = typer.Option(
432
+ None, "--chain", help="Named failover chain from .wmo/fallback.toml (default: its default)."
433
+ ),
434
+ train_split: float = typer.Option(
435
+ 0.8, help="Train/held-out ratio for GEPA's internal split (lower = bigger valset)."
436
+ ),
437
+ embed_provider: str = typer.Option(
438
+ "hashing", help="phi embedder: hashing (offline) | bedrock | openai | azure."
439
+ ),
440
+ embed_model: str = typer.Option(None, help="Embeddings model id / Azure embedding deployment."),
441
+ embed_dim: int = typer.Option(512, help="phi dimensionality (index + query must agree)."),
442
+ knowledge: bool = typer.Option(
443
+ False,
444
+ "--knowledge/--no-knowledge",
445
+ help="Seed a knowledge base (rules/entities/schemas markdown) from the train traces.",
446
+ ),
447
+ reasoning: bool = typer.Option(
448
+ False,
449
+ "--reasoning/--no-reasoning",
450
+ help="Serve with the deliberate-then-answer output contract.",
451
+ ),
452
+ grounder: str = typer.Option(
453
+ "none",
454
+ help="Web grounding for unknown entities: none | brave (needs BRAVE_SEARCH_API_KEY).",
455
+ ),
456
+ drop_degenerate: bool = typer.Option(
457
+ False,
458
+ "--drop-degenerate",
459
+ help="Drop all-empty-observation traces (failed captures) before building "
460
+ "(swe-bench is ~66% such junk).",
461
+ ),
462
+ interactive: bool = typer.Option(
463
+ None,
464
+ "--interactive/--no-interactive",
465
+ help="Guided creation wizard. Default: on at a TTY when inputs are missing.",
466
+ ),
467
+ ) -> None:
468
+ """Ingest traces (file upload or vendor SDK pull) and build a named world model.
469
+
470
+ Stores the artifact under `<root>/models/<name>/`: ingest -> normalize -> split(train/test) ->
471
+ embed/index -> GEPA optimize -> write. Re-running with the same `--name` rebuilds it.
472
+
473
+ With no `--name`/`--file` on an interactive terminal, this launches a guided creation wizard;
474
+ pass `--no-interactive` (or any of those flags) to stay fully scriptable.
475
+ """
476
+ # `--vendor <name>` is the deprecated alias for `--source <name> --pull`: it names the source
477
+ # adapter and implies a live pull.
478
+ if vendor:
479
+ source = vendor
480
+ pull = True
481
+
482
+ # Decide whether to run the wizard: explicit flag wins; otherwise auto when at a TTY and the
483
+ # essential inputs (a name and a trace source — a file or a live pull) were not supplied.
484
+ needs_input = name is None or (file is None and not pull)
485
+ use_wizard = interactive if interactive is not None else (_console.is_terminal and needs_input)
486
+
487
+ configured_worker = load_settings(root).models.resolve("worker")
488
+ use_configured_worker = configured_worker is not None and (
489
+ provider is None or provider == configured_worker.provider
490
+ )
491
+ params = BuildParams(
492
+ name=name or DEFAULT_MODEL_NAME,
493
+ source=source,
494
+ file=file,
495
+ pull=pull,
496
+ project=project,
497
+ api_key=api_key,
498
+ since=since,
499
+ limit=limit,
500
+ provider=provider or (configured_worker.provider if configured_worker else None),
501
+ model=(
502
+ model
503
+ or (configured_worker.model if use_configured_worker and configured_worker else None)
504
+ or "claude-opus-4-8"
505
+ ),
506
+ region=(
507
+ region
508
+ or (configured_worker.region if use_configured_worker and configured_worker else None)
509
+ ),
510
+ fidelity=fidelity,
511
+ train_split=train_split,
512
+ judge_model=judge_model,
513
+ embed_provider=embed_provider,
514
+ embed_model=embed_model,
515
+ embed_dim=embed_dim,
516
+ )
517
+ if use_wizard:
518
+ params = run_build_wizard(_console, params)
519
+ elif name is None and file is None and not pull:
520
+ raise typer.BadParameter(
521
+ "provide --file <export> or --pull (with --source), or run `wmo build` interactively"
522
+ )
523
+ if params.file and params.pull:
524
+ raise typer.BadParameter("pass either --file or --pull, not both")
525
+ if params.source not in list_adapters():
526
+ raise typer.BadParameter(
527
+ f"unknown --source {params.source!r}; choose one of: {', '.join(list_adapters())}"
528
+ )
529
+ # The wizard always resolves a provider; the flag path keeps its historical default.
530
+ params.provider = params.provider or "bedrock"
531
+ # The wizard may replace the configured worker's provider. Re-evaluate the match before
532
+ # carrying provider-specific connection fields into the build config.
533
+ use_configured_worker = (
534
+ configured_worker is not None and params.provider == configured_worker.provider
535
+ )
536
+
537
+ # Flag-supplied names get the same whitespace-to-dash normalization as the wizard.
538
+ params.name = normalize_name(params.name)
539
+ try:
540
+ validate_name(params.name)
541
+ except ValueError as err:
542
+ raise typer.BadParameter(str(err)) from None
543
+ if params.name == "harbor":
544
+ raise typer.BadParameter(
545
+ "world model name 'harbor' is reserved: `wmo optimize harness <agent> harbor` selects "
546
+ "the harbor benchmark environment; choose another name"
547
+ )
548
+ try:
549
+ tier = FidelityTier(params.fidelity)
550
+ except ValueError:
551
+ tiers = ", ".join(t.value for t in FidelityTier)
552
+ raise typer.BadParameter(
553
+ f"unknown fidelity {params.fidelity!r}; choose one of: {tiers}"
554
+ ) from None
555
+ spec = FIDELITY_TIERS[tier]
556
+ try:
557
+ serve_provider = ProviderKind(params.provider)
558
+ except ValueError:
559
+ kinds = ", ".join(k.value for k in ProviderKind)
560
+ raise typer.BadParameter(
561
+ f"unknown provider {params.provider!r}; choose one of: {kinds}"
562
+ ) from None
563
+ try:
564
+ embed_kind = EmbedderKind(params.embed_provider)
565
+ except ValueError:
566
+ kinds = ", ".join(k.value for k in EmbedderKind)
567
+ raise typer.BadParameter(
568
+ f"unknown embed provider {params.embed_provider!r}; choose one of: {kinds}"
569
+ ) from None
570
+ # A provider-backed embedder needs an embeddings model; fail fast, not deep inside embed().
571
+ if embed_kind is not EmbedderKind.HASHING and not params.embed_model:
572
+ raise typer.BadParameter(
573
+ f"--embed-provider {embed_kind.value} requires --embed-model "
574
+ "(the embeddings model id / Azure embedding deployment)"
575
+ )
576
+
577
+ store = WorldModelStore(root)
578
+ model_dir = str(store.model_dir(params.name))
579
+ # Provider wiring (reuse-vs-separate embed config) lives in HarnessConfig.for_build, not here.
580
+ config = HarnessConfig.for_build(
581
+ serve_provider=serve_provider,
582
+ serve_model=params.model,
583
+ region=params.region,
584
+ embed_provider=embed_kind,
585
+ embed_model=params.embed_model,
586
+ embed_dim=params.embed_dim,
587
+ gepa_budget=spec.gepa_budget,
588
+ train_split=params.train_split,
589
+ judge_model=params.judge_model or judge_model_default(params.provider, params.model),
590
+ trace_adapter=params.source,
591
+ )
592
+ if use_configured_worker and configured_worker is not None:
593
+ config.providers[0] = config.providers[0].model_copy(
594
+ update={
595
+ "endpoint": configured_worker.endpoint,
596
+ "deployment": configured_worker.deployment,
597
+ "api_version": configured_worker.api_version,
598
+ }
599
+ )
600
+ if grounder not in GROUNDER_KINDS:
601
+ raise typer.BadParameter(
602
+ f"unknown grounder {grounder!r}; choose one of: {', '.join(GROUNDER_KINDS)}"
603
+ )
604
+ # Agentic-mode flags (CLI-only, not in the wizard): persisted to config.toml so serve/load
605
+ # pick them up; knowledge additionally seeds knowledge/ during this build.
606
+ config.knowledge = knowledge
607
+ config.reasoning = reasoning
608
+ config.grounder = grounder
609
+ # Fail fast: ping the serve provider (and the embed path, if provider-backed) before spending
610
+ # any rollouts. A missing SDK or bad creds otherwise surfaces only deep inside GEPA, which
611
+ # silently swallows it and "succeeds" with a useless held-out-0.0 model.
612
+ if not use_wizard:
613
+ # The wizard already live-pinged the serve provider and embedder inline.
614
+ _verify_or_abort(config, chain=chain)
615
+
616
+ # Meter the build at the provider boundary; `classify_build_call` splits judge vs GEPA by
617
+ # system prompt. Rollouts/reflection may ride the failover chain, but the judge (GEPA's
618
+ # fitness metric) is PINNED to the single configured backend — a judge that silently switches
619
+ # models mid-build scores candidates on different scales. Both wrappers share one tracker,
620
+ # so cost/tokens still land in a single run record.
621
+ tracker = RunTracker(run_id=uuid.uuid4().hex, kind="build")
622
+ metered = MeteredProvider(
623
+ providers.provider_or_chain(config.serve_provider_config(), chain=chain),
624
+ tracker,
625
+ classify=classify_build_call,
626
+ )
627
+ metered_judge = metered
628
+ if config.judge_model and config.judge_model != config.serve_provider_config().model:
629
+ judge_cfg = config.serve_provider_config().model_copy(update={"model": config.judge_model})
630
+ metered_judge = MeteredProvider(
631
+ providers.get_provider(judge_cfg), tracker, classify=classify_build_call
632
+ )
633
+ build_stats = BuildTelemetryStats()
634
+ with tracker.timed(), RichBuildReporter(_console, params.name) as reporter:
635
+ result = run_build(
636
+ config,
637
+ file=None if params.pull else params.file,
638
+ vendor=(
639
+ VendorPull(
640
+ api_key=params.api_key,
641
+ project=params.project,
642
+ since=params.since,
643
+ limit=params.limit,
644
+ )
645
+ if params.pull
646
+ else None
647
+ ),
648
+ root=model_dir,
649
+ serve_provider=metered,
650
+ judge_provider=metered_judge,
651
+ embedder=get_embedder(config),
652
+ reporter=TelemetryBuildReporter(reporter, build_stats),
653
+ max_fidelity=spec.config_search,
654
+ fidelity_budget=spec.search_budget,
655
+ full_search=spec.full_ladder,
656
+ cheap_search=spec.cheap_frontier_only,
657
+ estimate_only=spec.estimate_only,
658
+ drop_degenerate=drop_degenerate,
659
+ gepa_val_cap=spec.gepa_val_cap or None,
660
+ )
661
+ record = tracker.record_summary()
662
+ save_run(record, ArtifactPaths(model_dir).runs)
663
+ # The card is additive metadata; a write failure (disk full, permissions) must not make an
664
+ # otherwise-complete build exit non-zero and then block retries with "already exists".
665
+ try:
666
+ save_card(
667
+ make_build_card(
668
+ name=params.name,
669
+ provider=params.provider,
670
+ model_id=params.model,
671
+ traces=build_stats.input_trace_count,
672
+ steps=build_stats.input_step_count,
673
+ built_at=datetime.now(UTC).isoformat(),
674
+ source=Path(params.file).name if params.file else params.vendor,
675
+ ),
676
+ model_dir,
677
+ )
678
+ except OSError as err:
679
+ _console.print(f"[yellow]warning[/yellow]: could not write card.json: {err}")
680
+ capture_build_completed(
681
+ stats=build_stats,
682
+ gepa_budget=spec.gepa_budget,
683
+ rollouts_used=result.metrics.rollouts_used,
684
+ frontier_size=len(result.frontier),
685
+ record=record,
686
+ root=root,
687
+ )
688
+
689
+ _console.print(build_summary_panel(store.info(params.name), model_dir))
690
+ auto_report = Path(model_dir) / "auto_fidelity.json"
691
+ if auto_report.exists():
692
+ auto = json.loads(auto_report.read_text(encoding="utf-8"))
693
+ # The low tier writes an estimate with no scores (no search ran); higher tiers write
694
+ # the searched scores. Render each honestly rather than an empty "(; 0 traces)".
695
+ if auto.get("scores"):
696
+ scores = ", ".join(f"{k}={v:.3f}" for k, v in auto["scores"].items())
697
+ provenance = f"searched: {scores}; {auto['val_traces']} held-out traces"
698
+ else:
699
+ provenance = "signature estimate — no search"
700
+ _console.print(
701
+ f"[bold]max-fidelity config[/bold]: [bold]{auto['winner_label']}[/bold] "
702
+ f"({provenance}) — activate with `wmo serve --max-fidelity` / "
703
+ f"`wmo play --max-fidelity`"
704
+ )
705
+ _console.print(
706
+ f"[bold]run[/bold] {record.run_id[:8]}: {record.duration_seconds:.1f}s, "
707
+ f"{record.total.total_tokens} tokens, ${record.total.cost_usd:.4f} "
708
+ f"({record.total.calls} calls)"
709
+ )
710
+ for phase in (Phase.GEPA, Phase.JUDGE):
711
+ bucket = record.by_phase.get(phase)
712
+ if bucket is not None:
713
+ _console.print(
714
+ f" {phase.value}: {bucket.total_tokens} tokens, "
715
+ f"${bucket.cost_usd:.4f} ({bucket.calls} calls)"
716
+ )
717
+
718
+
719
+ def _verify_or_abort(config: HarnessConfig, chain: str | None = None) -> None:
720
+ """Ping the serve provider (and any provider-backed embedder) and abort on failure.
721
+
722
+ Runs before any rollouts so a missing SDK or bad creds fails loudly and immediately, instead of
723
+ being swallowed inside GEPA and yielding a useless model. Raises `typer.Exit(1)` with an
724
+ actionable hint (`uv sync` for a missing SDK; "check creds / model id" otherwise).
725
+ """
726
+ checks = [(config.serve_provider_config(), False)]
727
+ if config.embed_provider is not EmbedderKind.HASHING:
728
+ checks.append((config.embed_provider_config(), True))
729
+
730
+ failed = False
731
+ for cfg, is_embed in checks:
732
+ label = f"embed:{cfg.kind.value}" if is_embed else cfg.kind.value
733
+ serve_provider = None if is_embed else providers.provider_or_chain(cfg, chain=chain)
734
+ if is_embed:
735
+ _console.print(f"verifying {label}…")
736
+ result = verify_embedder(cfg)
737
+ elif isinstance(serve_provider, providers.WaterfallProvider):
738
+ # Verify the provider the build will actually use — with a chain active, that means
739
+ # pinging every rung (a broken fallback must fail here, not hours into the build).
740
+ label = f"{label} chain (.wmo/fallback.toml)"
741
+ _console.print(f"verifying {label}…")
742
+ result = serve_provider.verify()
743
+ else:
744
+ _console.print(f"verifying {label}…")
745
+ result = verify_all([cfg])[0]
746
+ if result.ok:
747
+ _console.print(f" {_CHECK} {label} ({result.model}) reachable")
748
+ continue
749
+ failed = True
750
+ _console.print(f" [red]✗ {label} ({result.model}) failed[/red]: {result.detail}")
751
+ if "No module named" in result.detail:
752
+ # SDKs are core deps; a missing module means the env is stale or hand-rolled.
753
+ _console.print(" [yellow]run `uv sync` to install the provider SDKs[/yellow]")
754
+ else:
755
+ envs = ", ".join(PROVIDER_ENV_VARS.get(cfg.kind, []))
756
+ hint = f" ({envs})" if envs else ""
757
+ _console.print(
758
+ f" [yellow]check the model id and that your credentials are set{hint}[/yellow]"
759
+ )
760
+ if failed:
761
+ raise typer.Exit(1)
762
+
763
+
764
+ @app.command("list")
765
+ def list_models(root: str = typer.Option(ARTIFACT_DIR, help="Project dir to list.")) -> None:
766
+ """List every world model built under the project dir."""
767
+ infos = WorldModelStore(root).list_info()
768
+ if not infos:
769
+ _console.print("[yellow]no world models built yet[/yellow]; run `wmo build --name <name>`")
770
+ return
771
+ _console.print(models_table(infos))
772
+
773
+
774
+ @app.command("download")
775
+ def download(
776
+ benchmarks: list[str] = _DOWNLOAD_BENCHMARKS,
777
+ force: bool = typer.Option(False, "--force", help="Overwrite existing local files."),
778
+ ) -> None:
779
+ """Download benchmark data bundles (trace corpus + task data) from the Hub.
780
+
781
+ With no arguments, lists the org's published datasets (live, via the Hub API) and offers a
782
+ picker. Bundles land in `packages/environment-capture/<benchmark>/`; existing local files
783
+ are kept unless `--force`.
784
+ """
785
+ selected = list(benchmarks or [])
786
+ if selected == ["all"]:
787
+ # Prefer the Hub's live list: the static registry can name corpora that aren't
788
+ # published yet (a 404 mid-loop used to abort the remaining downloads).
789
+ try:
790
+ selected = sorted(corpus.benchmark for corpus in published_corpora())
791
+ except urllib.error.URLError:
792
+ selected = sorted(CORPORA)
793
+ if not selected:
794
+ try:
795
+ published = published_corpora()
796
+ except urllib.error.URLError as exc:
797
+ raise typer.BadParameter(
798
+ f"could not list the Hub's published datasets ({exc.reason}); check the "
799
+ "connection, or pass benchmark names directly, e.g. `wmo download bird-sql`"
800
+ ) from exc
801
+ if not published:
802
+ raise typer.BadParameter(
803
+ "no published corpora found on the Hub; "
804
+ "pass benchmark names directly, e.g. `wmo download bird-sql`"
805
+ )
806
+ notes = {}
807
+ for corpus in published:
808
+ local = (corpus_path(corpus.benchmark)).exists()
809
+ state = "local copy present" if local else "not downloaded"
810
+ when = f", updated {corpus.last_modified}" if corpus.last_modified else ""
811
+ notes[corpus.benchmark] = f"{state}{when}"
812
+ choices = [corpus.benchmark for corpus in published]
813
+ picked = select_option(
814
+ _console, "Download which data bundle?", [*choices, "all"], notes=notes
815
+ )
816
+ selected = choices if picked == "all" else [picked]
817
+ failures: list[str] = []
818
+ for name in selected:
819
+ existing = corpus_path(name).exists()
820
+ try:
821
+ path = _fetch_with_progress(name, force=force)
822
+ except ValueError as exc:
823
+ raise typer.BadParameter(str(exc)) from exc
824
+ except urllib.error.HTTPError as exc:
825
+ # One unpublished/broken dataset must not abort the REST of a multi-download:
826
+ # record it, keep fetching, and fail (with every name) at the end.
827
+ failures.append(f"{name}: the Hub answered {exc.code} for {exc.url}")
828
+ _console.print(f"[yellow]skipping {name}: Hub answered {exc.code}[/yellow]")
829
+ continue
830
+ except urllib.error.URLError as exc:
831
+ raise typer.BadParameter(
832
+ f"{name}: could not reach the Hub ({exc.reason}); check the connection and re-run"
833
+ " — fetches resume file-by-file"
834
+ ) from exc
835
+ state = "kept local" if existing and not force else "fetched"
836
+ _console.print(f"{_CHECK} {state} [bold]{name}[/bold] -> {path}")
837
+ if failures:
838
+ raise typer.BadParameter(
839
+ "some datasets could not be downloaded (unpublished? `wmo download` with no "
840
+ "arguments lists what is):\n " + "\n ".join(failures)
841
+ )
842
+
843
+
844
+ def _fetch_with_progress(name: str, *, force: bool) -> Path:
845
+ """fetch_corpus with a live byte progress bar (hidden when nothing needs downloading)."""
846
+ with Progress(
847
+ TextColumn("[progress.description]{task.description}"),
848
+ BarColumn(),
849
+ DownloadColumn(),
850
+ console=_console,
851
+ transient=True,
852
+ ) as progress:
853
+ task_id = progress.add_task(f"downloading {name}", total=None, visible=False)
854
+
855
+ def on_progress(done: int, total: int) -> None:
856
+ progress.update(task_id, completed=done, total=total or None, visible=True)
857
+
858
+ return fetch_corpus(name, force=force, on_progress=on_progress)
859
+
860
+
861
+ @app.command("serve")
862
+ def serve(
863
+ name: list[str] = typer.Option( # noqa: B008 - typer reads option defaults at definition time
864
+ None, "--name", help="World model(s) to serve. Repeatable; default: all built ones."
865
+ ),
866
+ port: int = typer.Option(8000, help="Port for the local backend."),
867
+ root: list[str] = typer.Option( # noqa: B008 - typer reads option defaults at definition time
868
+ [ARTIFACT_DIR],
869
+ "--root",
870
+ help="Project dir(s) to serve from. Repeatable; server-side builds land in the first.",
871
+ ),
872
+ max_fidelity: bool = typer.Option(
873
+ False,
874
+ "--max-fidelity",
875
+ help="Serve with the online extras on: the build-measured winning config when the "
876
+ "artifact has one, otherwise every extra it supports. Default: pure RAG.",
877
+ ),
878
+ ) -> None:
879
+ """Run the local FastAPI backend so agents can step against world models over HTTP.
880
+
881
+ Serves every built model by default, or just the `--name` ones, from one or more roots
882
+ (e.g. `--root .wmo --root examples/tau-bench`). Routes are namespaced:
883
+ `/world_models/{name}/sessions` and `.../step`.
884
+ """
885
+ names = list(name) if name else None
886
+ # Bad --name input (unsafe segment, unknown model, nothing built) is a usage error,
887
+ # not a traceback; load the models before uvicorn takes over the process.
888
+ try:
889
+ server_app = create_app(list(root), names=names, max_fidelity=max_fidelity)
890
+ except (ValueError, FileNotFoundError) as err:
891
+ raise typer.BadParameter(str(err)) from None
892
+ uvicorn.run(server_app, host="127.0.0.1", port=port)
893
+
894
+
895
+ @app.command("eval")
896
+ def eval_( # noqa: A001 - `eval` is the user-facing command name; the builtin isn't used here
897
+ tokens: list[str] | None = _EVAL_TOKENS,
898
+ mode: str = typer.Option(
899
+ "open-loop",
900
+ "--mode",
901
+ help="open-loop: replay traces, score per-step fidelity (default). "
902
+ "closed-loop: a live agent runs tasks with the world model as its environment.",
903
+ ),
904
+ prompt_file: str | None = typer.Option(
905
+ None, "--prompt", help="Prompt file; default=BASE_ENV_PROMPT."
906
+ ),
907
+ provider: str = typer.Option("bedrock", "--provider", help="Provider running the model."),
908
+ model: str = typer.Option("claude-opus-4-8", help="Canonical model type."),
909
+ region: str | None = typer.Option(None, help="AWS region (Bedrock)."),
910
+ chain: str | None = typer.Option(
911
+ None, "--chain", help="Named failover chain from .wmo/fallback.toml (default: its default)."
912
+ ),
913
+ train_split: float | None = typer.Option(
914
+ None, help="Train/holdout ratio per file (default: 0.7, or suite config)."
915
+ ),
916
+ val_frac: float | None = typer.Option(
917
+ None,
918
+ "--val-frac",
919
+ help="`wmo eval grid`: validation fraction reserved for GEPA in a 3-way split so cells "
920
+ "score only the leak-free test band (default: (1 - train_split)/2, matching build).",
921
+ ),
922
+ embed_dim: int | None = typer.Option(
923
+ None, help="phi dimensionality for the offline embedder (default: 512, or suite config)."
924
+ ),
925
+ rag: bool | None = typer.Option(
926
+ None, "--rag/--no-rag", help="Enable retrieval, or disable it for zero-shot replay."
927
+ ),
928
+ sample_turns: str | None = typer.Option(
929
+ None, help="Turns scored per trace: all | sampled (5). Default: all, or suite config."
930
+ ),
931
+ seed: int | None = typer.Option(None, help="Seed for reproducible turn sampling."),
932
+ top_k: int | None = typer.Option(
933
+ None, help="Retrieved demos per step (default: 5, or suite config)."
934
+ ),
935
+ knowledge: bool | None = typer.Option(
936
+ None,
937
+ "--knowledge/--no-knowledge",
938
+ help="Seed a knowledge base from the train split and render it into every prediction.",
939
+ ),
940
+ reasoning: bool | None = typer.Option(
941
+ None,
942
+ "--reasoning/--no-reasoning",
943
+ help="Deliberate-then-answer output contract (explicit reasoning pass).",
944
+ ),
945
+ out: str | None = typer.Option(None, help="Optional path to write the full JSON report."),
946
+ examples_root: str | None = typer.Option(
947
+ None, help="Directory containing example eval suites. Default: repo-local examples/."
948
+ ),
949
+ results_root: str = typer.Option(
950
+ f"{ARTIFACT_DIR}/evals", help="Local directory for named eval result JSON."
951
+ ),
952
+ limit: int = typer.Option(20, help="Rows to show for `wmo eval results`."),
953
+ models: str | None = typer.Option(
954
+ None,
955
+ help="`wmo eval grid`: comma-separated Label:provider:model cells "
956
+ "(e.g. 'Opus 4.8:bedrock:us.anthropic.claude-opus-4-8,GPT-5.5:openai:gpt-5.5').",
957
+ ),
958
+ gepa_prompts: str | None = typer.Option(
959
+ None, help="`wmo eval grid`: dir of <label>.txt evolved prompts (enables +GEPA cells)."
960
+ ),
961
+ dataset_label: str | None = typer.Option(
962
+ None, help="`wmo eval grid`: dataset name for the chart subtitle (default: suite id)."
963
+ ),
964
+ limit_traces: int | None = typer.Option(
965
+ None, help="`wmo eval grid`: cap test traces (dry-run). Default: all."
966
+ ),
967
+ judge_model: str = typer.Option(
968
+ "us.anthropic.claude-opus-4-8", help="`wmo eval grid`: pinned judge model (Bedrock)."
969
+ ),
970
+ name: str | None = typer.Option(
971
+ None, "--name", help="World model for --mode closed-loop (default: the only built one)."
972
+ ),
973
+ root: str = typer.Option(
974
+ ARTIFACT_DIR, help="Project dir holding world models (--mode closed-loop)."
975
+ ),
976
+ k: int = typer.Option(
977
+ 3, min=1, help="Closed-loop passes per task (means reported, never 1-pass)."
978
+ ),
979
+ max_turns: int | None = typer.Option(
980
+ None, min=1, help="Closed-loop agent turn cap (default: 20, or the harness's own)."
981
+ ),
982
+ threshold: float = typer.Option(
983
+ 0.5, help="Agreement pass threshold on a task's k-pass success rate."
984
+ ),
985
+ harness: str | None = typer.Option(
986
+ None, "--harness", help="Stored harness to run for `closed-loop` (default: baseline)."
987
+ ),
988
+ harness_backend: str = typer.Option(
989
+ "local",
990
+ "--harness-backend",
991
+ help="Where the closed-loop harness PROCESS runs: local (in/from this process) or e2b "
992
+ "(the pi-node harness inside pooled E2B sandboxes). The environment is always the "
993
+ "world model.",
994
+ ),
995
+ eval_concurrency: int | None = typer.Option(
996
+ None,
997
+ "--eval-concurrency",
998
+ min=0,
999
+ help="Closed-loop (task, attempt) cells run at once. Default: 1 for local; "
1000
+ "0 (= all cells at once) for e2b.",
1001
+ ),
1002
+ e2b_template: str | None = typer.Option(
1003
+ None,
1004
+ "--e2b-template",
1005
+ envvar="WMO_E2B_TEMPLATE",
1006
+ help="Prebaked E2B sandbox template for --harness-backend e2b (default: "
1007
+ "$WMO_E2B_TEMPLATE; without one, sandboxes bootstrap node + the pi runner deps on "
1008
+ "first use).",
1009
+ ),
1010
+ ) -> None:
1011
+ """Score reconstruction fidelity, or run named example-local eval suites.
1012
+
1013
+ Flows:
1014
+ - `wmo eval <trace files...>`: ad hoc replay scoring (open-loop, teacher-forced — the
1015
+ default mode).
1016
+ - `wmo eval <tasks.jsonl> --mode closed-loop`: a live agent runs tasks WITH the world model
1017
+ as its environment. `[models.agent]` selects a distinct agent provider when configured;
1018
+ otherwise the agent shares the world model's provider. `--harness-backend e2b` moves the
1019
+ pi-node harness process into pooled E2B sandboxes while the environment stays the world
1020
+ model. Score task success against gold assertions (see docs/reference/closed_loop.md).
1021
+ - `wmo eval list`: list named suites under `examples/<task>/evals/`.
1022
+ - `wmo eval run <suite>`: run a suite and save a local JSON result.
1023
+ - `wmo eval results optional-suite`: summarize local suite results.
1024
+ - `wmo eval agreement <a.json> <b.json>`: compare two closed-loop reports task-by-task
1025
+ (e.g. world-model vs real environment) — the outcome-agreement validity check.
1026
+ """
1027
+ args = tokens or []
1028
+ suite_roots = (
1029
+ [str(root) for root in _benchmark_roots()] if examples_root is None else [examples_root]
1030
+ )
1031
+ if mode not in ("open-loop", "closed-loop"):
1032
+ raise typer.BadParameter(f"unknown --mode {mode!r}; choose open-loop or closed-loop")
1033
+ if mode == "closed-loop":
1034
+ if len(args) != 1 or args[0] in ("list", "run", "results", "agreement"):
1035
+ raise typer.BadParameter("usage: wmo eval <tasks.jsonl> --mode closed-loop")
1036
+ run_closed_loop(
1037
+ _console,
1038
+ tasks_file=args[0],
1039
+ name=name,
1040
+ root=root,
1041
+ k=k,
1042
+ max_turns=max_turns,
1043
+ out=out,
1044
+ harness=harness,
1045
+ harness_backend=harness_backend,
1046
+ eval_concurrency=eval_concurrency,
1047
+ e2b_template=e2b_template,
1048
+ )
1049
+ return
1050
+ if args and args[0] == "agreement":
1051
+ if len(args) != 3:
1052
+ raise typer.BadParameter("usage: wmo eval agreement <report_a.json> <report_b.json>")
1053
+ run_agreement(_console, report_a=args[1], report_b=args[2], threshold=threshold)
1054
+ return
1055
+ if args and args[0] == "list":
1056
+ if len(args) != 1:
1057
+ raise typer.BadParameter("usage: wmo eval list")
1058
+ _eval_list(suite_roots)
1059
+ return
1060
+ if args and args[0] == "results":
1061
+ if len(args) > 2:
1062
+ raise typer.BadParameter("usage: wmo eval results [suite]")
1063
+ suite_filter = args[1] if len(args) == 2 else None
1064
+ _eval_results(results_root, suite_roots, suite_filter, limit=limit)
1065
+ return
1066
+ if args and args[0] == "grid":
1067
+ if len(args) != 2:
1068
+ raise typer.BadParameter("usage: wmo eval grid <suite>")
1069
+ _eval_run_grid(
1070
+ args[1],
1071
+ examples_roots=suite_roots,
1072
+ results_root=results_root,
1073
+ models=models,
1074
+ gepa_prompts=gepa_prompts,
1075
+ dataset_label=dataset_label,
1076
+ limit_traces=limit_traces,
1077
+ judge_model=judge_model,
1078
+ region=region,
1079
+ train_split=train_split,
1080
+ seed=seed,
1081
+ top_k=top_k,
1082
+ embed_dim=embed_dim,
1083
+ sample_turns=sample_turns,
1084
+ val_frac=val_frac,
1085
+ out=out,
1086
+ )
1087
+ return
1088
+ if args and args[0] == "grid-plot":
1089
+ if len(args) < 2:
1090
+ raise typer.BadParameter("usage: wmo eval grid-plot <result.json> [<result.json>...]")
1091
+ _eval_grid_plot(args[1:], out=out, dataset_label=dataset_label)
1092
+ return
1093
+ if args and args[0] == "grid-heatmap":
1094
+ if len(args) < 2:
1095
+ raise typer.BadParameter(
1096
+ "usage: wmo eval grid-heatmap <result.json> [<result.json>...]"
1097
+ )
1098
+ _eval_grid_heatmap(args[1:], out=out)
1099
+ return
1100
+ if args and args[0] == "run":
1101
+ if len(args) != 2:
1102
+ raise typer.BadParameter("usage: wmo eval run <suite>")
1103
+ _eval_run_suite(
1104
+ args[1],
1105
+ examples_roots=suite_roots,
1106
+ results_root=results_root,
1107
+ prompt_file=prompt_file,
1108
+ provider=provider,
1109
+ model=model,
1110
+ region=region,
1111
+ train_split=train_split,
1112
+ embed_dim=embed_dim,
1113
+ rag=rag,
1114
+ sample_turns=sample_turns,
1115
+ seed=seed,
1116
+ top_k=top_k,
1117
+ knowledge=knowledge,
1118
+ reasoning=reasoning,
1119
+ out=out,
1120
+ )
1121
+ return
1122
+ if not args:
1123
+ raise typer.BadParameter(
1124
+ "provide trace files, or use `wmo eval list`, `wmo eval run <suite>`, "
1125
+ "or `wmo eval results`"
1126
+ )
1127
+
1128
+ options = _eval_options(
1129
+ prompt_file=prompt_file,
1130
+ train_split=train_split,
1131
+ embed_dim=embed_dim,
1132
+ rag=rag,
1133
+ sample_turns=sample_turns,
1134
+ seed=seed,
1135
+ top_k=top_k,
1136
+ knowledge=knowledge,
1137
+ reasoning=reasoning,
1138
+ )
1139
+ report = _run_eval_files(
1140
+ [Path(f) for f in args],
1141
+ options,
1142
+ provider=provider,
1143
+ model=model,
1144
+ chain=chain,
1145
+ region=region,
1146
+ )
1147
+ _print_eval_report(report)
1148
+ if out:
1149
+ _write_ad_hoc_eval_report(Path(out), report)
1150
+ capture_eval_completed(
1151
+ mode="ad_hoc",
1152
+ file_count=len(args),
1153
+ scored_step_count=report.total_valid,
1154
+ rag_enabled=options.use_rag,
1155
+ sample_turns=options.sample_turns,
1156
+ train_split=options.train_split,
1157
+ top_k=options.top_k,
1158
+ root=ARTIFACT_DIR,
1159
+ )
1160
+
1161
+
1162
+ def _eval_list(examples_roots: list[str]) -> None:
1163
+ suites = discover_eval_suites(examples_roots)
1164
+ if not suites:
1165
+ _console.print("[yellow]no eval suites found[/yellow]")
1166
+ return
1167
+ table = Table(title="Eval suites")
1168
+ table.add_column("Suite", no_wrap=True)
1169
+ table.add_column("Files")
1170
+ table.add_column("Split")
1171
+ table.add_column("Description")
1172
+ for suite in suites:
1173
+ table.add_row(
1174
+ suite.id,
1175
+ ", ".join(suite.config.files),
1176
+ f"{suite.config.train_split:.2f}",
1177
+ suite.config.description or "",
1178
+ )
1179
+ _console.print(table)
1180
+
1181
+
1182
+ def _eval_results(
1183
+ results_root: str,
1184
+ examples_roots: list[str],
1185
+ suite_filter: str | None,
1186
+ *,
1187
+ limit: int,
1188
+ ) -> None:
1189
+ resolved_suite = suite_filter
1190
+ if suite_filter is not None:
1191
+ try:
1192
+ resolved_suite = resolve_eval_suite(suite_filter, examples_roots).id
1193
+ except ValueError:
1194
+ resolved_suite = suite_filter
1195
+ summaries = list_eval_results(results_root, resolved_suite, limit=limit)
1196
+ if not summaries:
1197
+ _console.print("[yellow]no eval results found[/yellow]")
1198
+ return
1199
+ table = Table(title="Eval results")
1200
+ table.add_column("Suite", no_wrap=True)
1201
+ table.add_column("Run")
1202
+ table.add_column("Started")
1203
+ table.add_column("Model")
1204
+ table.add_column("Fidelity", justify="right")
1205
+ table.add_column("Steps", justify="right")
1206
+ table.add_column("Path")
1207
+ for summary in summaries:
1208
+ steps = str(summary.total_steps)
1209
+ if summary.total_invalid:
1210
+ steps += f" ({summary.total_invalid} inv)"
1211
+ table.add_row(
1212
+ summary.suite,
1213
+ summary.run_id[:8],
1214
+ summary.started_at,
1215
+ summary.model,
1216
+ f"{summary.overall_fidelity:.3f}±{summary.overall_std:.3f}",
1217
+ steps,
1218
+ str(summary.path),
1219
+ )
1220
+ _console.print(table)
1221
+
1222
+
1223
+ # Default grid: one bar family per serving model. Qwen-AgentWorld is self-hosted (openai-compatible
1224
+ # vLLM via OPENAI_BASE_URL); the rest are frontier models the registry builds directly.
1225
+ _DEFAULT_GRID_MODELS = (
1226
+ "Opus 4.8:bedrock:us.anthropic.claude-opus-4-8",
1227
+ "Haiku 4.5:bedrock:us.anthropic.claude-haiku-4-5-20251001-v1:0",
1228
+ "GPT-5.5:openai:gpt-5.5",
1229
+ "GPT-5.4 Mini:openai:gpt-5.4-mini",
1230
+ "Qwen-AgentWorld:openai:Qwen/Qwen-AgentWorld-35B-A3B",
1231
+ )
1232
+
1233
+
1234
+ def _parse_model_specs(models: str | None) -> list[ModelSpec]:
1235
+ """Parse "Label:provider:model[,...]" into ModelSpecs (default set when None).
1236
+
1237
+ The provider is validated and the model resolved through the shared catalog
1238
+ (`resolve_provider_model`), so a friendly model type resolves to its canonical wire id while an
1239
+ unknown (self-hosted) id passes through unchanged, and a bad provider fails at parse time.
1240
+ """
1241
+ raw = models.split(",") if models else list(_DEFAULT_GRID_MODELS)
1242
+ specs: list[ModelSpec] = []
1243
+ for entry in raw:
1244
+ parts = entry.split(":", 2)
1245
+ if len(parts) != 3 or not all(p.strip() for p in parts):
1246
+ raise typer.BadParameter(f"bad --models entry {entry!r}; want 'Label:provider:model'")
1247
+ label, provider_str, model_str = (p.strip() for p in parts)
1248
+ try:
1249
+ kind = ProviderKind(provider_str)
1250
+ except ValueError:
1251
+ kinds = ", ".join(k.value for k in ProviderKind)
1252
+ raise typer.BadParameter(
1253
+ f"unknown provider {provider_str!r} in --models {entry!r}; want one of {kinds}"
1254
+ ) from None
1255
+ specs.append(ModelSpec(label, kind.value, resolve_provider_model(kind, model_str).model_id))
1256
+ return specs
1257
+
1258
+
1259
+ def _grid_output_paths(out: str | None, default_json: Path) -> tuple[Path, Path]:
1260
+ """Result-JSON and chart-PNG destinations for a grid run.
1261
+
1262
+ With `--out`, the JSON and PNG share the stem but ALWAYS take distinct suffixes, so passing
1263
+ `--out foo.json` can never make the PNG write clobber the result JSON at the same path (and
1264
+ `--out foo.png` still lands the JSON next to it). Without `--out`, use the default JSON dest and
1265
+ its `.png` sibling.
1266
+ """
1267
+ if out is None:
1268
+ return default_json, default_json.with_suffix(".png")
1269
+ base = Path(out)
1270
+ return base.with_suffix(".json"), base.with_suffix(".png")
1271
+
1272
+
1273
+ def _eval_run_grid( # noqa: PLR0913 - a CLI seam threading grid options; each maps to one flag
1274
+ selector: str,
1275
+ *,
1276
+ examples_roots: list[str],
1277
+ results_root: str,
1278
+ models: str | None,
1279
+ gepa_prompts: str | None,
1280
+ dataset_label: str | None,
1281
+ limit_traces: int | None,
1282
+ judge_model: str,
1283
+ region: str | None,
1284
+ train_split: float | None,
1285
+ seed: int | None,
1286
+ top_k: int | None,
1287
+ embed_dim: int | None,
1288
+ sample_turns: str | None,
1289
+ val_frac: float | None,
1290
+ out: str | None,
1291
+ ) -> None:
1292
+ """Run the model x condition grid for a suite, write result JSON + a fidelity bar chart PNG."""
1293
+ suite = resolve_eval_suite(selector, examples_roots)
1294
+ specs = _parse_model_specs(models)
1295
+ prompt_dir = Path(gepa_prompts) if gepa_prompts else None
1296
+ prompt_map: dict[str, str] | None = None
1297
+ if prompt_dir is not None:
1298
+ prompt_map = {
1299
+ s.label: str(prompt_dir / f"{s.label}.txt")
1300
+ for s in specs
1301
+ if (prompt_dir / f"{s.label}.txt").exists()
1302
+ }
1303
+ cfg = suite.config
1304
+ result = run_grid(
1305
+ suite_name=suite.id,
1306
+ files=[str(p) for p in suite.resolve_files()],
1307
+ models=specs,
1308
+ gepa_prompts=prompt_map,
1309
+ base_prompt=BASE_ENV_PROMPT,
1310
+ judge_provider="bedrock",
1311
+ judge_model=judge_model,
1312
+ judge_region=region,
1313
+ train_split=train_split if train_split is not None else cfg.train_split,
1314
+ val_frac=val_frac,
1315
+ top_k=top_k if top_k is not None else cfg.top_k,
1316
+ seed=seed if seed is not None else cfg.seed,
1317
+ sample_turns=sample_turns or cfg.sample_turns,
1318
+ embed_dim=embed_dim if embed_dim is not None else cfg.embed_dim,
1319
+ max_holdout_traces=limit_traces,
1320
+ )
1321
+ for cell in result.cells:
1322
+ cost = f" ${cell.cost_usd:.2f}" if cell.cost_usd else ""
1323
+ _console.print(
1324
+ f" {cell.model_label:16} {cell.condition_label:14} "
1325
+ f"fidelity={cell.fidelity:.3f} err_flag={cell.error_flag_acc:.3f} "
1326
+ f"n={cell.n_steps}{cost}"
1327
+ )
1328
+ run_id = uuid4().hex
1329
+ default_dest = Path(results_root) / "grid" / f"{suite.name}-{run_id}.json"
1330
+ dest, png = _grid_output_paths(out, default_dest)
1331
+ dest.parent.mkdir(parents=True, exist_ok=True)
1332
+ dest.write_text(result.model_dump_json(indent=2), encoding="utf-8")
1333
+ _console.print(f"wrote grid result -> {dest}")
1334
+ png.parent.mkdir(parents=True, exist_ok=True)
1335
+ plot_grid(
1336
+ result,
1337
+ png,
1338
+ dataset_label=dataset_label or suite.id,
1339
+ n_test_traces=result.total_test_traces,
1340
+ )
1341
+ _console.print(f"wrote grid chart -> {png}")
1342
+
1343
+
1344
+ def _eval_grid_plot(paths: list[str], *, out: str | None, dataset_label: str | None) -> None:
1345
+ """Merge one or more grid result JSONs and render a single combined fidelity chart.
1346
+
1347
+ Lets a self-hosted model's grid (run in its own process, since its OpenAI base URL is
1348
+ process-global) be combined with the API-model grid into one chart - and re-plots any saved
1349
+ result without re-running the eval.
1350
+ """
1351
+ results = [GridResult.model_validate_json(Path(p).read_text(encoding="utf-8")) for p in paths]
1352
+ merged = merge_results(results)
1353
+ for cell in merged.cells:
1354
+ cost = f" ${cell.cost_usd:.2f}" if cell.cost_usd else ""
1355
+ _console.print(
1356
+ f" {cell.model_label:16} {cell.condition_label:14} "
1357
+ f"fidelity={cell.fidelity:.3f} err_flag={cell.error_flag_acc:.3f} "
1358
+ f"n={cell.n_steps}{cost}"
1359
+ )
1360
+ # Force a .png suffix so `--out foo.json` writes a real PNG file, never a PNG mislabeled .json.
1361
+ png = Path(out).with_suffix(".png") if out else Path(paths[0]).with_suffix(".merged.png")
1362
+ plot_grid(
1363
+ merged,
1364
+ png,
1365
+ dataset_label=dataset_label or merged.suite,
1366
+ n_test_traces=merged.total_test_traces,
1367
+ )
1368
+ _console.print(f"wrote merged grid chart -> {png}")
1369
+
1370
+
1371
+ def _eval_grid_heatmap(paths: list[str], *, out: str | None) -> None:
1372
+ """Render the whole grid as one heatmap from result JSONs (merged per suite).
1373
+
1374
+ Accepts any mix of API/Qwen result JSONs; same-suite results are merged into one 5-model row
1375
+ set, then all suites become the heatmap's columns (rows = model x condition).
1376
+ """
1377
+ by_suite: dict[str, list[GridResult]] = {}
1378
+ for p in paths:
1379
+ res = GridResult.model_validate_json(Path(p).read_text(encoding="utf-8"))
1380
+ by_suite.setdefault(res.suite, []).append(res)
1381
+ merged = {suite: merge_results(rs) for suite, rs in by_suite.items()}
1382
+ png = Path(out).with_suffix(".png") if out else Path("grid-heatmap.png")
1383
+ plot_grid_heatmap(merged, png)
1384
+ _console.print(f"wrote grid heatmap -> {png} ({len(merged)} benchmarks)")
1385
+
1386
+
1387
+ def _eval_run_suite(
1388
+ selector: str,
1389
+ *,
1390
+ examples_roots: list[str],
1391
+ results_root: str,
1392
+ prompt_file: str | None,
1393
+ provider: str,
1394
+ model: str,
1395
+ region: str | None,
1396
+ train_split: float | None,
1397
+ embed_dim: int | None,
1398
+ rag: bool | None,
1399
+ sample_turns: str | None,
1400
+ seed: int | None,
1401
+ top_k: int | None,
1402
+ knowledge: bool | None,
1403
+ reasoning: bool | None,
1404
+ out: str | None,
1405
+ ) -> None:
1406
+ suite = resolve_eval_suite(selector, examples_roots)
1407
+ suite_prompt = suite.resolve_prompt()
1408
+ options = _eval_options(
1409
+ prompt_file=prompt_file or (str(suite_prompt) if suite_prompt is not None else None),
1410
+ train_split=train_split if train_split is not None else suite.config.train_split,
1411
+ embed_dim=embed_dim if embed_dim is not None else suite.config.embed_dim,
1412
+ rag=rag if rag is not None else not suite.config.no_rag,
1413
+ sample_turns=sample_turns or suite.config.sample_turns,
1414
+ seed=seed if seed is not None else suite.config.seed,
1415
+ top_k=top_k if top_k is not None else suite.config.top_k,
1416
+ knowledge=knowledge if knowledge is not None else suite.config.knowledge,
1417
+ reasoning=reasoning if reasoning is not None else suite.config.reasoning,
1418
+ )
1419
+ files = suite.resolve_files()
1420
+ report = _run_eval_files(files, options, provider=provider, model=model, region=region)
1421
+ _print_eval_report(report)
1422
+
1423
+ run_id = uuid4().hex
1424
+ started_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
1425
+ destination = Path(out) if out else result_path(results_root, suite, run_id)
1426
+ destination.parent.mkdir(parents=True, exist_ok=True)
1427
+ payload = {
1428
+ "run_id": run_id,
1429
+ "started_at": started_at,
1430
+ "suite": suite.id,
1431
+ "suite_path": str(suite.path),
1432
+ "suite_config": suite.config.model_dump(mode="json"),
1433
+ "config": {
1434
+ "provider": provider,
1435
+ "model": model,
1436
+ "region": region,
1437
+ "prompt": options.prompt_file,
1438
+ "files": [str(path) for path in files],
1439
+ "train_split": options.train_split,
1440
+ "top_k": options.top_k,
1441
+ "sample_turns": options.sample_turns,
1442
+ "seed": options.seed,
1443
+ "rag": options.use_rag,
1444
+ "embed_dim": options.embed_dim,
1445
+ "knowledge": options.knowledge,
1446
+ "reasoning": options.reasoning,
1447
+ },
1448
+ "report": _eval_report_payload(report),
1449
+ }
1450
+ destination.write_text(json.dumps(payload, indent=2), encoding="utf-8")
1451
+ _console.print(f"wrote eval result -> {destination}")
1452
+ capture_eval_completed(
1453
+ mode="suite",
1454
+ file_count=len(files),
1455
+ scored_step_count=report.total_valid,
1456
+ rag_enabled=options.use_rag,
1457
+ sample_turns=options.sample_turns,
1458
+ train_split=options.train_split,
1459
+ top_k=options.top_k,
1460
+ root=settings_root_from_results_root(results_root),
1461
+ )
1462
+
1463
+
1464
+ def _eval_options(
1465
+ *,
1466
+ prompt_file: str | None,
1467
+ train_split: float | None,
1468
+ embed_dim: int | None,
1469
+ rag: bool | None,
1470
+ sample_turns: str | None,
1471
+ seed: int | None,
1472
+ top_k: int | None,
1473
+ knowledge: bool | None = None,
1474
+ reasoning: bool | None = None,
1475
+ ) -> _EvalOptions:
1476
+ split = 0.7 if train_split is None else train_split
1477
+ dim = 512 if embed_dim is None else embed_dim
1478
+ retrieval = True if rag is None else rag
1479
+ turns = "all" if sample_turns is None else sample_turns
1480
+ rng_seed = 0 if seed is None else seed
1481
+ demos = 5 if top_k is None else top_k
1482
+ if not 0.0 < split < 1.0:
1483
+ raise typer.BadParameter("--train-split must be between 0 and 1")
1484
+ if dim <= 0:
1485
+ raise typer.BadParameter("--embed-dim must be positive")
1486
+ if demos < 0:
1487
+ raise typer.BadParameter("--top-k must be >= 0")
1488
+ if turns not in {"all", "sampled"}:
1489
+ raise typer.BadParameter("--sample-turns must be one of: all, sampled")
1490
+ return _EvalOptions(
1491
+ prompt_file=prompt_file,
1492
+ train_split=split,
1493
+ embed_dim=dim,
1494
+ use_rag=retrieval,
1495
+ sample_turns=turns,
1496
+ seed=rng_seed,
1497
+ top_k=demos,
1498
+ knowledge=bool(knowledge),
1499
+ reasoning=bool(reasoning),
1500
+ )
1501
+
1502
+
1503
+ def _run_eval_files(
1504
+ files: list[Path],
1505
+ options: _EvalOptions,
1506
+ *,
1507
+ provider: str,
1508
+ model: str,
1509
+ region: str | None,
1510
+ chain: str | None = None,
1511
+ ) -> EvalReport:
1512
+ for path in files:
1513
+ if not path.exists():
1514
+ raise typer.BadParameter(f"trace file not found: {path}")
1515
+ provider_config = _provider_config(provider, model, region)
1516
+ llm = providers.provider_or_chain(provider_config, chain=chain)
1517
+ if isinstance(llm, providers.WaterfallProvider):
1518
+ _console.print("failover chain active (.wmo/fallback.toml) — world-model calls only")
1519
+ prompt = (
1520
+ Path(options.prompt_file).read_text(encoding="utf-8")
1521
+ if options.prompt_file
1522
+ else BASE_ENV_PROMPT
1523
+ )
1524
+ embedder = HashingEmbedder(dim=options.embed_dim) if options.use_rag else None
1525
+ # The judge is the metric: it stays PINNED to the single requested backend and never rides
1526
+ # the failover chain — a judge that silently switches models mid-run makes fidelity numbers
1527
+ # incomparable across steps. World-model prediction calls (above) may fail over freely.
1528
+ scorer = RubricJudge(providers.get_provider(provider_config))
1529
+ evaluation = OpenLoopEval(
1530
+ files,
1531
+ prompt,
1532
+ llm,
1533
+ scorer,
1534
+ embedder=embedder,
1535
+ train_split=options.train_split,
1536
+ top_k=options.top_k,
1537
+ sample_turns=options.sample_turns,
1538
+ seed=options.seed,
1539
+ knowledge=options.knowledge,
1540
+ reasoning=options.reasoning,
1541
+ )
1542
+ return evaluation.run()
1543
+
1544
+
1545
+ def _print_eval_report(report: EvalReport) -> None:
1546
+ for name, rep in report.per_file.items():
1547
+ _console.print(f" {name:28} {rep.summary()}")
1548
+ invalid = f" ({report.total_invalid} judge-invalid excluded)" if report.total_invalid else ""
1549
+ _console.print(
1550
+ f"[bold]OVERALL[/bold] fidelity={report.overall_fidelity:.3f}±{report.overall_std:.3f} "
1551
+ f"over {report.total_steps} held-out steps{invalid}"
1552
+ )
1553
+
1554
+
1555
+ def _write_ad_hoc_eval_report(path: Path, report: EvalReport) -> None:
1556
+ path.write_text(
1557
+ json.dumps({n: r.model_dump(mode="json") for n, r in report.per_file.items()}, indent=2),
1558
+ encoding="utf-8",
1559
+ )
1560
+ _console.print(f"wrote full report -> {path}")
1561
+
1562
+
1563
+ def _eval_report_payload(report: EvalReport) -> JsonObject:
1564
+ return {
1565
+ "judge_version": JUDGE_VERSION,
1566
+ "overall_fidelity": report.overall_fidelity,
1567
+ "overall_std": report.overall_std,
1568
+ "total_steps": report.total_steps,
1569
+ "total_invalid": report.total_invalid,
1570
+ "per_file": {name: rep.model_dump(mode="json") for name, rep in report.per_file.items()},
1571
+ }
1572
+
1573
+
1574
+ @app.command("knowledge")
1575
+ def knowledge_(
1576
+ name: str = typer.Option(None, "--name", help="World model (default: the only one)."),
1577
+ root: str = typer.Option(ARTIFACT_DIR, help="Project dir."),
1578
+ ) -> None:
1579
+ """Show a model's knowledge base: the env's canonical facts, a folder of editable markdown.
1580
+
1581
+ The printed directory IS the editing interface — open it in any editor. `rules.md`/
1582
+ `entities.md`/`schemas.md` are seeded at build (with knowledge enabled); `learned.md` collects
1583
+ the env's own cross-session notes; `grounded.md` caches web-search groundings.
1584
+ """
1585
+ store = WorldModelStore(root)
1586
+ resolved = _resolve_name(store, name)
1587
+ kb = KnowledgeBase(ArtifactPaths(store.resolve(resolved)).knowledge)
1588
+ _console.print(f"[bold]{kb.directory}[/bold]")
1589
+ if kb.is_empty:
1590
+ _console.print(
1591
+ "(empty — enable knowledge at build, drop *.md files in this folder, "
1592
+ "or PUT files via the serving API)"
1593
+ )
1594
+ return
1595
+ for file_name, content in kb.files().items():
1596
+ _console.print(f"\n[bold]## {file_name}[/bold]")
1597
+ _console.print(content.strip())
1598
+
1599
+
1600
+ @scenarios_app.command("build")
1601
+ def scenarios_build(
1602
+ file: str = typer.Option(..., "--file", help="Path to exported traces (OTLP-JSON / JSONL)."),
1603
+ out: str = typer.Option("scenarios.json", "--out", help="Where to write the scenario set."),
1604
+ budget: int = typer.Option(20, help="Number of scenarios to construct."),
1605
+ k: int = typer.Option(None, help="Cluster count (default: sqrt(corpus size))."),
1606
+ limit: int = typer.Option(None, help="Only use the first N ingested traces (cost control)."),
1607
+ provider: str = typer.Option(
1608
+ None,
1609
+ "--provider",
1610
+ help=(
1611
+ "Pin ONE LLM for every role (facets/naming/synthesis/validation). When omitted, "
1612
+ "roles resolve from .wmo/settings.toml [models.worker|judge|summary]."
1613
+ ),
1614
+ ),
1615
+ model: str = typer.Option(None, help="Model id (pins all roles, like --provider)."),
1616
+ region: str = typer.Option(None, help="AWS region (Bedrock)."),
1617
+ embed_provider: str = typer.Option(
1618
+ "hashing",
1619
+ help=(
1620
+ "Facet embedder: hashing (offline but lexical-only — clusters by wording, not "
1621
+ "meaning; prefer a semantic embedder for real corpora) | bedrock | openai | "
1622
+ "azure_openai."
1623
+ ),
1624
+ ),
1625
+ embed_model: str = typer.Option(None, help="Embeddings model id / Azure deployment."),
1626
+ embed_dim: int = typer.Option(512, help="Embedding dimensionality."),
1627
+ seed: int = typer.Option(0, help="Clustering seed."),
1628
+ ) -> None:
1629
+ """Distill a trace corpus into a representative scenario set (facets -> cluster -> select).
1630
+
1631
+ Writes a `ScenarioSet` JSON: scenarios (task, seed state, checklist, weight, provenance),
1632
+ the named clusters they came from, and the corpus-coverage number that justifies them.
1633
+ """
1634
+ traces = get_adapter("otel-genai").from_file(file)
1635
+ if limit is not None:
1636
+ traces = traces[:limit]
1637
+ if not traces:
1638
+ raise typer.BadParameter(f"no traces ingested from {file}")
1639
+ summary_llm, worker_llm, judge_llm = _scenario_role_llms(provider, model, region)
1640
+ embedder = _resolve_scenario_embedder(embed_provider, embed_model, embed_dim, region)
1641
+
1642
+ _console.print(f"extracting facets for {len(traces)} traces…")
1643
+ facets = FacetExtractor(summary_llm).extract_all(traces)
1644
+ config = ScenarioBuildConfig(budget=budget, k=k, seed=seed)
1645
+ scenario_set = build_scenario_set(
1646
+ traces, facets, worker_llm, embedder, config, judge_provider=judge_llm
1647
+ )
1648
+ scenario_set.save(out)
1649
+
1650
+ table = Table(title="Scenario set")
1651
+ table.add_column("Cluster", no_wrap=True)
1652
+ table.add_column("Scenario task")
1653
+ table.add_column("Weight", justify="right")
1654
+ table.add_column("Source", no_wrap=True)
1655
+ for scenario in scenario_set.scenarios:
1656
+ source = scenario.failure_category or scenario.source_outcome.value
1657
+ table.add_row(scenario.cluster_name, scenario.task[:80], f"{scenario.weight:.3f}", source)
1658
+ _console.print(table)
1659
+ _console.print(
1660
+ f"{len(scenario_set.scenarios)} scenarios from {scenario_set.corpus_traces} traces; "
1661
+ f"coverage {scenario_set.corpus_coverage:.0%} at tau={scenario_set.coverage_tau} -> {out}"
1662
+ )
1663
+
1664
+
1665
+ @scenarios_app.command("verify")
1666
+ def scenarios_verify(
1667
+ scenarios_file: str = typer.Argument(..., help="Scenario set JSON from `wmo scenarios build`."),
1668
+ file: str = typer.Option(..., "--file", help="Source trace corpus (for back-agreement)."),
1669
+ name: str = typer.Option(None, "--name", help="World model to roll against."),
1670
+ root: str = typer.Option(ARTIFACT_DIR, help="Project dir holding world models."),
1671
+ provider: str = typer.Option(None, "--provider", help="Override serve provider kind."),
1672
+ model: str = typer.Option(None, help="Override canonical serve model type."),
1673
+ region: str = typer.Option(None, help="AWS region (Bedrock)."),
1674
+ max_steps: int = typer.Option(12, help="Rollout step budget per scenario."),
1675
+ drop: bool = typer.Option(False, "--drop", help="Write back only verified scenarios."),
1676
+ ) -> None:
1677
+ """Closed-loop verification: back-agreement on source traces + solvability rollouts.
1678
+
1679
+ Loads the world model (optionally overriding its serve provider with a cheaper model), rolls a
1680
+ baseline LLM agent on every scenario, and grades episodes against each scenario's checklist.
1681
+ With `--drop`, unverified scenarios are removed from the set in place.
1682
+ """
1683
+ scenario_set = ScenarioSet.load(scenarios_file)
1684
+ traces = get_adapter("otel-genai").from_file(file)
1685
+ if provider is not None or model is not None:
1686
+ store = WorldModelStore(root)
1687
+ model_dir = store.resolve(_resolve_name(store, name))
1688
+ override = _provider_config(provider or "bedrock", model or "claude-opus-4-8", region)
1689
+ llm = wrap_provider_with_retries(providers.get_provider(override))
1690
+ world_model = WorldModel.load(str(model_dir), llm)
1691
+ else:
1692
+ world_model, _resolved_name, llm = _load_model(name, root)
1693
+
1694
+ # The rollout agent takes the worker role and the grader the judge role when configured in
1695
+ # settings (judge should differ in family from the generator); both fall back to the world
1696
+ # model's serve provider, which was the only behavior before roles existed.
1697
+ worker_config = _role_provider_config("worker", region)
1698
+ judge_config = _role_provider_config("judge", region)
1699
+ agent_llm = providers.get_provider(worker_config) if worker_config else llm
1700
+ judge_llm = providers.get_provider(judge_config) if judge_config else llm
1701
+ report = verify_scenarios(
1702
+ scenario_set,
1703
+ traces,
1704
+ world_model,
1705
+ LLMAgent(agent_llm),
1706
+ ChecklistJudge(judge_llm),
1707
+ max_steps=max_steps,
1708
+ )
1709
+ table = Table(title="Scenario verification")
1710
+ table.add_column("Scenario", no_wrap=True)
1711
+ table.add_column("Back-agree")
1712
+ table.add_column("Solvable")
1713
+ table.add_column("Pass rate", justify="right")
1714
+ for verdict in report.verdicts:
1715
+ if verdict.back_agreement is None:
1716
+ agree = "-"
1717
+ else:
1718
+ agree = "yes" if verdict.back_agreement else "NO"
1719
+ table.add_row(
1720
+ verdict.scenario_id,
1721
+ agree,
1722
+ "yes" if verdict.solvable else "NO",
1723
+ f"{verdict.rollout_pass_rate:.2f}",
1724
+ )
1725
+ _console.print(table)
1726
+ _console.print(
1727
+ f"back-agreement {report.back_agreement_rate:.0%}, solvable {report.solvable_rate:.0%} "
1728
+ f"over {len(report.verdicts)} scenarios"
1729
+ )
1730
+ if drop:
1731
+ verified = {v.scenario_id for v in report.verdicts if v.ok}
1732
+ scenario_set.retain(verified)
1733
+ scenario_set.save(scenarios_file)
1734
+ _console.print(
1735
+ f"kept {len(scenario_set.scenarios)} verified scenarios "
1736
+ f"(weights renormalized, coverage reset) -> {scenarios_file}"
1737
+ )
1738
+
1739
+
1740
+ def _provider_config(provider: str, model: str, region: str | None) -> ProviderConfig:
1741
+ try:
1742
+ kind = ProviderKind(provider)
1743
+ except ValueError:
1744
+ kinds = ", ".join(k.value for k in ProviderKind)
1745
+ raise typer.BadParameter(f"unknown provider {provider!r}; choose one of: {kinds}") from None
1746
+ spec = resolve_provider_model(kind, model)
1747
+ return ProviderConfig(
1748
+ kind=kind,
1749
+ model_type=spec.model_type,
1750
+ model=spec.model_id,
1751
+ region=region,
1752
+ )
1753
+
1754
+
1755
+ _SCENARIO_DEFAULT_PROVIDER = "bedrock"
1756
+ _SCENARIO_DEFAULT_MODEL = "claude-opus-4-8"
1757
+
1758
+
1759
+ def _role_provider_config(role: str, region: str | None) -> ProviderConfig | None:
1760
+ """ProviderConfig for a settings-defined model role, or None when the role isn't configured.
1761
+
1762
+ Roles live in `.wmo/settings.toml` under `[models.worker|judge|summary]`; unset judge/summary
1763
+ fall back to worker (see `ModelsSettings.resolve`). A role's stored region wins over the
1764
+ generic `--region` flag — the flag also feeds the embedder, and e.g. a judge pinned to the
1765
+ one region where its model is enabled must not follow it.
1766
+ """
1767
+ configured = load_settings().models.resolve(role)
1768
+ if configured is None:
1769
+ return None
1770
+ config = _provider_config(configured.provider, configured.model, configured.region or region)
1771
+ return config.model_copy(
1772
+ update={"endpoint": configured.endpoint, "deployment": configured.deployment}
1773
+ )
1774
+
1775
+
1776
+ def _scenario_role_llms(
1777
+ provider: str | None, model: str | None, region: str | None
1778
+ ) -> tuple[Provider, Provider, Provider]:
1779
+ """(summary, worker, judge) providers for scenario construction.
1780
+
1781
+ Explicit `--provider`/`--model` flags pin ALL roles to that one model (the pre-roles
1782
+ behavior). Otherwise each role resolves from `.wmo/settings.toml`, falling back to worker,
1783
+ then to the built-in default. Judging benefits from a different family than the worker —
1784
+ a same-family judge carries self-preference bias toward the generator's outputs.
1785
+ """
1786
+ if provider is not None or model is not None:
1787
+ config = _provider_config(
1788
+ provider or _SCENARIO_DEFAULT_PROVIDER, model or _SCENARIO_DEFAULT_MODEL, region
1789
+ )
1790
+ llm = providers.get_provider(config)
1791
+ return llm, llm, llm
1792
+ default = _provider_config(_SCENARIO_DEFAULT_PROVIDER, _SCENARIO_DEFAULT_MODEL, region)
1793
+ cache: dict[str, Provider] = {}
1794
+ by_role: dict[str, Provider] = {}
1795
+ for role in ("summary", "worker", "judge"):
1796
+ config = _role_provider_config(role, region) or default
1797
+ key = f"{config.kind.value}:{config.model}:{config.endpoint}:{config.region}"
1798
+ if key not in cache:
1799
+ cache[key] = providers.get_provider(config)
1800
+ by_role[role] = cache[key]
1801
+ return by_role["summary"], by_role["worker"], by_role["judge"]
1802
+
1803
+
1804
+ def _resolve_scenario_embedder(
1805
+ embed_provider: str, embed_model: str | None, embed_dim: int, region: str | None
1806
+ ) -> Embedder:
1807
+ try:
1808
+ kind = EmbedderKind(embed_provider)
1809
+ except ValueError:
1810
+ kinds = ", ".join(k.value for k in EmbedderKind)
1811
+ raise typer.BadParameter(
1812
+ f"unknown embed provider {embed_provider!r}; choose one of: {kinds}"
1813
+ ) from None
1814
+ if kind is EmbedderKind.HASHING:
1815
+ return HashingEmbedder(dim=embed_dim)
1816
+ if not embed_model:
1817
+ raise typer.BadParameter(
1818
+ f"--embed-provider {kind.value} requires --embed-model "
1819
+ "(the embeddings model id / Azure embedding deployment)"
1820
+ )
1821
+ return providers.get_provider(
1822
+ ProviderConfig(
1823
+ kind=kind.provider_kind(),
1824
+ model=embed_model,
1825
+ embed_model=embed_model,
1826
+ embed_dim=embed_dim,
1827
+ region=region,
1828
+ )
1829
+ )
1830
+
1831
+
1832
+ @app.command("demo")
1833
+ def demo(
1834
+ name: str = typer.Option(None, "--name", help="World model to demo (default: pick one)."),
1835
+ root: str = typer.Option(ARTIFACT_DIR, help="Project dir (example models are found too)."),
1836
+ steps: int = typer.Option(5, help="Max scenario steps to replay."),
1837
+ traces: str = typer.Option(
1838
+ None, "--traces", help="Trace file to sample the scenario from (default: the model's)."
1839
+ ),
1840
+ seed: int = typer.Option(None, help="Seed for the scenario sample (default: random)."),
1841
+ show_prompt: bool = typer.Option(
1842
+ True,
1843
+ "--show-prompt/--no-prompt",
1844
+ help="Print the exact env prompt the world model sees for the first step.",
1845
+ ),
1846
+ max_fidelity: bool = typer.Option(
1847
+ False, "--max-fidelity", help="Run with the online extras on (default: pure RAG)."
1848
+ ),
1849
+ ) -> None:
1850
+ """Replay a randomly sampled recorded scenario against the world model, open loop."""
1851
+ wm, resolved_name, _provider, model_root = _load_model_any(
1852
+ name, root, max_fidelity=max_fidelity
1853
+ )
1854
+ traces_file = Path(traces) if traces else _traces_for_root(model_root)
1855
+ if traces_file is None or not traces_file.exists():
1856
+ raise typer.BadParameter(
1857
+ f"no trace file found for {resolved_name!r} under {model_root}; pass --traces"
1858
+ )
1859
+ model_dir = WorldModelStore(str(model_root)).resolve(resolved_name)
1860
+ config = load_config(model_dir) # the model dir holds its own HarnessConfig
1861
+ candidates = [t for t in ingest(config, file=str(traces_file)) if t.steps]
1862
+ if not candidates:
1863
+ raise typer.BadParameter(f"{traces_file} contains no replayable traces")
1864
+ trace = random.Random(seed).choice(candidates)
1865
+
1866
+ total = min(steps, len(trace.steps))
1867
+ _console.print(
1868
+ f"replaying scenario [bold]{trace.trace_id}[/bold] against [bold]{resolved_name}[/bold] "
1869
+ f"(open loop, {total} of {len(trace.steps)} steps)…"
1870
+ )
1871
+ if trace.steps[0].task:
1872
+ _console.print(f"[dim]task: {escape(trace.steps[0].task)}[/dim]")
1873
+ if show_prompt:
1874
+ _console.print(
1875
+ Panel(
1876
+ escape(_first_prompt(wm, trace)),
1877
+ title="[dim]env prompt (step 1) — what the world model sees[/dim]",
1878
+ border_style="bright_black",
1879
+ )
1880
+ )
1881
+
1882
+ done: list = [] # DemoStep results stream in as each prediction lands
1883
+
1884
+ def _print_result(i: int, n: int, demo_step) -> None: # noqa: ANN001 - engine DemoStep
1885
+ done.append(demo_step)
1886
+ action = demo_step.action
1887
+ call = (
1888
+ f"{action.name} {json.dumps(action.arguments)}"
1889
+ if action.name
1890
+ else (action.content or "")[:120]
1891
+ )
1892
+ verdict = (
1893
+ "[green]exact match[/green]" if demo_step.exact_match else "[yellow]differs[/yellow]"
1894
+ )
1895
+ _console.print(f"\n[bold]step {i}/{n}[/bold] [cyan]{escape(call)}[/cyan] {verdict}")
1896
+ _console.print(f" [green]predicted[/green]: {escape(demo_step.predicted.content)}")
1897
+ _console.print(f" [dim]actual[/dim]: {escape(demo_step.actual.content)}")
1898
+ note = demo_step.predicted.metadata.get("state_note")
1899
+ if isinstance(note, str) and note.strip():
1900
+ _console.print(f" [dim]model note: {escape(note.strip())}[/dim]")
1901
+
1902
+ while True:
1903
+ try:
1904
+ busy = "[dim]world model predicting…[/dim]"
1905
+ with _console.status(busy, spinner="dots") as status:
1906
+ _NARRATOR.attach(status, busy)
1907
+
1908
+ def _on_step(i: int, n: int) -> None:
1909
+ text = f"[dim]world model predicting step {i}/{n}…[/dim]"
1910
+ _NARRATOR.busy = text
1911
+ status.update(text)
1912
+
1913
+ try:
1914
+ run_demo(
1915
+ wm,
1916
+ trace,
1917
+ max_steps=steps,
1918
+ on_step=_on_step,
1919
+ on_result=_print_result,
1920
+ skip=len(done),
1921
+ )
1922
+ finally:
1923
+ _NARRATOR.detach()
1924
+ break
1925
+ except Exception as exc: # noqa: BLE001 - classified below
1926
+ if not is_capacity_error(exc) or not _console.is_terminal:
1927
+ raise
1928
+ # Retries are exhausted and the backend is still down: offer to re-point the model
1929
+ # at a different provider (same picker as the build wizard) and RESUME from the
1930
+ # failed step — completed steps stay done.
1931
+ _console.print(f"\n[red]serve provider is still failing[/red]: {_short_error(exc)}")
1932
+ _console.print("[yellow]pick a different provider to continue the demo[/yellow]")
1933
+ provider_name, model_type, region = select_provider_and_model(
1934
+ _console,
1935
+ lambda text: _console.input(text),
1936
+ lambda text: _console.input(text, password=True),
1937
+ default_provider=None,
1938
+ default_model=None,
1939
+ default_region=None,
1940
+ interactive=True,
1941
+ check=lambda cfg: verify_all([cfg])[0],
1942
+ )
1943
+ switched = _provider_config(provider_name, model_type, region)
1944
+ provider = wrap_provider_with_retries(
1945
+ providers.get_provider(switched), on_retry=_NARRATOR.on_retry, sleep=_NARRATOR.sleep
1946
+ )
1947
+ wm = WorldModel.load(str(model_dir), provider, telemetry_root=str(model_root))
1948
+ _console.print(
1949
+ f"[dim]resuming from step {len(done) + 1} with "
1950
+ f"{provider_name} ({model_type})…[/dim]"
1951
+ )
1952
+ matches = sum(1 for d in done if d.exact_match)
1953
+ _console.print(f"\n{matches}/{len(done)} exact matches (run `wmo eval` for judged fidelity)")
1954
+
1955
+
1956
+ def _first_prompt(wm: WorldModel, trace) -> str: # noqa: ANN001 - core Trace
1957
+ """Render the first step's env prompt on a throwaway session (display only)."""
1958
+ probe = wm.new_session(task=trace.steps[0].task)
1959
+ try:
1960
+ return wm.render_step_prompt(probe.id, trace.steps[0].action)
1961
+ finally:
1962
+ wm.end_session(probe.id)
1963
+
1964
+
1965
+ @app.command("play")
1966
+ def play(
1967
+ name: str = typer.Option(None, "--name", help="World model to play (default: pick one)."),
1968
+ task: str = typer.Option(None, "--task", help="Task to seed the session with."),
1969
+ root: str = typer.Option(ARTIFACT_DIR, help="Project dir (example models are found too)."),
1970
+ max_fidelity: bool = typer.Option(
1971
+ False, "--max-fidelity", help="Run with the online extras on (default: pure RAG)."
1972
+ ),
1973
+ ) -> None:
1974
+ """Step into the environment yourself: type actions, the world model returns observations."""
1975
+ wm, resolved_name, _provider, _model_root = _load_model_any(
1976
+ name, root, max_fidelity=max_fidelity
1977
+ )
1978
+ suggestions = _action_suggestions(wm)
1979
+ run_play_repl(_console, wm, resolved_name, task, suggestions=suggestions)
1980
+
1981
+
1982
+ def _traces_for_root(model_root: Path) -> Path | None:
1983
+ """The trace corpus that built the models under `model_root`, when it ships alongside."""
1984
+ candidate = model_root / "traces.otel.jsonl"
1985
+ return candidate if candidate.exists() else None
1986
+
1987
+
1988
+ def _action_suggestions(wm: WorldModel, n: int = 3) -> list[str]:
1989
+ """Real action lines sampled from the model's corpus, to seed the play prompt."""
1990
+ suggestions: list[str] = []
1991
+ for step in wm.sample_steps(8):
1992
+ action = step.action
1993
+ if action.name:
1994
+ args = json.dumps(action.arguments) if action.arguments else ""
1995
+ line = f"{action.name} {args}".strip()
1996
+ elif action.content:
1997
+ line = f"say {action.content[:60]}"
1998
+ else:
1999
+ continue
2000
+ if line not in suggestions:
2001
+ suggestions.append(line)
2002
+ if len(suggestions) >= n:
2003
+ break
2004
+ return suggestions
2005
+
2006
+
2007
+ def _load_model_any(name: str | None, root: str, *, max_fidelity: bool = False): # noqa: ANN202
2008
+ """Resolve a model across the project dir AND shipped examples, then load it.
2009
+
2010
+ An explicit non-default `--root` keeps the old single-root behavior. Otherwise the picker
2011
+ spans `<root>/models/*` plus `examples/*/models/*`, labeling each with its source.
2012
+ """
2013
+ if root != ARTIFACT_DIR:
2014
+ wm, resolved, provider = _load_model(name, root, max_fidelity=max_fidelity)
2015
+ return wm, resolved, provider, Path(root)
2016
+
2017
+ candidates: list[tuple[str, Path, str]] = [] # (label, store_root, name)
2018
+ local = WorldModelStore(root)
2019
+ candidates.extend((f"{n} (local)", Path(root), n) for n in local.list_names())
2020
+ for example_dir in _discover_examples():
2021
+ example_store = WorldModelStore(example_dir)
2022
+ candidates.extend(
2023
+ (f"{n} ({example_dir.name} example)", example_dir, n)
2024
+ for n in example_store.list_names()
2025
+ )
2026
+
2027
+ if name is not None:
2028
+ matched = [c for c in candidates if c[2] == name]
2029
+ if not matched:
2030
+ have = ", ".join(c[2] for c in candidates) or "none built"
2031
+ raise typer.BadParameter(f"no world model named {name!r} (have: {have})")
2032
+ # Prefer the local build over a same-named example artifact.
2033
+ label, store_root, resolved = matched[0]
2034
+ elif not candidates:
2035
+ raise typer.BadParameter(
2036
+ "no world models found; run `wmo build` or try an example (examples/tau-bench)"
2037
+ )
2038
+ elif len(candidates) == 1:
2039
+ label, store_root, resolved = candidates[0]
2040
+ elif _console.is_terminal:
2041
+ labels = [c[0] for c in candidates]
2042
+ chosen = _select_from(labels)
2043
+ label, store_root, resolved = candidates[labels.index(chosen)]
2044
+ else:
2045
+ have = ", ".join(c[2] for c in candidates)
2046
+ raise typer.BadParameter(f"multiple world models ({have}); pass --name")
2047
+
2048
+ wm, resolved, provider = _load_model(resolved, str(store_root), max_fidelity=max_fidelity)
2049
+ return wm, resolved, provider, store_root
2050
+
2051
+
2052
+ def _select_from(labels: list[str]) -> str:
2053
+ """Interactive picker over pre-rendered labels (arrow keys on a TTY)."""
2054
+ from wmo.cli import ui as _ui # package-internal: reuse the wizard's picker machinery
2055
+
2056
+ return _ui._select(
2057
+ _console,
2058
+ lambda text: _console.input(text),
2059
+ "Select a world model",
2060
+ labels,
2061
+ None,
2062
+ interactive=True,
2063
+ )
2064
+
2065
+
2066
+ def _resolve_name(store: WorldModelStore, name: str | None) -> str:
2067
+ """Resolve which model to run: explicit `--name`, an interactive picker, or the sole model.
2068
+
2069
+ With `--name`, validate it exists. Otherwise, when several models are built on an interactive
2070
+ terminal, show a numbered picker; on a non-TTY (or a single model) defer to `store.resolve`,
2071
+ which returns the lone model or raises a helpful "pass --name" error. Store errors
2072
+ (unknown/ambiguous name) are turned into a clean `typer.BadParameter` rather than a traceback.
2073
+ """
2074
+ try:
2075
+ if name is not None:
2076
+ store.resolve(name) # validates existence, raising a friendly error if missing
2077
+ return name
2078
+ # Only enumerate full model summaries when we actually need the picker (>1 model on a TTY).
2079
+ # `list_names` is cheap (a dir scan); `list_info` reads every config/metrics/frontier file.
2080
+ if _console.is_terminal and len(store.list_names()) > 1:
2081
+ return select_model(_console, store.list_info())
2082
+ return store.resolve(None).name
2083
+ except (FileNotFoundError, ValueError) as exc:
2084
+ raise typer.BadParameter(str(exc)) from exc
2085
+
2086
+
2087
+ def _benchmark_roots() -> tuple[Path, ...]:
2088
+ """Every root holding self-contained task dirs: examples/ + the capture member's data dirs."""
2089
+ repo = Path(__file__).resolve().parents[2]
2090
+ return (repo / "examples", repo / "packages" / "environment-capture")
2091
+
2092
+
2093
+ def _discover_examples() -> list[Path]:
2094
+ found: list[Path] = []
2095
+ for root in _benchmark_roots():
2096
+ if not root.exists():
2097
+ continue
2098
+ found.extend(
2099
+ path
2100
+ for path in root.iterdir()
2101
+ if path.is_dir()
2102
+ and _is_safe_example_name(path.name)
2103
+ and ((path / "traces.otel.jsonl").exists() or (path / "run.sh").exists())
2104
+ )
2105
+ return sorted(found)
2106
+
2107
+
2108
+ def _is_safe_example_name(name: str) -> bool:
2109
+ """Whether `name` would resolve via `wmo examples run` — keeps list/hint/run in agreement."""
2110
+ try:
2111
+ validate_name(name)
2112
+ except ValueError:
2113
+ return False
2114
+ return True
2115
+
2116
+
2117
+ def _resolve_example(name: str) -> Path:
2118
+ # An unsafe segment (spaces, path separators, ...) is simply not an example name; fall
2119
+ # through to the same "unknown example" usage error instead of a ValueError traceback.
2120
+ try:
2121
+ safe = validate_name(name)
2122
+ except ValueError:
2123
+ safe = None
2124
+ if safe is not None:
2125
+ matches = [root / safe for root in _benchmark_roots() if (root / safe).is_dir()]
2126
+ if len(matches) > 1:
2127
+ found = ", ".join(str(path) for path in matches)
2128
+ raise typer.BadParameter(f"example {name!r} exists in multiple roots: {found}")
2129
+ if matches:
2130
+ return matches[0]
2131
+ available = ", ".join(path.name for path in _discover_examples())
2132
+ hint = f" (available: {available})" if available else ""
2133
+ raise typer.BadParameter(f"unknown example {name!r}{hint}")
2134
+
2135
+
2136
+ @research_app.command("concurrency")
2137
+ def research_concurrency(
2138
+ suite: str = typer.Argument(
2139
+ ..., help="Eval suite / example name (e.g. tau-bench) — its corpus + config are reused."
2140
+ ),
2141
+ scenarios: int = typer.Option(16, "--scenarios", help="Batch size N held fixed across levels."),
2142
+ levels: str = typer.Option(
2143
+ "1,2,4,8,16", "--levels", help="Comma-separated concurrency levels (baseline first)."
2144
+ ),
2145
+ trials: int = typer.Option(1, "--trials", help="Timed repeats per level (for error bars)."),
2146
+ select: str = typer.Option(
2147
+ "random",
2148
+ "--select",
2149
+ help="Which held-out scenarios to draw: random (default — a representative sample) | "
2150
+ "simplest (fewest steps) | longest. simplest/longest order by step count to check whether "
2151
+ "a result is robust to the trace sample; the biased draws must not be the default.",
2152
+ ),
2153
+ select_seed: int = typer.Option(0, "--select-seed", help="Seed for --select random."),
2154
+ side: str = typer.Option(
2155
+ "both", "--side", help="both = differential | world = WM-only | real = sandbox-only."
2156
+ ),
2157
+ provider: str = typer.Option("bedrock", "--provider", help="Provider running the model."),
2158
+ model: str = typer.Option("us.anthropic.claude-opus-4-8", help="Model id (environment LLM)."),
2159
+ region: str | None = typer.Option(None, help="AWS region (Bedrock)."),
2160
+ deployment: str | None = typer.Option(None, help="Azure OpenAI deployment name."),
2161
+ api_version: str | None = typer.Option(None, help="Azure OpenAI API version."),
2162
+ endpoint: str | None = typer.Option(None, help="Azure OpenAI / custom base URL."),
2163
+ real_arg: list[str] | None = _RESEARCH_REAL_ARG,
2164
+ real_timeout: float | None = typer.Option(
2165
+ None, "--real-timeout", help="Abort a real sandbox run after N seconds."
2166
+ ),
2167
+ out: str | None = typer.Option(None, help="Path to write the ConcurrencyScalingReport JSON."),
2168
+ examples_root: str | None = typer.Option(None, help="Examples dir. Default: repo-local."),
2169
+ ) -> None:
2170
+ """Measure the concurrency scaling law: batch wall-clock vs. how many scenarios run at once.
2171
+
2172
+ Reconstructs a fixed batch of N held-out scenarios from `suite`'s corpus at each concurrency
2173
+ level, timing the world-model batch and (with `--side both`) the matching real-sandbox batch,
2174
+ to give the time differential T_real(W)/T_world(W). Reuses the suite's corpus + config
2175
+ (train_split, top_k, prompt) and the example's `run.sh`. See `wmo.research.concurrency_run`.
2176
+ """
2177
+ if scenarios < 1:
2178
+ raise typer.BadParameter("--scenarios must be at least 1")
2179
+ try:
2180
+ which = Side(side)
2181
+ except ValueError:
2182
+ allowed = ", ".join(s.value for s in Side)
2183
+ raise typer.BadParameter(f"--side must be one of: {allowed}") from None
2184
+ # Validate the provider up front, not lazily inside a worker thread mid-sweep.
2185
+ try:
2186
+ provider_kind = ProviderKind(provider)
2187
+ except ValueError:
2188
+ kinds = ", ".join(k.value for k in ProviderKind)
2189
+ raise typer.BadParameter(f"unknown provider {provider!r}; choose one of: {kinds}") from None
2190
+ if select not in ("simplest", "longest", "random"):
2191
+ raise typer.BadParameter("--select must be one of: simplest, longest, random")
2192
+ try:
2193
+ level_list = [int(x) for x in levels.split(",") if x.strip()]
2194
+ except ValueError:
2195
+ raise typer.BadParameter(
2196
+ f"--levels must be a comma-separated list of integers, got {levels!r}"
2197
+ ) from None
2198
+ if not level_list:
2199
+ raise typer.BadParameter("--levels must list at least one concurrency level")
2200
+ if any(level < 1 for level in level_list):
2201
+ raise typer.BadParameter("--levels must be positive concurrency counts")
2202
+ # Every level runs the same N scenarios, so a level above N would silently cap its effective
2203
+ # concurrency at N and just duplicate the N-worker point — a misleading flat tail.
2204
+ if max(level_list) > scenarios:
2205
+ raise typer.BadParameter(
2206
+ f"--levels goes up to {max(level_list)} but only --scenarios {scenarios} run at each "
2207
+ f"level, so W>{scenarios} would just duplicate W={scenarios}. Raise --scenarios to "
2208
+ f"{max(level_list)} or drop levels above {scenarios}."
2209
+ )
2210
+
2211
+ suite_roots = (
2212
+ [str(root) for root in _benchmark_roots()] if examples_root is None else [examples_root]
2213
+ )
2214
+ resolved = resolve_eval_suite(suite, suite_roots)
2215
+ files = resolved.resolve_files()
2216
+ missing = [f for f in files if not f.exists()]
2217
+ if not files or missing:
2218
+ raise typer.BadParameter(f"suite {suite!r} has no trace corpus at {missing or files}")
2219
+ adapter = get_adapter("otel-genai")
2220
+ traces = [t for f in files for t in adapter.from_file(str(f))]
2221
+ if not traces:
2222
+ raise typer.BadParameter(f"suite {suite!r} ingested no traces")
2223
+
2224
+ # Draw N held-out scenarios, so both sides replay the SAME held-out traces by index — keep N
2225
+ # within the held-out pool so the split stays aligned. `--select` picks the drawing rule:
2226
+ # `random` (default — a representative sample) or `simplest`/`longest`, which order by step
2227
+ # count to check a result is robust to the trace sample rather than an artifact of it.
2228
+ train, holdout = split_traces(traces, resolved.config.train_split)
2229
+ pool = holdout or traces
2230
+ need = scenarios
2231
+ if need > len(pool):
2232
+ raise typer.BadParameter(
2233
+ f"need {need} scenario(s) but suite {suite!r} has only {len(pool)} held-out trace(s); "
2234
+ "lower --scenarios/--levels or grow the corpus"
2235
+ )
2236
+ indexed = list(enumerate(pool))
2237
+ if select == "random":
2238
+ random.Random(select_seed).shuffle(indexed)
2239
+ else: # simplest | longest — order by step count, ascending or descending
2240
+ indexed.sort(key=lambda item: len(item[1].steps), reverse=(select == "longest"))
2241
+ selected = indexed[:need]
2242
+
2243
+ # Prompt + retrieval mirror the suite's eval config, so the timed reconstruction is the same
2244
+ # work `wmo eval run <suite>` scores.
2245
+ suite_prompt = resolved.resolve_prompt()
2246
+ prompt = (
2247
+ suite_prompt.read_text(encoding="utf-8") if suite_prompt is not None else BASE_ENV_PROMPT
2248
+ )
2249
+ use_rag = not resolved.config.no_rag
2250
+ embedder = HashingEmbedder(dim=resolved.config.embed_dim) if use_rag else None
2251
+ demos = DemoRetriever(
2252
+ EmbeddingRetriever(embedder) if embedder is not None else None,
2253
+ train if use_rag else [],
2254
+ top_k=resolved.config.top_k,
2255
+ )
2256
+
2257
+ def provider_factory() -> Provider:
2258
+ return providers.get_provider(
2259
+ ProviderConfig(
2260
+ kind=provider_kind,
2261
+ model=model,
2262
+ region=region,
2263
+ deployment=deployment,
2264
+ api_version=api_version,
2265
+ endpoint=endpoint,
2266
+ )
2267
+ )
2268
+
2269
+ world_runner = build_world_runner(provider_factory, prompt, demos, selected)
2270
+ real_runner = None
2271
+ if which in (Side.BOTH, Side.REAL):
2272
+ example_dir = _resolve_example(resolved.example)
2273
+ real_extra = list(real_arg or [])
2274
+ # Force each runner's cold-standup + isolation flags so the real side pays its TRUE from-
2275
+ # source standup and concurrent sandboxes don't clobber each other's docker state (see
2276
+ # _CONCURRENCY_ISOLATION_FLAGS). Skipped if the caller passed their own real-runner args.
2277
+ # Prepend the forced cold-standup/isolation flags; any user `--real-arg` comes AFTER so it
2278
+ # can still override (argparse takes the last value), but the forced flags are never dropped
2279
+ # just because the user passed an unrelated arg.
2280
+ forced = _CONCURRENCY_ISOLATION_FLAGS.get(resolved.example, ())
2281
+ if forced:
2282
+ real_extra = list(forced) + real_extra
2283
+ _console.print(
2284
+ f"[yellow]{resolved.example}: real runner uses {' '.join(forced)} "
2285
+ "(true from-source cold standup, isolated per concurrent scenario)[/yellow]"
2286
+ )
2287
+ real_runner = build_real_runner(
2288
+ example_dir,
2289
+ selected,
2290
+ train_split=resolved.config.train_split,
2291
+ extra_args=real_extra,
2292
+ timeout=real_timeout,
2293
+ )
2294
+
2295
+ batch_desc = f"batch of {len(selected)}"
2296
+ _console.print(
2297
+ f"\n[bold]concurrency scaling[/bold] {suite}: {batch_desc} held-out "
2298
+ f"scenario(s), levels={level_list}, side={which.value}, trials={trials}\n"
2299
+ )
2300
+
2301
+ def _progress(point: ConcurrencyPoint) -> None:
2302
+ _console.print(f" {point.summary()}")
2303
+
2304
+ report = run_concurrency_scaling(
2305
+ world_runner,
2306
+ real_runner,
2307
+ levels=level_list,
2308
+ scenarios=len(selected),
2309
+ trials=trials,
2310
+ side=which,
2311
+ on_point=_progress,
2312
+ )
2313
+ report.benchmark = resolved.example
2314
+
2315
+ # Speedup vs. W=1 is a like-for-like ratio because every level runs the same fixed-N batch.
2316
+ best = report.best_speedup()
2317
+ if best is not None and best.speedup:
2318
+ _console.print(
2319
+ f"\n[bold]best world-model speedup[/bold]: {best.speedup:.2f}x at concurrency "
2320
+ f"{best.level} (efficiency {best.efficiency:.0%})"
2321
+ )
2322
+ if out:
2323
+ Path(out).write_text(report.model_dump_json(indent=2), encoding="utf-8")
2324
+ _console.print(f"wrote report -> {out}")
2325
+
2326
+
2327
+ @research_app.command("plot-concurrency")
2328
+ def research_plot_concurrency(
2329
+ report: str = _RESEARCH_PLOT_REPORT,
2330
+ out: str = typer.Option("concurrency_scaling.png", "--out", help="Output image path."),
2331
+ title: str = typer.Option("Concurrency scaling law", "--title", help="Figure title."),
2332
+ ) -> None:
2333
+ """Render a concurrency scaling-law report JSON to a figure (needs the `viz` extra).
2334
+
2335
+ Draws batch wall-clock vs. concurrency (log-log, mean±std), the world-model speedup vs.
2336
+ ideal-linear, and the T_real/T_world differential when the report has both sides.
2337
+
2338
+ `concurrency_plot` is imported here (not at module scope) because it pulls in matplotlib/pandas
2339
+ from the optional `viz` extra — the harness runtime must not require them. A missing extra
2340
+ raises a plain ImportError naming the module (`uv sync --extra viz`); it is not caught.
2341
+ """
2342
+ from wmo.research.concurrency_plot import render_report
2343
+
2344
+ try:
2345
+ written = render_report(report, out, title=title)
2346
+ except (ValueError, FileNotFoundError) as exc:
2347
+ raise typer.BadParameter(str(exc)) from exc
2348
+ _console.print(f"wrote figure -> {written}")
2349
+
2350
+
2351
+ @research_app.command("plot-concurrency-combined")
2352
+ def research_plot_concurrency_combined(
2353
+ reports: list[str] = _RESEARCH_PLOT_COMBINED,
2354
+ out_speedup: str = typer.Option(
2355
+ "concurrency_speedup.png", "--out-speedup", help="Output path for the speed-up figure."
2356
+ ),
2357
+ out_cost: str = typer.Option(
2358
+ "concurrency_cost.png", "--out-cost", help="Output path for the cost figure."
2359
+ ),
2360
+ ) -> None:
2361
+ """Render the cross-benchmark comparison as two standalone figures (needs the `viz` extra).
2362
+
2363
+ Overlays several benchmarks so their RELATIVE differences read at a glance, split into two
2364
+ self-contained images: the speed-up figure (how many times faster the world model is than the
2365
+ real environment, per benchmark) and the cost figure (the reconstruction-vs-real-setup cost that
2366
+ explains it). Pass the per-benchmark report JSONs (`wmo research concurrency <suite> --out ...`)
2367
+ in the order you want them coloured.
2368
+
2369
+ Imported lazily for the same reason as `plot-concurrency` (the optional matplotlib viz extra).
2370
+ """
2371
+ from wmo.research.concurrency_plot import render_cost, render_speedup
2372
+
2373
+ try:
2374
+ speedup_path = render_speedup(reports, out_speedup)
2375
+ cost_path = render_cost(reports, out_cost)
2376
+ except (ValueError, FileNotFoundError) as exc:
2377
+ raise typer.BadParameter(str(exc)) from exc
2378
+ _console.print(f"wrote figures -> {speedup_path}, {cost_path}")
2379
+
2380
+
2381
+ def _short_error(exc: Exception) -> str:
2382
+ """The error's code + service message, without transport chatter.
2383
+
2384
+ botocore's text ("... (reached max retries: 1) ...") reads as OUR retry state and confuses
2385
+ the narration; the structured code + message is what the user needs.
2386
+ """
2387
+ response = getattr(exc, "response", None)
2388
+ if isinstance(response, dict):
2389
+ error = response.get("Error", {})
2390
+ code, message = error.get("Code"), error.get("Message") or ""
2391
+ if code:
2392
+ return f"{code}: {message}".rstrip(": ")[:110]
2393
+ return str(exc).splitlines()[0][:110]
2394
+
2395
+
2396
+ class _RetryNarrator:
2397
+ """Console narration for RetryingProvider: hiccup lines + an inline countdown.
2398
+
2399
+ The hiccup line prints only when the failure CHANGES (a stream of identical throttles says
2400
+ it once); while a rich status is attached (demo), the wait counts down in place as
2401
+ "retry k/3 — waiting Ns…" and then hands the spinner back to the busy text.
2402
+ """
2403
+
2404
+ def __init__(self, console: Console) -> None:
2405
+ self._console = console
2406
+ self._status = None # rich Status while a spinner context is active
2407
+ self.busy = ""
2408
+ self._last_error: str | None = None
2409
+ self._attempt = 0
2410
+ self._total = 0
2411
+
2412
+ def attach(self, status, busy: str) -> None: # noqa: ANN001 - rich Status
2413
+ self._status = status
2414
+ self.busy = busy
2415
+
2416
+ def detach(self) -> None:
2417
+ self._status = None
2418
+ self._last_error = None
2419
+
2420
+ def on_retry(self, attempt: int, total: int, delay: float, exc: Exception) -> None:
2421
+ detail = _short_error(exc)
2422
+ if detail != self._last_error:
2423
+ self._console.print(f" [yellow]provider hiccup: {escape(detail)}[/yellow]")
2424
+ self._last_error = detail
2425
+ self._attempt, self._total = attempt, total
2426
+ if self._status is None:
2427
+ self._console.print(f" [yellow]retry {attempt}/{total} in {delay:.0f}s…[/yellow]")
2428
+
2429
+ def sleep(self, delay: float) -> None:
2430
+ remaining = int(delay)
2431
+ while remaining > 0:
2432
+ if self._status is not None:
2433
+ self._status.update(
2434
+ f"[yellow]retry {self._attempt}/{self._total} — waiting {remaining}s…[/yellow]"
2435
+ )
2436
+ time.sleep(1)
2437
+ remaining -= 1
2438
+ if self._status is not None:
2439
+ self._status.update(self.busy)
2440
+
2441
+
2442
+ _NARRATOR = _RetryNarrator(_console)
2443
+
2444
+
2445
+ def _load_model(name: str | None, root: str, *, max_fidelity: bool = False): # noqa: ANN202
2446
+ """Resolve + load a named world model (or the single built one) with its serve provider.
2447
+
2448
+ The serve provider comes from the MODEL'S OWN config (the one it was built to serve on),
2449
+ wrapped so transient capacity errors retry with narrated exponential backoff instead of
2450
+ dying. `max_fidelity` = the online extras (see `WorldModel.load`); default is pure RAG.
2451
+ Returns `(world_model, resolved_name, provider)`.
2452
+ """
2453
+ store = WorldModelStore(root)
2454
+ resolved_name = _resolve_name(store, name)
2455
+ model_dir = store.resolve(resolved_name)
2456
+ config = load_config(model_dir)
2457
+ provider = wrap_provider_with_retries(
2458
+ providers.get_provider(config.serve_provider_config()),
2459
+ on_retry=_NARRATOR.on_retry,
2460
+ sleep=_NARRATOR.sleep,
2461
+ )
2462
+ world_model = WorldModel.load(
2463
+ str(model_dir), provider, telemetry_root=store.root, max_fidelity=max_fidelity
2464
+ )
2465
+ return world_model, resolved_name, provider
2466
+
2467
+
2468
+ if __name__ == "__main__":
2469
+ app()
2470
+
2471
+
2472
+ def _quiet_http_logs() -> None:
2473
+ """Cap noisy per-request loggers at WARNING.
2474
+
2475
+ The openai SDK (via httpx) logs one INFO line per API call; logging handlers write to the
2476
+ real stderr and bypass the live display's redirection, so during a build each request would
2477
+ scroll the GEPA activity region and litter orphaned frame headers across the terminal.
2478
+ """
2479
+ for name in ("httpx", "httpcore", "openai", "botocore", "urllib3", "anthropic"):
2480
+ logging.getLogger(name).setLevel(logging.WARNING)
2481
+
2482
+
2483
+ def main() -> None:
2484
+ """CLI entry point: load `.env` from the working directory (so wizard-saved provider keys
2485
+ persist across sessions), then dispatch. Kept out of import time so importing the module
2486
+ never mutates os.environ."""
2487
+ load_env_file()
2488
+ _quiet_http_logs()
2489
+ app()