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