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.
- llm_waterfall/LICENSE +21 -0
- llm_waterfall/__init__.py +53 -0
- llm_waterfall/adapters/__init__.py +36 -0
- llm_waterfall/adapters/anthropic.py +105 -0
- llm_waterfall/adapters/aws_mantle.py +47 -0
- llm_waterfall/adapters/azure_openai.py +71 -0
- llm_waterfall/adapters/base.py +51 -0
- llm_waterfall/adapters/bedrock.py +309 -0
- llm_waterfall/adapters/openai.py +130 -0
- llm_waterfall/classify.py +184 -0
- llm_waterfall/pricing.py +110 -0
- llm_waterfall/py.typed +0 -0
- llm_waterfall/types.py +295 -0
- llm_waterfall/waterfall.py +255 -0
- wmo/__init__.py +38 -0
- wmo/agents/__init__.py +7 -0
- wmo/agents/default.py +29 -0
- wmo/agents/meta.py +55 -0
- wmo/agents/optimizer.py +55 -0
- wmo/agents/project.py +928 -0
- wmo/cli/__init__.py +5 -0
- wmo/cli/agent_session.py +1123 -0
- wmo/cli/app.py +2489 -0
- wmo/cli/e2b_cmds.py +212 -0
- wmo/cli/eval_closed_loop.py +207 -0
- wmo/cli/harness_app.py +1147 -0
- wmo/cli/harness_distill.py +659 -0
- wmo/cli/hosted_session.py +880 -0
- wmo/cli/ingest_cmd.py +165 -0
- wmo/cli/model_roles.py +82 -0
- wmo/cli/platform_cmds.py +372 -0
- wmo/cli/route_app.py +274 -0
- wmo/cli/session_state.py +243 -0
- wmo/cli/ui.py +1107 -0
- wmo/cli/workspace_sync.py +504 -0
- wmo/config/__init__.py +60 -0
- wmo/config/card.py +129 -0
- wmo/config/config.py +367 -0
- wmo/config/dotenv.py +67 -0
- wmo/config/settings.py +128 -0
- wmo/config/store.py +177 -0
- wmo/conftest.py +19 -0
- wmo/connect/__init__.py +88 -0
- wmo/connect/apps.py +78 -0
- wmo/connect/brave.py +284 -0
- wmo/connect/connector.py +79 -0
- wmo/connect/credentials.py +164 -0
- wmo/connect/github.py +321 -0
- wmo/connect/google.py +627 -0
- wmo/connect/notion.py +790 -0
- wmo/connect/oauth.py +461 -0
- wmo/connect/slack.py +555 -0
- wmo/connect/store.py +199 -0
- wmo/connect/types.py +156 -0
- wmo/core/__init__.py +21 -0
- wmo/core/parsing.py +281 -0
- wmo/core/render.py +271 -0
- wmo/core/text.py +40 -0
- wmo/core/types.py +116 -0
- wmo/distill/__init__.py +14 -0
- wmo/distill/agents.py +140 -0
- wmo/distill/config.py +1006 -0
- wmo/distill/cost.py +437 -0
- wmo/distill/data.py +921 -0
- wmo/distill/deadlines.py +254 -0
- wmo/distill/fake_tinker.py +734 -0
- wmo/distill/gate.py +122 -0
- wmo/distill/loop.py +3499 -0
- wmo/distill/renderers.py +399 -0
- wmo/distill/rendering.py +620 -0
- wmo/distill/rollouts.py +726 -0
- wmo/distill/samples.py +195 -0
- wmo/distill/store.py +829 -0
- wmo/distill/teacher.py +714 -0
- wmo/distill/tokens.py +535 -0
- wmo/distill/tracking.py +552 -0
- wmo/distill/tripwire.py +411 -0
- wmo/distill/xtoken/byte_offsets.py +152 -0
- wmo/distill/xtoken/chunks.py +457 -0
- wmo/distill/xtoken/prompt_logprobs.py +475 -0
- wmo/distill/xtoken/teacher_render.py +346 -0
- wmo/engine/__init__.py +28 -0
- wmo/engine/autoconfig.py +367 -0
- wmo/engine/build.py +346 -0
- wmo/engine/demo.py +77 -0
- wmo/engine/eval_suites.py +245 -0
- wmo/engine/grounding.py +491 -0
- wmo/engine/knowledge.py +291 -0
- wmo/engine/loader.py +36 -0
- wmo/engine/play.py +92 -0
- wmo/engine/prompts.py +99 -0
- wmo/engine/replay.py +443 -0
- wmo/engine/reporting.py +58 -0
- wmo/engine/workspace.py +468 -0
- wmo/engine/world_model.py +568 -0
- wmo/env/__init__.py +22 -0
- wmo/env/base.py +121 -0
- wmo/env/closed_loop.py +229 -0
- wmo/env/episode.py +107 -0
- wmo/env/llm_agent.py +93 -0
- wmo/env/scenarios.py +73 -0
- wmo/evals/__init__.py +52 -0
- wmo/evals/agreement.py +110 -0
- wmo/evals/base.py +45 -0
- wmo/evals/closed_loop.py +480 -0
- wmo/evals/failover.py +96 -0
- wmo/evals/gold.py +127 -0
- wmo/evals/grid.py +394 -0
- wmo/evals/grid_plot.py +205 -0
- wmo/evals/harbor/__init__.py +27 -0
- wmo/evals/harbor/agent.py +573 -0
- wmo/evals/harbor/ctrf.py +171 -0
- wmo/evals/harbor/e2b_environment.py +587 -0
- wmo/evals/harbor/e2b_template_policy.py +144 -0
- wmo/evals/harbor/scorer.py +875 -0
- wmo/evals/harbor/tasks.py +140 -0
- wmo/evals/open_loop.py +194 -0
- wmo/evals/tasks.py +53 -0
- wmo/harness/__init__.py +51 -0
- wmo/harness/code_runtime.py +288 -0
- wmo/harness/create.py +1191 -0
- wmo/harness/delta.py +220 -0
- wmo/harness/doc.py +556 -0
- wmo/harness/e2b_ledger.py +342 -0
- wmo/harness/e2b_reap.py +476 -0
- wmo/harness/e2b_sandbox.py +350 -0
- wmo/harness/environment.py +35 -0
- wmo/harness/live_session.py +543 -0
- wmo/harness/mutate.py +343 -0
- wmo/harness/pi_e2b.py +1710 -0
- wmo/harness/pi_entry/entry.ts +268 -0
- wmo/harness/pi_entry/runner_frames.ts +92 -0
- wmo/harness/pi_entry/runner_live.ts +587 -0
- wmo/harness/pi_entry/runner_service.ts +270 -0
- wmo/harness/pi_entry/runner_stdio.ts +374 -0
- wmo/harness/pi_entry/runner_termination.ts +142 -0
- wmo/harness/pi_local.py +262 -0
- wmo/harness/pi_runtime.py +495 -0
- wmo/harness/pi_vendor.py +65 -0
- wmo/harness/population.py +509 -0
- wmo/harness/project_proposer.py +569 -0
- wmo/harness/proposer.py +977 -0
- wmo/harness/runner_link.py +619 -0
- wmo/harness/runtime.py +389 -0
- wmo/harness/scoring.py +247 -0
- wmo/harness/skills.py +116 -0
- wmo/harness/source_tree.py +319 -0
- wmo/harness/store.py +176 -0
- wmo/harness/tools.py +105 -0
- wmo/harness/vendor/manifest.sha256 +58 -0
- wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
- wmo/harness/vendor/pi-agent/LICENSE +21 -0
- wmo/harness/vendor/pi-agent/README.md +488 -0
- wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
- wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
- wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
- wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
- wmo/harness/vendor/pi-agent/docs/models.md +966 -0
- wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
- wmo/harness/vendor/pi-agent/package.json +60 -0
- wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
- wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
- wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
- wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
- wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
- wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
- wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
- wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
- wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
- wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
- wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
- wmo/harness/vendor/pi-agent/src/index.ts +44 -0
- wmo/harness/vendor/pi-agent/src/node.ts +2 -0
- wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
- wmo/harness/vendor/pi-agent/src/types.ts +428 -0
- wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
- wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
- wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
- wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
- wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
- wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
- wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
- wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
- wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
- wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
- wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
- wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
- wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
- wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
- wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
- wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
- wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
- wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
- wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
- wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
- wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
- wmo/harness/vendor/vendor_pi.sh +59 -0
- wmo/harness/workspace_patch.py +270 -0
- wmo/ingest/__init__.py +47 -0
- wmo/ingest/adapter.py +72 -0
- wmo/ingest/base.py +114 -0
- wmo/ingest/braintrust.py +339 -0
- wmo/ingest/detect.py +126 -0
- wmo/ingest/langfuse.py +291 -0
- wmo/ingest/langsmith.py +444 -0
- wmo/ingest/mastra.py +330 -0
- wmo/ingest/messages.py +170 -0
- wmo/ingest/normalize.py +679 -0
- wmo/ingest/otel_genai.py +69 -0
- wmo/ingest/otel_writer.py +100 -0
- wmo/ingest/phoenix.py +150 -0
- wmo/ingest/postgres.py +246 -0
- wmo/ingest/posthog.py +320 -0
- wmo/ingest/quality.py +28 -0
- wmo/ingest/stream.py +209 -0
- wmo/ingest/testdata/sample_otlp.json +60 -0
- wmo/ingest/testdata/sample_spans.jsonl +3 -0
- wmo/optimize/__init__.py +25 -0
- wmo/optimize/base.py +143 -0
- wmo/optimize/gepa.py +806 -0
- wmo/optimize/judge.py +262 -0
- wmo/optimize/judge_quality.py +359 -0
- wmo/optimize/knn.py +468 -0
- wmo/optimize/numeric.py +152 -0
- wmo/optimize/outcomes.py +103 -0
- wmo/optimize/policy.py +669 -0
- wmo/optimize/report.py +231 -0
- wmo/optimize/reward.py +129 -0
- wmo/optimize/routing.py +373 -0
- wmo/platform/__init__.py +6 -0
- wmo/platform/auth.py +115 -0
- wmo/platform/client.py +551 -0
- wmo/platform/credentials.py +126 -0
- wmo/platform/transfer.py +158 -0
- wmo/providers/__init__.py +40 -0
- wmo/providers/_bedrock_chat.py +155 -0
- wmo/providers/_openai_common.py +182 -0
- wmo/providers/_responses_common.py +472 -0
- wmo/providers/anthropic.py +134 -0
- wmo/providers/azure_openai.py +296 -0
- wmo/providers/base.py +300 -0
- wmo/providers/bedrock.py +312 -0
- wmo/providers/models.py +205 -0
- wmo/providers/openai.py +143 -0
- wmo/providers/openai_responses.py +240 -0
- wmo/providers/pool.py +170 -0
- wmo/providers/registry.py +73 -0
- wmo/providers/retry.py +151 -0
- wmo/providers/tinker.py +936 -0
- wmo/providers/waterfall.py +336 -0
- wmo/research/__init__.py +81 -0
- wmo/research/ablation.py +133 -0
- wmo/research/concurrency_plot.py +523 -0
- wmo/research/concurrency_run.py +240 -0
- wmo/research/concurrency_scaling.py +270 -0
- wmo/research/gepa_scaling.py +274 -0
- wmo/research/pipeline.py +198 -0
- wmo/research/scaling_split.py +82 -0
- wmo/research/scenario_fidelity.py +198 -0
- wmo/research/scenario_recovery.py +92 -0
- wmo/research/seed_stability.py +90 -0
- wmo/research/trace_scaling.py +348 -0
- wmo/retrieval/__init__.py +6 -0
- wmo/retrieval/embedders.py +105 -0
- wmo/retrieval/leakfree.py +52 -0
- wmo/retrieval/retriever.py +173 -0
- wmo/scenarios/__init__.py +58 -0
- wmo/scenarios/builder.py +152 -0
- wmo/scenarios/mining/__init__.py +27 -0
- wmo/scenarios/mining/clustering.py +171 -0
- wmo/scenarios/mining/facets.py +226 -0
- wmo/scenarios/mining/selection.py +220 -0
- wmo/scenarios/synthesis/__init__.py +6 -0
- wmo/scenarios/synthesis/scenario_set.py +63 -0
- wmo/scenarios/synthesis/synthesizer.py +85 -0
- wmo/scenarios/verification/__init__.py +17 -0
- wmo/scenarios/verification/judge.py +97 -0
- wmo/scenarios/verification/verify.py +135 -0
- wmo/serving/__init__.py +5 -0
- wmo/serving/builds.py +451 -0
- wmo/serving/chat.py +878 -0
- wmo/serving/endpoint_config.py +64 -0
- wmo/serving/savings.py +250 -0
- wmo/serving/server.py +553 -0
- wmo/serving/traces_source.py +206 -0
- wmo/telemetry.py +213 -0
- wmo/tracking/__init__.py +36 -0
- wmo/tracking/clock.py +24 -0
- wmo/tracking/metered.py +125 -0
- wmo/tracking/pricing.py +99 -0
- wmo/tracking/store.py +31 -0
- wmo/tracking/tracker.py +149 -0
- world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
- world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
- world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
- 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()
|