GEPA
Reflective mutation over execution feedback, with Pareto-frontier candidate selection.
# GEPA (`gepa`)
> Reflective mutation over execution feedback, with Pareto-frontier candidate selection.
## Overview
GEPA ("Genetic-Pareto", Agrawal et al., ICLR 2026 Oral) replaces the scalar reward signal of RL with the *text* an evaluation actually produces. Its thesis: a rollout emits far more than a number — error messages, tracebacks, rubric violations, profiling output, compiler diagnostics — and an LLM that reads that trace can propose a targeted fix in one shot, where a policy gradient needs thousands of samples to infer the same thing. The paper reports outperforming GRPO by up to 19% with up to 35× fewer rollouts.
Two mechanisms carry the method. **Reflective mutation**: each iteration shows the reflection LM one candidate together with the feedback its own evaluation produced, and asks for a complete drop-in replacement — not an edit script. **Pareto-frontier selection**: the candidate pool keeps every accepted candidate forever and tracks, for each axis the search is measured on, which candidates hold the best value. A parent is drawn by pruning the candidates that are dominated (every axis they lead is also led by someone else) and then sampling proportionally to how many axes each survivor leads. A candidate that wins on one narrow axis is never starved, which is what keeps GEPA off the single-lineage hill climb that greedy selection collapses into (paper Table 3: +12.44% over greedy, +7.33% over beam search).
This scaffold ports the reference implementation ([gepa-ai/gepa](https://github.com/gepa-ai/gepa)), specifically its `optimize_anything` API — the entry point GEPA itself uses to optimize *code* rather than prompts (its bundled examples cover circle packing, ARC-AGI and cloud-scheduling policies). A galapagos task is one problem scored by one `evaluate(program_path)` call, which is exactly upstream's **single-instance search mode** (`dataset=None, valset=None`): the engine evaluates a single sentinel example, pins the reflection minibatch to 1, and the minibatch, the acceptance test and the "full valset" evaluation collapse onto that one evaluation. The frontier axes are then the task's own declared metrics.
## Algorithm
Each iteration follows the standard select → prompt → propose → evaluate → admit cycle:
1. **Select** — build the frontier map (axis → candidates holding its best value) from the pool, drop the dominated candidates, and sample one parent with probability proportional to the number of axes it leads. No inspirations: reflective mutation reflects on one candidate and its own feedback, never on a gallery of rivals.
2. **Prompt** — render GEPA's reflection template verbatim, with `<curr_param>` = the parent program and `<side_info>` = the reflective dataset: the parent's score block (`Scores (Higher is Better)`) followed by every diagnostic artifact the evaluator returned, walked into `# Example` / `## key` / `### subkey` markdown. The task card's `system_message` fills the system slot.
3. **Propose** — one reflection call. The response's complete replacement text is extracted from between the first and last ``` fence and becomes the child.
4. **Evaluate** — the task-supplied evaluator scores the child.
5. **Admit** — the acceptance test: the child enters the pool only if it beats its parent's score (strictly, by default). An accepted candidate updates the Pareto envelope of every axis it now leads.
```text
pool = [seed] # unbounded: nothing is ever evicted
front = {axis: (best_score, {holders})}
for step in range(max_iterations):
mapping = {axis: holders for axis in front}
mapping = remove_dominated(mapping, aggregate_scores) # illumination
parent = sample(mapping, weight=axes_led) # Algorithm 2
prompt = reflection_template(parent.code, side_info=parent.scores + parent.artifacts)
child = extract_between_outer_fences(llm(prompt)) # a full replacement
score(child)
if sum(child.scores) > sum(parent.scores): # StrictImprovementAcceptance
pool.append(child)
for axis, value in child.frontier_scores():
if value > front[axis]: front[axis] = (value, {child})
elif value == front[axis]: front[axis].holders.add(child)
```
### Frontier axes
`selection_policy.frontier_type` selects which axes the frontier is tracked over — upstream's `EngineConfig.frontier_type`, defaulting to `hybrid`:
| Value | Axes |
|---|---|
| `instance` | one per evaluated example — in single-instance mode, the candidate's aggregate score |
| `objective` | one per metric the task card declares |
| `hybrid` | the union of both (**default**) |
| `cartesian` | one per (example, metric) pair |
The objective axes come from the card's `metrics:` block, because that is galapagos's explicit, directional metric declaration — the counterpart of the `side_info["scores"]` dict an upstream evaluator publishes. GEPA's objective contract is higher-is-better, so a metric declared `minimize` enters the frontier negated and its axis is led by the *lowest* value. A metric the card never declares carries no direction and stays informational.
On a task that declares only `combined_score`, every axis reduces to that one number and Pareto selection degenerates to "sample among the current best" — which is precisely what upstream does in the same situation. Pareto selection pays off in proportion to how many genuinely distinct, declared metrics a task exposes.
## Components
| Slot | Implementation | Role |
|---|---|---|
| Population | `GepaCandidatePool` (kind: pareto_candidate_pool) | Unbounded pool + per-axis Pareto envelope; `add` is GEPA's acceptance test. |
| SelectionPolicy | `ParetoCandidateSelector` (kind: pareto_frontier_sampling) | Dominance pruning, then frequency-weighted sampling from the frontier. |
| PromptBuilder | `GepaReflectionPromptBuilder` (kind: gepa_reflection) | The `optimize_anything` reflection template over the parent's evaluation feedback. |
| Proposer | `GepaReflectiveMutationProposer` (kind: reflective_rewrite) | One reflection call → one complete replacement candidate. |
| Evaluator | task-supplied | Scores each child with the task's evaluation function. |
| Memory | `NullMemory` (kind: none) | None; every insight GEPA keeps is carried by an accepted candidate's text. |
## Configuration
```yaml
seed: 0
general:
max_iterations: 100
mutation_approach: reflective_rewrite # the model returns a complete new value in ``` blocks
population:
acceptance_criterion: strict_improvement # or improvement_or_equal (allows lateral moves)
selection_policy:
frontier_type: hybrid # instance | objective | hybrid | cartesian
proposer:
temperature: 1.0 # upstream sends no sampling params (provider default)
```
```bash
galapagos run --scaffold gepa --task circle_packing \
--proposer.model_name openai/gpt-5.5 --proposer.api_base openrouter \
--general.max_iterations 40
```
## Fidelity notes
**What this port keeps.** The reflection prompt template is byte-identical to upstream's `optimize_anything_reflection_prompt_template`, the reflective-dataset markdown is `InstructionProposalSignature.prompt_renderer`'s renderer, the response extractor is `output_extractor` (outer-fence span, not first-fence — deliberate upstream, because the artifact under optimization is often itself a prompt containing fenced examples), and the selection utilities (`is_dominated` → `remove_dominated_programs` → `select_program_candidate_from_pareto_front`) are direct ports keyed by Genome id instead of pool index.
**Single-instance search.** galapagos evaluates a candidate with one `evaluate(program_path)` call and cannot score a subset of a task, so the port runs upstream's own no-dataset mode. The consequence: the acceptance test compares the child against its parent's *stored* verdict instead of re-evaluating the parent on a fresh minibatch. Upstream reaches the same numbers by cache hit whenever `cache_evaluation=True`, since it is the identical `(candidate, example)` pair.
**Deliberately not ported**, because each is structurally inert on a single-artifact candidate:
- **System-aware merge** (`MergeProposer`). The merge recombines two candidates that changed *different* components of a shared ancestor. With one component, `does_triplet_have_desirable_predictors` is never satisfied, so the proposer would report "no merge candidates found" on every invocation. Upstream also ships `GEPAConfig.merge = None` by default.
- **Component selector** (`round_robin` / `all`). One component to choose from.
- **Alternate selectors** (`current_best`, `epsilon_greedy`, `top_k_pareto`). These are the paper's *ablations* of Pareto selection; on the galapagos leaderboard the greedy and beam controls are separate methods already — `topk` and `beam_search`.
- **Refiner loop** (`RefinerConfig`), **parallel proposals**, and **evaluation caching**. Off by default upstream; the galapagos loop is strictly sequential by design, and it caches nothing, so each iteration is exactly one evaluation.
**Budget.** Upstream's primary budget unit is the rollout (`max_metric_calls`); galapagos implements exactly one stop condition, `general.max_iterations`. In single-instance mode the two are proportional — one evaluation per iteration, plus one for the seed — because the parent's side of the acceptance test is the verdict already stored in the pool.
**Reflection LM.** The proposer is the run's model (`--proposer.model_name`), which upstream calls the *reflection LM* — the paper's `openai/gpt-5.1` default. Proposal quality is entirely determined by it: on domains needing real expertise (CUDA, compiler passes) a weak reflection model spends budget on low-quality rewrites.
## Reference
Agrawal, L. A. et al. *GEPA: Reflective Prompt Evolution Can Outperform Reinforcement Learning.* ICLR 2026 (Oral). Implementation: [github.com/gepa-ai/gepa](https://github.com/gepa-ai/gepa) (MIT).