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/evals/failover.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Same-model failover for the eval grid (never silently switches models).
|
|
2
|
+
|
|
3
|
+
Main's failover architecture (`wmo.providers.provider_or_chain` + `.wmo/fallback.toml`) rides a
|
|
4
|
+
config-driven chain for *world-model* calls and keeps the *judge* pinned. The grid needs one thing
|
|
5
|
+
that config can't express: a **programmatic, per-cell, same-model** chain. Its judge is a single
|
|
6
|
+
pinned model and each cell's target is a distinct model, so there is no static named chain to point
|
|
7
|
+
at - yet Bedrock Anthropic models throttle hard, and the direct Anthropic API is the *identical*
|
|
8
|
+
model on an un-throttled endpoint. Failing over Bedrock -> direct-Anthropic (same model), or across
|
|
9
|
+
Bedrock regions (same model), keeps what's measured unchanged, so it honours main's
|
|
10
|
+
never-silently-switch-models invariant while surviving capacity pressure over an 80-cell grid.
|
|
11
|
+
|
|
12
|
+
This is deliberately NOT in `wmo.providers`: main removed the programmatic `FallbackProvider` in
|
|
13
|
+
favour of `.wmo/fallback.toml`, and this seam exists only because the grid composes pre-built
|
|
14
|
+
providers (metered/capped wrappers, injected fakes in tests) into same-model chains. Capacity
|
|
15
|
+
classification is reused from the shared llm-waterfall package (`is_capacity_error`), not
|
|
16
|
+
re-implemented here.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
from llm_waterfall import is_capacity_error
|
|
24
|
+
|
|
25
|
+
from wmo.providers.base import Completion, Message, Provider, ProviderConfig
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def anthropic_direct_id(bedrock_model: str) -> str | None:
|
|
29
|
+
"""The direct-Anthropic-API model id for a Bedrock Anthropic model id, or None otherwise.
|
|
30
|
+
|
|
31
|
+
Bedrock Anthropic models are heavily capacity-constrained; the direct Anthropic API is the SAME
|
|
32
|
+
model on a different endpoint (and a key that isn't Bedrock-rate-limited), so failing over to it
|
|
33
|
+
keeps what's measured identical. Strips the `us.anthropic.`/`anthropic.` prefix and any
|
|
34
|
+
dated/versioned tail: `us.anthropic.claude-opus-4-8` -> `claude-opus-4-8`;
|
|
35
|
+
`us.anthropic.claude-haiku-4-5-20251001-v1:0` -> `claude-haiku-4-5`.
|
|
36
|
+
"""
|
|
37
|
+
for prefix in ("us.anthropic.", "anthropic."):
|
|
38
|
+
if bedrock_model.startswith(prefix):
|
|
39
|
+
m = bedrock_model[len(prefix) :]
|
|
40
|
+
m = re.sub(r"-\d{8}.*$", "", m) # drop a -YYYYMMDD... date+version tail
|
|
41
|
+
m = re.sub(r"-v\d+(:\d+)?$", "", m) # drop a -v1 / -v1:0 tail
|
|
42
|
+
return m
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SameModelFailover:
|
|
47
|
+
"""Try a chain of pre-built providers in order; fail over only on capacity errors.
|
|
48
|
+
|
|
49
|
+
Every rung MUST serve the same underlying model (a Bedrock model, its direct-Anthropic twin, or
|
|
50
|
+
the same model in another region) - the chain spreads throttling load without changing what is
|
|
51
|
+
measured. Capacity errors (throttling, transient 5xx, timeouts - see llm-waterfall's
|
|
52
|
+
`is_capacity_error`) spill to the next rung; a real error (bad request, auth) propagates
|
|
53
|
+
immediately, and the last rung's error surfaces when the whole chain is capacity-constrained.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, chain: list[Provider]) -> None:
|
|
57
|
+
if not chain:
|
|
58
|
+
raise ValueError("SameModelFailover needs at least one provider")
|
|
59
|
+
self._chain = chain
|
|
60
|
+
self.config: ProviderConfig = chain[0].config
|
|
61
|
+
|
|
62
|
+
def complete(
|
|
63
|
+
self,
|
|
64
|
+
system: str,
|
|
65
|
+
messages: list[Message],
|
|
66
|
+
*,
|
|
67
|
+
temperature: float = 0.7,
|
|
68
|
+
max_tokens: int = 8192,
|
|
69
|
+
) -> Completion:
|
|
70
|
+
last: Exception | None = None
|
|
71
|
+
for provider in self._chain:
|
|
72
|
+
try:
|
|
73
|
+
return provider.complete(
|
|
74
|
+
system, messages, temperature=temperature, max_tokens=max_tokens
|
|
75
|
+
)
|
|
76
|
+
except Exception as exc: # noqa: BLE001 - classify, then re-raise or fall over
|
|
77
|
+
if not is_capacity_error(exc):
|
|
78
|
+
raise
|
|
79
|
+
last = exc
|
|
80
|
+
assert last is not None # noqa: S101 - the loop ran at least once (chain is non-empty)
|
|
81
|
+
raise last
|
|
82
|
+
|
|
83
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
84
|
+
return self._chain[0].embed(texts)
|
|
85
|
+
|
|
86
|
+
def verify(self): # noqa: ANN201 - delegate to the primary; unused on the eval path
|
|
87
|
+
return self._chain[0].verify()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def same_model_chain(
|
|
91
|
+
configs: list[ProviderConfig],
|
|
92
|
+
factory, # noqa: ANN001 - (ProviderConfig) -> Provider, injectable for tests
|
|
93
|
+
) -> Provider:
|
|
94
|
+
"""Build a `SameModelFailover` over `configs`; a single-config chain is returned unwrapped."""
|
|
95
|
+
chain = [factory(c) for c in configs]
|
|
96
|
+
return chain[0] if len(chain) == 1 else SameModelFailover(chain)
|
wmo/evals/gold.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Gold-assertion judging: did the agent's run satisfy the task's success conditions?
|
|
2
|
+
|
|
3
|
+
Closed-loop eval scores *task success*, not per-step fidelity (docs/reference/closed_loop.md).
|
|
4
|
+
Success is defined by the task's `gold` assertions — semantic post-conditions checked against the
|
|
5
|
+
transcript by an LLM judge, so they are robust to wording (exact-match rules systematically
|
|
6
|
+
under-report success on semantically-correct answers). The verdict is always scored against the FULL
|
|
7
|
+
gold list: a truncated judge reply that omits assertions cannot report success.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, ValidationError, field_validator
|
|
13
|
+
|
|
14
|
+
from wmo.core.parsing import extract_json_object
|
|
15
|
+
from wmo.core.text import normalize_durable_text
|
|
16
|
+
from wmo.providers.base import Message, Provider
|
|
17
|
+
|
|
18
|
+
GOLD_JUDGE_MARKER = "grade whether an agent completed a task"
|
|
19
|
+
|
|
20
|
+
GOLD_JUDGE_SYSTEM = """You grade whether an agent completed a task. You are given the task, the
|
|
21
|
+
agent's final answer, a transcript of what it did, and a list of GOLD assertions defining success.
|
|
22
|
+
For each assertion, decide whether the transcript+answer show it is satisfied. Judge by MEANING, not
|
|
23
|
+
wording: an assertion holds if the evidence shows the described outcome, even if phrased otherwise.
|
|
24
|
+
Do not give credit for merely attempting or claiming success without supporting evidence in the
|
|
25
|
+
transcript.
|
|
26
|
+
|
|
27
|
+
Respond with ONLY a JSON object, no prose:
|
|
28
|
+
{"assertions": [{"assertion": "<verbatim>", "passed": <bool>, "why": "<short reason>"}],
|
|
29
|
+
"passed": <bool: true iff every assertion passed>}"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AssertionResult(BaseModel):
|
|
33
|
+
assertion: str
|
|
34
|
+
passed: bool
|
|
35
|
+
why: str = ""
|
|
36
|
+
|
|
37
|
+
@field_validator("assertion", "why")
|
|
38
|
+
@classmethod
|
|
39
|
+
def _normalize_text(cls, value: str) -> str:
|
|
40
|
+
return normalize_durable_text(value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class GoldVerdict(BaseModel):
|
|
44
|
+
"""The judge's verdict on one run against its gold assertions."""
|
|
45
|
+
|
|
46
|
+
passed: bool = False # every assertion satisfied
|
|
47
|
+
fraction: float = 0.0 # fraction of assertions satisfied (partial-credit signal)
|
|
48
|
+
assertions: list[AssertionResult] = Field(default_factory=list)
|
|
49
|
+
rationale: str = ""
|
|
50
|
+
|
|
51
|
+
@field_validator("rationale")
|
|
52
|
+
@classmethod
|
|
53
|
+
def _normalize_rationale(cls, value: str) -> str:
|
|
54
|
+
return normalize_durable_text(value)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def trivially_passed(cls) -> GoldVerdict:
|
|
58
|
+
"""A run with no gold assertions cannot fail its (empty) spec; treat as passed."""
|
|
59
|
+
return cls(passed=True, fraction=1.0, rationale="no gold assertions")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _RawVerdict(BaseModel):
|
|
63
|
+
assertions: list[AssertionResult] = Field(default_factory=list)
|
|
64
|
+
passed: bool = False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class GoldJudge:
|
|
68
|
+
"""LLM judge that checks a run transcript against a task's gold assertions."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, provider: Provider) -> None:
|
|
71
|
+
self._provider = provider
|
|
72
|
+
|
|
73
|
+
def score(self, instruction: str, answer: str, transcript: str, gold: list[str]) -> GoldVerdict:
|
|
74
|
+
if not gold:
|
|
75
|
+
return GoldVerdict.trivially_passed()
|
|
76
|
+
user = _build_prompt(instruction, answer, transcript, gold)
|
|
77
|
+
completion = self._provider.complete(
|
|
78
|
+
GOLD_JUDGE_SYSTEM,
|
|
79
|
+
[Message(role="user", content=user)],
|
|
80
|
+
temperature=0.0,
|
|
81
|
+
max_tokens=1024,
|
|
82
|
+
)
|
|
83
|
+
return _parse(completion.text, gold)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _build_prompt(instruction: str, answer: str, transcript: str, gold: list[str]) -> str:
|
|
87
|
+
assertions = "\n".join(f"- {g}" for g in gold)
|
|
88
|
+
return (
|
|
89
|
+
f"TASK:\n{instruction}\n\n"
|
|
90
|
+
f"AGENT FINAL ANSWER:\n{answer or '(none)'}\n\n"
|
|
91
|
+
f"TRANSCRIPT:\n{transcript or '(empty)'}\n\n"
|
|
92
|
+
f"GOLD ASSERTIONS (all must hold for success):\n{assertions}\n"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse(text: str, gold: list[str]) -> GoldVerdict:
|
|
97
|
+
"""Parse the judge reply; fall back to a failed verdict if it is unusable."""
|
|
98
|
+
raw = extract_json_object(text)
|
|
99
|
+
if raw is not None:
|
|
100
|
+
try:
|
|
101
|
+
parsed = _RawVerdict.model_validate_json(raw)
|
|
102
|
+
except ValidationError:
|
|
103
|
+
parsed = None
|
|
104
|
+
if parsed is not None and parsed.assertions:
|
|
105
|
+
# Score against ALL required gold assertions, matched BY TEXT to what the judge echoed
|
|
106
|
+
# back (the prompt demands verbatim echoes). A truncated reply that omits an assertion,
|
|
107
|
+
# one that duplicates a passing assertion to pad the count, or one that echoes the same
|
|
108
|
+
# assertion as both failed and passed (a failing verdict is never discarded) cannot
|
|
109
|
+
# report success: every unmatched gold assertion counts as failed. Fail-closed by
|
|
110
|
+
# construction.
|
|
111
|
+
passed_texts = {a.assertion.strip() for a in parsed.assertions if a.passed}
|
|
112
|
+
failed_texts = {a.assertion.strip() for a in parsed.assertions if not a.passed}
|
|
113
|
+
total = len(gold)
|
|
114
|
+
n_pass = sum(
|
|
115
|
+
1 for g in gold if g.strip() in passed_texts and g.strip() not in failed_texts
|
|
116
|
+
)
|
|
117
|
+
return GoldVerdict(
|
|
118
|
+
passed=parsed.passed and n_pass == total,
|
|
119
|
+
fraction=n_pass / total if total else 1.0,
|
|
120
|
+
assertions=parsed.assertions,
|
|
121
|
+
rationale=f"{n_pass}/{total} assertions satisfied",
|
|
122
|
+
)
|
|
123
|
+
return GoldVerdict(
|
|
124
|
+
passed=False,
|
|
125
|
+
fraction=0.0,
|
|
126
|
+
rationale=f"unparseable judge response; treated as failure. raw: {text.strip()[:200]}",
|
|
127
|
+
)
|
wmo/evals/grid.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Model-comparison grid: run one eval suite across many (model x condition) cells.
|
|
2
|
+
|
|
3
|
+
A "grid" answers a single question for a benchmark: how do different serving models fare, each under
|
|
4
|
+
base / +RAG / +GEPA / +GEPA+RAG, on the SAME held-out split, scored by the SAME judge? It reuses
|
|
5
|
+
the open-loop eval (`wmo.evals.open_loop.evaluate_files`) once per cell and rolls the per-file
|
|
6
|
+
report into a `GridCell`. Two invariants make cells comparable:
|
|
7
|
+
|
|
8
|
+
- The **judge is pinned** (a single Bedrock Opus 4.8 `RubricJudge`) across every cell, independent
|
|
9
|
+
of the target model - a Qwen target must not be judged by Qwen - and it never switches models
|
|
10
|
+
(only to the SAME model on the direct Anthropic API under Bedrock throttling; see
|
|
11
|
+
`wmo.evals.failover`). Its `JUDGE_VERSION` is stamped on the result so numbers from different
|
|
12
|
+
judge generations are never silently compared.
|
|
13
|
+
- Target token **cost is metered separately** from the judge (a `MeteredProvider` wraps only the
|
|
14
|
+
target), so a cell reports target-side cost, not judge cost. Cost is `None` when the model has no
|
|
15
|
+
pricing row (see `wmo.tracking.pricing.price_for`) rather than a misleading 0.
|
|
16
|
+
|
|
17
|
+
`run_grid` is provider-agnostic: each `ModelSpec` names a provider/model the registry can build, so
|
|
18
|
+
a self-hosted OpenAI-compatible model (e.g. Qwen-AgentWorld on vLLM) is just `provider="openai"`
|
|
19
|
+
with `OPENAI_BASE_URL` in the environment.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import uuid
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, Field
|
|
28
|
+
|
|
29
|
+
from wmo.evals.failover import anthropic_direct_id, same_model_chain
|
|
30
|
+
from wmo.evals.open_loop import EvalReport, evaluate_files
|
|
31
|
+
from wmo.optimize.judge import JUDGE_VERSION, Judge, RubricJudge
|
|
32
|
+
from wmo.providers import ProviderConfig, ProviderKind, get_provider
|
|
33
|
+
from wmo.providers.base import Completion, Message, Provider
|
|
34
|
+
from wmo.retrieval import HashingEmbedder
|
|
35
|
+
from wmo.tracking import MeteredProvider, RunTracker
|
|
36
|
+
from wmo.tracking.pricing import price_for
|
|
37
|
+
|
|
38
|
+
# Regions a Bedrock TARGET fails over across (same model, so what's measured is unchanged - this
|
|
39
|
+
# only spreads throttling load, it does not switch models).
|
|
40
|
+
_TARGET_FALLBACK_REGIONS = ("us-east-1",)
|
|
41
|
+
|
|
42
|
+
# The four prompt/retrieval conditions each model is evaluated under. `gepa`/`gepa_rag` require a
|
|
43
|
+
# per-(benchmark x model) evolved prompt; they are skipped for a model with no GEPA prompt.
|
|
44
|
+
CONDITIONS = ("base", "base_rag", "gepa", "gepa_rag")
|
|
45
|
+
|
|
46
|
+
# Display labels (lowercase "wmo" per the chart convention).
|
|
47
|
+
_CONDITION_LABELS = {
|
|
48
|
+
"base": "base",
|
|
49
|
+
"base_rag": "wmo/rag",
|
|
50
|
+
"gepa": "wmo/gepa",
|
|
51
|
+
"gepa_rag": "wmo/gepa/rag",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Cap on the TARGET's generation per step. A world-model observation is short JSON; a reasoning
|
|
55
|
+
# target (GPT-5.5) otherwise spends the full 8192-token budget on reasoning, making each step
|
|
56
|
+
# ~80s and a whole grid many hours. 4096 leaves ample room for reasoning + the observation while
|
|
57
|
+
# roughly halving worst-case latency. The judge is never capped (it needs its full rubric budget).
|
|
58
|
+
DEFAULT_TARGET_MAX_TOKENS = 4096
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CappedProvider:
|
|
62
|
+
"""Wraps a target provider, clamping each completion's `max_tokens` to a ceiling.
|
|
63
|
+
|
|
64
|
+
Only the eval TARGET is wrapped (not the judge): observation prediction needs a short output, so
|
|
65
|
+
a lower ceiling bounds a reasoning model's per-step latency without affecting judge scoring.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, inner: Provider, cap: int) -> None:
|
|
69
|
+
self._inner = inner
|
|
70
|
+
self._cap = cap
|
|
71
|
+
self.config = inner.config
|
|
72
|
+
|
|
73
|
+
def complete(
|
|
74
|
+
self,
|
|
75
|
+
system: str,
|
|
76
|
+
messages: list[Message],
|
|
77
|
+
*,
|
|
78
|
+
temperature: float = 0.7,
|
|
79
|
+
max_tokens: int = 8192,
|
|
80
|
+
) -> Completion:
|
|
81
|
+
return self._inner.complete(
|
|
82
|
+
system, messages, temperature=temperature, max_tokens=min(max_tokens, self._cap)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
86
|
+
return self._inner.embed(texts)
|
|
87
|
+
|
|
88
|
+
def verify(self): # noqa: ANN201 - delegate; unused on the eval path
|
|
89
|
+
return self._inner.verify()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class ModelSpec:
|
|
94
|
+
"""A serving model to benchmark: display label + how the provider registry builds it."""
|
|
95
|
+
|
|
96
|
+
label: str # e.g. "Opus 4.8"
|
|
97
|
+
provider: str # ProviderKind value: "bedrock" | "openai" | ...
|
|
98
|
+
model: str # provider model id, e.g. "us.anthropic.claude-opus-4-8"
|
|
99
|
+
region: str | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class GridCell(BaseModel):
|
|
103
|
+
"""One (model x condition) result on a benchmark's held-out split."""
|
|
104
|
+
|
|
105
|
+
model_label: str
|
|
106
|
+
provider: str
|
|
107
|
+
model: str
|
|
108
|
+
condition: str # one of CONDITIONS
|
|
109
|
+
condition_label: str # human label incl. lowercase "wmo"
|
|
110
|
+
fidelity: float # step-weighted mean judge score across the split
|
|
111
|
+
error_flag_acc: float # step-weighted fraction where predicted is_error matched actual
|
|
112
|
+
n_steps: int
|
|
113
|
+
cost_usd: float | None = None # target-side USD; None when the model has no pricing row
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def bar_label(self) -> str:
|
|
117
|
+
"""Two-line x-axis label: "Opus 4.8\\nwmo/rag" (lowercase wmo)."""
|
|
118
|
+
return f"{self.model_label}\n{self.condition_label}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class GridResult(BaseModel):
|
|
122
|
+
"""A full grid: every cell plus the split/judge metadata that makes the numbers reproducible."""
|
|
123
|
+
|
|
124
|
+
suite: str
|
|
125
|
+
judge_model: str
|
|
126
|
+
judge_provider: str
|
|
127
|
+
# The rubric-judge version every cell was scored under. Stamped so results from different judge
|
|
128
|
+
# generations are never silently compared (rubric-v1 numbers run ~0.12 higher than rubric-v2).
|
|
129
|
+
judge_version: str = JUDGE_VERSION
|
|
130
|
+
train_split: float
|
|
131
|
+
val_frac: float = 0.0 # validation fraction reserved for GEPA; test band = 1 - train - val
|
|
132
|
+
top_k: int
|
|
133
|
+
seed: int
|
|
134
|
+
sample_turns: str
|
|
135
|
+
# Retrieval phi dimensionality (`base_rag`/`gepa_rag` cells) and any dry-run holdout cap. Both
|
|
136
|
+
# change what/how cells are scored, so they are comparability fields (see `merge_results`):
|
|
137
|
+
# merging RAG cells built at different embed_dim, or a capped dry-run with a full run, would put
|
|
138
|
+
# incomparable fidelities in one chart.
|
|
139
|
+
embed_dim: int = 0
|
|
140
|
+
max_holdout_traces: int | None = None
|
|
141
|
+
total_test_steps: int = 0
|
|
142
|
+
total_test_traces: int = 0
|
|
143
|
+
cells: list[GridCell] = Field(default_factory=list)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def merge_results(results: list[GridResult]) -> GridResult:
|
|
147
|
+
"""Combine several `GridResult`s (e.g. a 4-API-model grid + a separate Qwen grid) into one.
|
|
148
|
+
|
|
149
|
+
A self-hosted model runs in its own process (its OpenAI-compatible base URL is process-global
|
|
150
|
+
via `OPENAI_BASE_URL`), so its cells arrive in a separate result JSON. All results must be the
|
|
151
|
+
same suite/split - they score the same held-out set - so metadata is taken from the first and
|
|
152
|
+
cells are concatenated. `total_test_steps`/`total_test_traces` take the max across results
|
|
153
|
+
(equal in practice; max guards against a capped dry-run being merged with a full run).
|
|
154
|
+
"""
|
|
155
|
+
if not results:
|
|
156
|
+
raise ValueError("merge_results requires at least one GridResult")
|
|
157
|
+
head = results[0]
|
|
158
|
+
# Every result merged into one chart must be directly comparable: same suite, same judge
|
|
159
|
+
# (model + rubric version), and the SAME held-out split + retrieval config. Drift in any of
|
|
160
|
+
# these means the cells were scored on different bands or on different scales, so merging them
|
|
161
|
+
# would put incomparable fidelities side by side (rubric-v1 runs ~0.12 above rubric-v2; a
|
|
162
|
+
# different train_split/val_frac/seed reserves a different test band). The self-hosted grid
|
|
163
|
+
# runs in a SEPARATE process, so a flag typo there is a realistic way to get silent drift.
|
|
164
|
+
# Fail loudly instead of taking the first result's metadata and hiding the mismatch.
|
|
165
|
+
comparability_fields = (
|
|
166
|
+
"suite",
|
|
167
|
+
"judge_model",
|
|
168
|
+
"judge_version",
|
|
169
|
+
"train_split",
|
|
170
|
+
"val_frac",
|
|
171
|
+
"top_k",
|
|
172
|
+
"seed",
|
|
173
|
+
"sample_turns",
|
|
174
|
+
"embed_dim",
|
|
175
|
+
"max_holdout_traces",
|
|
176
|
+
)
|
|
177
|
+
for field in comparability_fields:
|
|
178
|
+
values = {getattr(r, field) for r in results}
|
|
179
|
+
if len(values) > 1:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"merge_results needs one {field} (cells not comparable across values); "
|
|
182
|
+
f"got {sorted(str(v) for v in values)}"
|
|
183
|
+
)
|
|
184
|
+
merged = GridResult(
|
|
185
|
+
suite=head.suite,
|
|
186
|
+
judge_model=head.judge_model,
|
|
187
|
+
judge_provider=head.judge_provider,
|
|
188
|
+
judge_version=head.judge_version,
|
|
189
|
+
train_split=head.train_split,
|
|
190
|
+
val_frac=head.val_frac,
|
|
191
|
+
top_k=head.top_k,
|
|
192
|
+
seed=head.seed,
|
|
193
|
+
sample_turns=head.sample_turns,
|
|
194
|
+
embed_dim=head.embed_dim,
|
|
195
|
+
max_holdout_traces=head.max_holdout_traces,
|
|
196
|
+
total_test_steps=max(r.total_test_steps for r in results),
|
|
197
|
+
total_test_traces=max(r.total_test_traces for r in results),
|
|
198
|
+
)
|
|
199
|
+
for r in results:
|
|
200
|
+
merged.cells.extend(r.cells)
|
|
201
|
+
return merged
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _make_judge(
|
|
205
|
+
judge_provider: str,
|
|
206
|
+
judge_model: str,
|
|
207
|
+
region: str | None,
|
|
208
|
+
factory, # noqa: ANN001 - a Provider builder (ProviderConfig) -> Provider, injectable for tests
|
|
209
|
+
) -> Judge:
|
|
210
|
+
"""Build the pinned `RubricJudge`. The judge NEVER switches to a different model.
|
|
211
|
+
|
|
212
|
+
A judge that silently swapped models mid-grid would score cells on different scales and make
|
|
213
|
+
fidelity numbers incomparable (see `docs/reference/failover.md`). The ONLY failover allowed is
|
|
214
|
+
to the SAME model on the direct Anthropic API (unlimited key) when the primary is a throttled
|
|
215
|
+
Bedrock Anthropic model - identical model, different endpoint - so what's measured is unchanged.
|
|
216
|
+
The judge is never metered as target cost.
|
|
217
|
+
"""
|
|
218
|
+
kind_enum = ProviderKind(judge_provider)
|
|
219
|
+
configs = [ProviderConfig(kind=kind_enum, model=judge_model, region=region)]
|
|
220
|
+
if kind_enum is ProviderKind.BEDROCK:
|
|
221
|
+
direct = anthropic_direct_id(judge_model)
|
|
222
|
+
if direct is not None:
|
|
223
|
+
configs.append(ProviderConfig(kind=ProviderKind.ANTHROPIC, model=direct))
|
|
224
|
+
return RubricJudge(same_model_chain(configs, factory))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _make_target(spec: ModelSpec, factory) -> Provider: # noqa: ANN001 - factory injectable for tests
|
|
228
|
+
"""Build a target provider. A Bedrock target fails over across `_TARGET_FALLBACK_REGIONS` (SAME
|
|
229
|
+
model - spreads throttle without changing what's measured); other providers are single."""
|
|
230
|
+
kind = ProviderKind(spec.provider)
|
|
231
|
+
if kind is ProviderKind.BEDROCK:
|
|
232
|
+
# Spread across regions only when the primary region is EXPLICIT: with region=None the
|
|
233
|
+
# primary already resolves via AWS_REGION/the boto3 chain, and appending a literal
|
|
234
|
+
# us-east-1 rung could just re-hit the same endpoint (a no-op duplicate) if that is what
|
|
235
|
+
# the ambient region resolves to. The direct-Anthropic rung below is the real resilience.
|
|
236
|
+
regions: list[str | None] = [spec.region]
|
|
237
|
+
if spec.region is not None:
|
|
238
|
+
regions += [r for r in _TARGET_FALLBACK_REGIONS if r != spec.region]
|
|
239
|
+
configs = [ProviderConfig(kind=kind, model=spec.model, region=r) for r in regions]
|
|
240
|
+
# Then fail over to the SAME model on the direct Anthropic API (unlimited key), so a target
|
|
241
|
+
# throttled across all Bedrock regions still produces real predictions on the identical
|
|
242
|
+
# model instead of scoring the step 0 - critical for Opus 4.8 under Bedrock load.
|
|
243
|
+
direct = anthropic_direct_id(spec.model)
|
|
244
|
+
if direct is not None:
|
|
245
|
+
configs.append(ProviderConfig(kind=ProviderKind.ANTHROPIC, model=direct))
|
|
246
|
+
return same_model_chain(configs, factory)
|
|
247
|
+
return factory(ProviderConfig(kind=kind, model=spec.model, region=spec.region))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _target_cost(model: str, tracker: RunTracker) -> float | None:
|
|
251
|
+
"""Target-side USD from the metered tracker, or None when the model has no pricing row."""
|
|
252
|
+
if price_for(model) is None:
|
|
253
|
+
return None
|
|
254
|
+
return tracker.totals().cost_usd
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _aggregate(report: EvalReport) -> tuple[float, float, int]:
|
|
258
|
+
"""(fidelity, step-weighted error-flag accuracy, total steps) from an EvalReport."""
|
|
259
|
+
total = report.total_steps
|
|
260
|
+
if total == 0:
|
|
261
|
+
return 0.0, 0.0, 0
|
|
262
|
+
err = sum(r.error_flag_accuracy * r.n_steps for r in report.per_file.values()) / total
|
|
263
|
+
return report.overall_fidelity, err, total
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def run_grid(
|
|
267
|
+
*,
|
|
268
|
+
suite_name: str,
|
|
269
|
+
files: list[str],
|
|
270
|
+
models: list[ModelSpec],
|
|
271
|
+
gepa_prompts: dict[str, str] | None,
|
|
272
|
+
base_prompt: str,
|
|
273
|
+
judge_provider: str,
|
|
274
|
+
judge_model: str,
|
|
275
|
+
judge_region: str | None,
|
|
276
|
+
train_split: float,
|
|
277
|
+
top_k: int,
|
|
278
|
+
seed: int,
|
|
279
|
+
sample_turns: str,
|
|
280
|
+
embed_dim: int,
|
|
281
|
+
val_frac: float | None = None,
|
|
282
|
+
max_holdout_traces: int | None = None,
|
|
283
|
+
target_max_tokens: int = DEFAULT_TARGET_MAX_TOKENS,
|
|
284
|
+
provider_factory=get_provider, # noqa: ANN001 - injectable for tests (no network)
|
|
285
|
+
) -> GridResult:
|
|
286
|
+
"""Run every (model x condition) cell of the grid and return the rolled-up result.
|
|
287
|
+
|
|
288
|
+
`gepa_prompts` maps a `ModelSpec.label` to an evolved-prompt file path; a model absent from it
|
|
289
|
+
skips the `gepa`/`gepa_rag` conditions. The judge is built once (a pinned `RubricJudge`) and
|
|
290
|
+
shared across all cells.
|
|
291
|
+
|
|
292
|
+
Cells are scored on the reserved **test** band of the SAME 3-way `train/val/test` split GEPA
|
|
293
|
+
used to evolve its prompts (`val_frac` defaults to `(1 - train_split) / 2`, matching
|
|
294
|
+
`wmo build`), so a `+GEPA` cell is never scored on the `val` traces its prompt was selected on.
|
|
295
|
+
"""
|
|
296
|
+
from pathlib import Path
|
|
297
|
+
|
|
298
|
+
from wmo.engine.build import split_traces, split_traces_3way
|
|
299
|
+
from wmo.ingest import get_adapter
|
|
300
|
+
|
|
301
|
+
if val_frac is None:
|
|
302
|
+
val_frac = (1.0 - train_split) / 2
|
|
303
|
+
# A 3-way split needs a strictly positive val band that still leaves a non-empty test band. When
|
|
304
|
+
# `train_split` leaves no room (e.g. train_split >= 1.0, or a --val-frac that overflows the
|
|
305
|
+
# [0,1) line) fall back to the plain 2-way split instead of crashing in `split_traces_3way`.
|
|
306
|
+
use_3way = val_frac > 0 and train_split + val_frac < 1
|
|
307
|
+
if not use_3way:
|
|
308
|
+
val_frac = 0.0
|
|
309
|
+
judge = _make_judge(judge_provider, judge_model, judge_region, provider_factory)
|
|
310
|
+
result = GridResult(
|
|
311
|
+
suite=suite_name,
|
|
312
|
+
judge_model=judge_model,
|
|
313
|
+
judge_provider=judge_provider,
|
|
314
|
+
train_split=train_split,
|
|
315
|
+
val_frac=val_frac,
|
|
316
|
+
top_k=top_k,
|
|
317
|
+
seed=seed,
|
|
318
|
+
sample_turns=sample_turns,
|
|
319
|
+
embed_dim=embed_dim,
|
|
320
|
+
max_holdout_traces=max_holdout_traces,
|
|
321
|
+
)
|
|
322
|
+
paths = [Path(f) for f in files]
|
|
323
|
+
|
|
324
|
+
# Held-out trace count (for reporting) - the reserved TEST band each cell scores, after any cap.
|
|
325
|
+
# Uses the same 3-way split as the scorer so the count matches what is actually evaluated.
|
|
326
|
+
adapter = get_adapter("otel-genai")
|
|
327
|
+
for path in paths:
|
|
328
|
+
traces = adapter.from_file(str(path))
|
|
329
|
+
if use_3way:
|
|
330
|
+
_, _, holdout = split_traces_3way(traces, train_split, val_frac)
|
|
331
|
+
else:
|
|
332
|
+
_, holdout = split_traces(traces, train_split)
|
|
333
|
+
holdout = holdout or traces
|
|
334
|
+
if max_holdout_traces is not None:
|
|
335
|
+
holdout = holdout[:max_holdout_traces]
|
|
336
|
+
result.total_test_traces += len(holdout)
|
|
337
|
+
for spec in models:
|
|
338
|
+
gepa_prompt_file = (gepa_prompts or {}).get(spec.label)
|
|
339
|
+
gepa_prompt: str | None = None
|
|
340
|
+
if gepa_prompt_file is not None:
|
|
341
|
+
text = Path(gepa_prompt_file).read_text(encoding="utf-8")
|
|
342
|
+
# A GEPA run that finds no improvement returns the base prompt verbatim. Scoring its
|
|
343
|
+
# `gepa`/`gepa_rag` cells would just re-run `base`/`base_rag` and report the judge/
|
|
344
|
+
# sampling noise between the two runs as a spurious "GEPA lift". Treat a base-identical
|
|
345
|
+
# evolved prompt as NO evolved prompt so the grid never presents that noise as a delta.
|
|
346
|
+
# Compare stripped so a prompt that differs only by trailing whitespace (a common
|
|
347
|
+
# artifact of hand-edited/exported prompt files) is still caught as a no-op.
|
|
348
|
+
gepa_prompt = text if text.strip() != base_prompt.strip() else None
|
|
349
|
+
for condition in CONDITIONS:
|
|
350
|
+
uses_gepa = condition in ("gepa", "gepa_rag")
|
|
351
|
+
if uses_gepa and gepa_prompt is None:
|
|
352
|
+
continue # no (real) evolved prompt for this model -> skip its GEPA cells
|
|
353
|
+
prompt = gepa_prompt if uses_gepa else base_prompt
|
|
354
|
+
assert prompt is not None # noqa: S101 - narrowed by the guard above
|
|
355
|
+
use_rag = condition in ("base_rag", "gepa_rag")
|
|
356
|
+
# Meter ONLY the target so cost is target-side, never judge cost. The target itself may
|
|
357
|
+
# be a region-fallback chain (Bedrock); MeteredProvider records whichever entry served.
|
|
358
|
+
tracker = RunTracker(run_id=uuid.uuid4().hex, kind="eval-grid")
|
|
359
|
+
capped = CappedProvider(_make_target(spec, provider_factory), target_max_tokens)
|
|
360
|
+
target: Provider = MeteredProvider(capped, tracker)
|
|
361
|
+
embedder = HashingEmbedder(dim=embed_dim) if use_rag else None
|
|
362
|
+
with tracker.timed():
|
|
363
|
+
report = evaluate_files(
|
|
364
|
+
paths,
|
|
365
|
+
prompt,
|
|
366
|
+
target,
|
|
367
|
+
judge,
|
|
368
|
+
embedder=embedder,
|
|
369
|
+
train_split=train_split,
|
|
370
|
+
val_frac=val_frac,
|
|
371
|
+
top_k=top_k,
|
|
372
|
+
sample_turns=sample_turns,
|
|
373
|
+
seed=seed,
|
|
374
|
+
max_holdout_traces=max_holdout_traces,
|
|
375
|
+
)
|
|
376
|
+
fidelity, err, steps = _aggregate(report)
|
|
377
|
+
# Every cell scores the same held-out band with the same sampling, so `steps` is
|
|
378
|
+
# invariant across cells; take the max defensively so the reported count can never
|
|
379
|
+
# under-report if a future per-model option makes one cell score fewer steps.
|
|
380
|
+
result.total_test_steps = max(result.total_test_steps, steps)
|
|
381
|
+
result.cells.append(
|
|
382
|
+
GridCell(
|
|
383
|
+
model_label=spec.label,
|
|
384
|
+
provider=spec.provider,
|
|
385
|
+
model=spec.model,
|
|
386
|
+
condition=condition,
|
|
387
|
+
condition_label=_CONDITION_LABELS[condition],
|
|
388
|
+
fidelity=fidelity,
|
|
389
|
+
error_flag_acc=err,
|
|
390
|
+
n_steps=steps,
|
|
391
|
+
cost_usd=_target_cost(spec.model, tracker),
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
return result
|