world-model-optimizer 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (308) hide show
  1. llm_waterfall/LICENSE +21 -0
  2. llm_waterfall/__init__.py +53 -0
  3. llm_waterfall/adapters/__init__.py +36 -0
  4. llm_waterfall/adapters/anthropic.py +105 -0
  5. llm_waterfall/adapters/aws_mantle.py +47 -0
  6. llm_waterfall/adapters/azure_openai.py +71 -0
  7. llm_waterfall/adapters/base.py +51 -0
  8. llm_waterfall/adapters/bedrock.py +309 -0
  9. llm_waterfall/adapters/openai.py +130 -0
  10. llm_waterfall/classify.py +184 -0
  11. llm_waterfall/pricing.py +110 -0
  12. llm_waterfall/py.typed +0 -0
  13. llm_waterfall/types.py +295 -0
  14. llm_waterfall/waterfall.py +255 -0
  15. wmo/__init__.py +38 -0
  16. wmo/agents/__init__.py +7 -0
  17. wmo/agents/default.py +29 -0
  18. wmo/agents/meta.py +55 -0
  19. wmo/agents/optimizer.py +55 -0
  20. wmo/agents/project.py +928 -0
  21. wmo/cli/__init__.py +5 -0
  22. wmo/cli/agent_session.py +1123 -0
  23. wmo/cli/app.py +2489 -0
  24. wmo/cli/e2b_cmds.py +212 -0
  25. wmo/cli/eval_closed_loop.py +207 -0
  26. wmo/cli/harness_app.py +1147 -0
  27. wmo/cli/harness_distill.py +659 -0
  28. wmo/cli/hosted_session.py +880 -0
  29. wmo/cli/ingest_cmd.py +165 -0
  30. wmo/cli/model_roles.py +82 -0
  31. wmo/cli/platform_cmds.py +372 -0
  32. wmo/cli/route_app.py +274 -0
  33. wmo/cli/session_state.py +243 -0
  34. wmo/cli/ui.py +1107 -0
  35. wmo/cli/workspace_sync.py +504 -0
  36. wmo/config/__init__.py +60 -0
  37. wmo/config/card.py +129 -0
  38. wmo/config/config.py +367 -0
  39. wmo/config/dotenv.py +67 -0
  40. wmo/config/settings.py +128 -0
  41. wmo/config/store.py +177 -0
  42. wmo/conftest.py +19 -0
  43. wmo/connect/__init__.py +88 -0
  44. wmo/connect/apps.py +78 -0
  45. wmo/connect/brave.py +284 -0
  46. wmo/connect/connector.py +79 -0
  47. wmo/connect/credentials.py +164 -0
  48. wmo/connect/github.py +321 -0
  49. wmo/connect/google.py +627 -0
  50. wmo/connect/notion.py +790 -0
  51. wmo/connect/oauth.py +461 -0
  52. wmo/connect/slack.py +555 -0
  53. wmo/connect/store.py +199 -0
  54. wmo/connect/types.py +156 -0
  55. wmo/core/__init__.py +21 -0
  56. wmo/core/parsing.py +281 -0
  57. wmo/core/render.py +271 -0
  58. wmo/core/text.py +40 -0
  59. wmo/core/types.py +116 -0
  60. wmo/distill/__init__.py +14 -0
  61. wmo/distill/agents.py +140 -0
  62. wmo/distill/config.py +1006 -0
  63. wmo/distill/cost.py +437 -0
  64. wmo/distill/data.py +921 -0
  65. wmo/distill/deadlines.py +254 -0
  66. wmo/distill/fake_tinker.py +734 -0
  67. wmo/distill/gate.py +122 -0
  68. wmo/distill/loop.py +3499 -0
  69. wmo/distill/renderers.py +399 -0
  70. wmo/distill/rendering.py +620 -0
  71. wmo/distill/rollouts.py +726 -0
  72. wmo/distill/samples.py +195 -0
  73. wmo/distill/store.py +829 -0
  74. wmo/distill/teacher.py +714 -0
  75. wmo/distill/tokens.py +535 -0
  76. wmo/distill/tracking.py +552 -0
  77. wmo/distill/tripwire.py +411 -0
  78. wmo/distill/xtoken/byte_offsets.py +152 -0
  79. wmo/distill/xtoken/chunks.py +457 -0
  80. wmo/distill/xtoken/prompt_logprobs.py +475 -0
  81. wmo/distill/xtoken/teacher_render.py +346 -0
  82. wmo/engine/__init__.py +28 -0
  83. wmo/engine/autoconfig.py +367 -0
  84. wmo/engine/build.py +346 -0
  85. wmo/engine/demo.py +77 -0
  86. wmo/engine/eval_suites.py +245 -0
  87. wmo/engine/grounding.py +491 -0
  88. wmo/engine/knowledge.py +291 -0
  89. wmo/engine/loader.py +36 -0
  90. wmo/engine/play.py +92 -0
  91. wmo/engine/prompts.py +99 -0
  92. wmo/engine/replay.py +443 -0
  93. wmo/engine/reporting.py +58 -0
  94. wmo/engine/workspace.py +468 -0
  95. wmo/engine/world_model.py +568 -0
  96. wmo/env/__init__.py +22 -0
  97. wmo/env/base.py +121 -0
  98. wmo/env/closed_loop.py +229 -0
  99. wmo/env/episode.py +107 -0
  100. wmo/env/llm_agent.py +93 -0
  101. wmo/env/scenarios.py +73 -0
  102. wmo/evals/__init__.py +52 -0
  103. wmo/evals/agreement.py +110 -0
  104. wmo/evals/base.py +45 -0
  105. wmo/evals/closed_loop.py +480 -0
  106. wmo/evals/failover.py +96 -0
  107. wmo/evals/gold.py +127 -0
  108. wmo/evals/grid.py +394 -0
  109. wmo/evals/grid_plot.py +205 -0
  110. wmo/evals/harbor/__init__.py +27 -0
  111. wmo/evals/harbor/agent.py +573 -0
  112. wmo/evals/harbor/ctrf.py +171 -0
  113. wmo/evals/harbor/e2b_environment.py +587 -0
  114. wmo/evals/harbor/e2b_template_policy.py +144 -0
  115. wmo/evals/harbor/scorer.py +875 -0
  116. wmo/evals/harbor/tasks.py +140 -0
  117. wmo/evals/open_loop.py +194 -0
  118. wmo/evals/tasks.py +53 -0
  119. wmo/harness/__init__.py +51 -0
  120. wmo/harness/code_runtime.py +288 -0
  121. wmo/harness/create.py +1191 -0
  122. wmo/harness/delta.py +220 -0
  123. wmo/harness/doc.py +556 -0
  124. wmo/harness/e2b_ledger.py +342 -0
  125. wmo/harness/e2b_reap.py +476 -0
  126. wmo/harness/e2b_sandbox.py +350 -0
  127. wmo/harness/environment.py +35 -0
  128. wmo/harness/live_session.py +543 -0
  129. wmo/harness/mutate.py +343 -0
  130. wmo/harness/pi_e2b.py +1710 -0
  131. wmo/harness/pi_entry/entry.ts +268 -0
  132. wmo/harness/pi_entry/runner_frames.ts +92 -0
  133. wmo/harness/pi_entry/runner_live.ts +587 -0
  134. wmo/harness/pi_entry/runner_service.ts +270 -0
  135. wmo/harness/pi_entry/runner_stdio.ts +374 -0
  136. wmo/harness/pi_entry/runner_termination.ts +142 -0
  137. wmo/harness/pi_local.py +262 -0
  138. wmo/harness/pi_runtime.py +495 -0
  139. wmo/harness/pi_vendor.py +65 -0
  140. wmo/harness/population.py +509 -0
  141. wmo/harness/project_proposer.py +569 -0
  142. wmo/harness/proposer.py +977 -0
  143. wmo/harness/runner_link.py +619 -0
  144. wmo/harness/runtime.py +389 -0
  145. wmo/harness/scoring.py +247 -0
  146. wmo/harness/skills.py +116 -0
  147. wmo/harness/source_tree.py +319 -0
  148. wmo/harness/store.py +176 -0
  149. wmo/harness/tools.py +105 -0
  150. wmo/harness/vendor/manifest.sha256 +58 -0
  151. wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
  152. wmo/harness/vendor/pi-agent/LICENSE +21 -0
  153. wmo/harness/vendor/pi-agent/README.md +488 -0
  154. wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
  155. wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
  156. wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
  157. wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
  158. wmo/harness/vendor/pi-agent/docs/models.md +966 -0
  159. wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
  160. wmo/harness/vendor/pi-agent/package.json +60 -0
  161. wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
  162. wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
  163. wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
  164. wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
  165. wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
  166. wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
  167. wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
  168. wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
  169. wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
  170. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
  171. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
  172. wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
  173. wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
  174. wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
  175. wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
  176. wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
  177. wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
  178. wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
  179. wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
  180. wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
  181. wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
  182. wmo/harness/vendor/pi-agent/src/index.ts +44 -0
  183. wmo/harness/vendor/pi-agent/src/node.ts +2 -0
  184. wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
  185. wmo/harness/vendor/pi-agent/src/types.ts +428 -0
  186. wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
  187. wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
  188. wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
  189. wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
  190. wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
  191. wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
  192. wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
  193. wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
  194. wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
  195. wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
  196. wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
  197. wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
  198. wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
  199. wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
  200. wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
  201. wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
  202. wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
  203. wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
  204. wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
  205. wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
  206. wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
  207. wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
  208. wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
  209. wmo/harness/vendor/vendor_pi.sh +59 -0
  210. wmo/harness/workspace_patch.py +270 -0
  211. wmo/ingest/__init__.py +47 -0
  212. wmo/ingest/adapter.py +72 -0
  213. wmo/ingest/base.py +114 -0
  214. wmo/ingest/braintrust.py +339 -0
  215. wmo/ingest/detect.py +126 -0
  216. wmo/ingest/langfuse.py +291 -0
  217. wmo/ingest/langsmith.py +444 -0
  218. wmo/ingest/mastra.py +330 -0
  219. wmo/ingest/messages.py +170 -0
  220. wmo/ingest/normalize.py +679 -0
  221. wmo/ingest/otel_genai.py +69 -0
  222. wmo/ingest/otel_writer.py +100 -0
  223. wmo/ingest/phoenix.py +150 -0
  224. wmo/ingest/postgres.py +246 -0
  225. wmo/ingest/posthog.py +320 -0
  226. wmo/ingest/quality.py +28 -0
  227. wmo/ingest/stream.py +209 -0
  228. wmo/ingest/testdata/sample_otlp.json +60 -0
  229. wmo/ingest/testdata/sample_spans.jsonl +3 -0
  230. wmo/optimize/__init__.py +25 -0
  231. wmo/optimize/base.py +143 -0
  232. wmo/optimize/gepa.py +806 -0
  233. wmo/optimize/judge.py +262 -0
  234. wmo/optimize/judge_quality.py +359 -0
  235. wmo/optimize/knn.py +468 -0
  236. wmo/optimize/numeric.py +152 -0
  237. wmo/optimize/outcomes.py +103 -0
  238. wmo/optimize/policy.py +669 -0
  239. wmo/optimize/report.py +231 -0
  240. wmo/optimize/reward.py +129 -0
  241. wmo/optimize/routing.py +373 -0
  242. wmo/platform/__init__.py +6 -0
  243. wmo/platform/auth.py +115 -0
  244. wmo/platform/client.py +551 -0
  245. wmo/platform/credentials.py +126 -0
  246. wmo/platform/transfer.py +158 -0
  247. wmo/providers/__init__.py +40 -0
  248. wmo/providers/_bedrock_chat.py +155 -0
  249. wmo/providers/_openai_common.py +182 -0
  250. wmo/providers/_responses_common.py +472 -0
  251. wmo/providers/anthropic.py +134 -0
  252. wmo/providers/azure_openai.py +296 -0
  253. wmo/providers/base.py +300 -0
  254. wmo/providers/bedrock.py +312 -0
  255. wmo/providers/models.py +205 -0
  256. wmo/providers/openai.py +143 -0
  257. wmo/providers/openai_responses.py +240 -0
  258. wmo/providers/pool.py +170 -0
  259. wmo/providers/registry.py +73 -0
  260. wmo/providers/retry.py +151 -0
  261. wmo/providers/tinker.py +936 -0
  262. wmo/providers/waterfall.py +336 -0
  263. wmo/research/__init__.py +81 -0
  264. wmo/research/ablation.py +133 -0
  265. wmo/research/concurrency_plot.py +523 -0
  266. wmo/research/concurrency_run.py +240 -0
  267. wmo/research/concurrency_scaling.py +270 -0
  268. wmo/research/gepa_scaling.py +274 -0
  269. wmo/research/pipeline.py +198 -0
  270. wmo/research/scaling_split.py +82 -0
  271. wmo/research/scenario_fidelity.py +198 -0
  272. wmo/research/scenario_recovery.py +92 -0
  273. wmo/research/seed_stability.py +90 -0
  274. wmo/research/trace_scaling.py +348 -0
  275. wmo/retrieval/__init__.py +6 -0
  276. wmo/retrieval/embedders.py +105 -0
  277. wmo/retrieval/leakfree.py +52 -0
  278. wmo/retrieval/retriever.py +173 -0
  279. wmo/scenarios/__init__.py +58 -0
  280. wmo/scenarios/builder.py +152 -0
  281. wmo/scenarios/mining/__init__.py +27 -0
  282. wmo/scenarios/mining/clustering.py +171 -0
  283. wmo/scenarios/mining/facets.py +226 -0
  284. wmo/scenarios/mining/selection.py +220 -0
  285. wmo/scenarios/synthesis/__init__.py +6 -0
  286. wmo/scenarios/synthesis/scenario_set.py +63 -0
  287. wmo/scenarios/synthesis/synthesizer.py +85 -0
  288. wmo/scenarios/verification/__init__.py +17 -0
  289. wmo/scenarios/verification/judge.py +97 -0
  290. wmo/scenarios/verification/verify.py +135 -0
  291. wmo/serving/__init__.py +5 -0
  292. wmo/serving/builds.py +451 -0
  293. wmo/serving/chat.py +878 -0
  294. wmo/serving/endpoint_config.py +64 -0
  295. wmo/serving/savings.py +250 -0
  296. wmo/serving/server.py +553 -0
  297. wmo/serving/traces_source.py +206 -0
  298. wmo/telemetry.py +213 -0
  299. wmo/tracking/__init__.py +36 -0
  300. wmo/tracking/clock.py +24 -0
  301. wmo/tracking/metered.py +125 -0
  302. wmo/tracking/pricing.py +99 -0
  303. wmo/tracking/store.py +31 -0
  304. wmo/tracking/tracker.py +149 -0
  305. world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
  306. world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
  307. world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
  308. world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
wmo/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