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