> ## Documentation Index
> Fetch the complete documentation index at: https://dsrs.herumbshandilya.com/llms.txt
> Use this file to discover all available pages before exploring further.

# GEPA

> Reflective instruction evolution from textual feedback: config, usage, and when to use it

**GEPA** (Genetic-Pareto) is a reflective prompt optimizer: it evolves instructions using the textual feedback your metric returns alongside each score, and it keeps a Pareto frontier of candidates that win on different examples instead of a single best candidate.

<Info>
  Reference: "GEPA: Reflective Prompt Evolution Can Outperform Reinforcement Learning" (Agrawal et al., 2025, [arxiv:2507.19457](https://arxiv.org/abs/2507.19457))
</Info>

## Overview

GEPA adaptively evolves the textual components of your module (its instructions). In addition to the scalar score, your metric returns text feedback explaining why the score is what it is. That feedback gives GEPA visibility into the failure, and a reflection LM uses it to propose a better instruction. Because each rollout carries an explanation rather than just a number, GEPA can find high-performing prompts in comparatively few rollouts.

## What GEPA adds

Compared to COPRO and MIPROv2, GEPA changes four things:

### Rich textual feedback

Instead of just scalar scores (0.8, 0.9), GEPA uses detailed explanations:

```
Incorrect classification
  Expected: "positive"
  Predicted: "negative"
  Input text: "Great product but shipping was slow"
  May have misunderstood mixed sentiment
```

### Pareto-based selection

GEPA maintains a diverse set of candidates that excel on different examples, preventing premature convergence:

* Candidate A: Best on examples 1, 3, 5
* Candidate B: Best on examples 2, 4, 6
* Both stay in the population (complementary strengths)

### LLM-driven reflection

A reflection LM (`prompt_model`) reads the current instruction, the per-example feedback, and the mutated component's execution trace, then proposes a targeted rewrite:

```
"The current instruction doesn't handle mixed sentiments well.
Suggest modifying to explicitly consider both positive and negative aspects..."
```

Without a `prompt_model`, mutation degrades to deterministic feedback concatenation, so setting one is strongly recommended.

### Inference-time search

GEPA can optimize at test time, not just training time (see [Inference-time search](#inference-time-search)).

## Quick start

### 1. Implement a typed metric with feedback

```rust theme={null}
use dspy_rs::*;

#[derive(Builder)]
struct MyModule {
    predictor: Predict<MySignature>,
}

dspy_rs::predictors!(MyModule { predictor });

impl Module for MyModule {
    type Input = MySignatureInput;
    type Output = MySignatureOutput;

    async fn forward(&self, inputs: MySignatureInput) -> Result<Predicted<MySignatureOutput>, PredictError> {
        self.predictor.call(inputs).await
    }
}

// The trainset row: any struct. #[derive(Example)] projects it into the
// signature's types by field name. See the Data page for the row model.
#[derive(Example, Clone, Debug, serde::Serialize)]
struct MyRow {
    question: String,
    answer: String,
}

struct MyMetric;

impl TypedMetric<MyRow, MyModule> for MyMetric {
    async fn evaluate(
        &self,
        example: &MyRow,
        prediction: &Predicted<<MyModule as Module>::Output>,
        _trace: Option<&Trace>,
    ) -> Result<Eval> {
        let predicted = prediction.answer.as_str();
        let expected = example.answer.as_str();

        let correct = predicted == expected;
        let score = if correct { 1.0 } else { 0.0 };

        let feedback = if correct {
            format!("Correct answer: {predicted}")
        } else {
            format!("Incorrect\n  Expected: {expected}\n  Predicted: {predicted}")
        };

        Ok(Eval::with_feedback(score, feedback))
    }
}
```

The third parameter is the rollout's execution [`Trace`](/docs/components/traces); metrics that inspect intermediate steps can slice it with `trace.for_component("predictor")`.

### 2. Configure and run GEPA

```rust theme={null}
// The reflection LM that rewrites instructions from feedback
let reflection_lm = LM::builder().temperature(1.0).build().await?;

let gepa = GEPA::builder()
    .num_iterations(20)
    .minibatch_size(25)
    .prompt_model(reflection_lm)
    .track_stats(true)
    .max_rollouts(500)  // Budget control
    .build();

let result = gepa.compile_module(&mut module, &trainset, &metric).await?;

println!("Best score: {:.3}", result.best_candidate.average_score());
println!("Best instruction: {}", result.best_candidate.instruction);
```

## Configuration options

```rust theme={null}
GEPA::builder()
    .num_iterations(20)          // Evolutionary generations
    .minibatch_size(25)          // Examples per parent re-evaluation
    .track_stats(true)           // Record candidates and evolution history
    .track_best_outputs(false)   // Record best outputs per eval example
    .max_rollouts(500)           // Budget: max evaluation rollouts
    .max_lm_calls(1000)          // Budget: max LM calls
    .prompt_model(reflection_lm) // Reflection LM (strongly recommended)
    .eval_concurrency(16)        // LM calls in flight during evaluation
    .seed(42)                    // Reproducible sampling
    .build()
```

`num_trials` and `temperature` also exist on the builder but are currently unused (reserved for multi-child evolution and mutation diversity control). The full field table is in the [optimizers reference](/docs/components/optimizers#gepa).

Pass validation data at compile time:

```rust theme={null}
let result = gepa
    .compile_module_with_valset(&mut module, &trainset, Some(&valset), &metric)
    .await?;
```

With `Some(valset)`, initial evaluation and child scoring use the validation set while parent re-evaluation uses trainset minibatches; with `None`, the trainset serves both roles (this is what `compile_module` does).

## Understanding GEPA results

```rust theme={null}
let result = gepa.compile_module(&mut module, &trainset, &metric).await?;

// Best candidate found (already installed on the module)
println!("Best instruction: {}", result.best_candidate.instruction);
println!("Average score: {:.3}", result.best_candidate.average_score());
println!("Generation: {}", result.best_candidate.generation);

// Resource usage
println!("Total rollouts: {}", result.total_rollouts);
println!("Total LM calls: {}", result.total_lm_calls);

// Evolution over time
for (generation, score) in &result.evolution_history {
    println!("Gen {}: {:.3}", generation, score);
}
```

## Architecture

### Core components

**Eval**

The metric result type: one score, optional textual feedback. GEPA errors if any `Eval` has `feedback: None`.

```rust theme={null}
pub struct Eval {
    pub score: f64,
    pub feedback: Option<String>,
}
```

**Execution traces**

Every rollout runs under trace capture, recording one span per `Predict` invocation. GEPA feeds the mutated component's spans (`trace.for_component(name)`) to the reflection LM alongside the feedback. See [Traces](/docs/components/traces).

**Pareto bookkeeping**

GEPA uses the engine's score matrix directly: `ParetoView` (see [Optimizer engine](/docs/components/optimizer-engine)) tracks which validation columns each candidate wins on, parent sampling is proportional to that coverage, and candidates with zero wins are dominated.

**GEPACandidate**

```rust theme={null}
pub struct GEPACandidate {
    pub id: usize,
    pub instruction: String,
    pub module_name: String,
    pub example_scores: Vec<f32>,
    pub parent_id: Option<usize>,
    pub generation: usize,
}
```

### Evolutionary algorithm

1. **Initialize** the candidate pool with the unoptimized program
2. **Iterate**:
   * Sample a candidate from the Pareto frontier (proportional to coverage)
   * Sample a minibatch from the training set
   * Collect execution traces with feedback
   * Select a module component for targeted improvement
   * LLM Reflection: Propose a new instruction using reflective meta-prompting
   * Roll out the new candidate; if improved, evaluate on the validation columns
   * Update the Pareto frontier
3. **Continue** until budget is exhausted
4. **Return** best candidate by average score

## Implementing feedback metrics

A well-designed metric is central to GEPA's sample efficiency. The DSRs implementation expects the metric to return an `Eval`; for GEPA that means `Eval::with_feedback(score, feedback)` on every example.

### Practical recipe for GEPA-friendly feedback

* **Leverage existing artifacts**: Use logs, unit tests, evaluation scripts, profiler outputs
* **Decompose outcomes**: Break scores into per-objective components
* **Expose trajectories**: Label pipeline stages with pass/fail and errors
* **Ground in checks**: Use validators or an [LLM judge](#using-an-llm-judge-for-feedback) for subjective tasks
* **Prioritize clarity**: Focus on error coverage and decision points

### Feedback examples by domain

**Document retrieval**: List correctly retrieved, incorrect, or missed documents

**Multi-objective tasks**: Decompose aggregate scores to reveal contributions from each objective

**Stacked pipelines**: Expose stage-specific failures (parse, compile, run, test)

## Best practices

### Design feedback for actionability

```rust theme={null}
// BAD: Vague feedback
Eval::with_feedback(0.5, "Wrong answer")

// GOOD: Specific, actionable feedback
Eval::with_feedback(0.5,
    "Incorrect answer\n\
     Expected: 'Paris'\n\
     Predicted: 'France'\n\
     Issue: Returned country instead of city")
```

### Leverage domain knowledge

* Code generation: Show stage-specific failures
* Retrieval: List specific documents missed
* QA: Explain reasoning errors

### Balance feedback detail

* Too brief: Not actionable
* Too verbose: Drowns out signal
* Aim for 2 to 5 lines per issue

### Set realistic budgets

```rust theme={null}
// For development/testing
GEPA::builder()
    .num_iterations(5)
    .max_rollouts(100)
    .build()

// For production optimization
GEPA::builder()
    .num_iterations(20)
    .max_rollouts(1000)
    .build()
```

## Using an LLM judge for feedback

For tasks where feedback rules are hard to codify, a second LLM can generate the feedback: it reads the task output and writes the evaluation text that becomes the metric's feedback string.

```
Task LM → generates answer + reasoning
    ↓
Judge LM → analyzes quality and provides feedback
    ↓
GEPA Reflection LM → reads feedback and improves prompt
    ↓
Better Task LM prompt
```

A judge fits these situations:

* Subjective quality assessment (writing style, helpfulness, clarity)
* Complex reasoning evaluation (soundness of the logic)
* Tasks where rules are hard to codify
* Analyzing reasoning quality beyond answer correctness

Prefer deterministic checks over a judge when they exist:

* Unit tests or schema validation cover the failure modes
* Correctness is verifiable (code compilation, exact matches)
* Evaluation must be fast and cheap
* The outcome is a simple binary pass/fail

### Task signature with reasoning

```rust theme={null}
#[derive(Signature, Clone, Debug)]
struct MathWordProblem {
    /// Solve the problem step by step.

    #[input]
    problem: String,

    #[output]
    reasoning: String,  // We want to optimize this too

    #[output]
    answer: String,
}
```

### Judge signature

```rust theme={null}
#[derive(Signature, Clone, Debug)]
struct MathJudge {
    /// Evaluate student reasoning and answer quality.

    #[input(desc = "The original problem")]
    problem: String,

    #[input(desc = "Expected answer")]
    expected_answer: String,

    #[input(desc = "Student answer")]
    student_answer: String,

    #[input(desc = "Student reasoning")]
    student_reasoning: String,

    #[output(desc = "Evaluation of the solution quality")]
    evaluation: String,  // This becomes the feedback
}
```

### Optimized module

```rust theme={null}
#[derive(Builder)]
struct MathSolver {
    #[builder(default = Predict::<MathWordProblem>::new())]
    solver: Predict<MathWordProblem>,  // This gets optimized
}

dspy_rs::predictors!(MathSolver { solver });
```

### TypedMetric with judge

```rust theme={null}
/// A labeled trainset row: `(input, gold output)` tuples are rows out of the
/// box — no dedicated struct needed for a small inline trainset.
type MathRow = (MathWordProblemInput, MathWordProblemOutput);

struct LlmJudgeMetric {
    judge: Predict<MathJudge>,
}

impl TypedMetric<MathRow, MathSolver> for LlmJudgeMetric {
    async fn evaluate(
        &self,
        example: &MathRow,
        prediction: &Predicted<MathWordProblemOutput>,
        _trace: Option<&Trace>,
    ) -> Result<Eval> {
        let (input, gold) = example;
        let problem = input.problem.clone();
        let expected = gold.answer.clone();

        let student_answer = prediction.answer.clone();
        let student_reasoning = prediction.reasoning.clone();
        let exact_match = student_answer.trim() == expected.trim();

        let judge_output = self
            .judge
            .call(MathJudgeInput {
                problem: problem.clone(),
                expected_answer: expected.clone(),
                student_answer: student_answer.clone(),
                student_reasoning: student_reasoning.clone(),
            })
            .await;

        let (score, evaluation_text) = match judge_output {
            Ok(evaluation) => {
                let evaluation_text = evaluation.evaluation.clone();
                let score = if exact_match {
                    if evaluation_text.to_lowercase().contains("clear")
                        || evaluation_text.to_lowercase().contains("correct")
                    {
                        1.0
                    } else {
                        0.7
                    }
                } else if evaluation_text.to_lowercase().contains("partially")
                    || evaluation_text.to_lowercase().contains("good start")
                {
                    0.3
                } else {
                    0.0
                };
                (score, evaluation_text)
            }
            Err(err) => {
                let fallback = format!(
                    "judge call failed: {err}; expected={expected}; predicted={student_answer}"
                );
                ((exact_match as u8 as f64), fallback)
            }
        };

        Ok(Eval::with_feedback(
            score,
            format!(
                "problem={problem}\nexpected={expected}\npredicted={student_answer}\njudge={evaluation_text}"
            ),
        ))
    }
}
```

`GEPA` itself does not own a special `feedback_metric` hook.
The feedback function lives in your `TypedMetric` implementation, and GEPA enforces that every evaluation returns `Eval::with_feedback(...)`.
That keeps the optimizer generic while preserving full judge-driven behavior.

### What a judge catches

* Lucky guesses: a correct answer reached through unsound reasoning is penalized instead of scoring 1.0.
* Partial progress: a wrong answer with a correct approach (an arithmetic slip in the final step) earns partial credit instead of 0.
* Systematic issues: the judge surfaces recurring patterns such as skipped intermediate steps, confused concepts (area versus perimeter), or missing unit checks, and GEPA's reflection turns them into explicit instructions.

In the worked example, the baseline instruction "Solve the math word problem step by step" produced solutions that skipped steps, which the judge flagged as incomplete reasoning. After optimization the instruction requires all intermediate calculations to be shown and each step to be labeled. The judge's analysis is the signal that drives the rewrite.

### Cost considerations

<Warning>
  LLM judges double your evaluation cost since every prediction requires both a task LM call and a judge LM call.
</Warning>

Budget accordingly:

```rust theme={null}
GEPA::builder()
    .num_iterations(3)     // Fewer iterations
    .minibatch_size(3)     // Smaller batches
    .max_lm_calls(100)     // Explicit limit
    .build()
```

Ways to contain cost:

* Use a cheaper model for judging (gpt-4o-mini vs gpt-4)
* Judge only failed examples (not ones that passed)
* Cache judge evaluations for identical outputs
* Use parallel evaluation to reduce wall-clock time

### Hybrid metrics

Combining explicit checks with LLM judging often gives the best results:

```rust theme={null}
impl TypedMetric<MyRow, MyModule> for HybridMetric {
    async fn evaluate(
        &self,
        example: &MyRow,
        prediction: &Predicted<<MyModule as Module>::Output>,
        _trace: Option<&Trace>,
    ) -> Result<Eval> {
        let mut score = 1.0;
        let mut feedback_parts = vec![];

        // Explicit checks first (fast, cheap, deterministic)
        if !is_valid_json(&prediction.result_json) {
            feedback_parts.push("Invalid JSON format".to_string());
            score = 0.0;
        }

        if score > 0.0 && missing_required_fields(&prediction.result_json) {
            feedback_parts.push("Missing fields: user_id, timestamp".to_string());
            score *= 0.5;
        }

        // Optional judge pass for qualitative scoring
        if score > 0.0 {
            let judge_feedback = self.judge_quality(example, prediction).await?;
            if judge_feedback.to_lowercase().contains("low quality") {
                score *= 0.7;
            }
            feedback_parts.push(judge_feedback);
        }

        Ok(Eval::with_feedback(score, feedback_parts.join("\n")))
    }
}
```

### Running the judge example

```bash theme={null}
OPENAI_API_KEY=your_key cargo run --example 10-gepa-llm-judge
```

The run shows baseline performance, judge evaluations during optimization, the prompt evolving from feedback, and a final test with judge analysis.

<Card title="Full Working Example" icon="code" href="https://github.com/krypticmouse/DSRs/blob/main/crates/dspy-rs/examples/10-gepa-llm-judge.rs">
  See the complete implementation with step-by-step comments
</Card>

## Examples

<Card title="Sentiment Analysis" icon="face-smile" href="https://github.com/krypticmouse/DSRs/blob/main/crates/dspy-rs/examples/09-gepa-sentiment.rs">
  Basic GEPA usage with explicit feedback for sentiment classification
</Card>

<Card title="LLM-as-Judge" icon="gavel" href="#using-an-llm-judge-for-feedback">
  Using an LLM judge to generate feedback
</Card>

## Comparison with other optimizers

| Optimizer          | Strategy                                                  | Needs feedback? | Cost                                       |
| ------------------ | --------------------------------------------------------- | --------------- | ------------------------------------------ |
| `BootstrapFewShot` | One-shot demo harvesting from a teacher pass              | No              | Low (2 × trainset)                         |
| `COPRO`            | Breadth-first instruction search                          | No              | Low (breadth × depth × trainset)           |
| `SIMBA`            | Minibatch introspective ascent (demos + rules)            | No              | Low (steps × minibatch)                    |
| `GEPA`             | Genetic-Pareto evolution with feedback                    | **Yes**         | Medium-high (iterations × eval)            |
| `MIPROv2`          | Trace-guided candidate generation                         | No              | Medium (candidates × trials × trainset)    |
| `Structural`       | LM-guided graph edits over `ir::Edit` (program lane only) | No              | Medium (examples + iterations × minibatch) |

GEPA is the only optimizer that requires textual feedback from the metric (`Eval::with_feedback`). The others use numerical scores alone. Full configuration tables for all six live in the [optimizers reference](/docs/components/optimizers).

### When to use GEPA

* Complex tasks with subtle failure modes
* When you can provide rich feedback
* Multi-objective optimization
* Need for diverse solutions
* Inference-time search

### When to use alternatives

* **COPRO**: Simple tasks, quick iteration
* **MIPROv2**: Best prompting practices, single objective

## Troubleshooting

### Issue: GEPA errors because an `Eval` has no feedback

GEPA requires feedback for every evaluated example.

```rust theme={null}
// Solution: Return Eval::with_feedback(...) from TypedMetric::evaluate
impl TypedMetric<MyRow, MyModule> for MyMetric {
    async fn evaluate(
        &self,
        example: &MyRow,
        prediction: &Predicted<<MyModule as Module>::Output>,
        _trace: Option<&Trace>,
    ) -> Result<Eval> {
        Ok(Eval::with_feedback(1.0, "detailed textual feedback"))
    }
}
```

### Issue: Slow convergence

```rust theme={null}
// Increase minibatch size for a better signal per generation
GEPA::builder().minibatch_size(50).build()

// Make sure a reflection LM is set; without one, mutation is just
// deterministic feedback concatenation
GEPA::builder().prompt_model(reflection_lm).build()
```

### Issue: Running out of budget

```rust theme={null}
// Reduce iterations or increase budget
GEPA::builder()
    .num_iterations(10)
    .max_rollouts(2000)
    .build()
```

## Inference-time search

GEPA can act as a test-time/inference search mechanism. By setting your `valset` to your evaluation batch and enabling `track_best_outputs(true)`, GEPA produces for each batch element the highest-scoring outputs found during the evolutionary search.

```rust theme={null}
let gepa = GEPA::builder()
    .track_stats(true)
    .track_best_outputs(true)
    .build();

let result = gepa
    .compile_module_with_valset(&mut module, &my_tasks, Some(&my_tasks), &metric)
    .await?;

// Access per-task best scores and outputs
let best_scores = result.highest_score_achieved_per_val_task;
let best_outputs = result.best_outputs_valset;
```

## Additional resources

* [GEPA Paper](https://arxiv.org/abs/2507.19457)
* [GEPA GitHub](https://github.com/gepa-ai/gepa)
* [Using an LLM judge for feedback](#using-an-llm-judge-for-feedback)
* [Example Code](https://github.com/krypticmouse/DSRs/blob/main/crates/dspy-rs/examples/09-gepa-sentiment.rs)
