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/engine/build.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""The build pipeline behind `wmo build`.
|
|
2
|
+
|
|
3
|
+
ingest -> normalize -> split(train/test) -> embed/index -> GEPA optimize -> write `.wmo/` artifact.
|
|
4
|
+
Ingestion is part of the build: there is no separate ingest step.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
|
|
13
|
+
from wmo.config import ArtifactPaths, HarnessConfig, save_config
|
|
14
|
+
from wmo.core.types import Trace
|
|
15
|
+
from wmo.engine.autoconfig import (
|
|
16
|
+
DEFAULT_VAL_CAP,
|
|
17
|
+
AutoFidelityReport,
|
|
18
|
+
CorpusSignature,
|
|
19
|
+
WinnerSpec,
|
|
20
|
+
search_max_fidelity,
|
|
21
|
+
select_candidates,
|
|
22
|
+
signature_estimate,
|
|
23
|
+
)
|
|
24
|
+
from wmo.engine.knowledge import KnowledgeBase, seed_knowledge
|
|
25
|
+
from wmo.engine.prompts import BASE_ENV_PROMPT
|
|
26
|
+
from wmo.engine.reporting import BuildReporter, NullReporter
|
|
27
|
+
from wmo.ingest import VendorPull, drop_degenerate_traces, get_adapter
|
|
28
|
+
from wmo.optimize import GEPAOptimizer, OptimizeResult, RubricJudge
|
|
29
|
+
from wmo.providers import get_provider
|
|
30
|
+
from wmo.providers.base import Embedder, Provider
|
|
31
|
+
from wmo.retrieval import EmbeddingRetriever, HashingEmbedder
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _count_steps(traces: list[Trace]) -> int:
|
|
35
|
+
return sum(len(trace.steps) for trace in traces)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ingest(
|
|
39
|
+
config: HarnessConfig, *, file: str | None = None, vendor: VendorPull | None = None
|
|
40
|
+
) -> list[Trace]:
|
|
41
|
+
"""Load + normalize traces from a file upload or a vendor SDK pull into `Trace` objects."""
|
|
42
|
+
adapter = get_adapter(config.trace_adapter)
|
|
43
|
+
if file is not None:
|
|
44
|
+
return adapter.from_file(file)
|
|
45
|
+
if vendor is not None:
|
|
46
|
+
return adapter.from_vendor(vendor)
|
|
47
|
+
raise ValueError("ingest needs either a file path or a vendor pull")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _trace_fraction(trace: Trace) -> float:
|
|
51
|
+
"""Stable hash of `trace_id` mapped to [0, 1). Order-independent, reproducible across runs."""
|
|
52
|
+
digest = hashlib.blake2b(trace.trace_id.encode("utf-8"), digest_size=8).digest()
|
|
53
|
+
return int.from_bytes(digest, "big") / 2**64
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def split_traces(traces: list[Trace], train_split: float) -> tuple[list[Trace], list[Trace]]:
|
|
57
|
+
"""Deterministic train/held-out split for GEPA (held-out is never seen during evolution).
|
|
58
|
+
|
|
59
|
+
Assignment is by a stable hash of `trace_id`, so the same corpus always splits the same way
|
|
60
|
+
regardless of order and rebuilds are reproducible. `train_split` is the target train fraction.
|
|
61
|
+
"""
|
|
62
|
+
train: list[Trace] = []
|
|
63
|
+
test: list[Trace] = []
|
|
64
|
+
for trace in traces:
|
|
65
|
+
(train if _trace_fraction(trace) < train_split else test).append(trace)
|
|
66
|
+
return train, test
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def split_traces_3way(
|
|
70
|
+
traces: list[Trace], train_frac: float, val_frac: float
|
|
71
|
+
) -> tuple[list[Trace], list[Trace], list[Trace]]:
|
|
72
|
+
"""Deterministic train / validation / test split by the same stable `trace_id` hash.
|
|
73
|
+
|
|
74
|
+
GEPA needs THREE disjoint sets, not two: `train` seeds reflection minibatches, `val` selects
|
|
75
|
+
among candidate prompts, and `test` is the truly-held-out set we report on — never seen by GEPA.
|
|
76
|
+
Using the val set as the reported held-out number (the old 2-way behaviour) lets GEPA select a
|
|
77
|
+
candidate on the very examples we then grade it on, so any "lift" can be selection overfitting.
|
|
78
|
+
|
|
79
|
+
Cut points are on the SAME [0,1) hash line as `split_traces`, so `train` is prefix-compatible:
|
|
80
|
+
`[0, train_frac)` = train, `[train_frac, train_frac+val_frac)` = val, rest = test.
|
|
81
|
+
Requires `train_frac + val_frac < 1` so test is non-empty.
|
|
82
|
+
"""
|
|
83
|
+
valid = train_frac > 0 and val_frac > 0 and train_frac + val_frac < 1
|
|
84
|
+
if not valid:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"need train_frac>0, val_frac>0, train_frac+val_frac<1; "
|
|
87
|
+
f"got train_frac={train_frac}, val_frac={val_frac}"
|
|
88
|
+
)
|
|
89
|
+
train: list[Trace] = []
|
|
90
|
+
val: list[Trace] = []
|
|
91
|
+
test: list[Trace] = []
|
|
92
|
+
val_cut = train_frac + val_frac
|
|
93
|
+
for trace in traces:
|
|
94
|
+
f = _trace_fraction(trace)
|
|
95
|
+
if f < train_frac:
|
|
96
|
+
train.append(trace)
|
|
97
|
+
elif f < val_cut:
|
|
98
|
+
val.append(trace)
|
|
99
|
+
else:
|
|
100
|
+
test.append(trace)
|
|
101
|
+
return train, val, test
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build(
|
|
105
|
+
config: HarnessConfig,
|
|
106
|
+
*,
|
|
107
|
+
file: str | None = None,
|
|
108
|
+
vendor: VendorPull | None = None,
|
|
109
|
+
root: str = ".wmo",
|
|
110
|
+
serve_provider: Provider | None = None,
|
|
111
|
+
judge_provider: Provider | None = None,
|
|
112
|
+
embedder: Embedder | None = None,
|
|
113
|
+
reporter: BuildReporter | None = None,
|
|
114
|
+
max_fidelity: bool = False,
|
|
115
|
+
fidelity_budget: int = DEFAULT_VAL_CAP,
|
|
116
|
+
full_search: bool = False,
|
|
117
|
+
cheap_search: bool = False,
|
|
118
|
+
estimate_only: bool = False,
|
|
119
|
+
drop_degenerate: bool = False,
|
|
120
|
+
gepa_val_cap: int | None = None,
|
|
121
|
+
) -> OptimizeResult:
|
|
122
|
+
"""Ingest traces and run the full build, creating + persisting the artifact under `root`.
|
|
123
|
+
|
|
124
|
+
`serve_provider` / `embedder` are injectable for testing; in production they are constructed
|
|
125
|
+
from `config` (serve provider via the registry, embedder = offline HashingEmbedder sized to
|
|
126
|
+
`config.embed_dim`). `judge_provider`, when given, runs the judge separately from the serve
|
|
127
|
+
provider: pass a pinned single backend when `serve_provider` is a failover chain, so GEPA's
|
|
128
|
+
fitness metric is scored by one model throughout even while rollouts fail over. `reporter`
|
|
129
|
+
receives progress events (defaults to a no-op). Returns the GEPA OptimizeResult (also
|
|
130
|
+
persisted).
|
|
131
|
+
|
|
132
|
+
`config.gepa_budget <= 0` skips prompt optimization entirely (the low fidelity tier: RAG
|
|
133
|
+
over the base prompt). `max_fidelity` runs the auto-configuration search after the build
|
|
134
|
+
(see `wmo.engine.autoconfig`): candidates — pruned by corpus signature unless
|
|
135
|
+
`full_search` — are replay-scored on `fidelity_budget` held-out traces with the prompt the
|
|
136
|
+
artifact will serve, and the result lands in `auto_fidelity.json`. The search never changes
|
|
137
|
+
the serve DEFAULTS (plain RAG unless flags were set explicitly): the winner activates at
|
|
138
|
+
runtime via `--max-fidelity`.
|
|
139
|
+
"""
|
|
140
|
+
report = reporter or NullReporter()
|
|
141
|
+
paths = ArtifactPaths(root)
|
|
142
|
+
traces = ingest(config, file=file, vendor=vendor)
|
|
143
|
+
if drop_degenerate:
|
|
144
|
+
# Some captures are polluted with all-empty-observation traces (swe-bench is 66% such
|
|
145
|
+
# junk, D24); building on them trains and measures the model on capture damage. Reuses
|
|
146
|
+
# `wmo.ingest.drop_degenerate_traces` — the same filter the research runners
|
|
147
|
+
# (run_trace_scaling / run_fidelity_tiers, via their own --drop-degenerate) apply.
|
|
148
|
+
traces, dropped = drop_degenerate_traces(traces)
|
|
149
|
+
report.activity(f"dropped {dropped} degenerate (all-empty-observation) traces")
|
|
150
|
+
if not traces:
|
|
151
|
+
raise ValueError("no traces ingested; nothing to build")
|
|
152
|
+
report.ingest_done(len(traces), _count_steps(traces))
|
|
153
|
+
|
|
154
|
+
# Three disjoint sets: train seeds GEPA's reflection minibatches, val (capped) selects
|
|
155
|
+
# among candidate prompts, and test is never seen by GEPA — `wmo eval` grades on it without
|
|
156
|
+
# selection overfitting. The remainder after train splits evenly into val/test.
|
|
157
|
+
train, val, test = split_traces_3way(traces, config.train_split, (1.0 - config.train_split) / 2)
|
|
158
|
+
report.split_done(len(train), len(val), len(test))
|
|
159
|
+
|
|
160
|
+
provider = serve_provider or get_provider(config.serve_provider_config())
|
|
161
|
+
# The GEPA judge can run on a cheaper model (config.judge_model) of the same provider kind;
|
|
162
|
+
# with no judge_model it shares the serve provider.
|
|
163
|
+
if judge_provider is None:
|
|
164
|
+
serve_cfg = config.serve_provider_config()
|
|
165
|
+
if config.judge_model and config.judge_model != serve_cfg.model:
|
|
166
|
+
judge_provider = get_provider(
|
|
167
|
+
serve_cfg.model_copy(update={"model": config.judge_model})
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
judge_provider = provider
|
|
171
|
+
embed = embedder or HashingEmbedder(dim=config.embed_dim)
|
|
172
|
+
|
|
173
|
+
# Optional knowledge base: extract canonical env facts (rules/gates, entities, schemas) from
|
|
174
|
+
# the TRAIN split only — the same leak-free discipline as retrieval — into human-editable
|
|
175
|
+
# markdown under knowledge/. Falls back to the full corpus only when the corpus is too small
|
|
176
|
+
# to split (mirrors the `test or train` GEPA fallback below).
|
|
177
|
+
# Known simplification: GEPA below still evolves the prompt under the BASE output contract,
|
|
178
|
+
# even when this artifact will serve with knowledge/reasoning. That composes — the evolved
|
|
179
|
+
# SYSTEM prompt never encodes the output contract (it is appended per-completion by
|
|
180
|
+
# build_env_prompt) — but the optimizer is not yet selecting under agentic-mode conditions.
|
|
181
|
+
if config.knowledge:
|
|
182
|
+
seed_knowledge(KnowledgeBase(paths.knowledge), train or traces, provider)
|
|
183
|
+
|
|
184
|
+
# Serving index over the full corpus: at serve time we retrieve from everything we have seen.
|
|
185
|
+
retriever = EmbeddingRetriever(embed)
|
|
186
|
+
retriever.index(traces)
|
|
187
|
+
report.index_done(_count_steps(traces))
|
|
188
|
+
|
|
189
|
+
# GEPA evolves the env prompt under serving conditions: it retrieves demos the same way the
|
|
190
|
+
# world model will, but from a SEPARATE retriever it re-indexes over train-only (so held-out
|
|
191
|
+
# steps never retrieve themselves). The embedder is stateless, so it's safe to share.
|
|
192
|
+
# The progress total is GEPA's REAL translated metric-call budget (reported via on_budget),
|
|
193
|
+
# not the iteration count — sizing the bar with iterations made it hit 100% while thousands
|
|
194
|
+
# of valset calls were still running.
|
|
195
|
+
metric_total = {"calls": config.gepa_budget}
|
|
196
|
+
|
|
197
|
+
def _on_budget(total: int) -> None:
|
|
198
|
+
metric_total["calls"] = total
|
|
199
|
+
report.optimize_start(total)
|
|
200
|
+
|
|
201
|
+
def _on_rollout(done: int, score: float | None) -> None:
|
|
202
|
+
report.rollout(done, metric_total["calls"], score)
|
|
203
|
+
|
|
204
|
+
if config.gepa_budget <= 0:
|
|
205
|
+
# Low fidelity tier: no prompt optimization — the artifact serves the base prompt with
|
|
206
|
+
# RAG. OptimizeResult's zeroed metrics honestly say "nothing was optimized".
|
|
207
|
+
result = OptimizeResult(prompt=BASE_ENV_PROMPT)
|
|
208
|
+
else:
|
|
209
|
+
# Optimize against the SAME rubric we evaluate with (RubricJudge). NOTE: the judge MODEL
|
|
210
|
+
# may differ — config.judge_model defaults to a cheap per-provider model for GEPA cost,
|
|
211
|
+
# while `wmo eval` pins the judge to the requested serve-grade model — so
|
|
212
|
+
# held_out_accuracy is only directly comparable to eval fidelity when --judge-model
|
|
213
|
+
# matches the eval judge.
|
|
214
|
+
optimizer = GEPAOptimizer(
|
|
215
|
+
provider,
|
|
216
|
+
RubricJudge(judge_provider),
|
|
217
|
+
retriever=EmbeddingRetriever(embed),
|
|
218
|
+
on_rollout=_on_rollout,
|
|
219
|
+
on_budget=_on_budget,
|
|
220
|
+
on_activity=report.activity,
|
|
221
|
+
)
|
|
222
|
+
# Candidate selection only needs a stable, SMALL sample: every GEPA iteration re-scores
|
|
223
|
+
# the whole valset, and steps (not traces) are what bound cost — a long-trace corpus once
|
|
224
|
+
# turned a 4-iteration tier into $131. The default ceiling applies always; a fidelity
|
|
225
|
+
# tier may widen it (`gepa_val_cap`, in steps).
|
|
226
|
+
gepa_val = _cap_gepa_valset(val or train, gepa_val_cap or _GEPA_VAL_STEP_CAP)
|
|
227
|
+
result = optimizer.optimize(train, gepa_val, BASE_ENV_PROMPT, config.gepa_budget)
|
|
228
|
+
# A GEPA candidate can be empty - a weak reflection LM (e.g. a self-reflecting open model)
|
|
229
|
+
# sometimes proposes a blank env prompt that still scores acceptably on easy steps and
|
|
230
|
+
# gets selected. An empty env prompt is never a valid artifact, so fall back to base.
|
|
231
|
+
if not result.prompt.strip():
|
|
232
|
+
result = OptimizeResult(
|
|
233
|
+
prompt=BASE_ENV_PROMPT,
|
|
234
|
+
frontier=result.frontier or [BASE_ENV_PROMPT],
|
|
235
|
+
metrics=result.metrics,
|
|
236
|
+
)
|
|
237
|
+
report.optimize_done(
|
|
238
|
+
result.metrics.held_out_accuracy, len(result.frontier), result.metrics.rollouts_used
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if estimate_only:
|
|
242
|
+
# Low tier: ship the signature's strongest ESTIMATED config with no LLM search — the
|
|
243
|
+
# ladder's floor. Persisted as the winner so `--max-fidelity` serves it and so the
|
|
244
|
+
# higher tiers (rebuilt separately) seed the identical incumbent from the same signature.
|
|
245
|
+
signature = CorpusSignature.from_traces(train or traces)
|
|
246
|
+
estimate = signature_estimate(signature, has_pins=False)
|
|
247
|
+
if estimate.knowledge and not paths.knowledge.is_dir():
|
|
248
|
+
seed_knowledge(KnowledgeBase(paths.knowledge), train or traces, provider)
|
|
249
|
+
paths.root.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
paths.auto_fidelity.write_text(
|
|
251
|
+
AutoFidelityReport(
|
|
252
|
+
winner_label=estimate.label,
|
|
253
|
+
considered=[estimate.label],
|
|
254
|
+
winner_spec=WinnerSpec.from_candidate(estimate),
|
|
255
|
+
).model_dump_json(indent=2),
|
|
256
|
+
encoding="utf-8",
|
|
257
|
+
)
|
|
258
|
+
elif max_fidelity:
|
|
259
|
+
# Score the candidate configs on the held-out split with the prompt the artifact will
|
|
260
|
+
# serve (leak-free: demos + candidate KB from train only; `test or train` mirrors GEPA's
|
|
261
|
+
# tiny-corpus fallback). The result is a RUNTIME menu, not a serve default: the report is
|
|
262
|
+
# persisted (plus the KB when the winner needs it) and `--max-fidelity` activates the
|
|
263
|
+
# winner when the model is run — plain runs stay pure RAG.
|
|
264
|
+
# The build's search considers only candidates the RUNTIME can activate — no
|
|
265
|
+
# source_pins here: the workspace candidate is research-measurable (the tiers runner
|
|
266
|
+
# passes pins), but serve-side activation doesn't exist yet, and a persisted winner
|
|
267
|
+
# the runtime can't serve would make auto_fidelity.json a lie.
|
|
268
|
+
# Seed the KB into the ARTIFACT before the search and hand its rendered text in, so a
|
|
269
|
+
# knowledge winner's score was measured on the exact KB that serves (and the extraction
|
|
270
|
+
# runs once, not once ephemeral + once to persist). If no knowledge candidate wins, the
|
|
271
|
+
# seeded dir is removed again — a plain artifact stays knowledge-free.
|
|
272
|
+
kb_seeded_for_search = False
|
|
273
|
+
signature = CorpusSignature.from_traces(train or traces)
|
|
274
|
+
# The floor: the same estimate the `low` tier ships. Every searching tier carries it as
|
|
275
|
+
# its incumbent, so medium/high/max improve-or-hold on low, never regress (monotonic).
|
|
276
|
+
incumbent = signature_estimate(signature, has_pins=False)
|
|
277
|
+
menu = select_candidates(signature, full_ladder=full_search, cheap_only=cheap_search)
|
|
278
|
+
if (any(c.knowledge for c in menu) or incumbent.knowledge) and not paths.knowledge.is_dir():
|
|
279
|
+
seed_knowledge(KnowledgeBase(paths.knowledge), train or traces, provider)
|
|
280
|
+
kb_seeded_for_search = True
|
|
281
|
+
kb = KnowledgeBase(paths.knowledge)
|
|
282
|
+
auto = search_max_fidelity(
|
|
283
|
+
result.prompt,
|
|
284
|
+
train or traces,
|
|
285
|
+
test or train,
|
|
286
|
+
provider,
|
|
287
|
+
RubricJudge(judge_provider),
|
|
288
|
+
embed,
|
|
289
|
+
val_cap=fidelity_budget,
|
|
290
|
+
# Pass the already-computed menu so the search doesn't recompute the identical
|
|
291
|
+
# signature+select_candidates (and so the KB-seed decision above and the scored set
|
|
292
|
+
# can't drift from separate calls).
|
|
293
|
+
candidates=menu,
|
|
294
|
+
incumbent=incumbent,
|
|
295
|
+
# Whatever the artifact's KB renders (even empty) is what candidates measure —
|
|
296
|
+
# passing None here would let the search re-extract a DIFFERENT ephemeral KB
|
|
297
|
+
# and crown a winner the shipped artifact cannot reproduce.
|
|
298
|
+
knowledge_text=kb.render() if paths.knowledge.is_dir() else None,
|
|
299
|
+
)
|
|
300
|
+
if kb_seeded_for_search and not auto.winner.knowledge and not config.knowledge:
|
|
301
|
+
# The search created the KB only to measure the kb candidate; a non-knowledge
|
|
302
|
+
# winner means the artifact should stay knowledge-free (a knowledge/ dir is a
|
|
303
|
+
# user-visible surface).
|
|
304
|
+
shutil.rmtree(paths.knowledge, ignore_errors=True)
|
|
305
|
+
paths.root.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
paths.auto_fidelity.write_text(auto.model_dump_json(indent=2), encoding="utf-8")
|
|
307
|
+
|
|
308
|
+
_persist(paths, config, retriever, result)
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# Ceiling on GEPA's candidate-selection valset, in steps (~one full-val pass per iteration).
|
|
313
|
+
# Small on purpose: selection only needs a stable ranking signal, and fidelity saturates fast.
|
|
314
|
+
_GEPA_VAL_STEP_CAP = 16
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _cap_gepa_valset(traces: list[Trace], max_steps: int = _GEPA_VAL_STEP_CAP) -> list[Trace]:
|
|
318
|
+
"""A prefix of `traces` totalling at most `max_steps` steps (always at least one trace).
|
|
319
|
+
|
|
320
|
+
`split_traces` already shuffles deterministically, so the prefix is an unbiased, stable
|
|
321
|
+
sample of the held-out split.
|
|
322
|
+
"""
|
|
323
|
+
capped: list[Trace] = []
|
|
324
|
+
steps = 0
|
|
325
|
+
for trace in traces:
|
|
326
|
+
if capped and steps + len(trace.steps) > max_steps:
|
|
327
|
+
break
|
|
328
|
+
capped.append(trace)
|
|
329
|
+
steps += len(trace.steps)
|
|
330
|
+
return capped
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _persist(
|
|
334
|
+
paths: ArtifactPaths,
|
|
335
|
+
config: HarnessConfig,
|
|
336
|
+
retriever: EmbeddingRetriever,
|
|
337
|
+
result: OptimizeResult,
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Write config, prompts, frontier, metrics, and the retrieval index under `.wmo/`."""
|
|
340
|
+
save_config(config, paths.root)
|
|
341
|
+
paths.base_prompt.parent.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
paths.base_prompt.write_text(BASE_ENV_PROMPT, encoding="utf-8")
|
|
343
|
+
paths.optimized_prompt.write_text(result.prompt, encoding="utf-8")
|
|
344
|
+
paths.frontier.write_text(json.dumps(result.frontier, indent=2), encoding="utf-8")
|
|
345
|
+
paths.metrics.write_text(result.metrics.model_dump_json(indent=2), encoding="utf-8")
|
|
346
|
+
retriever.save(paths.index)
|
wmo/engine/demo.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""`wmo demo`: replay a real recorded scenario against the world model, open loop.
|
|
2
|
+
|
|
3
|
+
A randomly sampled trace supplies the task and the agent's recorded actions; the world model
|
|
4
|
+
predicts each observation while the session is teacher-forced with the recorded one
|
|
5
|
+
(`step_open_loop`), so every prediction is conditioned on the real trajectory. The result shows
|
|
6
|
+
predicted vs. actual side by side — the harness working end-to-end without needing a live agent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from wmo.core.types import Action, Observation, Trace
|
|
16
|
+
from wmo.engine.world_model import WorldModel
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DemoStep(BaseModel):
|
|
20
|
+
"""One replayed step: the recorded action with the predicted and recorded observations."""
|
|
21
|
+
|
|
22
|
+
action: Action
|
|
23
|
+
predicted: Observation
|
|
24
|
+
actual: Observation
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def exact_match(self) -> bool:
|
|
28
|
+
return self.predicted.content.strip() == self.actual.content.strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DemoReplay(BaseModel):
|
|
32
|
+
"""The rendered scenario replay for `wmo demo`."""
|
|
33
|
+
|
|
34
|
+
trace_id: str
|
|
35
|
+
task: str | None
|
|
36
|
+
steps: list[DemoStep]
|
|
37
|
+
first_env_prompt: str # the exact prompt the world model saw for the first step
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_demo(
|
|
41
|
+
world_model: WorldModel,
|
|
42
|
+
trace: Trace,
|
|
43
|
+
max_steps: int = 5,
|
|
44
|
+
on_step: Callable[[int, int], None] | None = None,
|
|
45
|
+
on_result: Callable[[int, int, DemoStep], None] | None = None,
|
|
46
|
+
skip: int = 0,
|
|
47
|
+
) -> DemoReplay:
|
|
48
|
+
"""Replay up to `max_steps` of `trace` open-loop and collect predicted vs. actual.
|
|
49
|
+
|
|
50
|
+
`on_step(i, total)` fires before each prediction and `on_result(i, total, step)` right
|
|
51
|
+
after it, so callers can stream the interaction as it happens. `skip` resumes a replay
|
|
52
|
+
mid-scenario (e.g. after a provider switch): the skipped prefix seeds the session from the
|
|
53
|
+
recorded trajectory without any predictions, and the returned steps cover only the rest.
|
|
54
|
+
"""
|
|
55
|
+
if not trace.steps:
|
|
56
|
+
raise ValueError(f"trace {trace.trace_id!r} has no steps to replay")
|
|
57
|
+
task = trace.steps[0].task
|
|
58
|
+
session = world_model.new_session(task=task)
|
|
59
|
+
replayed = trace.steps[:max_steps]
|
|
60
|
+
if skip:
|
|
61
|
+
world_model.seed_session(session.id, replayed[:skip])
|
|
62
|
+
first_env_prompt = world_model.render_step_prompt(
|
|
63
|
+
session.id, replayed[min(skip, len(replayed) - 1)].action
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
steps: list[DemoStep] = []
|
|
67
|
+
for i, step in enumerate(replayed[skip:], start=skip + 1):
|
|
68
|
+
if on_step is not None:
|
|
69
|
+
on_step(i, len(replayed))
|
|
70
|
+
predicted = world_model.step_open_loop(session.id, step.action, step.observation)
|
|
71
|
+
result = DemoStep(action=step.action, predicted=predicted, actual=step.observation)
|
|
72
|
+
steps.append(result)
|
|
73
|
+
if on_result is not None:
|
|
74
|
+
on_result(i, len(replayed), result)
|
|
75
|
+
return DemoReplay(
|
|
76
|
+
trace_id=trace.trace_id, task=task, steps=steps, first_env_prompt=first_env_prompt
|
|
77
|
+
)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Named eval suites for repeatable reconstruction-fidelity runs.
|
|
2
|
+
|
|
3
|
+
Suites live next to examples (`examples/<task>/evals/*.toml`) and point at trace files relative to
|
|
4
|
+
the suite file. Generated run results are local artifacts, normally written under `.wmo/evals/`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import tomllib
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
SampleTurns = Literal["all", "sampled"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EvalSuiteConfig(BaseModel):
|
|
25
|
+
"""A TOML-backed eval suite definition."""
|
|
26
|
+
|
|
27
|
+
model_config = ConfigDict(extra="forbid")
|
|
28
|
+
|
|
29
|
+
title: str | None = None
|
|
30
|
+
description: str | None = None
|
|
31
|
+
files: list[str] = Field(default_factory=lambda: ["../traces.otel.jsonl"])
|
|
32
|
+
prompt: str | None = None
|
|
33
|
+
train_split: float = Field(default=0.7, gt=0.0, lt=1.0)
|
|
34
|
+
top_k: int = Field(default=5, ge=0)
|
|
35
|
+
sample_turns: SampleTurns = "all"
|
|
36
|
+
seed: int = 0
|
|
37
|
+
no_rag: bool = False
|
|
38
|
+
embed_dim: int = Field(default=512, gt=0)
|
|
39
|
+
# Agentic mode (default off): seed a train-split knowledge base into every prediction, and/or
|
|
40
|
+
# request the deliberate-then-answer output contract. See `wmo.engine.knowledge`.
|
|
41
|
+
knowledge: bool = False
|
|
42
|
+
reasoning: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class EvalSuite:
|
|
47
|
+
"""A discovered suite plus its parsed config."""
|
|
48
|
+
|
|
49
|
+
id: str
|
|
50
|
+
example: str
|
|
51
|
+
name: str
|
|
52
|
+
path: Path
|
|
53
|
+
config: EvalSuiteConfig
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def aliases(self) -> tuple[str, ...]:
|
|
57
|
+
if self.name == "default":
|
|
58
|
+
return (self.id, self.example)
|
|
59
|
+
return (self.id,)
|
|
60
|
+
|
|
61
|
+
def resolve_files(self) -> list[Path]:
|
|
62
|
+
return [_resolve_relative(self.path.parent, value) for value in self.config.files]
|
|
63
|
+
|
|
64
|
+
def resolve_prompt(self) -> Path | None:
|
|
65
|
+
if self.config.prompt is None:
|
|
66
|
+
return None
|
|
67
|
+
return _resolve_relative(self.path.parent, self.config.prompt)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class EvalResultSummary:
|
|
72
|
+
"""One persisted eval result, for `wmo eval results`."""
|
|
73
|
+
|
|
74
|
+
path: Path
|
|
75
|
+
suite: str
|
|
76
|
+
run_id: str
|
|
77
|
+
started_at: str
|
|
78
|
+
provider: str
|
|
79
|
+
model: str
|
|
80
|
+
overall_fidelity: float
|
|
81
|
+
overall_std: float
|
|
82
|
+
total_steps: int # all steps attempted, including judge-invalid ones
|
|
83
|
+
total_invalid: int # judge failures, excluded from overall_fidelity (0 for old results)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def discover_eval_suites(examples_root: str | Path | Iterable[str | Path]) -> list[EvalSuite]:
|
|
87
|
+
"""Find every example-local suite under `<root>/*/evals/*.toml` across one or more roots.
|
|
88
|
+
|
|
89
|
+
A malformed suite file is skipped with a warning naming the file and the parse error —
|
|
90
|
+
one broken benchmark dir must not take down listing/resolution for every other suite.
|
|
91
|
+
Loading the broken suite directly (by path) still raises the full error.
|
|
92
|
+
"""
|
|
93
|
+
suites: list[EvalSuite] = []
|
|
94
|
+
for root in _as_roots(examples_root):
|
|
95
|
+
if not root.exists():
|
|
96
|
+
continue
|
|
97
|
+
for path in sorted(root.glob("*/evals/*.toml")):
|
|
98
|
+
try:
|
|
99
|
+
suites.append(load_eval_suite(path))
|
|
100
|
+
except ValueError as exc:
|
|
101
|
+
logger.warning("skipping eval suite %s: %s", path, exc)
|
|
102
|
+
return suites
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _as_roots(examples_root: str | Path | Iterable[str | Path]) -> list[Path]:
|
|
106
|
+
if isinstance(examples_root, (str, Path)):
|
|
107
|
+
return [Path(examples_root)]
|
|
108
|
+
return [Path(root) for root in examples_root]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_eval_suite(path: str | Path) -> EvalSuite:
|
|
112
|
+
"""Read and validate one eval suite TOML file."""
|
|
113
|
+
suite_path = Path(path)
|
|
114
|
+
try:
|
|
115
|
+
with suite_path.open("rb") as fh:
|
|
116
|
+
raw = tomllib.load(fh)
|
|
117
|
+
except tomllib.TOMLDecodeError as exc:
|
|
118
|
+
raise ValueError(f"{suite_path} is not valid TOML ({exc})") from exc
|
|
119
|
+
try:
|
|
120
|
+
config = EvalSuiteConfig.model_validate(raw)
|
|
121
|
+
except ValidationError as exc:
|
|
122
|
+
has_judge = isinstance(raw, dict) and "judge" in raw
|
|
123
|
+
if has_judge and exc.error_count() == 1:
|
|
124
|
+
# Pre-overhaul suites (and the old shipped defaults) carried this knob; the generic
|
|
125
|
+
# schema error would never say it was removed or what to do. Only replace the error
|
|
126
|
+
# when `judge` is the sole problem — otherwise the full listing must surface so the
|
|
127
|
+
# user fixes everything in one pass.
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"{suite_path} sets `judge`, an option that no longer exists — the harness has a "
|
|
130
|
+
"single judge (the 5-dimension rubric); delete the `judge` line from the suite file"
|
|
131
|
+
) from exc
|
|
132
|
+
hint = (
|
|
133
|
+
" (note: the `judge` option no longer exists; delete that line too)"
|
|
134
|
+
if has_judge
|
|
135
|
+
else ""
|
|
136
|
+
)
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"{suite_path} does not match the eval suite schema ({exc}){hint}"
|
|
139
|
+
) from exc
|
|
140
|
+
example = (
|
|
141
|
+
suite_path.parent.parent.name
|
|
142
|
+
if suite_path.parent.name == "evals"
|
|
143
|
+
else suite_path.parent.name
|
|
144
|
+
)
|
|
145
|
+
name = suite_path.stem
|
|
146
|
+
return EvalSuite(
|
|
147
|
+
id=f"{example}/{name}",
|
|
148
|
+
example=example,
|
|
149
|
+
name=name,
|
|
150
|
+
path=suite_path,
|
|
151
|
+
config=config,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def resolve_eval_suite(
|
|
156
|
+
selector: str, examples_root: str | Path | Iterable[str | Path]
|
|
157
|
+
) -> EvalSuite:
|
|
158
|
+
"""Resolve `selector` as `example/suite`, `example` for default, or a direct TOML path."""
|
|
159
|
+
direct = Path(selector)
|
|
160
|
+
if direct.suffix == ".toml" and direct.exists():
|
|
161
|
+
return load_eval_suite(direct)
|
|
162
|
+
|
|
163
|
+
suites = discover_eval_suites(examples_root)
|
|
164
|
+
exact = [suite for suite in suites if suite.id == selector]
|
|
165
|
+
if len(exact) > 1:
|
|
166
|
+
paths = ", ".join(str(suite.path) for suite in exact)
|
|
167
|
+
raise ValueError(f"eval suite {selector!r} exists in multiple roots: {paths}")
|
|
168
|
+
if exact:
|
|
169
|
+
return exact[0]
|
|
170
|
+
aliased = [suite for suite in suites if selector in suite.aliases]
|
|
171
|
+
if len(aliased) == 1:
|
|
172
|
+
return aliased[0]
|
|
173
|
+
if len(aliased) > 1:
|
|
174
|
+
choices = ", ".join(suite.id for suite in aliased)
|
|
175
|
+
raise ValueError(f"ambiguous eval suite {selector!r}; choose one of: {choices}")
|
|
176
|
+
available = ", ".join(suite.id for suite in suites)
|
|
177
|
+
hint = f" (available: {available})" if available else ""
|
|
178
|
+
raise ValueError(f"unknown eval suite {selector!r}{hint}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def result_path(results_root: str | Path, suite: EvalSuite, run_id: str) -> Path:
|
|
182
|
+
"""Default JSON output path for one suite run."""
|
|
183
|
+
return Path(results_root) / suite.example / suite.name / f"{run_id}.json"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def list_eval_results(
|
|
187
|
+
results_root: str | Path, suite: str | None = None, *, limit: int = 20
|
|
188
|
+
) -> list[EvalResultSummary]:
|
|
189
|
+
"""Read persisted eval result summaries, newest first."""
|
|
190
|
+
root = Path(results_root)
|
|
191
|
+
if not root.exists():
|
|
192
|
+
return []
|
|
193
|
+
summaries: list[EvalResultSummary] = []
|
|
194
|
+
for path in sorted(root.glob("**/*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
195
|
+
summary = _read_result_summary(path)
|
|
196
|
+
if summary is None:
|
|
197
|
+
continue
|
|
198
|
+
if suite is not None and summary.suite != suite:
|
|
199
|
+
continue
|
|
200
|
+
summaries.append(summary)
|
|
201
|
+
if len(summaries) >= limit:
|
|
202
|
+
break
|
|
203
|
+
return summaries
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _resolve_relative(base: Path, value: str) -> Path:
|
|
207
|
+
path = Path(value)
|
|
208
|
+
return path.resolve(strict=False) if path.is_absolute() else (base / path).resolve(strict=False)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _read_result_summary(path: Path) -> EvalResultSummary | None:
|
|
212
|
+
try:
|
|
213
|
+
raw: JsonValue = json.loads(path.read_text(encoding="utf-8"))
|
|
214
|
+
except (OSError, json.JSONDecodeError):
|
|
215
|
+
return None
|
|
216
|
+
if not isinstance(raw, dict):
|
|
217
|
+
return None
|
|
218
|
+
report = raw.get("report")
|
|
219
|
+
config = raw.get("config")
|
|
220
|
+
if not isinstance(report, dict) or not isinstance(config, dict):
|
|
221
|
+
return None
|
|
222
|
+
return EvalResultSummary(
|
|
223
|
+
path=path,
|
|
224
|
+
suite=_as_str(raw.get("suite"), default="unknown"),
|
|
225
|
+
run_id=_as_str(raw.get("run_id"), default=path.stem),
|
|
226
|
+
started_at=_as_str(raw.get("started_at"), default="unknown"),
|
|
227
|
+
provider=_as_str(config.get("provider"), default="unknown"),
|
|
228
|
+
model=_as_str(config.get("model"), default="unknown"),
|
|
229
|
+
overall_fidelity=_as_float(report.get("overall_fidelity")),
|
|
230
|
+
overall_std=_as_float(report.get("overall_std")),
|
|
231
|
+
total_steps=_as_int(report.get("total_steps")),
|
|
232
|
+
total_invalid=_as_int(report.get("total_invalid")),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _as_str(value: JsonValue | None, *, default: str) -> str:
|
|
237
|
+
return value if isinstance(value, str) else default
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _as_float(value: JsonValue | None) -> float:
|
|
241
|
+
return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else 0.0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _as_int(value: JsonValue | None) -> int:
|
|
245
|
+
return int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else 0
|