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/optimize/gepa.py ADDED
@@ -0,0 +1,806 @@
1
+ """GEPA reflective prompt evolution.
2
+
3
+ GEPA (arXiv 2507.19457): replay held-out steps through a candidate prompt, score predicted vs.
4
+ real observation with the LLM judge (which also returns a natural-language critique), reflect on
5
+ those critiques to mutate the prompt, and keep a Pareto frontier of candidates across trace buckets.
6
+
7
+ We do NOT re-implement the evolutionary search: we drive the GEPA authors' reference engine
8
+ (`gepa` on PyPI) through a small `GEPAAdapter`. The adapter is the only integration point — it
9
+ replays a candidate prompt over held-out steps, scores each with our `Judge`, and turns the judge
10
+ critiques into the reflective dataset the engine feeds back to the reflection LM.
11
+
12
+ The optimizer stays decoupled from the serving engine: replaying a candidate only needs a
13
+ `Provider` (see `predict_observation`), so we do NOT import `wmo.engine` (that would create the
14
+ cycle engine -> optimize -> engine). Prompt assembly is the shared
15
+ `wmo.core.render.build_env_prompt` — the exact assembly the world model serves — so GEPA evolves
16
+ against what is actually deployed.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable, Mapping, Sequence
22
+ from concurrent.futures import ThreadPoolExecutor, as_completed
23
+ from dataclasses import dataclass
24
+
25
+ import gepa
26
+ from gepa.core.adapter import EvaluationBatch, GEPAAdapter
27
+
28
+ from wmo.core.parsing import dumps_observation_contract, parse_observation
29
+ from wmo.core.render import build_env_prompt, encode_state_action
30
+ from wmo.core.types import Action, EnvState, JsonValue, Observation, Step, Trace
31
+ from wmo.optimize.base import OptimizeMetrics, OptimizeResult
32
+ from wmo.optimize.judge import Judge
33
+ from wmo.providers.base import DEFAULT_MAX_TOKENS, Message, Provider
34
+ from wmo.retrieval import Retriever
35
+ from wmo.retrieval.leakfree import DemoRetriever
36
+
37
+ # The single named component GEPA evolves: the specialized env (system) prompt.
38
+ ENV_PROMPT_COMPONENT = "env_prompt"
39
+
40
+ # Concurrent rollout+judge calls per GEPA evaluation batch (I/O bound; modest for rate limits).
41
+ _EVAL_CONCURRENCY = 4
42
+
43
+ # Called once per judged rollout: (rollouts_done, mean_score_so_far). Used to drive build progress.
44
+ RolloutCallback = Callable[[int, float | None], None]
45
+
46
+
47
+ # --- prediction helper (provider-only; no engine import, to avoid an engine<->optimize cycle) ----
48
+
49
+
50
+ def predict_observation(
51
+ provider: Provider,
52
+ prompt: str,
53
+ task: str | None,
54
+ state: EnvState,
55
+ action: Action,
56
+ demos: list[Step],
57
+ history: list[Step] | None = None,
58
+ *,
59
+ knowledge: str | None = None,
60
+ reasoning: bool = False,
61
+ confidence: bool = False,
62
+ confidence_why: bool = False,
63
+ max_retrieved_observation_chars: int | None = None,
64
+ ) -> Observation:
65
+ """Predict the observation for (state, action) under `prompt`, using only a Provider.
66
+
67
+ This is the single rollout primitive GEPA and replay use. It assembles the prompt with the
68
+ shared `wmo.core.render.build_env_prompt` and parses the completion with the shared
69
+ `parse_observation` — the exact assembly AND output contract the serving engine uses — so the
70
+ predicted observation (content + is_error + state_note) matches what the world model produces.
71
+ `knowledge`/`reasoning` mirror the serving engine's agentic mode (grounding stays serve-only:
72
+ optimization and eval rollouts never touch the network beyond the provider).
73
+ `confidence`/`confidence_why` add the verbalized-confidence contract fields (WS-A6). GEPA's
74
+ own optimize path never sets them — a prompt must not be evolved against a model that is
75
+ also emitting a gameable confidence field (D75).
76
+
77
+ Rollouts run deterministically: the providers (Opus 4.8 / GPT 5.5) reject sampling params, so no
78
+ temperature is forwarded.
79
+ """
80
+ system, user = build_env_prompt(
81
+ prompt,
82
+ task,
83
+ state,
84
+ action,
85
+ history=history,
86
+ demos=demos,
87
+ knowledge=knowledge,
88
+ reasoning=reasoning,
89
+ confidence=confidence,
90
+ confidence_why=confidence_why,
91
+ max_retrieved_observation_chars=max_retrieved_observation_chars,
92
+ )
93
+ completion = provider.complete(
94
+ system, [Message(role="user", content=user)], temperature=0.0, max_tokens=DEFAULT_MAX_TOKENS
95
+ )
96
+ return parse_observation(completion.text)
97
+
98
+
99
+ VERIFY_INSTRUCTION = (
100
+ "\n\nYOUR DRAFT RESPONSE:\n{draft}\n\n"
101
+ "Re-examine the draft against the evidence above before answering: the gates the environment"
102
+ " itself enforces, the interaction history, how similar commands behaved in the examples, and"
103
+ " any exact computations (counts, off-by-one, trailing newlines). If the draft is right,"
104
+ " return it unchanged — do not invent differences. Reply with ONLY the corrected JSON object"
105
+ " in the required format."
106
+ )
107
+
108
+
109
+ def verify_observation(
110
+ provider: Provider,
111
+ prompt: str,
112
+ task: str | None,
113
+ state: EnvState,
114
+ action: Action,
115
+ draft: Observation,
116
+ demos: list[Step],
117
+ history: list[Step] | None = None,
118
+ *,
119
+ knowledge: str | None = None,
120
+ reasoning: bool = False,
121
+ confidence: bool = False,
122
+ confidence_why: bool = False,
123
+ max_retrieved_observation_chars: int | None = None,
124
+ ) -> Observation:
125
+ """Second-pass self-check: re-present the full evidence plus the draft, return the revision.
126
+
127
+ The "adding steps" lever: one extra completion that audits the draft against gates, history,
128
+ examples, and arithmetic. Measured via the `reason+verify` ablation mode before any engine
129
+ adoption — it doubles the per-step provider cost, so it must earn its keep empirically.
130
+ """
131
+ system, user = build_env_prompt(
132
+ prompt,
133
+ task,
134
+ state,
135
+ action,
136
+ history=history,
137
+ demos=demos,
138
+ knowledge=knowledge,
139
+ reasoning=reasoning,
140
+ confidence=confidence,
141
+ confidence_why=confidence_why,
142
+ max_retrieved_observation_chars=max_retrieved_observation_chars,
143
+ )
144
+ verify_user = user + VERIFY_INSTRUCTION.format(draft=dumps_observation_contract(draft))
145
+ completion = provider.complete(
146
+ system,
147
+ [Message(role="user", content=verify_user)],
148
+ temperature=0.0,
149
+ max_tokens=DEFAULT_MAX_TOKENS,
150
+ )
151
+ return parse_observation(completion.text)
152
+
153
+
154
+ PROFILE_SYSTEM = (
155
+ "You maintain the belief state of a simulated environment. From the interaction history you"
156
+ " are shown, write the CURRENT environment profile: what is running, installed, or existing"
157
+ " RIGHT NOW. This is a REVISED state, not an event log — a belief later contradicted by the"
158
+ " history must not appear (a killed server is DOWN, an overwritten file has its NEW content)."
159
+ " Cover: services/processes and their state, installed packages/versions, files created or"
160
+ " modified (with their current relevant content), auth/session state, and pending effects."
161
+ " At most 15 terse bullet lines. Reply with ONLY the bullets."
162
+ )
163
+
164
+
165
+ def distill_profile(
166
+ provider: Provider,
167
+ task: str | None,
168
+ history: list[Step],
169
+ ) -> str | None:
170
+ """One completion: revise the session history into a compact current-state belief profile.
171
+
172
+ The eval face of the `profile` lever (the serve face revises incrementally via the
173
+ `state_update` contract field): open-loop replay derives the profile from the teacher-forced
174
+ history each step. Returns None when there is no history to digest.
175
+ """
176
+ if not history:
177
+ return None
178
+ rendered = "\n".join(
179
+ f"{encode_state_action(h.state_before, h.action)}\n"
180
+ f"OBSERVATION (is_error={h.observation.is_error}): {h.observation.content}"
181
+ for h in history
182
+ )
183
+ user = f"TASK:\n{task or '(none)'}\n\nINTERACTION HISTORY:\n{rendered}"
184
+ completion = provider.complete(
185
+ PROFILE_SYSTEM,
186
+ [Message(role="user", content=user)],
187
+ temperature=0.0,
188
+ max_tokens=DEFAULT_MAX_TOKENS,
189
+ )
190
+ text = completion.text.strip()
191
+ return text or None
192
+
193
+
194
+ # --- GEPA adapter --------------------------------------------------------------------------------
195
+
196
+
197
+ @dataclass
198
+ class _EvalStep:
199
+ """A held-out step bundled with the demos the serving world model would retrieve for it, plus
200
+ the teacher-forced `history` (the recorded steps before it in its trace).
201
+
202
+ This is GEPA's DataInst. Bundling demos + history with the step (not a side lookup) keeps
203
+ evaluation self-contained and robust to however the engine slices/forwards the dataset. `demos`
204
+ is empty in the zero-shot configuration (no embedder); `history` is the recorded prefix so a
205
+ candidate prompt is scored predicting each step WITH its prior turns in scope — matching serving
206
+ (which passes `session.history`) and replay eval (which passes the recorded prefix).
207
+ """
208
+
209
+ step: Step
210
+ demos: list[Step]
211
+ history: list[Step]
212
+
213
+
214
+ @dataclass
215
+ class _StepTrajectory:
216
+ """Per-example trace captured during evaluation, consumed by make_reflective_dataset."""
217
+
218
+ step: Step
219
+ predicted: Observation
220
+ score: float
221
+ critique: str
222
+ valid: bool = True # False = the judge failed on this step (see JudgeResult.valid)
223
+
224
+
225
+ class WorldModelGEPAAdapter(GEPAAdapter[_EvalStep, _StepTrajectory, Observation]):
226
+ """Bridges the world model to the GEPA engine.
227
+
228
+ - DataInst is an `_EvalStep`: a held-out `Step` (its `state_before`, `action`, `task` are the
229
+ input; its `observation` is the ground truth) plus its retrieved `demos`.
230
+ - A candidate is `{ENV_PROMPT_COMPONENT: <prompt text>}`.
231
+ - Scores are judge scores in 0..1 (higher is better), aggregated by GEPA via sum/mean.
232
+
233
+ RAG-aware: each step is evaluated with the SAME retrieved demos the serving world model would
234
+ use (DreamGym top-k), so GEPA optimizes the prompt under serving conditions rather than a
235
+ zero-shot one. Retrieval depends on (state, action) — not on the candidate prompt — so demos are
236
+ precomputed once (see the module-level `_eval_steps`) and reused across every candidate.
237
+ """
238
+
239
+ def __init__(
240
+ self,
241
+ provider: Provider,
242
+ judge: Judge,
243
+ on_rollout: RolloutCallback | None = None,
244
+ on_activity: Callable[[str], None] | None = None,
245
+ ) -> None:
246
+ self._provider = provider
247
+ self._judge = judge
248
+ self._on_rollout = on_rollout
249
+ self._on_activity = on_activity
250
+ self._rollouts = 0
251
+ self._score_sum = 0.0
252
+
253
+ def evaluate(
254
+ self,
255
+ batch: list[_EvalStep],
256
+ candidate: dict[str, str],
257
+ capture_traces: bool = False,
258
+ ) -> EvaluationBatch[_StepTrajectory, Observation]:
259
+ prompt = candidate[ENV_PROMPT_COMPONENT]
260
+ if not batch:
261
+ return EvaluationBatch(
262
+ outputs=[], scores=[], trajectories=[] if capture_traces else None
263
+ )
264
+ if self._on_activity is not None:
265
+ self._on_activity(f"evaluating candidate on {len(batch)} steps…")
266
+
267
+ def eval_one(item: _EvalStep) -> tuple[Observation, float, str, bool]:
268
+ step = item.step
269
+ try:
270
+ predicted = predict_observation(
271
+ self._provider,
272
+ prompt,
273
+ step.task,
274
+ step.state_before,
275
+ step.action,
276
+ demos=item.demos,
277
+ history=item.history,
278
+ )
279
+ except Exception as exc: # noqa: BLE001 - per-example failure must not abort the run
280
+ # A rollout failure IS world-model signal, so it keeps its 0.0 and stays
281
+ # valid / in the reflective dataset.
282
+ return Observation(content="", is_error=True), 0.0, f"Rollout failed: {exc}", True
283
+ try:
284
+ result = self._judge.score(predicted, step.observation, step)
285
+ except Exception as exc: # noqa: BLE001 - judge infra failure ≠ world-model failure
286
+ # A judge call that RAISES (throttle, 5xx) says nothing about the prediction:
287
+ # route it through the same valid=False machinery as a malformed reply, and keep
288
+ # the prediction the model actually produced.
289
+ return predicted, 0.0, f"Judge call failed: {exc}", False
290
+ return predicted, result.score, result.critique, result.valid
291
+
292
+ # Rollout+judge calls are I/O bound; evaluate the batch concurrently (order preserved by
293
+ # index) and emit callbacks from THIS thread as results land — the live display and the
294
+ # run tracker see a serial stream.
295
+ results: list[tuple[Observation, float, str, bool] | None] = [None] * len(batch)
296
+ with ThreadPoolExecutor(max_workers=min(_EVAL_CONCURRENCY, len(batch))) as pool:
297
+ futures = {pool.submit(eval_one, item): i for i, item in enumerate(batch)}
298
+ landed = 0
299
+ for future in as_completed(futures):
300
+ index = futures[future]
301
+ outcome = future.result()
302
+ results[index] = outcome
303
+ landed += 1
304
+ _, score, critique, valid = outcome
305
+ self._note_rollout(score)
306
+ if self._on_activity is not None:
307
+ if valid:
308
+ note = f" — {critique.strip()[:110]}" if critique.strip() else ""
309
+ self._on_activity(f"[{landed}/{len(batch)}] fidelity {score:.2f}{note}")
310
+ else:
311
+ self._on_activity(f"[{landed}/{len(batch)}] judge invalid")
312
+
313
+ outputs = [r[0] for r in results if r is not None]
314
+ raw_scores = [r[1] for r in results if r is not None]
315
+ valids = [r[3] for r in results if r is not None]
316
+ # A judge failure (valid=False) says nothing about the prediction. GEPA needs one score
317
+ # per example, so exclusion isn't possible here (unlike replay/eval): impute the mean of
318
+ # the batch's valid scores rather than a phantom 0.0 that would make GEPA hill-climb
319
+ # judge noise. Known trade-off: the imputed value is aggregate-neutral but not neutral
320
+ # for per-instance Pareto comparison (the candidate neither earned nor lost that slot);
321
+ # with the judge's own retry, invalid verdicts are rare enough that this beats both
322
+ # alternatives (0.0 punishes judge outages; dropping the instance breaks GEPA's
323
+ # aligned-scores contract). All-invalid batches keep their zeros (no signal to impute).
324
+ earned = [s for s, ok in zip(raw_scores, valids, strict=True) if ok]
325
+ scores = raw_scores
326
+ if earned and len(earned) < len(raw_scores):
327
+ neutral = sum(earned) / len(earned)
328
+ scores = [s if ok else neutral for s, ok in zip(raw_scores, valids, strict=True)]
329
+ trajectories: list[_StepTrajectory] | None = None
330
+ if capture_traces:
331
+ # Trajectories carry the raw pre-imputation score with `valid` marking judge
332
+ # failures; `EvaluationBatch.scores` is the (possibly imputed) fitness GEPA sees.
333
+ trajectories = [
334
+ _StepTrajectory(
335
+ step=item.step, predicted=r[0], score=r[1], critique=r[2], valid=r[3]
336
+ )
337
+ for item, r in zip(batch, results, strict=True)
338
+ if r is not None
339
+ ]
340
+ return EvaluationBatch(outputs=outputs, scores=scores, trajectories=trajectories)
341
+
342
+ def make_reflective_dataset(
343
+ self,
344
+ candidate: dict[str, str],
345
+ eval_batch: EvaluationBatch[_StepTrajectory, Observation],
346
+ components_to_update: list[str],
347
+ ) -> Mapping[str, Sequence[Mapping[str, JsonValue]]]:
348
+ if self._on_activity is not None:
349
+ count = len(eval_batch.trajectories or [])
350
+ self._on_activity(f"distilling {count} scored steps into reflection examples…")
351
+ records: list[Mapping[str, JsonValue]] = []
352
+ # Judge failures carry parse-error critiques ("Unparseable judge reply ...") that would
353
+ # steer reflection at a non-existent world-model defect — drop them. If the judge failed
354
+ # on the whole batch, fall back to everything (an empty reflective dataset would break
355
+ # GEPA's mutation step outright) but with the judge-noise critiques scrubbed: reflection
356
+ # can still learn from predicted-vs-expected text without chasing parse errors.
357
+ trajectories = list(eval_batch.trajectories or [])
358
+ valid_trajectories = [traj for traj in trajectories if traj.valid]
359
+ for traj in valid_trajectories or trajectories:
360
+ # The same canonical (state, action) text the model saw at prediction time.
361
+ state_action = encode_state_action(traj.step.state_before, traj.step.action)
362
+ feedback = (
363
+ f"score={traj.score:.2f}. {traj.critique} "
364
+ f"Expected (real) observation: {traj.step.observation.content}"
365
+ if traj.valid
366
+ else "(judge unavailable for this step — compare the output to the expected "
367
+ f"observation directly.) Expected (real) observation: "
368
+ f"{traj.step.observation.content}"
369
+ )
370
+ records.append(
371
+ {
372
+ "Inputs": {
373
+ "task": traj.step.task or "(none)",
374
+ "state_action": state_action,
375
+ },
376
+ "Generated Outputs": traj.predicted.content,
377
+ "Feedback": feedback,
378
+ }
379
+ )
380
+ # GEPA only ever asks us to update the components it selected; we own a single one.
381
+ return {component: records for component in components_to_update}
382
+
383
+ def _note_rollout(self, score: float) -> None:
384
+ """Tick the rollout counter + running MEAN score and notify the callback, if any.
385
+
386
+ Reports the running mean across rollouts, not max-over-single-steps. The old max saturated
387
+ to 1.000 the instant any one step scored perfectly (common), making the progress display
388
+ meaningless — it always read "best held-out 1.000" regardless of real fidelity.
389
+ """
390
+ self._rollouts += 1
391
+ self._score_sum += score
392
+ if self._on_rollout is not None:
393
+ self._on_rollout(self._rollouts, self._score_sum / self._rollouts)
394
+
395
+
396
+ # --- reflection LM adapter -----------------------------------------------------------------------
397
+
398
+ _REFLECTION_SYSTEM = (
399
+ "You improve the system prompt for an LLM that simulates an environment for an AI agent. You "
400
+ "distill reusable knowledge about the environment (output conventions, domain facts, id/value "
401
+ "patterns, error and empty-result behaviors) from feedback on the model's mispredictions, and "
402
+ "ADD it to the prompt so future observations are more faithful. Preserve the existing rules, "
403
+ "add to a growing notes section rather than rewriting — full rewrites regress cases that "
404
+ "already pass. Keep additions general across actions, not tied to one example's literal values."
405
+ )
406
+
407
+ # GEPA's reflection prompt template (replaces the library default). `<curr_param>` is the current
408
+ # prompt; `<side_info>` is the per-example inputs/outputs/feedback.
409
+ #
410
+ # Design (from the GEPA paper + gepa-ai/gepa): GEPA's edge over plain RAG is that it distills
411
+ # reusable, domain-specific KNOWLEDGE from training traces into the prompt — the library's own
412
+ # default asks the reflector to capture "niche and domain-specific factual information" and any
413
+ # "generalizable strategy". Retrieval only surfaces similar examples at serve time; a prompt that
414
+ # already encodes the environment's output conventions, id/value patterns, and error behaviors
415
+ # helps even when retrieval misses. So we ENCOURAGE additive knowledge accumulation — into a
416
+ # dedicated growing section, leaving the hand-tuned rules intact (a full rewrite empirically
417
+ # regressed many already-passing steps to fix a handful). Placeholders required by GEPA.
418
+ _REFLECTION_PROMPT_TEMPLATE = """You are improving the prompt for an LLM that role-plays an \
419
+ ENVIRONMENT: it reads an agent's action (a tool call or command) and must output exactly what the \
420
+ real system would return — the same JSON shape, field names, error format, and success/empty \
421
+ behavior the real environment produces.
422
+
423
+ Current prompt:
424
+
425
+ ```
426
+ <curr_param>
427
+ ```
428
+
429
+ Below are examples where the current prompt was used, each with the action, the model's predicted \
430
+ observation, the REAL observation, and feedback/score:
431
+
432
+ <side_info>
433
+
434
+ FIRST, diagnose. For each low-scoring example, classify what actually went wrong before writing \
435
+ anything:
436
+ a. Wrong OUTCOME (predicted an error/empty where the real call succeeded, or vice versa);
437
+ b. Wrong SHAPE (fields, ordering, wrapping, formatting, missing envelope like returncode);
438
+ c. Wrong DERIVABLE value (the answer is computable from inputs present in the action/history -
439
+ e.g. counting words given in the command, echoing a value the session already established);
440
+ d. Wrong UNKNOWABLE value (live API data, uncaptured file contents - nothing in the prompt can
441
+ fix these; getting shape and outcome right is the ceiling);
442
+ e. Ignored EVIDENCE (retrieved examples or session history contained the exact or near-exact
443
+ answer and the model deviated from it).
444
+ Only classes a, b, c, and e are fixable by prompt guidance. Do NOT write notes that try to fix \
445
+ class d - say in one line why those examples are unknowable and move on.
446
+
447
+ THEN improve the prompt, following these rules:
448
+
449
+ - PRESERVE the existing prompt's rules and structure. Do not delete or reword working guidance — a \
450
+ rewrite reliably regresses the many cases that already pass. Improve by ADDING, not replacing.
451
+ - Make each addition TARGETED: a short note traceable to a diagnosed failure class above, \
452
+ describing the class of situation and the rule - never one example's literal ids/values (those \
453
+ change per episode). Add at most a handful of notes per round; quality over quantity.
454
+ - Put every addition in a single dedicated, growing section at the END of the prompt (create it \
455
+ if absent, e.g. "Environment-specific notes:") rather than interleaving with the core rules, so \
456
+ knowledge compounds across rounds without disturbing working guidance.
457
+ - Keep that notes section COMPACT: before adding a note, check whether an existing note already \
458
+ covers it - extend or sharpen that note instead of adding a near-duplicate. A bloated prompt \
459
+ dilutes the core rules and regresses cases that pass today.
460
+ - EVIDENCE PRECEDENCE (the single most important behavior - never write a note that violates it): \
461
+ when the interaction history, current state, or a retrieved similar example shows the exact or \
462
+ near-exact answer for this action (same entity, same file, same query), REPRODUCE that evidence \
463
+ verbatim. Distilled general knowledge is a fallback for when evidence is absent - it must never \
464
+ override concrete evidence. Notes you add are hints for the evidence-free case only.
465
+ - Never add a rule that flips OUTCOMES based on how often something happened in these examples. \
466
+ That an environment often rate-limits, errors, or returns empty does NOT mean the current action \
467
+ will - outcome must be decided from the current step's own evidence (state, history, the action \
468
+ itself). "Usually fails/empty here" priors break every case that succeeds.
469
+ - For VALUES, teach the model to distinguish and say which applies: (1) DERIVABLE from the action \
470
+ itself or the session - compute or copy exactly, never approximate; (2) external and genuinely \
471
+ unknowable - invent plausible, internally consistent values while keeping shape and outcome \
472
+ right, defaulting to the modal SUCCESS result unless this step's own evidence says otherwise.
473
+ - Reusable knowledge worth accumulating: output conventions (exact schema, field names, ordering, \
474
+ raw vs wrapped, empty/error formats, what silent success looks like), domain facts (id/code \
475
+ formats, value ranges, units, status enums, how results relate to arguments), and behavioral \
476
+ rules (which actions are deterministic vs unknowable, found vs not-found).
477
+
478
+ Provide the full improved prompt (existing prompt + your additions) within ``` blocks."""
479
+
480
+
481
+ def _reflection_lm( # noqa: ANN202 - returns gepa's LanguageModel callable
482
+ provider: Provider, on_activity: Callable[[str], None] | None = None
483
+ ):
484
+ """Wrap a Provider as GEPA's reflection LM: `(str | list[dict]) -> str`.
485
+
486
+ The reflection call is the longest silent gap in an iteration, so it brackets itself in the
487
+ activity stream ("proposing…" / "proposal ready").
488
+ """
489
+
490
+ def call(prompt: str | list[dict[str, JsonValue]]) -> str:
491
+ text = prompt if isinstance(prompt, str) else _flatten_chat(prompt)
492
+ if on_activity is not None:
493
+ on_activity("reflection: proposing an improved env prompt…")
494
+ completion = provider.complete(
495
+ _REFLECTION_SYSTEM,
496
+ [Message(role="user", content=text)],
497
+ temperature=1.0,
498
+ max_tokens=DEFAULT_MAX_TOKENS,
499
+ )
500
+ if on_activity is not None:
501
+ on_activity(f"reflection: proposal ready ({len(completion.text)} chars)")
502
+ return completion.text
503
+
504
+ return call
505
+
506
+
507
+ def _flatten_chat(messages: list[dict[str, JsonValue]]) -> str:
508
+ parts: list[str] = []
509
+ for msg in messages:
510
+ role = msg.get("role", "user")
511
+ content = msg.get("content", "")
512
+ parts.append(f"[{role}]\n{content}")
513
+ return "\n\n".join(parts)
514
+
515
+
516
+ # --- the optimizer -------------------------------------------------------------------------------
517
+
518
+
519
+ class _ActivityLogger:
520
+ """gepa `LoggerProtocol` sink that forwards narration to `on_activity`.
521
+
522
+ Every message contributes its FIRST line (selection, proposal, subsample verdicts, pareto
523
+ and merge updates all narrate this way); the proposed-prompt message continues with the full
524
+ multi-line prompt body, which would flood a fixed-height window, so continuations drop.
525
+ """
526
+
527
+ def __init__(self, emit: Callable[[str], None]) -> None:
528
+ self._emit = emit
529
+
530
+ def log(self, message: str) -> None:
531
+ line = message.split("\n", 1)[0].strip()
532
+ if line:
533
+ self._emit(line[:240])
534
+
535
+
536
+ class GEPAOptimizer:
537
+ """Reflective prompt evolution against the held-out trace split (drives the `gepa` engine)."""
538
+
539
+ def __init__(
540
+ self,
541
+ provider: Provider,
542
+ judge: Judge,
543
+ retriever: Retriever | None = None,
544
+ on_rollout: RolloutCallback | None = None,
545
+ *,
546
+ on_budget: Callable[[int], None] | None = None,
547
+ on_activity: Callable[[str], None] | None = None,
548
+ seed: int = 0,
549
+ ) -> None:
550
+ self._provider = provider
551
+ self._judge = judge
552
+ # `on_budget` receives the REAL translated max_metric_calls right before the run starts —
553
+ # callers reporting progress must size their bar with it, not with `budget` (iterations),
554
+ # or the bar finishes while GEPA is still burning valset calls.
555
+ self._on_budget = on_budget
556
+ self._on_activity = on_activity
557
+ # Optional retriever for RAG-aware evaluation. When None, GEPA evaluates zero-shot.
558
+ self._retriever = retriever
559
+ self._on_rollout = on_rollout
560
+ # The GEPA engine seed (minibatch sampling + candidate selection). Defaults to the
561
+ # historical 0; the research harness sweeps it for seed stability (docs/gepa_research.md).
562
+ self._seed = seed
563
+
564
+ def optimize(
565
+ self,
566
+ train: list[Trace],
567
+ test: list[Trace],
568
+ base_prompt: str,
569
+ budget: int,
570
+ *,
571
+ rag_corpus: list[Trace] | None = None,
572
+ hard_step_filter: Callable[[Step], bool] | None = None,
573
+ select_on_hard: bool = False,
574
+ recheck: list[Trace] | None = None,
575
+ minibatch_size: int = 3,
576
+ ) -> OptimizeResult:
577
+ """Run GEPA over optimization splits, optionally retrieving demos from another corpus.
578
+
579
+ `train`/`test` are GEPA's optimization data: minibatch examples and validation examples.
580
+ `hard_step_filter`, when given, restricts the GEPA TRAINSET (the minibatch pool reflection
581
+ draws from) to steps it accepts. Most steps are easy and score perfectly, so a random
582
+ reflection minibatch usually contains no failure to learn from ("all subsample scores
583
+ perfect. skipping" — a wasted iteration). Filtering the trainset to the informative/hard
584
+ steps concentrates reflection on the failure modes that actually have headroom.
585
+ `select_on_hard` (only meaningful with `hard_step_filter`) additionally filters the VALSET
586
+ that GEPA selects candidates on: when overall val fidelity is near-saturated, a candidate
587
+ that fixes the few hard cases barely moves the mean and loses to base on noise, so pareto
588
+ keeps base. Selecting on hard-step fidelity lets the real improvement win. NOTE: with
589
+ `select_on_hard`, the returned `metrics.held_out_accuracy` is the HARD-subset mean, not the
590
+ full-val mean — report a separate full-set test number for cross-run comparability. (Also:
591
+ GEPA's merge proposer needs >= merge_val_overlap_floor (default 5) val examples, so on a
592
+ hard-filtered valset smaller than that, merge silently no-ops.)
593
+ `budget` is the number of optimization ITERATIONS (candidate prompts to propose and fully
594
+ evaluate) — NOT a raw metric-call count. It is translated to GEPA's `max_metric_calls`
595
+ budget by `_metric_call_budget`, which adds the one-time seed valset evaluation so the
596
+ iterations actually fund exploration. (Passing `budget` straight through as
597
+ `max_metric_calls` is the classic footgun: if `budget < len(valset)`, GEPA spends the whole
598
+ budget validating the seed prompt and proposes ZERO candidates — "no lift" that is really
599
+ "no search".)
600
+ `rag_corpus`, when supplied, is the replay-buffer corpus used for retrieved demos during
601
+ those GEPA evaluations. Keeping it separate lets callers optimize a prompt on a dev split
602
+ while using an independently chosen RAG/index split, instead of forcing the GEPA trainset to
603
+ double as the retrieval corpus. When omitted, the historical behavior is preserved: demos
604
+ come from the GEPA train source.
605
+ `recheck`, when supplied, is an INDEPENDENT trace set (disjoint from the valset) that the
606
+ stagnant-or-improve acceptance re-check evaluates on instead of the valset. A winner can
607
+ genuinely beat base on a small (possibly biased) selection valset and still not transfer;
608
+ re-checking on steps GEPA never selected against catches that without touching test data.
609
+ `minibatch_size` is the reflection minibatch (GEPA paper: ~8; historical default here: 3).
610
+ When most steps score perfectly, the chance a random minibatch contains zero failures - a
611
+ wasted "all subsample scores perfect, skipping" iteration - falls exponentially with this
612
+ size (~0.8^b), so raising it is the principled fix for skip-wasted budget. Capped at the
613
+ trainset size.
614
+ """
615
+ train_steps = [step for trace in train for step in trace.steps]
616
+ val_steps = [step for trace in test for step in trace.steps]
617
+ # GEPA samples minibatches from the trainset; fall back to val when train is empty.
618
+ train_src = train if train_steps else test
619
+ val_src = test if val_steps else train
620
+ if not (train_steps or val_steps) or budget <= 0:
621
+ # Nothing to optimize against (or no budget): the base prompt is the only candidate.
622
+ return OptimizeResult(prompt=base_prompt, frontier=[base_prompt])
623
+
624
+ # RAG-aware, leak-free: retrieve demos from the configured RAG corpus only, never a step's
625
+ # own trace. By default, preserve the original behavior and use GEPA's train source.
626
+ # Built once and reused for both splits (retrieval is independent of the candidate prompt).
627
+ demo_src = train_src if rag_corpus is None else rag_corpus
628
+ demos = DemoRetriever(self._retriever, demo_src)
629
+ trainset = _eval_steps(train_src, demos)
630
+ if hard_step_filter is not None:
631
+ hard = [es for es in trainset if hard_step_filter(es.step)]
632
+ if hard: # keep the full trainset if the filter would empty it (never starve GEPA)
633
+ trainset = hard
634
+ valset = _eval_steps(val_src, demos)
635
+ # The acceptance re-check must run on the FULL validation distribution even when selection
636
+ # is hard-filtered below - re-checking base vs winner on base's known-failure steps would
637
+ # bias base_fresh low by construction and neutralize the guard.
638
+ full_valset = valset
639
+ if select_on_hard and hard_step_filter is not None:
640
+ # Select candidates on hard-step val fidelity, not the full (near-saturated) val set.
641
+ # When most steps score ~perfectly, a candidate that genuinely fixes the few hard cases
642
+ # barely moves the overall mean and loses to base on noise; pareto then keeps base. Same
643
+ # filter as the trainset, so selection optimizes exactly the failures we target.
644
+ hard_val = [es for es in valset if hard_step_filter(es.step)]
645
+ if hard_val:
646
+ valset = hard_val
647
+ adapter = WorldModelGEPAAdapter(
648
+ self._provider, self._judge, self._on_rollout, on_activity=self._on_activity
649
+ )
650
+ # Route gepa's own narration (iteration selections, proposed prompt edits, subsample
651
+ # scores) into the activity stream; the default StdOutLogger would fight a live display.
652
+ logger = _ActivityLogger(self._on_activity) if self._on_activity is not None else None
653
+ minibatch = min(minibatch_size, len(trainset))
654
+ metric_calls = _metric_call_budget(budget, len(valset), minibatch)
655
+ if self._on_budget is not None:
656
+ self._on_budget(metric_calls)
657
+ if self._on_activity is not None:
658
+ # First line lands before any LLM call: the activity window must never sit empty
659
+ # while the (long) seed valset evaluation runs.
660
+ self._on_activity(
661
+ f"scoring the seed prompt on {len(valset)} valset steps "
662
+ f"({metric_calls} metric calls budgeted)…"
663
+ )
664
+ result = gepa.optimize(
665
+ seed_candidate={ENV_PROMPT_COMPONENT: base_prompt},
666
+ trainset=trainset,
667
+ valset=valset,
668
+ adapter=adapter,
669
+ reflection_lm=_reflection_lm(self._provider, self._on_activity),
670
+ reflection_prompt_template=_REFLECTION_PROMPT_TEMPLATE,
671
+ candidate_selection_strategy="pareto",
672
+ # Merge is a headline GEPA feature: combine complementary lessons from two Pareto-front
673
+ # candidates (each having learned different environment facts) into one. Off by default
674
+ # in the library; we enable it so knowledge accumulated on different failure modes
675
+ # composes instead of competing.
676
+ use_merge=True,
677
+ max_metric_calls=metric_calls,
678
+ reflection_minibatch_size=minibatch,
679
+ display_progress_bar=False,
680
+ raise_on_exception=False,
681
+ logger=logger,
682
+ seed=self._seed,
683
+ )
684
+
685
+ best = _candidate_text(result.candidates[result.best_idx])
686
+ frontier = _frontier_prompts(result)
687
+ held_out = float(result.val_aggregate_scores[result.best_idx])
688
+ reverted = False
689
+ recheck_rollouts = 0
690
+ if best != base_prompt:
691
+ # Stagnant-or-improve acceptance re-check. GEPA promotes by argmax over SINGLE-sample
692
+ # valset evaluations, and rollout+judge scores are noisy run-to-run even at T=0 (the
693
+ # same base prompt measured 0.68-0.76 on one fixed 30-step valset across runs) - so
694
+ # argmax systematically favors noise-inflated candidates (the winner's curse), which is
695
+ # how an "optimized" prompt can leave a run WORSE than base. Re-evaluating base and
696
+ # winner on a fresh, PAIRED pass (same steps, same window - a stored earlier score
697
+ # would not control for temporal drift) breaks that correlation: keep the winner only
698
+ # if its win replicates. Costs two eval passes - small next to the search itself. With
699
+ # `recheck` traces, the re-check runs on that independent (valset-disjoint) sample
700
+ # instead, which also catches winners whose valset win is real but biased (e.g. a
701
+ # step-capped valset over-representing short traces). Always falls back to the FULL
702
+ # valset - never the hard-filtered selection subset - and to the full valset again if
703
+ # the recheck traces carry no steps (0.0-vs-0.0 would silently keep any winner).
704
+ recheck_steps = _eval_steps(recheck, demos) if recheck else full_valset
705
+ if not recheck_steps:
706
+ recheck_steps = full_valset
707
+ base_fresh = _mean_valset_score(adapter, recheck_steps, base_prompt)
708
+ best_fresh = _mean_valset_score(adapter, recheck_steps, best)
709
+ recheck_rollouts = 2 * len(recheck_steps)
710
+ if best_fresh < base_fresh:
711
+ best = base_prompt
712
+ reverted = True
713
+ held_out = base_fresh
714
+ # The re-check affirmatively rejected the search's winner: the returned prompt must
715
+ # lead the frontier, or a caller picking "the top frontier candidate" deploys the
716
+ # very prompt the check proved worse than base.
717
+ frontier = [base_prompt] + [p for p in frontier if p != base_prompt]
718
+ else:
719
+ held_out = best_fresh
720
+ return OptimizeResult(
721
+ prompt=best,
722
+ frontier=frontier,
723
+ metrics=OptimizeMetrics(
724
+ # total_metric_calls covers the search only; the re-check's paired passes go
725
+ # through the same adapter (and tick the same progress callback), so count them.
726
+ held_out_accuracy=held_out,
727
+ rollouts_used=int(result.total_metric_calls or 0) + recheck_rollouts,
728
+ reverted_to_base=reverted,
729
+ ),
730
+ )
731
+
732
+
733
+ def _mean_valset_score(
734
+ adapter: WorldModelGEPAAdapter, valset: list[_EvalStep], prompt: str
735
+ ) -> float:
736
+ """One fresh evaluation pass of `prompt` over `valset` -> mean judge score (0 if empty).
737
+
738
+ Raises on a total judge outage rather than returning 0.0: the acceptance re-check compares
739
+ two of these means, and an all-invalid pass (raw zeros, nothing to impute) would silently
740
+ revert a good winner or wave through a bad one on a number that says nothing about fidelity.
741
+ Same contract as `wmo.research.pipeline.score_prompt`.
742
+
743
+ Raises:
744
+ RuntimeError: if steps were scored but every judgement was invalid (judge outage).
745
+ """
746
+ batch = adapter.evaluate(valset, {ENV_PROMPT_COMPONENT: prompt}, True)
747
+ trajectories = batch.trajectories or []
748
+ if trajectories and all(not t.valid for t in trajectories):
749
+ raise RuntimeError(
750
+ f"judge outage during the acceptance re-check ({len(trajectories)} steps, zero valid "
751
+ "judgements) - the base-vs-winner comparison would be decided by noise; check the "
752
+ "judge model, quota, and region before rerunning"
753
+ )
754
+ return sum(batch.scores) / len(batch.scores) if batch.scores else 0.0
755
+
756
+
757
+ def _metric_call_budget(iterations: int, valset_size: int, minibatch: int) -> int:
758
+ """Translate `iterations` (candidates to try) into GEPA's `max_metric_calls`.
759
+
760
+ GEPA's budget is a raw count of per-example metric calls. Each optimization iteration costs
761
+ roughly one reflection minibatch eval (~`minibatch` calls) plus, when a candidate looks
762
+ promising, a full valset eval (~`valset_size` calls). On top of that, GEPA always spends one
763
+ full valset eval up front to score the seed prompt. So a budget that merely equals the desired
764
+ iteration count starves the search — the seed eval alone can exceed it (this was the
765
+ "GEPA proposes nothing" bug: budget 50 < valset 84).
766
+
767
+ We size the budget as: seed eval + iterations * (minibatch + full valset), with a floor of two
768
+ valset passes so even `iterations=1` can evaluate the seed AND one real candidate.
769
+ """
770
+ per_iter = minibatch + valset_size
771
+ return max(2 * valset_size, valset_size + max(1, iterations) * per_iter)
772
+
773
+
774
+ def _eval_steps(traces: list[Trace], demos: DemoRetriever) -> list[_EvalStep]:
775
+ """Bundle each step with its (leak-free) demos AND its teacher-forced history.
776
+
777
+ `history` is the recorded steps before this one in its own trace, so a candidate prompt is
778
+ scored predicting the step WITH its prior turns in scope — matching serving and replay eval.
779
+ Demos still come from the train corpus (never the own trace); history is the within-trace
780
+ recorded prefix, which is the context the real environment actually had.
781
+ """
782
+ return [
783
+ _EvalStep(step=step, demos=demos.demos_for(trace.trace_id, step), history=trace.steps[:i])
784
+ for trace in traces
785
+ for i, step in enumerate(trace.steps)
786
+ ]
787
+
788
+
789
+ def _candidate_text(candidate: dict[str, str]) -> str:
790
+ return candidate[ENV_PROMPT_COMPONENT]
791
+
792
+
793
+ def _frontier_prompts(result: gepa.GEPAResult) -> list[str]:
794
+ """Collect the Pareto-frontier candidate prompts (deduped, best first)."""
795
+ frontier_idxs: set[int] = set()
796
+ for idxs in result.per_val_instance_best_candidates.values():
797
+ frontier_idxs.update(idxs)
798
+ if not frontier_idxs:
799
+ frontier_idxs = {result.best_idx}
800
+ ordered = sorted(frontier_idxs, key=lambda i: result.val_aggregate_scores[i], reverse=True)
801
+ prompts: list[str] = []
802
+ for i in ordered:
803
+ text = _candidate_text(result.candidates[i])
804
+ if text not in prompts:
805
+ prompts.append(text)
806
+ return prompts