world-model-optimizer 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llm_waterfall/LICENSE +21 -0
- llm_waterfall/__init__.py +53 -0
- llm_waterfall/adapters/__init__.py +36 -0
- llm_waterfall/adapters/anthropic.py +105 -0
- llm_waterfall/adapters/aws_mantle.py +47 -0
- llm_waterfall/adapters/azure_openai.py +71 -0
- llm_waterfall/adapters/base.py +51 -0
- llm_waterfall/adapters/bedrock.py +309 -0
- llm_waterfall/adapters/openai.py +130 -0
- llm_waterfall/classify.py +184 -0
- llm_waterfall/pricing.py +110 -0
- llm_waterfall/py.typed +0 -0
- llm_waterfall/types.py +295 -0
- llm_waterfall/waterfall.py +255 -0
- wmo/__init__.py +38 -0
- wmo/agents/__init__.py +7 -0
- wmo/agents/default.py +29 -0
- wmo/agents/meta.py +55 -0
- wmo/agents/optimizer.py +55 -0
- wmo/agents/project.py +928 -0
- wmo/cli/__init__.py +5 -0
- wmo/cli/agent_session.py +1123 -0
- wmo/cli/app.py +2489 -0
- wmo/cli/e2b_cmds.py +212 -0
- wmo/cli/eval_closed_loop.py +207 -0
- wmo/cli/harness_app.py +1147 -0
- wmo/cli/harness_distill.py +659 -0
- wmo/cli/hosted_session.py +880 -0
- wmo/cli/ingest_cmd.py +165 -0
- wmo/cli/model_roles.py +82 -0
- wmo/cli/platform_cmds.py +372 -0
- wmo/cli/route_app.py +274 -0
- wmo/cli/session_state.py +243 -0
- wmo/cli/ui.py +1107 -0
- wmo/cli/workspace_sync.py +504 -0
- wmo/config/__init__.py +60 -0
- wmo/config/card.py +129 -0
- wmo/config/config.py +367 -0
- wmo/config/dotenv.py +67 -0
- wmo/config/settings.py +128 -0
- wmo/config/store.py +177 -0
- wmo/conftest.py +19 -0
- wmo/connect/__init__.py +88 -0
- wmo/connect/apps.py +78 -0
- wmo/connect/brave.py +284 -0
- wmo/connect/connector.py +79 -0
- wmo/connect/credentials.py +164 -0
- wmo/connect/github.py +321 -0
- wmo/connect/google.py +627 -0
- wmo/connect/notion.py +790 -0
- wmo/connect/oauth.py +461 -0
- wmo/connect/slack.py +555 -0
- wmo/connect/store.py +199 -0
- wmo/connect/types.py +156 -0
- wmo/core/__init__.py +21 -0
- wmo/core/parsing.py +281 -0
- wmo/core/render.py +271 -0
- wmo/core/text.py +40 -0
- wmo/core/types.py +116 -0
- wmo/distill/__init__.py +14 -0
- wmo/distill/agents.py +140 -0
- wmo/distill/config.py +1006 -0
- wmo/distill/cost.py +437 -0
- wmo/distill/data.py +921 -0
- wmo/distill/deadlines.py +254 -0
- wmo/distill/fake_tinker.py +734 -0
- wmo/distill/gate.py +122 -0
- wmo/distill/loop.py +3499 -0
- wmo/distill/renderers.py +399 -0
- wmo/distill/rendering.py +620 -0
- wmo/distill/rollouts.py +726 -0
- wmo/distill/samples.py +195 -0
- wmo/distill/store.py +829 -0
- wmo/distill/teacher.py +714 -0
- wmo/distill/tokens.py +535 -0
- wmo/distill/tracking.py +552 -0
- wmo/distill/tripwire.py +411 -0
- wmo/distill/xtoken/byte_offsets.py +152 -0
- wmo/distill/xtoken/chunks.py +457 -0
- wmo/distill/xtoken/prompt_logprobs.py +475 -0
- wmo/distill/xtoken/teacher_render.py +346 -0
- wmo/engine/__init__.py +28 -0
- wmo/engine/autoconfig.py +367 -0
- wmo/engine/build.py +346 -0
- wmo/engine/demo.py +77 -0
- wmo/engine/eval_suites.py +245 -0
- wmo/engine/grounding.py +491 -0
- wmo/engine/knowledge.py +291 -0
- wmo/engine/loader.py +36 -0
- wmo/engine/play.py +92 -0
- wmo/engine/prompts.py +99 -0
- wmo/engine/replay.py +443 -0
- wmo/engine/reporting.py +58 -0
- wmo/engine/workspace.py +468 -0
- wmo/engine/world_model.py +568 -0
- wmo/env/__init__.py +22 -0
- wmo/env/base.py +121 -0
- wmo/env/closed_loop.py +229 -0
- wmo/env/episode.py +107 -0
- wmo/env/llm_agent.py +93 -0
- wmo/env/scenarios.py +73 -0
- wmo/evals/__init__.py +52 -0
- wmo/evals/agreement.py +110 -0
- wmo/evals/base.py +45 -0
- wmo/evals/closed_loop.py +480 -0
- wmo/evals/failover.py +96 -0
- wmo/evals/gold.py +127 -0
- wmo/evals/grid.py +394 -0
- wmo/evals/grid_plot.py +205 -0
- wmo/evals/harbor/__init__.py +27 -0
- wmo/evals/harbor/agent.py +573 -0
- wmo/evals/harbor/ctrf.py +171 -0
- wmo/evals/harbor/e2b_environment.py +587 -0
- wmo/evals/harbor/e2b_template_policy.py +144 -0
- wmo/evals/harbor/scorer.py +875 -0
- wmo/evals/harbor/tasks.py +140 -0
- wmo/evals/open_loop.py +194 -0
- wmo/evals/tasks.py +53 -0
- wmo/harness/__init__.py +51 -0
- wmo/harness/code_runtime.py +288 -0
- wmo/harness/create.py +1191 -0
- wmo/harness/delta.py +220 -0
- wmo/harness/doc.py +556 -0
- wmo/harness/e2b_ledger.py +342 -0
- wmo/harness/e2b_reap.py +476 -0
- wmo/harness/e2b_sandbox.py +350 -0
- wmo/harness/environment.py +35 -0
- wmo/harness/live_session.py +543 -0
- wmo/harness/mutate.py +343 -0
- wmo/harness/pi_e2b.py +1710 -0
- wmo/harness/pi_entry/entry.ts +268 -0
- wmo/harness/pi_entry/runner_frames.ts +92 -0
- wmo/harness/pi_entry/runner_live.ts +587 -0
- wmo/harness/pi_entry/runner_service.ts +270 -0
- wmo/harness/pi_entry/runner_stdio.ts +374 -0
- wmo/harness/pi_entry/runner_termination.ts +142 -0
- wmo/harness/pi_local.py +262 -0
- wmo/harness/pi_runtime.py +495 -0
- wmo/harness/pi_vendor.py +65 -0
- wmo/harness/population.py +509 -0
- wmo/harness/project_proposer.py +569 -0
- wmo/harness/proposer.py +977 -0
- wmo/harness/runner_link.py +619 -0
- wmo/harness/runtime.py +389 -0
- wmo/harness/scoring.py +247 -0
- wmo/harness/skills.py +116 -0
- wmo/harness/source_tree.py +319 -0
- wmo/harness/store.py +176 -0
- wmo/harness/tools.py +105 -0
- wmo/harness/vendor/manifest.sha256 +58 -0
- wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
- wmo/harness/vendor/pi-agent/LICENSE +21 -0
- wmo/harness/vendor/pi-agent/README.md +488 -0
- wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
- wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
- wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
- wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
- wmo/harness/vendor/pi-agent/docs/models.md +966 -0
- wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
- wmo/harness/vendor/pi-agent/package.json +60 -0
- wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
- wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
- wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
- wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
- wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
- wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
- wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
- wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
- wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
- wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
- wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
- wmo/harness/vendor/pi-agent/src/index.ts +44 -0
- wmo/harness/vendor/pi-agent/src/node.ts +2 -0
- wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
- wmo/harness/vendor/pi-agent/src/types.ts +428 -0
- wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
- wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
- wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
- wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
- wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
- wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
- wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
- wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
- wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
- wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
- wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
- wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
- wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
- wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
- wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
- wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
- wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
- wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
- wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
- wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
- wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
- wmo/harness/vendor/vendor_pi.sh +59 -0
- wmo/harness/workspace_patch.py +270 -0
- wmo/ingest/__init__.py +47 -0
- wmo/ingest/adapter.py +72 -0
- wmo/ingest/base.py +114 -0
- wmo/ingest/braintrust.py +339 -0
- wmo/ingest/detect.py +126 -0
- wmo/ingest/langfuse.py +291 -0
- wmo/ingest/langsmith.py +444 -0
- wmo/ingest/mastra.py +330 -0
- wmo/ingest/messages.py +170 -0
- wmo/ingest/normalize.py +679 -0
- wmo/ingest/otel_genai.py +69 -0
- wmo/ingest/otel_writer.py +100 -0
- wmo/ingest/phoenix.py +150 -0
- wmo/ingest/postgres.py +246 -0
- wmo/ingest/posthog.py +320 -0
- wmo/ingest/quality.py +28 -0
- wmo/ingest/stream.py +209 -0
- wmo/ingest/testdata/sample_otlp.json +60 -0
- wmo/ingest/testdata/sample_spans.jsonl +3 -0
- wmo/optimize/__init__.py +25 -0
- wmo/optimize/base.py +143 -0
- wmo/optimize/gepa.py +806 -0
- wmo/optimize/judge.py +262 -0
- wmo/optimize/judge_quality.py +359 -0
- wmo/optimize/knn.py +468 -0
- wmo/optimize/numeric.py +152 -0
- wmo/optimize/outcomes.py +103 -0
- wmo/optimize/policy.py +669 -0
- wmo/optimize/report.py +231 -0
- wmo/optimize/reward.py +129 -0
- wmo/optimize/routing.py +373 -0
- wmo/platform/__init__.py +6 -0
- wmo/platform/auth.py +115 -0
- wmo/platform/client.py +551 -0
- wmo/platform/credentials.py +126 -0
- wmo/platform/transfer.py +158 -0
- wmo/providers/__init__.py +40 -0
- wmo/providers/_bedrock_chat.py +155 -0
- wmo/providers/_openai_common.py +182 -0
- wmo/providers/_responses_common.py +472 -0
- wmo/providers/anthropic.py +134 -0
- wmo/providers/azure_openai.py +296 -0
- wmo/providers/base.py +300 -0
- wmo/providers/bedrock.py +312 -0
- wmo/providers/models.py +205 -0
- wmo/providers/openai.py +143 -0
- wmo/providers/openai_responses.py +240 -0
- wmo/providers/pool.py +170 -0
- wmo/providers/registry.py +73 -0
- wmo/providers/retry.py +151 -0
- wmo/providers/tinker.py +936 -0
- wmo/providers/waterfall.py +336 -0
- wmo/research/__init__.py +81 -0
- wmo/research/ablation.py +133 -0
- wmo/research/concurrency_plot.py +523 -0
- wmo/research/concurrency_run.py +240 -0
- wmo/research/concurrency_scaling.py +270 -0
- wmo/research/gepa_scaling.py +274 -0
- wmo/research/pipeline.py +198 -0
- wmo/research/scaling_split.py +82 -0
- wmo/research/scenario_fidelity.py +198 -0
- wmo/research/scenario_recovery.py +92 -0
- wmo/research/seed_stability.py +90 -0
- wmo/research/trace_scaling.py +348 -0
- wmo/retrieval/__init__.py +6 -0
- wmo/retrieval/embedders.py +105 -0
- wmo/retrieval/leakfree.py +52 -0
- wmo/retrieval/retriever.py +173 -0
- wmo/scenarios/__init__.py +58 -0
- wmo/scenarios/builder.py +152 -0
- wmo/scenarios/mining/__init__.py +27 -0
- wmo/scenarios/mining/clustering.py +171 -0
- wmo/scenarios/mining/facets.py +226 -0
- wmo/scenarios/mining/selection.py +220 -0
- wmo/scenarios/synthesis/__init__.py +6 -0
- wmo/scenarios/synthesis/scenario_set.py +63 -0
- wmo/scenarios/synthesis/synthesizer.py +85 -0
- wmo/scenarios/verification/__init__.py +17 -0
- wmo/scenarios/verification/judge.py +97 -0
- wmo/scenarios/verification/verify.py +135 -0
- wmo/serving/__init__.py +5 -0
- wmo/serving/builds.py +451 -0
- wmo/serving/chat.py +878 -0
- wmo/serving/endpoint_config.py +64 -0
- wmo/serving/savings.py +250 -0
- wmo/serving/server.py +553 -0
- wmo/serving/traces_source.py +206 -0
- wmo/telemetry.py +213 -0
- wmo/tracking/__init__.py +36 -0
- wmo/tracking/clock.py +24 -0
- wmo/tracking/metered.py +125 -0
- wmo/tracking/pricing.py +99 -0
- wmo/tracking/store.py +31 -0
- wmo/tracking/tracker.py +149 -0
- world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
- world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
- world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
- world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
wmo/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
|