Skip to content

Parallel Proposals: Sampling & Selection Strategies

GEPA's reflective mutation loop can generate and evaluate multiple candidate proposals per iteration. This is controlled by two small, composable strategy objects rather than a numeric knob:

  • a sampling strategy decides which (parent, minibatch) proposal tasks are attempted this iteration, and
  • a selection strategy decides which of the resulting improvements enter the candidate pool.

The defaults (SingleMutationSampling + AllImprovements) reproduce classic GEPA behavior exactly: one parent, one mutation per iteration. Existing runs do not change.

Note

These strategies are the interface for multi-proposal iterations (design: RFC #329).

Sampling strategies

from gepa.strategies.proposal_sampling import (
    SingleMutationSampling,  # default: 1 parent, 1 mutation
    SameParentSampling,      # n mutations of the same parent
    IndependentSampling,     # n independent (parent, minibatch) tasks
    PxNSampling,             # p parents x n mutations each
)
Strategy Per iteration Use when
SingleMutationSampling() 1 parent, 1 mutation Default; classic GEPA
SameParentSampling(n=4) 1 parent, n mutations Exploit a strong parent with diverse edits
IndependentSampling(n=4) n parents, 1 mutation each Explore the frontier broadly
PxNSampling(p=2, n=2) p parents × n mutations Balanced explore/exploit

Selection strategies

from gepa.strategies.proposal_selection import (
    AllImprovements,   # default: every improving proposal joins the pool
    BestImprovement,   # only the single best improvement joins
    TopKImprovements,  # the k best improvements join
)

Using them

gepa.optimize() accepts the strategies directly; in optimize_anything they are fields of EngineConfig (EngineConfig(sampling_strategy=..., selection_strategy=...)):

import gepa
from gepa.strategies.proposal_sampling import PxNSampling
from gepa.strategies.proposal_selection import TopKImprovements

result = gepa.optimize(
    seed_candidate=seed,
    trainset=trainset,
    valset=valset,
    task_lm="openai/gpt-4.1-mini",
    reflection_lm="openai/gpt-5",
    max_metric_calls=600,
    sampling_strategy=PxNSampling(p=2, n=2),
    selection_strategy=TopKImprovements(k=2),
)

Every proposal is still evaluated on its minibatch. The selection strategy is the authority over which proposals enter the pool: it receives all evaluated proposals plus the configured acceptance_criterion. The built-in strategies apply the criterion (so defaults only admit improvements); a custom strategy may deliberately surface a proposal the criterion would reject — e.g. an exploration pick. Anything not selected fires on_candidate_rejected.

Choosing an evaluation tier

Three tiers, from easiest to most control — they compose, and none replaces the others:

Tier You write You get Reach for it when
parallel=False a singleton evaluator(candidate, example) sequential, deterministic evaluation on the caller thread debugging, or your evaluator is not thread-safe (shared state, GPU contexts)
parallel=True (default) the same singleton evaluator automatic thread-pool concurrency with the full per-call feature surface: oa.log()/stdio capture, per-pair exception isolation, per-call opt_state the common case — I/O-bound evaluators (LLM calls, HTTP)
batch_evaluator= a function over all pending pairs at once one grouped external call: provider batch APIs, cluster/Slurm/Ray dispatch, vLLM server batching, cross-pair dedup, CPU-bound work beyond the GIL threads-over-singletons can't express your batching

When batch_evaluator is provided, it is the preferred transport for all grouped evaluations — the parallel=True thread pool is not used for them (a log line notes this). Providing both gives grouped work to the batch function and single-pair resolutions (refiner steps) to the evaluator, whose per-call features apply there.

Where the parallelism actually runs: batch_evaluate

Multi-proposal iterations batch their evaluations at the adapter edge. The engine and proposer stay sequential; adapters opt into true parallelism by implementing one optional method:

class MyAdapter:
    def evaluate(self, batch, candidate, capture_traces=False): ...

    # Optional. When absent, GEPA calls evaluate() once per item.
    def batch_evaluate(self, items: list[tuple[Candidate, list]]) -> list[EvaluationBatch]:
        ...  # e.g. fan out across threads, processes, or an eval service

Third-party adapters that only implement evaluate() keep working unmodified (default_batch_evaluate is the sequential fallback). The reflection LM side is batched analogously: the built-in stateless reflector batches all per-component reflection calls via litellm.batch_completion.

batch_evaluator: one external call for the whole batch

In optimize_anything, you can take over batching entirely: pass batch_evaluator= and GEPA hands you all pending (candidate, example) pairs in a single call — submit them as one provider batch job, one Slurm array, one Ray job — and return one result per pair (score or (score, side_info)).

def my_batch_eval(pairs, opt_states):   # opt_states is optional — declare it to receive warm-start state
    jobs = [submit(candidate, example) for candidate, example in pairs]
    return [(job.score(), {"log": job.diagnostics()}) for job in jobs]

optimize_anything(evaluator=my_eval, batch_evaluator=my_batch_eval, ...)

The contract, precisely: str-candidate unwrapping, per-pair opt_states injection (if your signature accepts it), exception policy (raise_on_exception), evaluation caching (shared store and keys with the sequential path — only cache misses reach your function, still as one call), warm-start history, and output packaging all match the sequential path. What does not apply — structurally, because there is no per-evaluation call to scope them to — is oa.log() and stdout/stderr capture: return diagnostics inside each pair's side_info instead. If the whole batch call raises with raise_on_exception=False, each affected pair's side_info carries {"error": ..., "_gepa_transient_failure": True}; such results are never cached, so they are re-evaluated on the next run.

In single-task mode (no dataset/valset) the example slot of each pair is None — mirror of the evaluator path, which omits example entirely.

evaluator= is optional when batch_evaluator= is set. With only a batch function, everything routes through it: multi-pair work (minibatch stages, valset/seed/merge evaluations) as grouped calls, and single-pair resolutions (refiner steps) as singleton batches — make your function efficient for len(pairs) == 1 if you enable the refiner, and thread-safe if you combine the refiner with parallel=True (refinement loops run on the thread pool and may call it concurrently). When both are provided, grouped work prefers batch_evaluator and singles use evaluator (whose per-call oa.log()/stdio capture then applies to those calls).

Budget accounting and events

Totals are identical to the sequential path, at finer event granularity: each iteration emits separate on_budget_updated events for the parent-evaluation and child-evaluation stages (plus one per accepted candidate's full-valset evaluation), and metric_calls_used remains monotonic with self-consistent deltas. If you aggregate budget events, sum metric_calls_delta rather than counting events. Note that budget overshoot past max_metric_calls scales with the per-iteration proposal count (the stop condition is checked between iterations): with PxNSampling(p=4, n=4) a single iteration can spend up to 16 minibatches + 16 child evaluations beyond the limit.

Advanced: custom reflection strategies

The reflection call itself is a seam (ReflectionLM, RFC #329): pass reflection_strategy= to gepa.optimize() (or set ReflectionConfig.reflection_strategy in optimize_anything) to replace the stateless single-call reflector with a stateful or aggregating implementation (e.g. session-based reflection, or map-reduce aggregation over parallel reflections as in ComBEE, PR #307). Implementations provide reflect(); reflect_many() is optional and enables batched reflection.