Skip to content

Full Module Index

gepa_adk

GEPA-ADK: Async-first evolution engine for agentic development.

This package provides the evolution entry points (evolve, evolve_group, evolve_workflow and run_sync), domain models and utilities for evolving agent instructions using the GEPA (Generalized Evolutionary Prompt-programming Architecture) approach.

ATTRIBUTE DESCRIPTION
__version__

Package version from pyproject.toml.

TYPE: str

EvolutionConfig

Configuration parameters for evolution runs.

TYPE: class

EvolutionResult

Outcome of a completed evolution run.

TYPE: class

MultiAgentEvolutionResult

Outcome of a multi-agent evolution run.

TYPE: class

Candidate

Instruction candidate being evolved.

TYPE: class

IterationRecord

Metrics for a single evolution iteration.

TYPE: class

TokenRollup

Token usage summed over evaluated rows.

TYPE: class

Score

Type alias for normalized scores.

TYPE: type

ComponentName

Type alias for component identifiers.

TYPE: type

DEFAULT_COMPONENT_NAME

Default component name constant.

TYPE: str

ModelName

Type alias for model identifiers.

TYPE: type

StopReason

Why an evolution run terminated.

TYPE: enum

OnIterationCallback

Callback invoked after each iteration.

TYPE: type

ProposalValidator

Check run on each proposed component text.

TYPE: type

FrontierType

Pareto frontier type selector.

TYPE: enum

CURRENT_SCHEMA_VERSION

Current result schema version.

TYPE: int

TrajectoryConfig

Configuration for trajectory extraction.

TYPE: class

SchemaConstraints

Schema-level field constraints for evolution.

TYPE: class

EvolutionError

Base exception for all gepa-adk errors.

TYPE: class

ConfigurationError

Raised when configuration validation fails.

TYPE: class

VideoValidationError

Raised for invalid video input.

TYPE: class

RegressionStopper

Stop evolution when score declines over N iterations.

TYPE: class

AsyncGEPAEngine

Core async evolution engine.

TYPE: class

MergeProposer

Proposes merged candidates from the Pareto frontier.

TYPE: class

CheckpointSummary

Read-only summary of an engine checkpoint file.

TYPE: class

inspect_checkpoint

Summarise a checkpoint file without an engine.

TYPE: function

AgentProvider

Protocol for loading and persisting agents.

TYPE: protocol

AsyncGEPAAdapter

Async adapter protocol for evaluation.

TYPE: protocol

ComponentHandler

Protocol for component serialization/application.

TYPE: protocol

MappingComponentHandler

Handler for one key of a caller-owned mapping.

TYPE: class

register_mapping_components

Register a handler for every key of a caller-owned mapping so each key becomes a component name.

TYPE: function

EvaluationBatch

Evaluation results container for adapters.

TYPE: class

DataInst

Type variable for adapter input instances.

TYPE: type

Trajectory

Type variable for adapter traces.

TYPE: type

RolloutOutput

Type variable for adapter outputs.

TYPE: type

Scorer

Protocol for custom scorers passed as scorer=.

TYPE: protocol

ComponentSelectorProtocol

Protocol for component selection.

TYPE: protocol

RoundRobinComponentSelector

Round-robin component selector.

TYPE: class

AllComponentSelector

Selector that returns all components.

TYPE: class

create_component_selector

Factory for component selectors.

TYPE: function

SimpleCriticOutput

Pydantic schema for simple critic output.

TYPE: class

CriticOutput

Pydantic schema for advanced critic output.

TYPE: class

SIMPLE_CRITIC_INSTRUCTION

Default simple critic instruction.

TYPE: str

ADVANCED_CRITIC_INSTRUCTION

Default advanced critic instruction.

TYPE: str

STRUCTURED_OUTPUT_CRITIC_INSTRUCTION

Preset instruction for structure evaluation.

TYPE: str

ACCURACY_CRITIC_INSTRUCTION

Preset instruction for factual accuracy evaluation.

TYPE: str

RELEVANCE_CRITIC_INSTRUCTION

Preset instruction for relevance evaluation.

TYPE: str

normalize_feedback

Normalize critic feedback to standard form.

TYPE: function

create_critic

Factory for pre-configured critic agents by preset name.

TYPE: function

critic_presets

Maps preset name to human-readable description.

TYPE: dict

LabelAgreementScorer

Exact-match scorer against labelled expected values.

TYPE: class

RequireToolScorer

Scorer wrapper that scores 0.0 unless a named tool ran.

TYPE: class

evolve

Async single-agent evolution entry point.

TYPE: function

evolve_sync

Deprecated synchronous wrapper for evolve().

TYPE: function

evolve_group

Async multi-agent group evolution.

TYPE: function

evolve_workflow

Async workflow-level evolution.

TYPE: function

run_sync

Universal sync wrapper for any async evolution call.

TYPE: function

Examples:

Basic usage with configuration and candidates:

from gepa_adk import EvolutionConfig, Candidate

config = EvolutionConfig(max_iterations=10, patience=3)
candidate = Candidate(components={"instruction": "Be helpful"})

Configuring trajectory extraction:

from gepa_adk import TrajectoryConfig

trajectory_config = TrajectoryConfig(
    redact_sensitive=True,
    max_string_length=5000,
)
See Also
Notes

This is the main entry point for the gepa-adk package. Domain models are re-exported here for convenient top-level access.

Importing the package writes nothing to stdout. Unless the host has already configured structlog, the import routes structlog through the standard library logging module, so DEBUG events such as component handler registration appear only when the host enables them.

CURRENT_SCHEMA_VERSION module-attribute

CURRENT_SCHEMA_VERSION = 7

Schema version for evolution result serialization.

Incremented when the result schema changes in a way that requires migration logic in from_dict().

DEFAULT_COMPONENT_NAME module-attribute

DEFAULT_COMPONENT_NAME: ComponentName = 'instruction'

Default component name for single-component evolution.

This constant provides a single source of truth for the default component name used when evolving a single component (typically an agent's instruction). Use this constant instead of hardcoding 'instruction' throughout the codebase.

ComponentName module-attribute

ComponentName: TypeAlias = str

Name of a candidate component (e.g., 'instruction', 'output_schema').

ModelName module-attribute

ModelName: TypeAlias = str

Model identifier (e.g., 'ollama_chat/gpt-oss:20b', 'gemini-3.8-flash', 'gpt-4o').

OnIterationCallback module-attribute

OnIterationCallback: TypeAlias = Callable[
    ["IterationRecord", str | None], Awaitable[None] | None
]

Callback the engine invokes after each iteration record is appended.

Type

Callable[[IterationRecord, str | None], Awaitable[None] | None]: Receives the iteration's record and the id of the candidate it concerns (None for an empty_proposal skip). May be sync or async.

Examples:

from gepa_adk.domain.types import OnIterationCallback


def log_iteration(record, candidate_id):
    print(record.iteration_number, record.score, candidate_id)


callback: OnIterationCallback = log_iteration
Notes

Set it as EvolutionConfig.on_iteration. An awaitable return value is awaited before the loop continues; exceptions propagate out of run().

ProposalValidator module-attribute

ProposalValidator: TypeAlias = Callable[
    [str, str], str | None
]

Check the engine runs on each proposed component text before evaluation.

Type

Callable[[str, str], str | None]: Receives the component name and the proposed text. Returns None to accept the proposal, or a short reason string to reject it.

Examples:

from gepa_adk.domain.types import ProposalValidator


def require_placeholder(component, text):
    if "{question}" not in text:
        return "dropped the {question} placeholder"
    return None


validator: ProposalValidator = require_placeholder
Notes

Set it as EvolutionConfig.proposal_validator. A rejected proposal is recorded with skip_reason="proposal_rejected" and the reason, costs no evaluation and counts toward patience. Exceptions propagate out of run().

Score module-attribute

Score: TypeAlias = float

Normalized score, typically in [0.0, 1.0].

MappingComponentHandler

Handler for one string value in a mapping the caller owns.

Serializes, applies and restores mapping[key]. The agent argument of each method is ignored, because the value does not live on the agent.

ATTRIBUTE DESCRIPTION
mapping

The caller's mapping, held by reference so writes are visible to its other readers.

TYPE: MutableMapping[str, str]

key

The mapping key this handler evolves.

TYPE: str

Examples:

prompts = {"greeting": "hello"}
handler = MappingComponentHandler(prompts, key="greeting")
original = handler.apply(None, "hi")  # prompts["greeting"] == "hi"
handler.restore(None, original)  # prompts["greeting"] == "hello"
Notes

The handler keeps a reference to the mapping, not a copy. Replacing the mapping object elsewhere detaches the handler from it.

Source code in src/gepa_adk/adapters/components/mapping_handler.py
class MappingComponentHandler:
    """Handler for one string value in a mapping the caller owns.

    Serializes, applies and restores ``mapping[key]``. The agent argument of
    each method is ignored, because the value does not live on the agent.

    Attributes:
        mapping (MutableMapping[str, str]): The caller's mapping, held by
            reference so writes are visible to its other readers.
        key (str): The mapping key this handler evolves.

    Examples:
        ```python
        prompts = {"greeting": "hello"}
        handler = MappingComponentHandler(prompts, key="greeting")
        original = handler.apply(None, "hi")  # prompts["greeting"] == "hi"
        handler.restore(None, original)  # prompts["greeting"] == "hello"
        ```

    Notes:
        The handler keeps a reference to the mapping, not a copy. Replacing
        the mapping object elsewhere detaches the handler from it.
    """

    def __init__(self, mapping: MutableMapping[str, str], key: str) -> None:
        """Bind the handler to one key of the caller's mapping.

        Args:
            mapping: The caller-owned mapping holding the prompt text.
            key: The key whose value this handler evolves.

        Raises:
            ValueError: If ``key`` is not in ``mapping``.
            TypeError: If ``mapping[key]`` is not a ``str``.

        Examples:
            ```python
            handler = MappingComponentHandler({"k": "v"}, key="k")
            ```
        """
        if key not in mapping:
            raise ValueError(f"Mapping has no key {key!r} to evolve")
        value: object = mapping[key]
        if not isinstance(value, str):
            raise TypeError(
                f"Mapping value for key {key!r} must be str, got {type(value).__name__}"
            )
        self.mapping = mapping
        self.key = key

    def serialize(self, agent: Any) -> str:
        """Return the current text of the mapping key.

        Args:
            agent: Ignored; may be ``None``.

        Returns:
            The value of ``mapping[key]``, or an empty string when the caller
            has removed the key or replaced its value with non-text since
            registration.

        Examples:
            ```python
            text = handler.serialize(None)
            ```

        Notes:
            Never raises, per the ``ComponentHandler`` contract. A missing or
            non-text value is logged as ``mapping_handler.key_missing``.
        """
        value = self.mapping.get(self.key)
        if not isinstance(value, str):
            logger.warning(
                "mapping_handler.key_missing",
                key=self.key,
                value_type=type(value).__name__,
            )
            return ""
        return value

    def apply(self, agent: Any, value: str) -> str:
        """Write new text to the mapping key and return the previous text.

        Args:
            agent: Ignored; may be ``None``.
            value: The new text for the key.

        Returns:
            The text the key held before this call.

        Examples:
            ```python
            original = handler.apply(None, "new text")
            ```
        """
        original = self.mapping[self.key]
        self.mapping[self.key] = value
        logger.debug(
            "mapping_handler.apply",
            key=self.key,
            original_preview=original[:50],
            new_preview=value[:50],
        )
        return original

    def restore(self, agent: Any, original: str) -> None:
        """Write the original text back to the mapping key.

        Args:
            agent: Ignored; may be ``None``.
            original: The text returned by ``apply()``.

        Examples:
            ```python
            handler.restore(None, original)
            ```
        """
        self.mapping[self.key] = original
        logger.debug("mapping_handler.restore", key=self.key, preview=original[:50])

__init__

__init__(
    mapping: MutableMapping[str, str], key: str
) -> None

Bind the handler to one key of the caller's mapping.

PARAMETER DESCRIPTION
mapping

The caller-owned mapping holding the prompt text.

TYPE: MutableMapping[str, str]

key

The key whose value this handler evolves.

TYPE: str

RAISES DESCRIPTION
ValueError

If key is not in mapping.

TypeError

If mapping[key] is not a str.

Examples:

handler = MappingComponentHandler({"k": "v"}, key="k")
Source code in src/gepa_adk/adapters/components/mapping_handler.py
def __init__(self, mapping: MutableMapping[str, str], key: str) -> None:
    """Bind the handler to one key of the caller's mapping.

    Args:
        mapping: The caller-owned mapping holding the prompt text.
        key: The key whose value this handler evolves.

    Raises:
        ValueError: If ``key`` is not in ``mapping``.
        TypeError: If ``mapping[key]`` is not a ``str``.

    Examples:
        ```python
        handler = MappingComponentHandler({"k": "v"}, key="k")
        ```
    """
    if key not in mapping:
        raise ValueError(f"Mapping has no key {key!r} to evolve")
    value: object = mapping[key]
    if not isinstance(value, str):
        raise TypeError(
            f"Mapping value for key {key!r} must be str, got {type(value).__name__}"
        )
    self.mapping = mapping
    self.key = key

serialize

serialize(agent: Any) -> str

Return the current text of the mapping key.

PARAMETER DESCRIPTION
agent

Ignored; may be None.

TYPE: Any

RETURNS DESCRIPTION
str

The value of mapping[key], or an empty string when the caller

str

has removed the key or replaced its value with non-text since

str

registration.

Examples:

text = handler.serialize(None)
Notes

Never raises, per the ComponentHandler contract. A missing or non-text value is logged as mapping_handler.key_missing.

Source code in src/gepa_adk/adapters/components/mapping_handler.py
def serialize(self, agent: Any) -> str:
    """Return the current text of the mapping key.

    Args:
        agent: Ignored; may be ``None``.

    Returns:
        The value of ``mapping[key]``, or an empty string when the caller
        has removed the key or replaced its value with non-text since
        registration.

    Examples:
        ```python
        text = handler.serialize(None)
        ```

    Notes:
        Never raises, per the ``ComponentHandler`` contract. A missing or
        non-text value is logged as ``mapping_handler.key_missing``.
    """
    value = self.mapping.get(self.key)
    if not isinstance(value, str):
        logger.warning(
            "mapping_handler.key_missing",
            key=self.key,
            value_type=type(value).__name__,
        )
        return ""
    return value

apply

apply(agent: Any, value: str) -> str

Write new text to the mapping key and return the previous text.

PARAMETER DESCRIPTION
agent

Ignored; may be None.

TYPE: Any

value

The new text for the key.

TYPE: str

RETURNS DESCRIPTION
str

The text the key held before this call.

Examples:

original = handler.apply(None, "new text")
Source code in src/gepa_adk/adapters/components/mapping_handler.py
def apply(self, agent: Any, value: str) -> str:
    """Write new text to the mapping key and return the previous text.

    Args:
        agent: Ignored; may be ``None``.
        value: The new text for the key.

    Returns:
        The text the key held before this call.

    Examples:
        ```python
        original = handler.apply(None, "new text")
        ```
    """
    original = self.mapping[self.key]
    self.mapping[self.key] = value
    logger.debug(
        "mapping_handler.apply",
        key=self.key,
        original_preview=original[:50],
        new_preview=value[:50],
    )
    return original

restore

restore(agent: Any, original: str) -> None

Write the original text back to the mapping key.

PARAMETER DESCRIPTION
agent

Ignored; may be None.

TYPE: Any

original

The text returned by apply().

TYPE: str

Examples:

handler.restore(None, original)
Source code in src/gepa_adk/adapters/components/mapping_handler.py
def restore(self, agent: Any, original: str) -> None:
    """Write the original text back to the mapping key.

    Args:
        agent: Ignored; may be ``None``.
        original: The text returned by ``apply()``.

    Examples:
        ```python
        handler.restore(None, original)
        ```
    """
    self.mapping[self.key] = original
    logger.debug("mapping_handler.restore", key=self.key, preview=original[:50])

LabelAgreementScorer

Scorer that measures exact agreement between output and label.

With field set, the output is parsed as a JSON object and str(parsed[field]).strip() is compared with expected.strip(). Without field, the output and expected agree when both parse as equal JSON documents, or otherwise when their stripped texts are equal.

ATTRIBUTE DESCRIPTION
field

Name of the output JSON field holding the label, or None to compare the whole output.

TYPE: str | None

Examples:

Evolve a schema agent against labelled rows:

from gepa_adk import LabelAgreementScorer, evolve, run_sync

trainset = [{"input": "Buy now!!!", "expected": "spam"}]
result = run_sync(
    evolve(agent, trainset, scorer=LabelAgreementScorer(field="label"))
)
Notes

Adheres to the Scorer protocol. The score is 0.0 with metadata["reason"] set to "missing_expected" when the row has no label, "output_not_json" when field is set and the output does not parse, and "field_missing" when the parsed output is not an object or lacks field. Metadata always carries field, actual, expected and agreement.

Source code in src/gepa_adk/adapters/scoring/label_agreement.py
class LabelAgreementScorer:
    """Scorer that measures exact agreement between output and label.

    With ``field`` set, the output is parsed as a JSON object and
    ``str(parsed[field]).strip()`` is compared with ``expected.strip()``.
    Without ``field``, the output and ``expected`` agree when both parse as equal JSON
    documents, or otherwise when their stripped texts are equal.

    Attributes:
        field (str | None): Name of the output JSON field holding the
            label, or ``None`` to compare the whole output.

    Examples:
        Evolve a schema agent against labelled rows:

        ```python
        from gepa_adk import LabelAgreementScorer, evolve, run_sync

        trainset = [{"input": "Buy now!!!", "expected": "spam"}]
        result = run_sync(
            evolve(agent, trainset, scorer=LabelAgreementScorer(field="label"))
        )
        ```

    Notes:
        Adheres to the Scorer protocol. The score is 0.0 with
        ``metadata["reason"]`` set to ``"missing_expected"`` when the row has
        no label, ``"output_not_json"`` when ``field`` is set and the output
        does not parse, and ``"field_missing"`` when the parsed output is not
        an object or lacks ``field``. Metadata always carries ``field``,
        ``actual``, ``expected`` and ``agreement``.
    """

    def __init__(self, field: str | None = None) -> None:
        """Store the output field to compare.

        Args:
            field: Name of the JSON field holding the label, or ``None`` to
                compare the whole output.
        """
        self.field = field

    def _result(
        self,
        *,
        actual: str | None,
        expected: str | None,
        agreement: bool,
        reason: str | None = None,
    ) -> tuple[float, dict[str, Any]]:
        """Build the score and metadata pair.

        Args:
            actual: Extracted value that was compared, if any.
            expected: Label from the trainset row.
            agreement: Whether the value agreed with the label.
            reason: Why no comparison was possible, if applicable.

        Returns:
            The score and its metadata dictionary.
        """
        metadata: dict[str, Any] = {
            "field": self.field,
            "actual": actual,
            "expected": expected,
            "agreement": agreement,
        }
        if reason is not None:
            metadata["reason"] = reason
        return (1.0 if agreement else 0.0), metadata

    def score(
        self,
        input_text: str,
        output: str,
        expected: str | None = None,
    ) -> tuple[float, dict[str, Any]]:
        """Score the output by agreement with the expected label.

        Args:
            input_text: Input given to the agent. Not used.
            output: Agent output text.
            expected: Label from the trainset row.

        Returns:
            ``(1.0, metadata)`` on agreement, else ``(0.0, metadata)``.
        """
        if expected is None:
            return self._result(
                actual=None,
                expected=None,
                agreement=False,
                reason="missing_expected",
            )
        if self.field is None:
            return self._score_whole(output, expected)
        parsed = _parse_json(output)
        if parsed is _MISSING:
            return self._result(
                actual=None,
                expected=expected,
                agreement=False,
                reason="output_not_json",
            )
        if not isinstance(parsed, dict) or self.field not in parsed:
            return self._result(
                actual=None,
                expected=expected,
                agreement=False,
                reason="field_missing",
            )
        actual = str(parsed[self.field]).strip()
        return self._result(
            actual=actual,
            expected=expected,
            agreement=actual == expected.strip(),
        )

    def _score_whole(self, output: str, expected: str) -> tuple[float, dict[str, Any]]:
        """Compare the whole output with the label.

        Args:
            output: Agent output text.
            expected: Label from the trainset row.

        Returns:
            The score and its metadata dictionary.
        """
        actual = output.strip()
        parsed_output = _parse_json(output)
        parsed_expected = _parse_json(expected)
        if parsed_output is not _MISSING and parsed_expected is not _MISSING:
            agreement = parsed_output == parsed_expected
        else:
            agreement = actual == expected.strip()
        return self._result(actual=actual, expected=expected, agreement=agreement)

    async def async_score(
        self,
        input_text: str,
        output: str,
        expected: str | None = None,
    ) -> tuple[float, dict[str, Any]]:
        """Score asynchronously by delegating to ``score``.

        Args:
            input_text: Input given to the agent. Not used.
            output: Agent output text.
            expected: Label from the trainset row.

        Returns:
            The same result as ``score``.
        """
        return self.score(input_text, output, expected)

__init__

__init__(field: str | None = None) -> None

Store the output field to compare.

PARAMETER DESCRIPTION
field

Name of the JSON field holding the label, or None to compare the whole output.

TYPE: str | None DEFAULT: None

Source code in src/gepa_adk/adapters/scoring/label_agreement.py
def __init__(self, field: str | None = None) -> None:
    """Store the output field to compare.

    Args:
        field: Name of the JSON field holding the label, or ``None`` to
            compare the whole output.
    """
    self.field = field

score

score(
    input_text: str,
    output: str,
    expected: str | None = None,
) -> tuple[float, dict[str, Any]]

Score the output by agreement with the expected label.

PARAMETER DESCRIPTION
input_text

Input given to the agent. Not used.

TYPE: str

output

Agent output text.

TYPE: str

expected

Label from the trainset row.

TYPE: str | None DEFAULT: None

RETURNS DESCRIPTION
tuple[float, dict[str, Any]]

(1.0, metadata) on agreement, else (0.0, metadata).

Source code in src/gepa_adk/adapters/scoring/label_agreement.py
def score(
    self,
    input_text: str,
    output: str,
    expected: str | None = None,
) -> tuple[float, dict[str, Any]]:
    """Score the output by agreement with the expected label.

    Args:
        input_text: Input given to the agent. Not used.
        output: Agent output text.
        expected: Label from the trainset row.

    Returns:
        ``(1.0, metadata)`` on agreement, else ``(0.0, metadata)``.
    """
    if expected is None:
        return self._result(
            actual=None,
            expected=None,
            agreement=False,
            reason="missing_expected",
        )
    if self.field is None:
        return self._score_whole(output, expected)
    parsed = _parse_json(output)
    if parsed is _MISSING:
        return self._result(
            actual=None,
            expected=expected,
            agreement=False,
            reason="output_not_json",
        )
    if not isinstance(parsed, dict) or self.field not in parsed:
        return self._result(
            actual=None,
            expected=expected,
            agreement=False,
            reason="field_missing",
        )
    actual = str(parsed[self.field]).strip()
    return self._result(
        actual=actual,
        expected=expected,
        agreement=actual == expected.strip(),
    )

async_score async

async_score(
    input_text: str,
    output: str,
    expected: str | None = None,
) -> tuple[float, dict[str, Any]]

Score asynchronously by delegating to score.

PARAMETER DESCRIPTION
input_text

Input given to the agent. Not used.

TYPE: str

output

Agent output text.

TYPE: str

expected

Label from the trainset row.

TYPE: str | None DEFAULT: None

RETURNS DESCRIPTION
tuple[float, dict[str, Any]]

The same result as score.

Source code in src/gepa_adk/adapters/scoring/label_agreement.py
async def async_score(
    self,
    input_text: str,
    output: str,
    expected: str | None = None,
) -> tuple[float, dict[str, Any]]:
    """Score asynchronously by delegating to ``score``.

    Args:
        input_text: Input given to the agent. Not used.
        output: Agent output text.
        expected: Label from the trainset row.

    Returns:
        The same result as ``score``.
    """
    return self.score(input_text, output, expected)

RequireToolScorer

Scorer that gates an inner scorer on a required tool call.

ATTRIBUTE DESCRIPTION
inner

Wrapped scorer consulted when the tool ran.

TYPE: Scorer

tool

Name of the tool that must appear in the trajectory.

TYPE: str

Examples:

from gepa_adk import LabelAgreementScorer, RequireToolScorer, evolve

scorer = RequireToolScorer(LabelAgreementScorer(field="label"), tool="ask_x")
result = await evolve(agent, trainset, scorer=scorer)
Notes

Adheres to the Scorer protocol. Metadata always carries required_tool and tool_called. The score is 0.0 with metadata["reason"] set to "trajectory_unavailable" when no trajectory was given and "tool_not_called" when the tool is absent. Otherwise the inner scorer's score and metadata are returned.

Source code in src/gepa_adk/adapters/scoring/require_tool.py
class RequireToolScorer:
    """Scorer that gates an inner scorer on a required tool call.

    Attributes:
        inner (Scorer): Wrapped scorer consulted when the tool ran.
        tool (str): Name of the tool that must appear in the trajectory.

    Examples:
        ```python
        from gepa_adk import LabelAgreementScorer, RequireToolScorer, evolve

        scorer = RequireToolScorer(LabelAgreementScorer(field="label"), tool="ask_x")
        result = await evolve(agent, trainset, scorer=scorer)
        ```

    Notes:
        Adheres to the Scorer protocol. Metadata always carries
        ``required_tool`` and ``tool_called``. The score is 0.0 with
        ``metadata["reason"]`` set to ``"trajectory_unavailable"`` when no
        trajectory was given and ``"tool_not_called"`` when the tool is
        absent. Otherwise the inner scorer's score and metadata are returned.
    """

    def __init__(self, inner: Scorer, tool: str) -> None:
        """Store the wrapped scorer and the required tool name.

        Args:
            inner: Scorer to delegate to when the tool ran.
            tool: Name of the tool that must appear in the trajectory.

        Raises:
            ValueError: If ``tool`` is not a non-blank string.
            TypeError: If ``inner`` lacks ``score`` or ``async_score``.

        Notes:
            Records separately whether ``inner.score`` and
            ``inner.async_score`` accept ``trajectory`` by keyword.
        """
        if not isinstance(tool, str) or not tool.strip():
            raise ValueError(f"tool must be a non-blank string, got {tool!r}")
        if not hasattr(inner, "score") or not hasattr(inner, "async_score"):
            raise TypeError(
                f"inner must implement the Scorer protocol, got {type(inner)}"
            )
        self.inner = inner
        self.tool = tool
        # Each method is inspected separately: an inner scorer may declare
        # ``trajectory`` on one and not the other.
        self._inner_async_accepts_trajectory = scorer_accepts_trajectory(inner)
        self._inner_sync_accepts_trajectory = _method_accepts_trajectory(inner.score)

    def _gate(
        self, trajectory: ADKTrajectory | None
    ) -> tuple[float, dict[str, Any]] | None:
        """Return the zero result when the tool check fails.

        Args:
            trajectory: Row trajectory, or ``None`` when unavailable.

        Returns:
            ``(0.0, metadata)`` when the trajectory is missing or lacks the
            tool, else ``None`` to signal delegation.
        """
        if trajectory is None:
            reason = "trajectory_unavailable"
        elif any(call.name == self.tool for call in trajectory.tool_calls):
            return None
        else:
            reason = "tool_not_called"
        return 0.0, {
            "reason": reason,
            "required_tool": self.tool,
            "tool_called": False,
        }

    def _merge(self, result: Any) -> tuple[float, dict[str, Any]]:
        """Attach the tool metadata to the inner scorer's result.

        Args:
            result: Inner result, either ``(score, metadata)`` or a bare float.

        Returns:
            The inner score with its metadata plus ``required_tool`` and
            ``tool_called``.
        """
        if isinstance(result, tuple):
            score, metadata = result[0], dict(result[1] or {})
        else:
            score, metadata = float(result), {}
        metadata["required_tool"] = self.tool
        metadata["tool_called"] = True
        return score, metadata

    def score(
        self,
        input_text: str,
        output: str,
        expected: str | None = None,
        *,
        trajectory: ADKTrajectory | None = None,
    ) -> tuple[float, dict[str, Any]]:
        """Score synchronously, requiring the tool before delegating.

        Args:
            input_text: Input given to the agent.
            output: Agent output text.
            expected: Label from the trainset row, if any.
            trajectory: Row trajectory from the adapter, if available.

        Returns:
            ``(0.0, metadata)`` when the tool did not run, else the inner
            scorer's result with the tool metadata added.

        Notes:
            Forwards ``trajectory`` only when the corresponding inner method
            declares it.
        """
        gated = self._gate(trajectory)
        if gated is not None:
            return gated
        if self._inner_sync_accepts_trajectory:
            # The Scorer protocol omits ``trajectory``; the inner scorer
            # declared it, so call through an untyped reference.
            inner: Any = self.inner
            result = inner.score(input_text, output, expected, trajectory=trajectory)
        else:
            result = self.inner.score(input_text, output, expected)
        return self._merge(result)

    async def async_score(
        self,
        input_text: str,
        output: str,
        expected: str | None = None,
        *,
        trajectory: ADKTrajectory | None = None,
    ) -> tuple[float, dict[str, Any]]:
        """Score asynchronously, requiring the tool before delegating.

        Args:
            input_text: Input given to the agent.
            output: Agent output text.
            expected: Label from the trainset row, if any.
            trajectory: Row trajectory from the adapter, if available.

        Returns:
            ``(0.0, metadata)`` when the tool did not run, else the inner
            scorer's result with the tool metadata added.

        Notes:
            Forwards ``trajectory`` only when the corresponding inner method
            declares it.
        """
        gated = self._gate(trajectory)
        if gated is not None:
            return gated
        if self._inner_async_accepts_trajectory:
            # The Scorer protocol omits ``trajectory``; the inner scorer
            # declared it, so call through an untyped reference.
            inner: Any = self.inner
            result = await inner.async_score(
                input_text, output, expected, trajectory=trajectory
            )
        else:
            result = await self.inner.async_score(input_text, output, expected)
        return self._merge(result)

__init__

__init__(inner: Scorer, tool: str) -> None

Store the wrapped scorer and the required tool name.

PARAMETER DESCRIPTION
inner

Scorer to delegate to when the tool ran.

TYPE: Scorer

tool

Name of the tool that must appear in the trajectory.

TYPE: str

RAISES DESCRIPTION
ValueError

If tool is not a non-blank string.

TypeError

If inner lacks score or async_score.

Notes

Records separately whether inner.score and inner.async_score accept trajectory by keyword.

Source code in src/gepa_adk/adapters/scoring/require_tool.py
def __init__(self, inner: Scorer, tool: str) -> None:
    """Store the wrapped scorer and the required tool name.

    Args:
        inner: Scorer to delegate to when the tool ran.
        tool: Name of the tool that must appear in the trajectory.

    Raises:
        ValueError: If ``tool`` is not a non-blank string.
        TypeError: If ``inner`` lacks ``score`` or ``async_score``.

    Notes:
        Records separately whether ``inner.score`` and
        ``inner.async_score`` accept ``trajectory`` by keyword.
    """
    if not isinstance(tool, str) or not tool.strip():
        raise ValueError(f"tool must be a non-blank string, got {tool!r}")
    if not hasattr(inner, "score") or not hasattr(inner, "async_score"):
        raise TypeError(
            f"inner must implement the Scorer protocol, got {type(inner)}"
        )
    self.inner = inner
    self.tool = tool
    # Each method is inspected separately: an inner scorer may declare
    # ``trajectory`` on one and not the other.
    self._inner_async_accepts_trajectory = scorer_accepts_trajectory(inner)
    self._inner_sync_accepts_trajectory = _method_accepts_trajectory(inner.score)

score

score(
    input_text: str,
    output: str,
    expected: str | None = None,
    *,
    trajectory: ADKTrajectory | None = None,
) -> tuple[float, dict[str, Any]]

Score synchronously, requiring the tool before delegating.

PARAMETER DESCRIPTION
input_text

Input given to the agent.

TYPE: str

output

Agent output text.

TYPE: str

expected

Label from the trainset row, if any.

TYPE: str | None DEFAULT: None

trajectory

Row trajectory from the adapter, if available.

TYPE: ADKTrajectory | None DEFAULT: None

RETURNS DESCRIPTION
float

(0.0, metadata) when the tool did not run, else the inner

dict[str, Any]

scorer's result with the tool metadata added.

Notes

Forwards trajectory only when the corresponding inner method declares it.

Source code in src/gepa_adk/adapters/scoring/require_tool.py
def score(
    self,
    input_text: str,
    output: str,
    expected: str | None = None,
    *,
    trajectory: ADKTrajectory | None = None,
) -> tuple[float, dict[str, Any]]:
    """Score synchronously, requiring the tool before delegating.

    Args:
        input_text: Input given to the agent.
        output: Agent output text.
        expected: Label from the trainset row, if any.
        trajectory: Row trajectory from the adapter, if available.

    Returns:
        ``(0.0, metadata)`` when the tool did not run, else the inner
        scorer's result with the tool metadata added.

    Notes:
        Forwards ``trajectory`` only when the corresponding inner method
        declares it.
    """
    gated = self._gate(trajectory)
    if gated is not None:
        return gated
    if self._inner_sync_accepts_trajectory:
        # The Scorer protocol omits ``trajectory``; the inner scorer
        # declared it, so call through an untyped reference.
        inner: Any = self.inner
        result = inner.score(input_text, output, expected, trajectory=trajectory)
    else:
        result = self.inner.score(input_text, output, expected)
    return self._merge(result)

async_score async

async_score(
    input_text: str,
    output: str,
    expected: str | None = None,
    *,
    trajectory: ADKTrajectory | None = None,
) -> tuple[float, dict[str, Any]]

Score asynchronously, requiring the tool before delegating.

PARAMETER DESCRIPTION
input_text

Input given to the agent.

TYPE: str

output

Agent output text.

TYPE: str

expected

Label from the trainset row, if any.

TYPE: str | None DEFAULT: None

trajectory

Row trajectory from the adapter, if available.

TYPE: ADKTrajectory | None DEFAULT: None

RETURNS DESCRIPTION
float

(0.0, metadata) when the tool did not run, else the inner

dict[str, Any]

scorer's result with the tool metadata added.

Notes

Forwards trajectory only when the corresponding inner method declares it.

Source code in src/gepa_adk/adapters/scoring/require_tool.py
async def async_score(
    self,
    input_text: str,
    output: str,
    expected: str | None = None,
    *,
    trajectory: ADKTrajectory | None = None,
) -> tuple[float, dict[str, Any]]:
    """Score asynchronously, requiring the tool before delegating.

    Args:
        input_text: Input given to the agent.
        output: Agent output text.
        expected: Label from the trainset row, if any.
        trajectory: Row trajectory from the adapter, if available.

    Returns:
        ``(0.0, metadata)`` when the tool did not run, else the inner
        scorer's result with the tool metadata added.

    Notes:
        Forwards ``trajectory`` only when the corresponding inner method
        declares it.
    """
    gated = self._gate(trajectory)
    if gated is not None:
        return gated
    if self._inner_async_accepts_trajectory:
        # The Scorer protocol omits ``trajectory``; the inner scorer
        # declared it, so call through an untyped reference.
        inner: Any = self.inner
        result = await inner.async_score(
            input_text, output, expected, trajectory=trajectory
        )
    else:
        result = await self.inner.async_score(input_text, output, expected)
    return self._merge(result)

CriticOutput

Bases: BaseModel


              flowchart TD
              gepa_adk.CriticOutput[CriticOutput]

              

              click gepa_adk.CriticOutput href "" "gepa_adk.CriticOutput"
            

Advanced schema for structured critic feedback with dimensions.

This schema defines the expected JSON structure that critic agents should return when configured with output_schema. The score field is required, while other fields are optional and will be preserved in metadata.

ATTRIBUTE DESCRIPTION
score

Score value between 0.0 and 1.0 (required).

TYPE: float

feedback

Human-readable feedback text (optional).

TYPE: str

dimension_scores

Per-dimension evaluation scores (optional).

TYPE: dict[str, float]

actionable_guidance

Specific improvement suggestions (optional).

TYPE: str

Examples:

Advanced critic output:

{
    "score": 0.75,
    "feedback": "Good response but could be more concise",
    "dimension_scores": {
        "accuracy": 0.9,
        "clarity": 0.6,
        "completeness": 0.8
    },
    "actionable_guidance": "Reduce response length by 30%"
}
Notes

All critic agents using this schema must return structured JSON. When this schema is used as output_schema on an LlmAgent, the agent can ONLY reply and CANNOT use any tools. This is acceptable for critic agents focused on scoring.

See Also

gepa_adk.adapters.scoring.critic_scorer.SimpleCriticOutput: KISS schema with just score + feedback.

Source code in src/gepa_adk/adapters/scoring/critic_scorer.py
class CriticOutput(BaseModel):
    """Advanced schema for structured critic feedback with dimensions.

    This schema defines the expected JSON structure that critic agents
    should return when configured with output_schema. The score field is
    required, while other fields are optional and will be preserved in
    metadata.

    Attributes:
        score (float): Score value between 0.0 and 1.0 (required).
        feedback (str): Human-readable feedback text (optional).
        dimension_scores (dict[str, float]): Per-dimension evaluation scores (optional).
        actionable_guidance (str): Specific improvement suggestions (optional).

    Examples:
        Advanced critic output:

        ```json
        {
            "score": 0.75,
            "feedback": "Good response but could be more concise",
            "dimension_scores": {
                "accuracy": 0.9,
                "clarity": 0.6,
                "completeness": 0.8
            },
            "actionable_guidance": "Reduce response length by 30%"
        }
        ```

    Notes:
        All critic agents using this schema must return structured JSON.
        When this schema is used as output_schema on an LlmAgent, the
        agent can ONLY reply and CANNOT use any tools. This is acceptable
        for critic agents focused on scoring.

    See Also:
        [gepa_adk.adapters.scoring.critic_scorer.SimpleCriticOutput][]:
            KISS schema with just score + feedback.
    """

    score: float = Field(
        ...,
        ge=0.0,
        le=1.0,
        description="Score from 0.0 to 1.0",
    )
    feedback: str = Field(
        default="",
        description="Human-readable feedback",
    )
    dimension_scores: dict[str, float] = Field(
        default_factory=dict,
        description="Per-dimension scores",
    )
    actionable_guidance: str = Field(
        default="",
        description="Improvement suggestions",
    )

SimpleCriticOutput

Bases: BaseModel


              flowchart TD
              gepa_adk.SimpleCriticOutput[SimpleCriticOutput]

              

              click gepa_adk.SimpleCriticOutput href "" "gepa_adk.SimpleCriticOutput"
            

KISS schema for basic critic feedback.

This is the minimal schema for critic agents that only need to provide a score and text feedback. Use this for straightforward evaluation tasks where dimension breakdowns are not needed.

ATTRIBUTE DESCRIPTION
score

Score value between 0.0 and 1.0 (required).

TYPE: float

feedback

Human-readable feedback text (required).

TYPE: str

Examples:

Simple critic output:

{
    "score": 0.75,
    "feedback": "Good response but could be more concise."
}

Using with LlmAgent:

from google.adk.agents import LlmAgent
from google.adk.models.lite_llm import LiteLlm
from gepa_adk.adapters.scoring.critic_scorer import SimpleCriticOutput

critic = LlmAgent(
    name="simple_critic",
    model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
    instruction=SIMPLE_CRITIC_INSTRUCTION,
    output_schema=SimpleCriticOutput,
)
Notes

Applies to basic evaluation tasks where only a score and feedback are needed. For more detailed evaluations with dimension scores, use CriticOutput instead.

See Also

gepa_adk.adapters.scoring.critic_scorer.CriticOutput: Advanced schema with dimension scores and guidance.

Source code in src/gepa_adk/adapters/scoring/critic_scorer.py
class SimpleCriticOutput(BaseModel):
    """KISS schema for basic critic feedback.

    This is the minimal schema for critic agents that only need to provide
    a score and text feedback. Use this for straightforward evaluation tasks
    where dimension breakdowns are not needed.

    Attributes:
        score (float): Score value between 0.0 and 1.0 (required).
        feedback (str): Human-readable feedback text (required).

    Examples:
        Simple critic output:

        ```json
        {
            "score": 0.75,
            "feedback": "Good response but could be more concise."
        }
        ```

        Using with LlmAgent:

        ```python
        from google.adk.agents import LlmAgent
        from google.adk.models.lite_llm import LiteLlm
        from gepa_adk.adapters.scoring.critic_scorer import SimpleCriticOutput

        critic = LlmAgent(
            name="simple_critic",
            model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
            instruction=SIMPLE_CRITIC_INSTRUCTION,
            output_schema=SimpleCriticOutput,
        )
        ```

    Notes:
        Applies to basic evaluation tasks where only a score and feedback
        are needed. For more detailed evaluations with dimension scores,
        use CriticOutput instead.

    See Also:
        [gepa_adk.adapters.scoring.critic_scorer.CriticOutput][]:
            Advanced schema with dimension scores and guidance.
    """

    score: float = Field(
        ...,
        ge=0.0,
        le=1.0,
        description="Score from 0.0 to 1.0",
    )
    feedback: str = Field(
        ...,
        description="Human-readable feedback explaining the score",
    )

AllComponentSelector

Selects all available components for simultaneous update.

This selector returns the full list of components every time, enabling simultaneous evolution of all parts of the candidate.

Examples:

selector = AllComponentSelector()
all_comps = await selector.select_components(["a", "b"], 1, 0)
# Returns ["a", "b"]
Notes

Always returns all components, enabling comprehensive mutations across the entire candidate in a single iteration.

Source code in src/gepa_adk/adapters/selection/component_selector.py
class AllComponentSelector:
    """Selects all available components for simultaneous update.

    This selector returns the full list of components every time, enabling
    simultaneous evolution of all parts of the candidate.

    Examples:
        ```python
        selector = AllComponentSelector()
        all_comps = await selector.select_components(["a", "b"], 1, 0)
        # Returns ["a", "b"]
        ```

    Notes:
        Always returns all components, enabling comprehensive mutations
        across the entire candidate in a single iteration.
    """

    async def select_components(
        self, components: list[str], iteration: int, candidate_idx: int
    ) -> list[str]:
        """Select all components to update.

        Args:
            components: List of available component keys.
            iteration: Current global iteration number (unused).
            candidate_idx: Index of the candidate being evolved (unused).

        Returns:
            List containing all component keys.

        Raises:
            ValueError: If components list is empty.

        Examples:
            ```python
            selected = await selector.select_components(["a", "b"], 1, 0)
            ```

        Notes:
            Outputs the complete component list unchanged, enabling
            simultaneous evolution of all candidate parts.
        """
        if not components:
            raise ValueError("No components provided for selection")

        return list(components)

select_components async

select_components(
    components: list[str],
    iteration: int,
    candidate_idx: int,
) -> list[str]

Select all components to update.

PARAMETER DESCRIPTION
components

List of available component keys.

TYPE: list[str]

iteration

Current global iteration number (unused).

TYPE: int

candidate_idx

Index of the candidate being evolved (unused).

TYPE: int

RETURNS DESCRIPTION
list[str]

List containing all component keys.

RAISES DESCRIPTION
ValueError

If components list is empty.

Examples:

selected = await selector.select_components(["a", "b"], 1, 0)
Notes

Outputs the complete component list unchanged, enabling simultaneous evolution of all candidate parts.

Source code in src/gepa_adk/adapters/selection/component_selector.py
async def select_components(
    self, components: list[str], iteration: int, candidate_idx: int
) -> list[str]:
    """Select all components to update.

    Args:
        components: List of available component keys.
        iteration: Current global iteration number (unused).
        candidate_idx: Index of the candidate being evolved (unused).

    Returns:
        List containing all component keys.

    Raises:
        ValueError: If components list is empty.

    Examples:
        ```python
        selected = await selector.select_components(["a", "b"], 1, 0)
        ```

    Notes:
        Outputs the complete component list unchanged, enabling
        simultaneous evolution of all candidate parts.
    """
    if not components:
        raise ValueError("No components provided for selection")

    return list(components)

RoundRobinComponentSelector

Selects components in a round-robin fashion.

This selector cycles through the list of components one by one, maintaining state per candidate index to ensure consistent rotation.

ATTRIBUTE DESCRIPTION
_next_index

Mapping of candidate_idx to next component index.

TYPE: dict[int, int]

Examples:

selector = RoundRobinComponentSelector()
# First call selects first component
c1 = await selector.select_components(["a", "b"], 1, 0)  # ["a"]
# Second call selects second component
c2 = await selector.select_components(["a", "b"], 2, 0)  # ["b"]
Notes

Alternates through components sequentially, ensuring balanced evolution across all candidate parts.

Source code in src/gepa_adk/ports/defaults.py
class RoundRobinComponentSelector:
    """Selects components in a round-robin fashion.

    This selector cycles through the list of components one by one, maintaining
    state per candidate index to ensure consistent rotation.

    Attributes:
        _next_index (dict[int, int]): Mapping of candidate_idx to next component index.

    Examples:
        ```python
        selector = RoundRobinComponentSelector()
        # First call selects first component
        c1 = await selector.select_components(["a", "b"], 1, 0)  # ["a"]
        # Second call selects second component
        c2 = await selector.select_components(["a", "b"], 2, 0)  # ["b"]
        ```

    Notes:
        Alternates through components sequentially, ensuring balanced evolution
        across all candidate parts.
    """

    def __init__(self) -> None:
        """Initialize the round-robin selector.

        Notes:
            Creates empty index tracking dictionary for per-candidate rotation state.
        """
        self._next_index: dict[int, int] = defaultdict(int)

    async def select_components(
        self, components: list[str], iteration: int, candidate_idx: int
    ) -> list[str]:
        """Select a single component to update using round-robin logic.

        Args:
            components: List of available component keys.
            iteration: Current global iteration number (unused by this strategy).
            candidate_idx: Index of the candidate being evolved.

        Returns:
            List containing the single selected component key.

        Raises:
            ValueError: If components list is empty.

        Examples:
            ```python
            selected = await selector.select_components(["a", "b"], 1, 0)
            ```

        Notes:
            Outputs one component per call, advancing the rotation index for
            the specified candidate.
        """
        if not components:
            raise ValueError("No components provided for selection")

        # Get current index for this candidate
        current_idx = self._next_index[candidate_idx]

        # Select component
        selected = components[current_idx % len(components)]

        # Advance index for next time
        self._next_index[candidate_idx] = (current_idx + 1) % len(components)

        return [selected]

__init__

__init__() -> None

Initialize the round-robin selector.

Notes

Creates empty index tracking dictionary for per-candidate rotation state.

Source code in src/gepa_adk/ports/defaults.py
def __init__(self) -> None:
    """Initialize the round-robin selector.

    Notes:
        Creates empty index tracking dictionary for per-candidate rotation state.
    """
    self._next_index: dict[int, int] = defaultdict(int)

select_components async

select_components(
    components: list[str],
    iteration: int,
    candidate_idx: int,
) -> list[str]

Select a single component to update using round-robin logic.

PARAMETER DESCRIPTION
components

List of available component keys.

TYPE: list[str]

iteration

Current global iteration number (unused by this strategy).

TYPE: int

candidate_idx

Index of the candidate being evolved.

TYPE: int

RETURNS DESCRIPTION
list[str]

List containing the single selected component key.

RAISES DESCRIPTION
ValueError

If components list is empty.

Examples:

selected = await selector.select_components(["a", "b"], 1, 0)
Notes

Outputs one component per call, advancing the rotation index for the specified candidate.

Source code in src/gepa_adk/ports/defaults.py
async def select_components(
    self, components: list[str], iteration: int, candidate_idx: int
) -> list[str]:
    """Select a single component to update using round-robin logic.

    Args:
        components: List of available component keys.
        iteration: Current global iteration number (unused by this strategy).
        candidate_idx: Index of the candidate being evolved.

    Returns:
        List containing the single selected component key.

    Raises:
        ValueError: If components list is empty.

    Examples:
        ```python
        selected = await selector.select_components(["a", "b"], 1, 0)
        ```

    Notes:
        Outputs one component per call, advancing the rotation index for
        the specified candidate.
    """
    if not components:
        raise ValueError("No components provided for selection")

    # Get current index for this candidate
    current_idx = self._next_index[candidate_idx]

    # Select component
    selected = components[current_idx % len(components)]

    # Advance index for next time
    self._next_index[candidate_idx] = (current_idx + 1) % len(components)

    return [selected]

RegressionStopper

Stops evolution when the latest score declines over a lookback window.

Detects regression by comparing the latest iteration's score (StopperState.latest_score) to the score from window iterations ago. Returns True (stop) when the current score is strictly lower than the score window steps prior. When latest_score is None the stopper uses best_score instead.

Requires at least window + 1 calls before any regression can be detected. Call setup() (or let the engine call it) to reset history between runs.

ATTRIBUTE DESCRIPTION
window

Number of iterations to look back for comparison.

TYPE: int

PARAMETER DESCRIPTION
window

Number of iterations to look back for comparison. Must be >= 1. Default is 3.

TYPE: int DEFAULT: 3

Examples:

Detecting degrading runs:

from gepa_adk import RegressionStopper
from gepa_adk.domain.stopper import StopperState

stopper = RegressionStopper(window=3)

# Simulated evolution calls (best_score stays at the accepted best)
stopper(
    StopperState(
        iteration=0,
        best_score=0.5,
        stagnation_counter=0,
        total_evaluations=0,
        candidates_count=1,
        elapsed_seconds=0.0,
        latest_score=0.5,
    )
)  # False (cold start)
stopper(
    StopperState(
        iteration=1,
        best_score=0.6,
        stagnation_counter=0,
        total_evaluations=1,
        candidates_count=1,
        elapsed_seconds=1.0,
        latest_score=0.6,
    )
)  # False
stopper(
    StopperState(
        iteration=2,
        best_score=0.7,
        stagnation_counter=0,
        total_evaluations=2,
        candidates_count=1,
        elapsed_seconds=2.0,
        latest_score=0.7,
    )
)  # False
stopper(
    StopperState(
        iteration=3,
        best_score=0.7,
        stagnation_counter=0,
        total_evaluations=3,
        candidates_count=1,
        elapsed_seconds=3.0,
        latest_score=0.4,
    )
)  # True (0.4 < 0.5, the baseline from window=3 ago)
Notes

Equal scores (plateau) are NOT considered regression. Only strictly lower scores trigger a stop. best_score never decreases during a run, so the stopper reads latest_score; an iteration that produced no proposal records 0.0 and therefore counts as a decline.

Source code in src/gepa_adk/adapters/stoppers/regression.py
class RegressionStopper:
    """Stops evolution when the latest score declines over a lookback window.

    Detects regression by comparing the latest iteration's score
    (``StopperState.latest_score``) to the score from ``window`` iterations
    ago. Returns ``True`` (stop) when the current score is strictly lower than
    the score ``window`` steps prior. When ``latest_score`` is ``None`` the
    stopper uses ``best_score`` instead.

    Requires at least ``window + 1`` calls before any regression can be detected.
    Call ``setup()`` (or let the engine call it) to reset history between runs.

    Attributes:
        window (int): Number of iterations to look back for comparison.

    Args:
        window: Number of iterations to look back for comparison. Must be >= 1.
            Default is 3.

    Examples:
        Detecting degrading runs:

        ```python
        from gepa_adk import RegressionStopper
        from gepa_adk.domain.stopper import StopperState

        stopper = RegressionStopper(window=3)

        # Simulated evolution calls (best_score stays at the accepted best)
        stopper(
            StopperState(
                iteration=0,
                best_score=0.5,
                stagnation_counter=0,
                total_evaluations=0,
                candidates_count=1,
                elapsed_seconds=0.0,
                latest_score=0.5,
            )
        )  # False (cold start)
        stopper(
            StopperState(
                iteration=1,
                best_score=0.6,
                stagnation_counter=0,
                total_evaluations=1,
                candidates_count=1,
                elapsed_seconds=1.0,
                latest_score=0.6,
            )
        )  # False
        stopper(
            StopperState(
                iteration=2,
                best_score=0.7,
                stagnation_counter=0,
                total_evaluations=2,
                candidates_count=1,
                elapsed_seconds=2.0,
                latest_score=0.7,
            )
        )  # False
        stopper(
            StopperState(
                iteration=3,
                best_score=0.7,
                stagnation_counter=0,
                total_evaluations=3,
                candidates_count=1,
                elapsed_seconds=3.0,
                latest_score=0.4,
            )
        )  # True (0.4 < 0.5, the baseline from window=3 ago)
        ```

    Notes:
        Equal scores (plateau) are NOT considered regression. Only strictly
        lower scores trigger a stop. ``best_score`` never decreases during a
        run, so the stopper reads ``latest_score``; an iteration that produced
        no proposal records ``0.0`` and therefore counts as a decline.
    """

    def __init__(self, *, window: int = 3) -> None:
        """Initialize RegressionStopper with lookback window.

        Args:
            window: Number of iterations to look back for score comparison.
                Must be >= 1. Default is 3.

        Raises:
            ConfigurationError: If window < 1.

        Examples:
            ```python
            stopper = RegressionStopper()  # window=3
            stopper = RegressionStopper(window=5)  # window=5
            ```

        Notes:
            Score history is stored in a ``deque(maxlen=window+1)`` so memory
            usage is bounded to exactly ``window + 1`` floats regardless of
            run length.
        """
        if window < 1:
            raise ConfigurationError(
                f"RegressionStopper window must be >= 1, got {window}",
                field="window",
                value=window,
                constraint="Must be >= 1",
            )
        self.window = window
        self._score_history: deque[float] = deque(maxlen=self.window + 1)

    def setup(self) -> None:
        """Reset score history. Called by engine at start of each run.

        Clears the internal score history by replacing the bounded deque with
        a fresh one, so the stopper can be safely reused across multiple
        ``evolve()`` calls with the same instance. Without this reset, history
        from one run would bleed into the next.

        Examples:
            ```python
            stopper = RegressionStopper()
            # ... run evolution ...
            stopper.setup()  # reset for next run
            ```
        """
        self._score_history = deque(maxlen=self.window + 1)

    def __call__(self, state: StopperState) -> bool:
        """Check if evolution should stop due to score regression.

        Appends ``state.latest_score`` to history, or ``state.best_score``
        when ``latest_score`` is ``None``, then compares the newest score
        against the score from ``window`` iterations ago. Returns ``False``
        during the cold-start phase (fewer than ``window + 1`` calls).

        Args:
            state: Current evolution state snapshot containing latest_score
                and best_score.

        Returns:
            True if the newest recorded score is strictly lower than the score
            ``window`` iterations ago, False otherwise.

        Examples:
            ```python
            stopper = RegressionStopper(window=3)
            # Prime with 3 cold-start calls
            for score in [0.5, 0.6, 0.7]:
                stopper(
                    StopperState(
                        iteration=0,
                        best_score=0.7,
                        stagnation_counter=0,
                        total_evaluations=0,
                        candidates_count=1,
                        elapsed_seconds=0.0,
                        latest_score=score,
                    )
                )
            state = StopperState(
                iteration=3,
                best_score=0.7,
                stagnation_counter=0,
                total_evaluations=3,
                candidates_count=1,
                elapsed_seconds=3.0,
                latest_score=0.4,
            )
            stopper(state)  # True (0.4 < 0.5, the baseline from window=3 ago)
            ```
        """
        latest = state.latest_score
        self._score_history.append(latest if latest is not None else state.best_score)
        if len(self._score_history) <= self.window:
            return False
        current = self._score_history[-1]
        baseline = self._score_history[-(self.window + 1)]
        if current < baseline:
            logger.info(
                "stopper.regression.triggered",
                window=self.window,
                current_score=current,
                baseline_score=baseline,
            )
            return True
        return False

__init__

__init__(*, window: int = 3) -> None

Initialize RegressionStopper with lookback window.

PARAMETER DESCRIPTION
window

Number of iterations to look back for score comparison. Must be >= 1. Default is 3.

TYPE: int DEFAULT: 3

RAISES DESCRIPTION
ConfigurationError

If window < 1.

Examples:

stopper = RegressionStopper()  # window=3
stopper = RegressionStopper(window=5)  # window=5
Notes

Score history is stored in a deque(maxlen=window+1) so memory usage is bounded to exactly window + 1 floats regardless of run length.

Source code in src/gepa_adk/adapters/stoppers/regression.py
def __init__(self, *, window: int = 3) -> None:
    """Initialize RegressionStopper with lookback window.

    Args:
        window: Number of iterations to look back for score comparison.
            Must be >= 1. Default is 3.

    Raises:
        ConfigurationError: If window < 1.

    Examples:
        ```python
        stopper = RegressionStopper()  # window=3
        stopper = RegressionStopper(window=5)  # window=5
        ```

    Notes:
        Score history is stored in a ``deque(maxlen=window+1)`` so memory
        usage is bounded to exactly ``window + 1`` floats regardless of
        run length.
    """
    if window < 1:
        raise ConfigurationError(
            f"RegressionStopper window must be >= 1, got {window}",
            field="window",
            value=window,
            constraint="Must be >= 1",
        )
    self.window = window
    self._score_history: deque[float] = deque(maxlen=self.window + 1)

setup

setup() -> None

Reset score history. Called by engine at start of each run.

Clears the internal score history by replacing the bounded deque with a fresh one, so the stopper can be safely reused across multiple evolve() calls with the same instance. Without this reset, history from one run would bleed into the next.

Examples:

stopper = RegressionStopper()
# ... run evolution ...
stopper.setup()  # reset for next run
Source code in src/gepa_adk/adapters/stoppers/regression.py
def setup(self) -> None:
    """Reset score history. Called by engine at start of each run.

    Clears the internal score history by replacing the bounded deque with
    a fresh one, so the stopper can be safely reused across multiple
    ``evolve()`` calls with the same instance. Without this reset, history
    from one run would bleed into the next.

    Examples:
        ```python
        stopper = RegressionStopper()
        # ... run evolution ...
        stopper.setup()  # reset for next run
        ```
    """
    self._score_history = deque(maxlen=self.window + 1)

__call__

__call__(state: StopperState) -> bool

Check if evolution should stop due to score regression.

Appends state.latest_score to history, or state.best_score when latest_score is None, then compares the newest score against the score from window iterations ago. Returns False during the cold-start phase (fewer than window + 1 calls).

PARAMETER DESCRIPTION
state

Current evolution state snapshot containing latest_score and best_score.

TYPE: StopperState

RETURNS DESCRIPTION
bool

True if the newest recorded score is strictly lower than the score

bool

window iterations ago, False otherwise.

Examples:

stopper = RegressionStopper(window=3)
# Prime with 3 cold-start calls
for score in [0.5, 0.6, 0.7]:
    stopper(
        StopperState(
            iteration=0,
            best_score=0.7,
            stagnation_counter=0,
            total_evaluations=0,
            candidates_count=1,
            elapsed_seconds=0.0,
            latest_score=score,
        )
    )
state = StopperState(
    iteration=3,
    best_score=0.7,
    stagnation_counter=0,
    total_evaluations=3,
    candidates_count=1,
    elapsed_seconds=3.0,
    latest_score=0.4,
)
stopper(state)  # True (0.4 < 0.5, the baseline from window=3 ago)
Source code in src/gepa_adk/adapters/stoppers/regression.py
def __call__(self, state: StopperState) -> bool:
    """Check if evolution should stop due to score regression.

    Appends ``state.latest_score`` to history, or ``state.best_score``
    when ``latest_score`` is ``None``, then compares the newest score
    against the score from ``window`` iterations ago. Returns ``False``
    during the cold-start phase (fewer than ``window + 1`` calls).

    Args:
        state: Current evolution state snapshot containing latest_score
            and best_score.

    Returns:
        True if the newest recorded score is strictly lower than the score
        ``window`` iterations ago, False otherwise.

    Examples:
        ```python
        stopper = RegressionStopper(window=3)
        # Prime with 3 cold-start calls
        for score in [0.5, 0.6, 0.7]:
            stopper(
                StopperState(
                    iteration=0,
                    best_score=0.7,
                    stagnation_counter=0,
                    total_evaluations=0,
                    candidates_count=1,
                    elapsed_seconds=0.0,
                    latest_score=score,
                )
            )
        state = StopperState(
            iteration=3,
            best_score=0.7,
            stagnation_counter=0,
            total_evaluations=3,
            candidates_count=1,
            elapsed_seconds=3.0,
            latest_score=0.4,
        )
        stopper(state)  # True (0.4 < 0.5, the baseline from window=3 ago)
        ```
    """
    latest = state.latest_score
    self._score_history.append(latest if latest is not None else state.best_score)
    if len(self._score_history) <= self.window:
        return False
    current = self._score_history[-1]
    baseline = self._score_history[-(self.window + 1)]
    if current < baseline:
        logger.info(
            "stopper.regression.triggered",
            window=self.window,
            current_score=current,
            baseline_score=baseline,
        )
        return True
    return False

Candidate dataclass

Represents an instruction candidate being evolved.

Unlike GEPA's simple dict[str, str] type alias, this class provides richer state tracking for async scenarios including lineage and metadata.

ATTRIBUTE DESCRIPTION
components

Component name to text value mapping. Common keys include 'instruction' (main agent prompt) and 'output_schema'.

TYPE: dict[str, str]

generation

Generation number in the evolution lineage (0 = initial). A mutation proposal gets its parent's generation plus one when the engine creates it.

TYPE: int

parent_id

Candidate.id of the candidate this one was mutated from, set when the engine creates the proposal. None for seed candidates and merge candidates.

TYPE: str | None

parent_ids

Pareto indices of both parents of a merge candidate, set by the merge proposer. None for seed candidates and for mutation proposals, whose lineage is parent_id plus the genealogy edge the engine records.

TYPE: list[int] | None

metadata

Extensible metadata dict for async tracking and debugging.

TYPE: dict[str, Any]

id

Read-only short content hash of components: the first 12 hex characters of the SHA-256 of their canonical JSON. Equal components give equal ids across processes and runs. The engine logs it as candidate_id.

TYPE: str

Examples:

Creating a candidate:

from gepa_adk.domain.models import Candidate

candidate = Candidate(
    components={"instruction": "Be helpful"},
    generation=0,
)
print(candidate.components["instruction"])  # Be helpful
print(candidate.generation)  # 0
Notes

to_dict() and from_dict() round-trip every field; the engine uses them to checkpoint the best candidate. A mutable candidate representation with richer state tracking than GEPA's simple dict. Components and metadata can be modified during the evolution process. Use generation and parent_id to track lineage.

Source code in src/gepa_adk/domain/models.py
@dataclass(slots=True, kw_only=True)
class Candidate:
    """Represents an instruction candidate being evolved.

    Unlike GEPA's simple `dict[str, str]` type alias, this class provides
    richer state tracking for async scenarios including lineage and metadata.

    Attributes:
        components (dict[str, str]): Component name to text value mapping.
            Common keys include 'instruction' (main agent prompt) and
            'output_schema'.
        generation (int): Generation number in the evolution lineage
            (0 = initial). A mutation proposal gets its parent's generation
            plus one when the engine creates it.
        parent_id (str | None): ``Candidate.id`` of the candidate this one
            was mutated from, set when the engine creates the proposal.
            None for seed candidates and merge candidates.
        parent_ids (list[int] | None): Pareto indices of both parents of a
            merge candidate, set by the merge proposer. None for seed
            candidates and for mutation proposals, whose lineage is
            ``parent_id`` plus the genealogy edge the engine records.
        metadata (dict[str, Any]): Extensible metadata dict for async tracking
            and debugging.
        id (str): Read-only short content hash of ``components``: the first
            12 hex characters of the SHA-256 of their canonical JSON. Equal
            components give equal ids across processes and runs. The engine
            logs it as ``candidate_id``.

    Examples:
        Creating a candidate:

        ```python
        from gepa_adk.domain.models import Candidate

        candidate = Candidate(
            components={"instruction": "Be helpful"},
            generation=0,
        )
        print(candidate.components["instruction"])  # Be helpful
        print(candidate.generation)  # 0
        ```

    Notes:
        ``to_dict()`` and ``from_dict()`` round-trip every field; the
        engine uses them to checkpoint the best candidate.
        A mutable candidate representation with richer state tracking than
        GEPA's simple dict. Components and metadata can be modified during
        the evolution process. Use generation and parent_id to track lineage.
    """

    components: dict[str, str] = field(default_factory=dict)
    generation: int = 0
    parent_id: str | None = None
    parent_ids: list[int] | None = None
    metadata: dict[str, Any] = field(default_factory=dict)

    @property
    def id(self) -> str:
        """Return a short, stable hash of this candidate's components.

        Returns:
            The first 12 hex characters of the SHA-256 digest of the
            components serialised as JSON with sorted keys.

        Examples:
            ```python
            a = Candidate(components={"instruction": "seed"})
            b = Candidate(components={"instruction": "seed"}, generation=2)
            assert a.id == b.id
            ```
        """
        canonical = json.dumps(self.components, sort_keys=True, ensure_ascii=False)
        return hashlib.sha256(canonical.encode()).hexdigest()[:12]

    def to_dict(self) -> dict[str, Any]:
        """Serialise this candidate to a stdlib-only dict.

        Returns:
            Dict with ``components``, ``generation``, ``parent_id``,
            ``parent_ids`` and ``metadata``. ``id`` is derived from
            ``components`` and is not stored. The output is
            ``json.dumps()``-compatible when ``metadata`` is.

        Examples:
            ```python
            data = Candidate(components={"instruction": "seed"}).to_dict()
            assert Candidate.from_dict(data).components == {"instruction": "seed"}
            ```
        """
        return {
            "components": dict(self.components),
            "generation": self.generation,
            "parent_id": self.parent_id,
            "parent_ids": None if self.parent_ids is None else list(self.parent_ids),
            "metadata": dict(self.metadata),
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "Candidate":
        """Rebuild a candidate from ``to_dict()`` output.

        Args:
            data: Dict produced by ``to_dict()``. Only ``components`` is
                required; the other fields take their defaults when absent.

        Returns:
            The reconstructed candidate.

        Raises:
            KeyError: If ``components`` is missing from ``data``.
        """
        parent_ids = data.get("parent_ids")
        return cls(
            components=dict(data["components"]),
            generation=data.get("generation", 0),
            parent_id=data.get("parent_id"),
            parent_ids=None if parent_ids is None else list(parent_ids),
            metadata=dict(data.get("metadata") or {}),
        )

id property

id: str

Return a short, stable hash of this candidate's components.

RETURNS DESCRIPTION
str

The first 12 hex characters of the SHA-256 digest of the

str

components serialised as JSON with sorted keys.

Examples:

a = Candidate(components={"instruction": "seed"})
b = Candidate(components={"instruction": "seed"}, generation=2)
assert a.id == b.id

to_dict

to_dict() -> dict[str, Any]

Serialise this candidate to a stdlib-only dict.

RETURNS DESCRIPTION
dict[str, Any]

Dict with components, generation, parent_id,

dict[str, Any]

parent_ids and metadata. id is derived from

dict[str, Any]

components and is not stored. The output is

dict[str, Any]

json.dumps()-compatible when metadata is.

Examples:

data = Candidate(components={"instruction": "seed"}).to_dict()
assert Candidate.from_dict(data).components == {"instruction": "seed"}
Source code in src/gepa_adk/domain/models.py
def to_dict(self) -> dict[str, Any]:
    """Serialise this candidate to a stdlib-only dict.

    Returns:
        Dict with ``components``, ``generation``, ``parent_id``,
        ``parent_ids`` and ``metadata``. ``id`` is derived from
        ``components`` and is not stored. The output is
        ``json.dumps()``-compatible when ``metadata`` is.

    Examples:
        ```python
        data = Candidate(components={"instruction": "seed"}).to_dict()
        assert Candidate.from_dict(data).components == {"instruction": "seed"}
        ```
    """
    return {
        "components": dict(self.components),
        "generation": self.generation,
        "parent_id": self.parent_id,
        "parent_ids": None if self.parent_ids is None else list(self.parent_ids),
        "metadata": dict(self.metadata),
    }

from_dict classmethod

from_dict(data: dict[str, Any]) -> Candidate

Rebuild a candidate from to_dict() output.

PARAMETER DESCRIPTION
data

Dict produced by to_dict(). Only components is required; the other fields take their defaults when absent.

TYPE: dict[str, Any]

RETURNS DESCRIPTION
Candidate

The reconstructed candidate.

RAISES DESCRIPTION
KeyError

If components is missing from data.

Source code in src/gepa_adk/domain/models.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Candidate":
    """Rebuild a candidate from ``to_dict()`` output.

    Args:
        data: Dict produced by ``to_dict()``. Only ``components`` is
            required; the other fields take their defaults when absent.

    Returns:
        The reconstructed candidate.

    Raises:
        KeyError: If ``components`` is missing from ``data``.
    """
    parent_ids = data.get("parent_ids")
    return cls(
        components=dict(data["components"]),
        generation=data.get("generation", 0),
        parent_id=data.get("parent_id"),
        parent_ids=None if parent_ids is None else list(parent_ids),
        metadata=dict(data.get("metadata") or {}),
    )

ConfigurationError

Bases: EvolutionError


              flowchart TD
              gepa_adk.ConfigurationError[ConfigurationError]
              gepa_adk.domain.exceptions.EvolutionError[EvolutionError]

                              gepa_adk.domain.exceptions.EvolutionError --> gepa_adk.ConfigurationError
                


              click gepa_adk.ConfigurationError href "" "gepa_adk.ConfigurationError"
              click gepa_adk.domain.exceptions.EvolutionError href "" "gepa_adk.domain.exceptions.EvolutionError"
            

Raised when configuration validation fails.

This exception is raised during EvolutionConfig initialization when a parameter violates its validation constraints.

ATTRIBUTE DESCRIPTION
field

The name of the configuration field that failed validation.

TYPE: str | None

value

The invalid value that was provided.

TYPE: object

constraint

Description of the validation constraint that was violated.

TYPE: str | None

Examples:

Creating a configuration error with context:

from gepa_adk.domain.exceptions import ConfigurationError

error = ConfigurationError(
    "max_iterations must be non-negative",
    field="max_iterations",
    value=-5,
    constraint=">= 0",
)
print(error.field, error.value, error.constraint)
# Output: max_iterations -5 >= 0
Notes

Arises from user-provided invalid settings, not programming errors. Should be caught and reported with clear guidance on valid values.

Source code in src/gepa_adk/domain/exceptions.py
class ConfigurationError(EvolutionError):
    """Raised when configuration validation fails.

    This exception is raised during EvolutionConfig initialization
    when a parameter violates its validation constraints.

    Attributes:
        field (str | None): The name of the configuration field that failed
            validation.
        value (object): The invalid value that was provided.
        constraint (str | None): Description of the validation constraint that
            was violated.

    Examples:
        Creating a configuration error with context:

        ```python
        from gepa_adk.domain.exceptions import ConfigurationError

        error = ConfigurationError(
            "max_iterations must be non-negative",
            field="max_iterations",
            value=-5,
            constraint=">= 0",
        )
        print(error.field, error.value, error.constraint)
        # Output: max_iterations -5 >= 0
        ```

    Notes:
        Arises from user-provided invalid settings, not programming errors.
        Should be caught and reported with clear guidance on valid values.
    """

    def __init__(
        self,
        message: str,
        *,
        field: str | None = None,
        value: object = None,
        constraint: str | None = None,
    ) -> None:
        """Initialize ConfigurationError with context.

        Args:
            message: Human-readable error description.
            field: Name of the invalid configuration field.
            value: The invalid value provided.
            constraint: Description of the validation constraint.

        """
        super().__init__(message)
        self.field = field
        self.value = value
        self.constraint = constraint

    def __str__(self) -> str:
        """Return string representation with context.

        Returns:
            Formatted error message including field and value context.

        Notes:
            Outputs formatted error message with field and value context
            when available, preserving base message structure.
        """
        base = super().__str__()
        context_parts = []
        if self.field is not None:
            context_parts.append(f"field={self.field!r}")
        if self.value is not None:
            context_parts.append(f"value={self.value!r}")
        if context_parts:
            return f"{base} [{', '.join(context_parts)}]"
        return base

__init__

__init__(
    message: str,
    *,
    field: str | None = None,
    value: object = None,
    constraint: str | None = None,
) -> None

Initialize ConfigurationError with context.

PARAMETER DESCRIPTION
message

Human-readable error description.

TYPE: str

field

Name of the invalid configuration field.

TYPE: str | None DEFAULT: None

value

The invalid value provided.

TYPE: object DEFAULT: None

constraint

Description of the validation constraint.

TYPE: str | None DEFAULT: None

Source code in src/gepa_adk/domain/exceptions.py
def __init__(
    self,
    message: str,
    *,
    field: str | None = None,
    value: object = None,
    constraint: str | None = None,
) -> None:
    """Initialize ConfigurationError with context.

    Args:
        message: Human-readable error description.
        field: Name of the invalid configuration field.
        value: The invalid value provided.
        constraint: Description of the validation constraint.

    """
    super().__init__(message)
    self.field = field
    self.value = value
    self.constraint = constraint

__str__

__str__() -> str

Return string representation with context.

RETURNS DESCRIPTION
str

Formatted error message including field and value context.

Notes

Outputs formatted error message with field and value context when available, preserving base message structure.

Source code in src/gepa_adk/domain/exceptions.py
def __str__(self) -> str:
    """Return string representation with context.

    Returns:
        Formatted error message including field and value context.

    Notes:
        Outputs formatted error message with field and value context
        when available, preserving base message structure.
    """
    base = super().__str__()
    context_parts = []
    if self.field is not None:
        context_parts.append(f"field={self.field!r}")
    if self.value is not None:
        context_parts.append(f"value={self.value!r}")
    if context_parts:
        return f"{base} [{', '.join(context_parts)}]"
    return base

EvolutionConfig dataclass

Configuration parameters for an evolution run.

Defines the parameters that control how evolution proceeds, including iteration limits, concurrency settings, and stopping criteria.

ATTRIBUTE DESCRIPTION
max_iterations

Maximum number of evolution iterations. 0 means just evaluate baseline without evolving.

TYPE: int

max_concurrent_evals

Maximum concurrent evaluation rows per batch in evolve(). Does not apply to evolve_group() or evolve_workflow(). Must be at least 1.

TYPE: int

min_improvement_threshold

Minimum score improvement to accept a new candidate. Set to 0.0 to accept any improvement.

TYPE: float

patience

Number of iterations without improvement before stopping early. Set to 0 to disable early stopping.

TYPE: int

reflection_model

Model for reflection/mutation operations: a non-empty model identifier string or a BaseLlm instance (e.g. a LiteLlm with a custom api_base or temperature), which is passed to the reflection agent unchanged.

TYPE: str | BaseLlm

frontier_type

Frontier tracking strategy for Pareto selection (default: INSTANCE). OBJECTIVE, HYBRID and CARTESIAN require per-example objective scores. The single-agent adapter forwards non-empty dimension_scores dictionaries from scorer metadata automatically when every example provides them; mixed batches raise ConfigurationError naming the example index.

TYPE: FrontierType

acceptance_metric

Aggregation method for acceptance decisions on iteration evaluation batches. "sum" uses sum of scores (default, aligns with upstream GEPA). "mean" uses mean of scores (legacy behavior).

TYPE: Literal['sum', 'mean']

use_merge

Enable merge proposals for genetic crossover. Defaults to False.

TYPE: bool

max_merge_invocations

Maximum number of merge attempts per run. Defaults to 10. Must be non-negative.

TYPE: int

reflection_prompt

Custom reflection/mutation prompt template. If provided, this template is used instead of the default when the reflection model proposes improved text. Required placeholders: - {component_text}: The current component text being evolved - {trials}: Trial data with feedback and trajectory for each test case If None or empty string, the default prompt template is used.

TYPE: str | None

stop_callbacks

List of stopper callbacks for custom stop conditions. Each callback receives a StopperState and returns True to signal stop. Defaults to an empty list.

TYPE: list[StopperProtocol]

seed

Random seed for deterministic engine decisions. When set, a seeded random.Random is created and shared across all stochastic components (candidate selector, merge proposer). None (default) preserves current random behavior.

TYPE: int | None

reflection_max_trials

Maximum number of trials per component sent to the reflection prompt. When a component has more, failing trials (score below 1.0) fill up to half the slots (rounded up) and passing trials fill the rest, each in batch order. None (default) sends every trial. Must be at least 1.

TYPE: int | None

reflection_max_trial_chars

Maximum length of any string value inside a trial sent to the reflection prompt, at any depth. Longer strings are cut and end with a …[truncated, N chars omitted] marker. None (default) leaves strings intact. Must be at least 1.

TYPE: int | None

reflection_timeout_seconds

Seconds the reflection agent may run for one proposal before the executor stops it. None (default) keeps the executor's own default (300 seconds in AgentExecutor). A timed-out reflection skips the iteration with skip_reason="reflection_timeout" and counts toward patience; a reflection that keeps raising a retryable provider error after one retry is skipped the same way with skip_reason="reflection_error", and one cut off at the output-token limit (or opening a reasoning tag it never closes) with skip_reason="incomplete_proposal". Must be an int (not bool) of at least 1.

TYPE: int | None

max_consecutive_reflection_errors

Number of consecutive reflection_error skips whose causes share the same exception type and message after which the engine stops retrying and raises a non-retryable ReflectionError carrying the partial result (StopReason.ERROR). A different error, or any other iteration in between, resets the count. Defaults to 3. Must be at least 1.

TYPE: int

reflection_minibatch_size

Number of trainset rows a proposal runs on before it earns its full evaluation. Each iteration the engine draws a fresh seeded sample of this many rows, evaluates the proposal on them and compares its mean score with the parent's cached scores on the same rows. Only a strictly higher mean earns the full trainset pass and the scoring pass; a proposal that loses or ties is recorded with skip_reason="minibatch_rejected". None (default), or a size at least the trainset size, evaluates every proposal on the full trainset. Must be an int of at least 1.

TYPE: int | None

on_iteration

Called once per iteration, after its IterationRecord is appended to the history and before stop conditions are checked, with the record and the id of the candidate it concerns. Skipped iterations are reported too: an empty_proposal skip passes None as the id and a duplicate skip passes the duplicate proposal's id. The baseline evaluation triggers no call. An awaitable return value is awaited before the loop continues, and an exception raised by the callback propagates out of run(). None (default) disables it.

TYPE: OnIterationCallback | None

proposal_validator

Called with each evolved component's name and proposed text after reflection and before schema validation or evaluation. The first non-None return rejects the proposal: the iteration is recorded with skip_reason="proposal_rejected", score=0.0 and the reason in rejection_reason, nothing is evaluated, and the rejection counts toward patience. An exception raised by the validator propagates out of run(). None (default) accepts every proposal.

TYPE: ProposalValidator | None

checkpoint_path

JSON file the engine writes its state to after the baseline and after every recorded iteration, atomically (a temporary file beside it, then os.replace). A str is converted to Path in __post_init__, so the stored value is a Path or None. None (default) writes no checkpoint. The engine refuses it together with a candidate_selector, because Pareto state is not checkpointed.

TYPE: Path | str | None

resume

When True, run() restores the state in checkpoint_path instead of evaluating the baseline, and continues the iteration count, the stagnation counter and the evaluation counter without re-evaluating a scored candidate. Requires checkpoint_path. Defaults to False, which starts fresh and overwrites an existing checkpoint on its first write.

TYPE: bool

Examples:

Creating a configuration with defaults:

from gepa_adk.domain.models import EvolutionConfig

config = EvolutionConfig(max_iterations=100, patience=10)
print(config.max_iterations)  # 100
print(config.reflection_model)  # ollama_chat/gpt-oss:20b
print(config.max_consecutive_reflection_errors)  # 3

Aborting after the first repeat of an identical reflection error:

config = EvolutionConfig(max_consecutive_reflection_errors=2)
Notes

All numeric parameters are validated in post_init to ensure they meet their constraints. Cross-field consistency is also checked (e.g., use_merge requires max_merge_invocations > 0, stop_callbacks, on_iteration and proposal_validator must be callable). Invalid values raise ConfigurationError.

Determinism applies to engine decisions only (candidate selection, component selection, merge proposals). LLM inference is inherently stochastic and not covered by the seed guarantee.

Source code in src/gepa_adk/domain/models.py
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
@dataclass(slots=True, kw_only=True)
class EvolutionConfig:
    """Configuration parameters for an evolution run.

    Defines the parameters that control how evolution proceeds, including
    iteration limits, concurrency settings, and stopping criteria.

    Attributes:
        max_iterations (int): Maximum number of evolution iterations. 0 means
            just evaluate baseline without evolving.
        max_concurrent_evals (int): Maximum concurrent evaluation rows per batch
            in evolve(). Does not apply to evolve_group() or evolve_workflow().
            Must be at least 1.
        min_improvement_threshold (float): Minimum score improvement to accept
            a new candidate. Set to 0.0 to accept any improvement.
        patience (int): Number of iterations without improvement before stopping
            early. Set to 0 to disable early stopping.
        reflection_model (str | BaseLlm): Model for reflection/mutation
            operations: a non-empty model identifier string or a ``BaseLlm``
            instance (e.g. a ``LiteLlm`` with a custom ``api_base`` or
            temperature), which is passed to the reflection agent unchanged.
        frontier_type (FrontierType): Frontier tracking strategy for Pareto
            selection (default: INSTANCE). OBJECTIVE, HYBRID and CARTESIAN
            require per-example objective scores. The single-agent adapter
            forwards non-empty ``dimension_scores`` dictionaries from scorer
            metadata automatically when every example provides them; mixed
            batches raise ``ConfigurationError`` naming the example index.
        acceptance_metric (Literal["sum", "mean"]): Aggregation method for
            acceptance decisions on iteration evaluation batches. "sum" uses
            sum of scores (default, aligns with upstream GEPA). "mean" uses
            mean of scores (legacy behavior).
        use_merge (bool): Enable merge proposals for genetic crossover.
            Defaults to False.
        max_merge_invocations (int): Maximum number of merge attempts per run.
            Defaults to 10. Must be non-negative.
        reflection_prompt (str | None): Custom reflection/mutation prompt template.
            If provided, this template is used instead of the default when the
            reflection model proposes improved text. Required placeholders:
            - {component_text}: The current component text being evolved
            - {trials}: Trial data with feedback and trajectory for each test case
            If None or empty string, the default prompt template is used.
        stop_callbacks (list[StopperProtocol]): List of stopper callbacks for
            custom stop conditions. Each callback receives a StopperState and
            returns True to signal stop. Defaults to an empty list.
        seed (int | None): Random seed for deterministic engine decisions.
            When set, a seeded ``random.Random`` is created and shared across
            all stochastic components (candidate selector, merge proposer).
            ``None`` (default) preserves current random behavior.
        reflection_max_trials (int | None): Maximum number of trials per
            component sent to the reflection prompt. When a component has
            more, failing trials (score below 1.0) fill up to half the slots
            (rounded up) and passing trials fill the rest, each in batch
            order. ``None`` (default) sends every trial. Must be at least 1.
        reflection_max_trial_chars (int | None): Maximum length of any string
            value inside a trial sent to the reflection prompt, at any depth.
            Longer strings are cut and end with a
            ``…[truncated, N chars omitted]`` marker. ``None`` (default)
            leaves strings intact. Must be at least 1.
        reflection_timeout_seconds (int | None): Seconds the reflection agent
            may run for one proposal before the executor stops it. ``None``
            (default) keeps the executor's own default (300 seconds in
            ``AgentExecutor``). A timed-out reflection skips the iteration
            with ``skip_reason="reflection_timeout"`` and counts toward
            ``patience``; a reflection that keeps raising a retryable
            provider error after one retry is skipped the same way with
            ``skip_reason="reflection_error"``, and one cut off at the
            output-token limit (or opening a reasoning tag it never closes)
            with ``skip_reason="incomplete_proposal"``. Must be an ``int`` (not
            ``bool``) of at least 1.
        max_consecutive_reflection_errors (int): Number of consecutive
            ``reflection_error`` skips whose causes share the same exception
            type and message after which the engine stops retrying and raises
            a non-retryable ``ReflectionError`` carrying the partial result
            (``StopReason.ERROR``). A different error, or any other
            iteration in between, resets the count. Defaults to 3. Must be at least 1.
        reflection_minibatch_size (int | None): Number of trainset rows a
            proposal runs on before it earns its full evaluation. Each
            iteration the engine draws a fresh seeded sample of this many
            rows, evaluates the proposal on them and compares its mean score
            with the parent's cached scores on the same rows. Only a strictly
            higher mean earns the full trainset pass and the scoring pass; a
            proposal that loses or ties is recorded with
            ``skip_reason="minibatch_rejected"``. ``None`` (default), or a
            size at least the trainset size, evaluates every proposal on the
            full trainset. Must be an ``int`` of at least 1.
        on_iteration (OnIterationCallback | None): Called once per iteration,
            after its IterationRecord is appended to the history and before
            stop conditions are checked, with the record and the id of the
            candidate it concerns. Skipped iterations are reported too: an
            ``empty_proposal`` skip passes ``None`` as the id and a
            ``duplicate`` skip passes the duplicate proposal's id. The baseline
            evaluation triggers no call. An awaitable return value is awaited
            before the loop continues, and an exception raised by the callback
            propagates out of ``run()``. ``None`` (default) disables it.
        proposal_validator (ProposalValidator | None): Called with each
            evolved component's name and proposed text after reflection and
            before schema validation or evaluation. The first non-None
            return rejects the proposal: the iteration is recorded with
            ``skip_reason="proposal_rejected"``, ``score=0.0`` and the reason
            in ``rejection_reason``, nothing is evaluated, and the rejection
            counts toward ``patience``. An exception raised by the validator
            propagates out of ``run()``. ``None`` (default) accepts every
            proposal.
        checkpoint_path (Path | str | None): JSON file the engine writes its state
            to after the baseline and after every recorded iteration,
            atomically (a temporary file beside it, then ``os.replace``).
            A ``str`` is converted to ``Path`` in ``__post_init__``, so the
            stored value is a ``Path`` or ``None``. ``None`` (default) writes no
            checkpoint. The engine refuses it together with a
            ``candidate_selector``, because Pareto state is not checkpointed.
        resume (bool): When True, ``run()`` restores the state in
            ``checkpoint_path`` instead of evaluating the baseline, and
            continues the iteration count, the stagnation counter and the
            evaluation counter without re-evaluating a scored candidate.
            Requires ``checkpoint_path``. Defaults to False, which starts
            fresh and overwrites an existing checkpoint on its first write.

    Examples:
        Creating a configuration with defaults:

        ```python
        from gepa_adk.domain.models import EvolutionConfig

        config = EvolutionConfig(max_iterations=100, patience=10)
        print(config.max_iterations)  # 100
        print(config.reflection_model)  # ollama_chat/gpt-oss:20b
        print(config.max_consecutive_reflection_errors)  # 3
        ```

        Aborting after the first repeat of an identical reflection error:

        ```python
        config = EvolutionConfig(max_consecutive_reflection_errors=2)
        ```

    Notes:
        All numeric parameters are validated in __post_init__ to ensure
        they meet their constraints. Cross-field consistency is also checked
        (e.g., use_merge requires max_merge_invocations > 0, stop_callbacks,
        on_iteration and proposal_validator must be callable). Invalid values
        raise ConfigurationError.

        Determinism applies to engine decisions only (candidate selection,
        component selection, merge proposals). LLM inference is inherently
        stochastic and not covered by the seed guarantee.
    """

    max_iterations: int = 50
    max_concurrent_evals: int = 5
    min_improvement_threshold: float = 0.01
    patience: int = 5
    reflection_model: "str | BaseLlm" = "ollama_chat/gpt-oss:20b"
    frontier_type: FrontierType = FrontierType.INSTANCE
    acceptance_metric: Literal["sum", "mean"] = "sum"
    use_merge: bool = False
    max_merge_invocations: int = 10
    reflection_prompt: str | None = None
    stop_callbacks: list["StopperProtocol"] = field(default_factory=list)
    seed: int | None = None
    reflection_max_trials: int | None = None
    reflection_max_trial_chars: int | None = None
    reflection_timeout_seconds: int | None = None
    max_consecutive_reflection_errors: int = 3
    reflection_minibatch_size: int | None = None
    on_iteration: OnIterationCallback | None = None
    proposal_validator: ProposalValidator | None = None
    checkpoint_path: Path | str | None = None
    resume: bool = False

    def __post_init__(self) -> None:
        """Validate configuration parameters after initialization.

        Raises:
            ConfigurationError: If any parameter violates its constraints,
                including non-finite floats (NaN, Inf), cross-field consistency
                rules (e.g., use_merge requires max_merge_invocations > 0,
                stop_callbacks, on_iteration and proposal_validator must be
                callable), a reflection cap (reflection_max_trials,
                reflection_max_trial_chars) below 1, a
                ``max_consecutive_reflection_errors`` below 1, a
                ``reflection_timeout_seconds`` or ``reflection_minibatch_size``
                that is not ``None`` or an
                ``int`` of at least 1, or a ``reflection_model`` that is
                ``None`` or an empty string, or ``resume=True`` without a
                ``checkpoint_path``.

        Notes:
            Operates automatically after dataclass __init__ completes. Validates
            all fields including finite-float checks, cross-field consistency,
            and raises ConfigurationError with context on failure. A non-string
            ``reflection_model`` passes this check; the resolver in
            ``gepa_adk.api`` rejects values that are not a ``BaseLlm``.
            A ``str`` ``checkpoint_path`` is converted to ``Path``.
        """
        if self.max_iterations < 0:
            raise ConfigurationError(
                "max_iterations must be non-negative",
                field="max_iterations",
                value=self.max_iterations,
                constraint=">= 0",
            )

        if self.max_concurrent_evals < 1:
            raise ConfigurationError(
                "max_concurrent_evals must be at least 1",
                field="max_concurrent_evals",
                value=self.max_concurrent_evals,
                constraint=">= 1",
            )

        if self.max_consecutive_reflection_errors < 1:
            raise ConfigurationError(
                "max_consecutive_reflection_errors must be at least 1",
                field="max_consecutive_reflection_errors",
                value=self.max_consecutive_reflection_errors,
                constraint=">= 1",
            )

        if not math.isfinite(self.min_improvement_threshold):
            raise ConfigurationError(
                "min_improvement_threshold must be a finite number",
                field="min_improvement_threshold",
                value=self.min_improvement_threshold,
                constraint="finite float",
            )

        if self.min_improvement_threshold < 0.0:
            raise ConfigurationError(
                "min_improvement_threshold must be non-negative",
                field="min_improvement_threshold",
                value=self.min_improvement_threshold,
                constraint=">= 0.0",
            )

        if self.patience < 0:
            raise ConfigurationError(
                "patience must be non-negative",
                field="patience",
                value=self.patience,
                constraint=">= 0",
            )

        if not self.reflection_model:
            raise ConfigurationError(
                "reflection_model must be a non-empty string or BaseLlm instance",
                field="reflection_model",
                value=self.reflection_model,
                constraint="non-empty string",
            )

        if not isinstance(self.frontier_type, FrontierType):
            try:
                self.frontier_type = FrontierType(self.frontier_type)
            except ValueError as exc:
                raise ConfigurationError(
                    "frontier_type must be a supported FrontierType value",
                    field="frontier_type",
                    value=self.frontier_type,
                    constraint=", ".join(t.value for t in FrontierType),
                ) from exc

        if self.acceptance_metric not in ("sum", "mean"):
            raise ConfigurationError(
                "acceptance_metric must be 'sum' or 'mean'",
                field="acceptance_metric",
                value=self.acceptance_metric,
                constraint="sum|mean",
            )

        if self.max_merge_invocations < 0:
            raise ConfigurationError(
                "max_merge_invocations must be non-negative",
                field="max_merge_invocations",
                value=self.max_merge_invocations,
                constraint=">= 0",
            )

        self._validate_reflection_caps()
        self._validate_reflection_timeout()
        self._validate_minibatch_size()
        self._validate_checkpoint()

        # Cross-field consistency checks
        self._validate_consistency()

        # Validate reflection_prompt if provided
        self._validate_reflection_prompt()

    def _validate_reflection_caps(self) -> None:
        """Validate the optional caps on what the reflector sees.

        Raises:
            ConfigurationError: If ``reflection_max_trials`` or
                ``reflection_max_trial_chars`` is set to a value below 1.
        """
        for name, value in (
            ("reflection_max_trials", self.reflection_max_trials),
            ("reflection_max_trial_chars", self.reflection_max_trial_chars),
        ):
            if value is not None and value < 1:
                raise ConfigurationError(
                    f"{name} must be at least 1 or None",
                    field=name,
                    value=value,
                    constraint=">= 1 or None",
                )

    def _validate_reflection_timeout(self) -> None:
        """Validate the optional reflection timeout.

        Raises:
            ConfigurationError: If ``reflection_timeout_seconds`` is set and
                is not an ``int`` (a ``bool`` is rejected) of at least 1.
        """
        value = self.reflection_timeout_seconds
        if value is None:
            return
        if isinstance(value, bool) or not isinstance(value, int) or value < 1:
            raise ConfigurationError(
                "reflection_timeout_seconds must be an int of at least 1 or None",
                field="reflection_timeout_seconds",
                value=value,
                constraint="int >= 1 or None",
            )

    def _validate_minibatch_size(self) -> None:
        """Validate the optional reflection minibatch size.

        Raises:
            ConfigurationError: If ``reflection_minibatch_size`` is neither
                ``None`` nor an ``int`` (a ``bool`` does not count) of at
                least 1.
        """
        value = self.reflection_minibatch_size
        if value is None:
            return
        if isinstance(value, bool) or not isinstance(value, int) or value < 1:
            raise ConfigurationError(
                "reflection_minibatch_size must be an int of at least 1 or None",
                field="reflection_minibatch_size",
                value=value,
                constraint="int >= 1 or None",
            )

    def _validate_checkpoint(self) -> None:
        """Normalise ``checkpoint_path`` and check ``resume`` against it.

        Raises:
            ConfigurationError: If ``checkpoint_path`` is neither None, a
                ``str`` nor a ``Path``, or if ``resume`` is True and
                ``checkpoint_path`` is None.
        """
        if isinstance(self.checkpoint_path, str):
            self.checkpoint_path = Path(self.checkpoint_path)
        elif self.checkpoint_path is not None and not isinstance(
            self.checkpoint_path, Path
        ):
            raise ConfigurationError(
                "checkpoint_path must be a str, a Path or None",
                field="checkpoint_path",
                value=self.checkpoint_path,
                constraint="str, Path or None",
            )
        if self.resume and self.checkpoint_path is None:
            raise ConfigurationError(
                "resume=True requires checkpoint_path",
                field="checkpoint_path",
                value=None,
                constraint="not None when resume=True",
            )

    def _validate_consistency(self) -> None:
        """Validate cross-field consistency rules.

        Raises:
            ConfigurationError: If use_merge is True but max_merge_invocations
                is zero, if stop_callbacks contains non-callable items, or if
                on_iteration or proposal_validator is set and not callable.

        Notes:
            Hard errors raise ConfigurationError; soft issues log warnings.
            Called from __post_init__ after individual field validation.
        """
        if self.use_merge and self.max_merge_invocations == 0:
            raise ConfigurationError(
                "use_merge=True requires max_merge_invocations > 0",
                field="max_merge_invocations",
                value=self.max_merge_invocations,
                constraint="> 0 when use_merge=True",
            )

        if (
            self.patience > 0
            and self.max_iterations > 0
            and self.patience > self.max_iterations
        ):
            logger.warning(
                "config.patience.exceeds_max_iterations",
                patience=self.patience,
                max_iterations=self.max_iterations,
                message="patience exceeds max_iterations; early stopping will never trigger",
            )

        for i, callback in enumerate(self.stop_callbacks):
            if not callable(callback):
                raise ConfigurationError(
                    f"stop_callbacks[{i}] is not callable",
                    field=f"stop_callbacks[{i}]",
                    value=type(callback).__name__,
                    constraint="must be callable",
                )

        if self.on_iteration is not None and not callable(self.on_iteration):
            raise ConfigurationError(
                "on_iteration is not callable",
                field="on_iteration",
                value=type(self.on_iteration).__name__,
                constraint="must be callable or None",
            )

        if self.proposal_validator is not None and not callable(
            self.proposal_validator
        ):
            raise ConfigurationError(
                "proposal_validator is not callable",
                field="proposal_validator",
                value=type(self.proposal_validator).__name__,
                constraint="must be callable or None",
            )

    def _validate_reflection_prompt(self) -> None:
        """Validate reflection_prompt and handle empty string.

        Converts empty string to None with info log. Warns if required
        placeholders are missing but allows the config to be created.

        Notes:
            Soft validation approach - missing placeholders trigger warnings
            but don't prevent config creation for maximum flexibility.
        """
        # Handle empty string as "use default"
        if self.reflection_prompt == "":
            logger.info(
                "config.reflection_prompt.empty",
                message="Empty reflection_prompt provided, using default template",
            )
            # Use object.__setattr__ because slots=True prevents direct assignment
            object.__setattr__(self, "reflection_prompt", None)
            return

        # Skip validation if None
        if self.reflection_prompt is None:
            return

        # Warn about missing placeholders
        if "{component_text}" not in self.reflection_prompt:
            logger.warning(
                "config.reflection_prompt.missing_placeholder",
                placeholder="component_text",
                message="reflection_prompt is missing {component_text} placeholder",
            )

        if "{trials}" not in self.reflection_prompt:
            logger.warning(
                "config.reflection_prompt.missing_placeholder",
                placeholder="trials",
                message="reflection_prompt is missing {trials} placeholder",
            )

__post_init__

__post_init__() -> None

Validate configuration parameters after initialization.

RAISES DESCRIPTION
ConfigurationError

If any parameter violates its constraints, including non-finite floats (NaN, Inf), cross-field consistency rules (e.g., use_merge requires max_merge_invocations > 0, stop_callbacks, on_iteration and proposal_validator must be callable), a reflection cap (reflection_max_trials, reflection_max_trial_chars) below 1, a max_consecutive_reflection_errors below 1, a reflection_timeout_seconds or reflection_minibatch_size that is not None or an int of at least 1, or a reflection_model that is None or an empty string, or resume=True without a checkpoint_path.

Notes

Operates automatically after dataclass init completes. Validates all fields including finite-float checks, cross-field consistency, and raises ConfigurationError with context on failure. A non-string reflection_model passes this check; the resolver in gepa_adk.api rejects values that are not a BaseLlm. A str checkpoint_path is converted to Path.

Source code in src/gepa_adk/domain/models.py
def __post_init__(self) -> None:
    """Validate configuration parameters after initialization.

    Raises:
        ConfigurationError: If any parameter violates its constraints,
            including non-finite floats (NaN, Inf), cross-field consistency
            rules (e.g., use_merge requires max_merge_invocations > 0,
            stop_callbacks, on_iteration and proposal_validator must be
            callable), a reflection cap (reflection_max_trials,
            reflection_max_trial_chars) below 1, a
            ``max_consecutive_reflection_errors`` below 1, a
            ``reflection_timeout_seconds`` or ``reflection_minibatch_size``
            that is not ``None`` or an
            ``int`` of at least 1, or a ``reflection_model`` that is
            ``None`` or an empty string, or ``resume=True`` without a
            ``checkpoint_path``.

    Notes:
        Operates automatically after dataclass __init__ completes. Validates
        all fields including finite-float checks, cross-field consistency,
        and raises ConfigurationError with context on failure. A non-string
        ``reflection_model`` passes this check; the resolver in
        ``gepa_adk.api`` rejects values that are not a ``BaseLlm``.
        A ``str`` ``checkpoint_path`` is converted to ``Path``.
    """
    if self.max_iterations < 0:
        raise ConfigurationError(
            "max_iterations must be non-negative",
            field="max_iterations",
            value=self.max_iterations,
            constraint=">= 0",
        )

    if self.max_concurrent_evals < 1:
        raise ConfigurationError(
            "max_concurrent_evals must be at least 1",
            field="max_concurrent_evals",
            value=self.max_concurrent_evals,
            constraint=">= 1",
        )

    if self.max_consecutive_reflection_errors < 1:
        raise ConfigurationError(
            "max_consecutive_reflection_errors must be at least 1",
            field="max_consecutive_reflection_errors",
            value=self.max_consecutive_reflection_errors,
            constraint=">= 1",
        )

    if not math.isfinite(self.min_improvement_threshold):
        raise ConfigurationError(
            "min_improvement_threshold must be a finite number",
            field="min_improvement_threshold",
            value=self.min_improvement_threshold,
            constraint="finite float",
        )

    if self.min_improvement_threshold < 0.0:
        raise ConfigurationError(
            "min_improvement_threshold must be non-negative",
            field="min_improvement_threshold",
            value=self.min_improvement_threshold,
            constraint=">= 0.0",
        )

    if self.patience < 0:
        raise ConfigurationError(
            "patience must be non-negative",
            field="patience",
            value=self.patience,
            constraint=">= 0",
        )

    if not self.reflection_model:
        raise ConfigurationError(
            "reflection_model must be a non-empty string or BaseLlm instance",
            field="reflection_model",
            value=self.reflection_model,
            constraint="non-empty string",
        )

    if not isinstance(self.frontier_type, FrontierType):
        try:
            self.frontier_type = FrontierType(self.frontier_type)
        except ValueError as exc:
            raise ConfigurationError(
                "frontier_type must be a supported FrontierType value",
                field="frontier_type",
                value=self.frontier_type,
                constraint=", ".join(t.value for t in FrontierType),
            ) from exc

    if self.acceptance_metric not in ("sum", "mean"):
        raise ConfigurationError(
            "acceptance_metric must be 'sum' or 'mean'",
            field="acceptance_metric",
            value=self.acceptance_metric,
            constraint="sum|mean",
        )

    if self.max_merge_invocations < 0:
        raise ConfigurationError(
            "max_merge_invocations must be non-negative",
            field="max_merge_invocations",
            value=self.max_merge_invocations,
            constraint=">= 0",
        )

    self._validate_reflection_caps()
    self._validate_reflection_timeout()
    self._validate_minibatch_size()
    self._validate_checkpoint()

    # Cross-field consistency checks
    self._validate_consistency()

    # Validate reflection_prompt if provided
    self._validate_reflection_prompt()

EvolutionError

Bases: Exception


              flowchart TD
              gepa_adk.EvolutionError[EvolutionError]

              

              click gepa_adk.EvolutionError href "" "gepa_adk.EvolutionError"
            

Base exception for all gepa-adk errors.

All custom exceptions in gepa-adk should inherit from this class to allow for unified exception handling.

ATTRIBUTE DESCRIPTION
partial_result

Partial result the engine attaches when it aborts a run after the baseline was scored, built with StopReason.ERROR from the iterations recorded so far. None when the error was raised before the baseline score existed or outside an engine run.

TYPE: EvolutionResult | None

Examples:

Catching evolution errors:

from gepa_adk.domain.exceptions import EvolutionError

try:
    raise EvolutionError("Evolution failed unexpectedly")
except EvolutionError as e:
    print(f"Caught: {e}")
# Output: Caught: Evolution failed unexpectedly
Notes

Always use this base class or its subclasses for domain errors. Standard Python exceptions should still be raised for programming errors (e.g., TypeError, ValueError for developer mistakes).

Source code in src/gepa_adk/domain/exceptions.py
class EvolutionError(Exception):
    """Base exception for all gepa-adk errors.

    All custom exceptions in gepa-adk should inherit from this class
    to allow for unified exception handling.

    Attributes:
        partial_result (EvolutionResult | None): Partial result the engine
            attaches when it aborts a run after the baseline was scored,
            built with ``StopReason.ERROR`` from the iterations recorded so
            far. ``None`` when the error was raised before the baseline
            score existed or outside an engine run.

    Examples:
        Catching evolution errors:

        ```python
        from gepa_adk.domain.exceptions import EvolutionError

        try:
            raise EvolutionError("Evolution failed unexpectedly")
        except EvolutionError as e:
            print(f"Caught: {e}")
        # Output: Caught: Evolution failed unexpectedly
        ```

    Notes:
        Always use this base class or its subclasses for domain errors.
        Standard Python exceptions should still be raised for programming
        errors (e.g., TypeError, ValueError for developer mistakes).
    """

    partial_result: "EvolutionResult | None" = None

EvolutionResult dataclass

Outcome of a completed evolution run.

Contains the final results after evolution completes, including all evolved component values, performance metrics, and full history.

ATTRIBUTE DESCRIPTION
schema_version

Schema version for forward-compatible serialization. Always CURRENT_SCHEMA_VERSION for newly created results.

TYPE: int

stop_reason

Why the evolution run terminated. Defaults to StopReason.COMPLETED. StopReason.ERROR marks the partial result attached to an EvolutionError in partial_result.

TYPE: StopReason

original_score

Starting performance score (baseline).

TYPE: float

final_score

Ending performance score (best achieved).

TYPE: float

evolved_components

Dictionary mapping component names to their final evolved text values. Keys include "instruction" and optionally "output_schema" or other components. Access individual components via result.evolved_components["instruction"].

TYPE: dict[str, str]

iteration_history

Chronological list of iteration records. A skipped iteration's record names why in skip_reason: "empty_proposal", "duplicate", "reflection_timeout", "reflection_error", "incomplete_proposal", "minibatch_rejected", "schema_validation_failed" or "proposal_rejected".

TYPE: list[IterationRecord]

total_iterations

Number of iterations performed.

TYPE: int

valset_score

Score on validation set used for acceptance decisions. None if no validation set was used.

TYPE: float | None

trainset_score

Score on trainset used for reflection diagnostics. None if not computed.

TYPE: float | None

objective_scores

Optional per-example multi-objective scores from the best candidate's final evaluation. None when no objective scores were tracked. Each dict maps objective name to score value. Index-aligned with evaluation batch examples.

TYPE: list[dict[str, float]] | None

original_components

Optional snapshot of pre-evolution component values. When present, enables zero-arg show_diff() calls. None for results created before this field was added or when originals were not captured.

TYPE: dict[str, str] | None

baseline_failed_evaluations

Number of rows whose agent run or scorer raised, or whose run returned a failed execution, during the baseline evaluation of the initial candidate. Defaults to 0.

TYPE: int

total_failed_evaluations

baseline_failed_evaluations plus the sum of failed_evaluations over iteration_history. A failed row scores 0.0 like a wrong answer; this count tells the two apart. Defaults to 0.

TYPE: int

token_usage

Token usage observed on the rows evaluated over the run, baseline included, and on its reflection calls. None when not recorded (results saved before schema version 3). The rollup carries an evaluation split, set on every result the engine wrote at version 6 and on migrated results, and a reflection split that is None when no reflection call was observed.

TYPE: TokenRollup | None

reflection_reasoning

Read-only property returning the reflection reasoning from the last iteration. Convenience accessor; None if no iterations or last iteration has no reasoning.

TYPE: str | None

Examples:

Creating and analyzing a result:

from gepa_adk.domain.models import EvolutionResult, IterationRecord

result = EvolutionResult(
    original_score=0.60,
    final_score=0.85,
    evolved_components={"instruction": "Be helpful and concise"},
    original_components={"instruction": "Be helpful"},
    iteration_history=[],
    total_iterations=10,
)
print(result.improvement)  # 0.25
print(result.show_diff())  # unified diff of instruction changes

Serialization round-trip:

import json

d = result.to_dict()
json_str = json.dumps(d)
restored = EvolutionResult.from_dict(json.loads(json_str))
Notes

As a frozen dataclass, EvolutionResult instances cannot be modified.

Source code in src/gepa_adk/domain/models.py
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
@dataclass(slots=True, frozen=True, kw_only=True)
class EvolutionResult:
    """Outcome of a completed evolution run.

    Contains the final results after evolution completes, including
    all evolved component values, performance metrics, and full history.

    Attributes:
        schema_version (int): Schema version for forward-compatible serialization.
            Always ``CURRENT_SCHEMA_VERSION`` for newly created results.
        stop_reason (StopReason): Why the evolution run terminated. Defaults to
            ``StopReason.COMPLETED``. ``StopReason.ERROR`` marks the partial
            result attached to an ``EvolutionError`` in ``partial_result``.
        original_score (float): Starting performance score (baseline).
        final_score (float): Ending performance score (best achieved).
        evolved_components (dict[str, str]): Dictionary mapping component names
            to their final evolved text values. Keys include "instruction" and
            optionally "output_schema" or other components. Access individual
            components via ``result.evolved_components["instruction"]``.
        iteration_history (list[IterationRecord]): Chronological list of
            iteration records. A skipped iteration's record names why in
            ``skip_reason``: ``"empty_proposal"``, ``"duplicate"``,
            ``"reflection_timeout"``, ``"reflection_error"``,
            ``"incomplete_proposal"``, ``"minibatch_rejected"``,
            ``"schema_validation_failed"`` or ``"proposal_rejected"``.
        total_iterations (int): Number of iterations performed.
        valset_score (float | None): Score on validation set used for
            acceptance decisions. None if no validation set was used.
        trainset_score (float | None): Score on trainset used for reflection
            diagnostics. None if not computed.
        objective_scores (list[dict[str, float]] | None): Optional per-example
            multi-objective scores from the best candidate's final evaluation.
            None when no objective scores were tracked. Each dict maps objective
            name to score value. Index-aligned with evaluation batch examples.
        original_components (dict[str, str] | None): Optional snapshot of
            pre-evolution component values. When present, enables zero-arg
            ``show_diff()`` calls. None for results created before this field
            was added or when originals were not captured.
        baseline_failed_evaluations (int): Number of rows whose agent run or
            scorer raised, or whose run returned a failed execution, during
            the baseline evaluation of the initial candidate. Defaults to 0.
        total_failed_evaluations (int): ``baseline_failed_evaluations`` plus
            the sum of ``failed_evaluations`` over ``iteration_history``.
            A failed row scores 0.0 like a wrong answer; this count tells
            the two apart. Defaults to 0.
        token_usage (TokenRollup | None): Token usage observed on the rows
            evaluated over the run, baseline included, and on its
            reflection calls. None when not recorded (results saved before
            schema version 3). The rollup carries an ``evaluation`` split,
            set on every result the engine wrote at version 6 and on
            migrated results, and a ``reflection`` split that is None when
            no reflection call was observed.
        reflection_reasoning (str | None): Read-only property returning the
            reflection reasoning from the last iteration. Convenience
            accessor; None if no iterations or last iteration has no reasoning.

    Examples:
        Creating and analyzing a result:

        ```python
        from gepa_adk.domain.models import EvolutionResult, IterationRecord

        result = EvolutionResult(
            original_score=0.60,
            final_score=0.85,
            evolved_components={"instruction": "Be helpful and concise"},
            original_components={"instruction": "Be helpful"},
            iteration_history=[],
            total_iterations=10,
        )
        print(result.improvement)  # 0.25
        print(result.show_diff())  # unified diff of instruction changes
        ```

        Serialization round-trip:

        ```python
        import json

        d = result.to_dict()
        json_str = json.dumps(d)
        restored = EvolutionResult.from_dict(json.loads(json_str))
        ```

    Notes:
        As a frozen dataclass, EvolutionResult instances cannot be modified.
    """

    schema_version: int = CURRENT_SCHEMA_VERSION
    stop_reason: StopReason = StopReason.COMPLETED
    original_score: float
    final_score: float
    evolved_components: dict[str, str]
    iteration_history: list[IterationRecord]
    total_iterations: int
    valset_score: float | None = None
    trainset_score: float | None = None
    objective_scores: list[dict[str, float]] | None = None
    original_components: dict[str, str] | None = None
    baseline_failed_evaluations: int = 0
    total_failed_evaluations: int = 0
    token_usage: TokenRollup | None = None

    @property
    def reflection_reasoning(self) -> str | None:
        """Return the reflection reasoning from the last iteration.

        Convenience accessor for the most recent iteration's reasoning
        explaining why the reflection agent proposed its mutation.

        Returns:
            The reasoning string from the last iteration record, or None
            if no iterations exist or the last iteration has no reasoning.
        """
        if not self.iteration_history:
            return None
        return self.iteration_history[-1].reflection_reasoning

    def to_dict(self) -> dict[str, Any]:
        """Serialize this result to a stdlib-only dict.

        Returns:
            Dict containing all fields. ``stop_reason`` is serialized
            as its string value. ``iteration_history`` is serialized as a
            list of dicts. Includes ``baseline_failed_evaluations``,
            ``total_failed_evaluations`` and ``token_usage`` (None when
            unset). Output is directly ``json.dumps()``-compatible.
        """
        return {
            "schema_version": self.schema_version,
            "stop_reason": self.stop_reason.value,
            "original_score": self.original_score,
            "final_score": self.final_score,
            "evolved_components": self.evolved_components,
            "iteration_history": [r.to_dict() for r in self.iteration_history],
            "total_iterations": self.total_iterations,
            "valset_score": self.valset_score,
            "trainset_score": self.trainset_score,
            "objective_scores": self.objective_scores,
            "original_components": self.original_components,
            "baseline_failed_evaluations": self.baseline_failed_evaluations,
            "total_failed_evaluations": self.total_failed_evaluations,
            "token_usage": _rollup_to_dict(self.token_usage),
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "EvolutionResult":
        """Reconstruct an EvolutionResult from a dict.

        Validates schema version, applies migration if needed, and
        reconstructs all nested objects including optional
        original_components. Version 1 dicts migrate through
        ``_migrate_v1_to_v2()``, so their failure counts read as 0, and
        version 1 and 2 dicts through ``_migrate_v2_to_v3()``, so their
        ``token_usage`` reads as None, and version 1 to 3 dicts through
        ``_migrate_v3_to_v4()``, so each record's ``candidate_id`` and
        ``parent_ids`` read as None, and version 1 to 4 dicts through
        ``_migrate_v4_to_v5()``, so each record's ``rejection_reason`` reads
        as None, and version 1 to 5 dicts through ``_migrate_v5_to_v6()``,
        so each ``token_usage`` loads as its ``evaluation`` split with
        ``reflection`` None, and version 1 to 6 dicts through
        ``_migrate_v6_to_v7()``, so each record's ``rows_evaluated`` reads
        as None.

        Args:
            data: Dict containing evolution result fields.

        Returns:
            Reconstructed EvolutionResult instance.

        Raises:
            ConfigurationError: If ``schema_version`` exceeds the current
                version or ``stop_reason`` is not a valid enum value.
            KeyError: If a required field is missing from the dict.
        """
        version = data.get("schema_version", 1)
        if version > CURRENT_SCHEMA_VERSION:
            raise ConfigurationError(
                f"Cannot deserialize result with schema_version {version} "
                f"(current version is {CURRENT_SCHEMA_VERSION}). "
                f"Upgrade gepa-adk to load this result.",
                field="schema_version",
                value=version,
                constraint=f"<= {CURRENT_SCHEMA_VERSION}",
            )
        migrated = _migrate_result_dict(data, from_version=version)
        try:
            stop_reason = StopReason(migrated.get("stop_reason", "completed"))
        except ValueError:
            raw = migrated.get("stop_reason")
            raise ConfigurationError(
                f"Invalid stop_reason value: {raw!r}",
                field="stop_reason",
                value=raw,
                constraint=("one of: " + ", ".join(sr.value for sr in StopReason)),
            ) from None
        return cls(
            schema_version=migrated["schema_version"],
            stop_reason=stop_reason,
            original_score=migrated["original_score"],
            final_score=migrated["final_score"],
            evolved_components=migrated["evolved_components"],
            iteration_history=[
                IterationRecord.from_dict(r)
                for r in migrated.get("iteration_history", [])
            ],
            total_iterations=migrated["total_iterations"],
            valset_score=migrated.get("valset_score"),
            trainset_score=migrated.get("trainset_score"),
            objective_scores=migrated.get("objective_scores"),
            original_components=migrated.get("original_components"),
            baseline_failed_evaluations=migrated.get("baseline_failed_evaluations", 0),
            total_failed_evaluations=migrated.get("total_failed_evaluations", 0),
            token_usage=_rollup_from_dict(migrated.get("token_usage")),
        )

    @property
    def improvement(self) -> float:
        """Calculate the score improvement from original to final.

        Returns:
            The difference between final_score and original_score.
            Positive values indicate improvement, negative indicates degradation.

        Notes:
            Override is not needed since frozen dataclasses support properties.
        """
        return self.final_score - self.original_score

    @property
    def improved(self) -> bool:
        """Check if the final score is better than the original.

        Returns:
            True if final_score > original_score, False otherwise.

        Notes:
            Only returns True for strict improvement, not equal scores.
        """
        return self.final_score > self.original_score

    def __repr__(self) -> str:
        """Narrative summary of the evolution result.

        Returns:
            Human-readable multi-line summary with improvement percentage,
            iterations, stop reason, component names, and acceptance rate.
            Uses 2-space indent, no box-drawing characters, every line
            greppable. When the original score is zero the improvement is
            reported as an absolute value instead of a percentage.
        """
        if abs(self.original_score) < 1e-9:  # noqa: PLR2004  # zero guard before a percentage division
            imp_str = f"{self.improvement:+.4f} improvement"
        else:
            pct = self.improvement * 100 / abs(self.original_score)
            sign = "+" if pct > 0 else ""
            imp_str = f"{sign}{pct:.1f}% improvement"
        lines = [
            f"EvolutionResult: {imp_str} "
            f"({self.original_score:.2f} \u2192 {self.final_score:.2f})",
            f"  iterations: {self.total_iterations}, "
            f"stop_reason: {self.stop_reason.value}",
            f"  components: {', '.join(sorted(self.evolved_components))}",
        ]
        if self.total_iterations > 0:
            accepted = sum(1 for r in self.iteration_history if r.accepted)
            lines.append(f"  acceptance_rate: {accepted}/{self.total_iterations}")
        return "\n".join(lines)

    def show_diff(self, original_components: dict[str, str] | None = None) -> str:
        """Show unified diff between original and evolved components.

        Uses stored ``original_components`` if no explicit argument is
        provided. Produces git-diff-style output (``---``/``+++``/``@@``)
        for each component that changed.

        Args:
            original_components: Pre-evolution component values. If None,
                falls back to ``self.original_components``.

        Returns:
            Unified diff string, or ``"No changes detected."`` if all
            components are identical.

        Raises:
            ValueError: If both the argument and ``self.original_components``
                are None.
        """
        originals = (
            original_components
            if original_components is not None
            else self.original_components
        )
        if originals is None:
            raise ValueError(
                "No original components available. "
                "Pass original_components or use a result that stores them."
            )
        return _build_diff(self.evolved_components, originals)

    def _repr_html_(self) -> str:
        """Render an HTML summary for Jupyter notebooks.

        Returns:
            HTML string with summary and components tables. Uses semantic
            ``<th>`` header elements and inline CSS for portability across
            JupyterLab, Colab, and VS Code. Iteration history is wrapped
            in a collapsible ``<details>``/``<summary>`` block. The improvement
            is shown as a signed absolute value when the original score is
            zero, since a percentage is undefined there.
        """
        if abs(self.original_score) < 1e-9:  # noqa: PLR2004  # zero guard before a percentage division
            imp_html = f"{self.improvement:+.4f}"
        else:
            pct = self.improvement * 100 / abs(self.original_score)
            sign = "+" if pct > 0 else ""
            imp_html = f"{sign}{pct:.1f}%"
        style = (
            'style="border-collapse:collapse;'
            "font-family:monospace;font-size:13px;"
            'margin:8px 0"'
        )
        td = 'style="padding:4px 12px;border:1px solid #ddd"'
        th = 'style="padding:4px 12px;border:1px solid #ddd;background:#f5f5f5"'

        rows = [
            f"<tr><th {th}>Improvement</th><td {td}>{imp_html}</td></tr>",
            f"<tr><th {th}>Original Score</th>"
            f"<td {td}>{self.original_score:.4f}</td></tr>",
            f"<tr><th {th}>Final Score</th><td {td}>{self.final_score:.4f}</td></tr>",
            f"<tr><th {th}>Iterations</th><td {td}>{self.total_iterations}</td></tr>",
            f"<tr><th {th}>Stop Reason</th>"
            f"<td {td}>{html_mod.escape(self.stop_reason.value)}</td></tr>",
        ]
        summary_table = f"<table {style}>{''.join(rows)}</table>"

        comp_rows = []
        for name in sorted(self.evolved_components):
            val = html_mod.escape(_truncate(self.evolved_components[name], 200))
            comp_rows.append(
                f"<tr><td {td}>{html_mod.escape(name)}</td><td {td}>{val}</td></tr>"
            )
        comp_header = f"<tr><th {th}>Component</th><th {th}>Evolved Value</th></tr>"
        comp_table = f"<table {style}>{comp_header}{''.join(comp_rows)}</table>"

        history_rows = []
        for rec in self.iteration_history:
            accepted_mark = "\u2713" if rec.accepted else ""
            history_rows.append(
                f"<tr><td {td}>{rec.iteration_number}</td>"
                f"<td {td}>{rec.score:.4f}</td>"
                f"<td {td}>{html_mod.escape(rec.evolved_component)}</td>"
                f"<td {td}>{accepted_mark}</td></tr>"
            )
        history_header = (
            f"<tr><th {th}>#</th><th {th}>Score</th>"
            f"<th {th}>Component</th><th {th}>Accepted</th></tr>"
        )
        history_table = (
            f"<table {style}>{history_header}{''.join(history_rows)}</table>"
        )
        history_section = (
            "<details><summary>Iteration History "
            f"({len(self.iteration_history)} records)</summary>"
            f"{history_table}</details>"
        )

        return (
            "<div>"
            "<strong>EvolutionResult</strong>"
            f"{summary_table}{comp_table}{history_section}"
            "</div>"
        )

reflection_reasoning property

reflection_reasoning: str | None

Return the reflection reasoning from the last iteration.

Convenience accessor for the most recent iteration's reasoning explaining why the reflection agent proposed its mutation.

RETURNS DESCRIPTION
str | None

The reasoning string from the last iteration record, or None

str | None

if no iterations exist or the last iteration has no reasoning.

improvement property

improvement: float

Calculate the score improvement from original to final.

RETURNS DESCRIPTION
float

The difference between final_score and original_score.

float

Positive values indicate improvement, negative indicates degradation.

Notes

Override is not needed since frozen dataclasses support properties.

improved property

improved: bool

Check if the final score is better than the original.

RETURNS DESCRIPTION
bool

True if final_score > original_score, False otherwise.

Notes

Only returns True for strict improvement, not equal scores.

to_dict

to_dict() -> dict[str, Any]

Serialize this result to a stdlib-only dict.

RETURNS DESCRIPTION
dict[str, Any]

Dict containing all fields. stop_reason is serialized

dict[str, Any]

as its string value. iteration_history is serialized as a

dict[str, Any]

list of dicts. Includes baseline_failed_evaluations,

dict[str, Any]

total_failed_evaluations and token_usage (None when

dict[str, Any]

unset). Output is directly json.dumps()-compatible.

Source code in src/gepa_adk/domain/models.py
def to_dict(self) -> dict[str, Any]:
    """Serialize this result to a stdlib-only dict.

    Returns:
        Dict containing all fields. ``stop_reason`` is serialized
        as its string value. ``iteration_history`` is serialized as a
        list of dicts. Includes ``baseline_failed_evaluations``,
        ``total_failed_evaluations`` and ``token_usage`` (None when
        unset). Output is directly ``json.dumps()``-compatible.
    """
    return {
        "schema_version": self.schema_version,
        "stop_reason": self.stop_reason.value,
        "original_score": self.original_score,
        "final_score": self.final_score,
        "evolved_components": self.evolved_components,
        "iteration_history": [r.to_dict() for r in self.iteration_history],
        "total_iterations": self.total_iterations,
        "valset_score": self.valset_score,
        "trainset_score": self.trainset_score,
        "objective_scores": self.objective_scores,
        "original_components": self.original_components,
        "baseline_failed_evaluations": self.baseline_failed_evaluations,
        "total_failed_evaluations": self.total_failed_evaluations,
        "token_usage": _rollup_to_dict(self.token_usage),
    }

from_dict classmethod

from_dict(data: dict[str, Any]) -> EvolutionResult

Reconstruct an EvolutionResult from a dict.

Validates schema version, applies migration if needed, and reconstructs all nested objects including optional original_components. Version 1 dicts migrate through _migrate_v1_to_v2(), so their failure counts read as 0, and version 1 and 2 dicts through _migrate_v2_to_v3(), so their token_usage reads as None, and version 1 to 3 dicts through _migrate_v3_to_v4(), so each record's candidate_id and parent_ids read as None, and version 1 to 4 dicts through _migrate_v4_to_v5(), so each record's rejection_reason reads as None, and version 1 to 5 dicts through _migrate_v5_to_v6(), so each token_usage loads as its evaluation split with reflection None, and version 1 to 6 dicts through _migrate_v6_to_v7(), so each record's rows_evaluated reads as None.

PARAMETER DESCRIPTION
data

Dict containing evolution result fields.

TYPE: dict[str, Any]

RETURNS DESCRIPTION
EvolutionResult

Reconstructed EvolutionResult instance.

RAISES DESCRIPTION
ConfigurationError

If schema_version exceeds the current version or stop_reason is not a valid enum value.

KeyError

If a required field is missing from the dict.

Source code in src/gepa_adk/domain/models.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EvolutionResult":
    """Reconstruct an EvolutionResult from a dict.

    Validates schema version, applies migration if needed, and
    reconstructs all nested objects including optional
    original_components. Version 1 dicts migrate through
    ``_migrate_v1_to_v2()``, so their failure counts read as 0, and
    version 1 and 2 dicts through ``_migrate_v2_to_v3()``, so their
    ``token_usage`` reads as None, and version 1 to 3 dicts through
    ``_migrate_v3_to_v4()``, so each record's ``candidate_id`` and
    ``parent_ids`` read as None, and version 1 to 4 dicts through
    ``_migrate_v4_to_v5()``, so each record's ``rejection_reason`` reads
    as None, and version 1 to 5 dicts through ``_migrate_v5_to_v6()``,
    so each ``token_usage`` loads as its ``evaluation`` split with
    ``reflection`` None, and version 1 to 6 dicts through
    ``_migrate_v6_to_v7()``, so each record's ``rows_evaluated`` reads
    as None.

    Args:
        data: Dict containing evolution result fields.

    Returns:
        Reconstructed EvolutionResult instance.

    Raises:
        ConfigurationError: If ``schema_version`` exceeds the current
            version or ``stop_reason`` is not a valid enum value.
        KeyError: If a required field is missing from the dict.
    """
    version = data.get("schema_version", 1)
    if version > CURRENT_SCHEMA_VERSION:
        raise ConfigurationError(
            f"Cannot deserialize result with schema_version {version} "
            f"(current version is {CURRENT_SCHEMA_VERSION}). "
            f"Upgrade gepa-adk to load this result.",
            field="schema_version",
            value=version,
            constraint=f"<= {CURRENT_SCHEMA_VERSION}",
        )
    migrated = _migrate_result_dict(data, from_version=version)
    try:
        stop_reason = StopReason(migrated.get("stop_reason", "completed"))
    except ValueError:
        raw = migrated.get("stop_reason")
        raise ConfigurationError(
            f"Invalid stop_reason value: {raw!r}",
            field="stop_reason",
            value=raw,
            constraint=("one of: " + ", ".join(sr.value for sr in StopReason)),
        ) from None
    return cls(
        schema_version=migrated["schema_version"],
        stop_reason=stop_reason,
        original_score=migrated["original_score"],
        final_score=migrated["final_score"],
        evolved_components=migrated["evolved_components"],
        iteration_history=[
            IterationRecord.from_dict(r)
            for r in migrated.get("iteration_history", [])
        ],
        total_iterations=migrated["total_iterations"],
        valset_score=migrated.get("valset_score"),
        trainset_score=migrated.get("trainset_score"),
        objective_scores=migrated.get("objective_scores"),
        original_components=migrated.get("original_components"),
        baseline_failed_evaluations=migrated.get("baseline_failed_evaluations", 0),
        total_failed_evaluations=migrated.get("total_failed_evaluations", 0),
        token_usage=_rollup_from_dict(migrated.get("token_usage")),
    )

__repr__

__repr__() -> str

Narrative summary of the evolution result.

RETURNS DESCRIPTION
str

Human-readable multi-line summary with improvement percentage,

str

iterations, stop reason, component names, and acceptance rate.

str

Uses 2-space indent, no box-drawing characters, every line

str

greppable. When the original score is zero the improvement is

str

reported as an absolute value instead of a percentage.

Source code in src/gepa_adk/domain/models.py
def __repr__(self) -> str:
    """Narrative summary of the evolution result.

    Returns:
        Human-readable multi-line summary with improvement percentage,
        iterations, stop reason, component names, and acceptance rate.
        Uses 2-space indent, no box-drawing characters, every line
        greppable. When the original score is zero the improvement is
        reported as an absolute value instead of a percentage.
    """
    if abs(self.original_score) < 1e-9:  # noqa: PLR2004  # zero guard before a percentage division
        imp_str = f"{self.improvement:+.4f} improvement"
    else:
        pct = self.improvement * 100 / abs(self.original_score)
        sign = "+" if pct > 0 else ""
        imp_str = f"{sign}{pct:.1f}% improvement"
    lines = [
        f"EvolutionResult: {imp_str} "
        f"({self.original_score:.2f} \u2192 {self.final_score:.2f})",
        f"  iterations: {self.total_iterations}, "
        f"stop_reason: {self.stop_reason.value}",
        f"  components: {', '.join(sorted(self.evolved_components))}",
    ]
    if self.total_iterations > 0:
        accepted = sum(1 for r in self.iteration_history if r.accepted)
        lines.append(f"  acceptance_rate: {accepted}/{self.total_iterations}")
    return "\n".join(lines)

show_diff

show_diff(
    original_components: dict[str, str] | None = None,
) -> str

Show unified diff between original and evolved components.

Uses stored original_components if no explicit argument is provided. Produces git-diff-style output (---/+++/@@) for each component that changed.

PARAMETER DESCRIPTION
original_components

Pre-evolution component values. If None, falls back to self.original_components.

TYPE: dict[str, str] | None DEFAULT: None

RETURNS DESCRIPTION
str

Unified diff string, or "No changes detected." if all

str

components are identical.

RAISES DESCRIPTION
ValueError

If both the argument and self.original_components are None.

Source code in src/gepa_adk/domain/models.py
def show_diff(self, original_components: dict[str, str] | None = None) -> str:
    """Show unified diff between original and evolved components.

    Uses stored ``original_components`` if no explicit argument is
    provided. Produces git-diff-style output (``---``/``+++``/``@@``)
    for each component that changed.

    Args:
        original_components: Pre-evolution component values. If None,
            falls back to ``self.original_components``.

    Returns:
        Unified diff string, or ``"No changes detected."`` if all
        components are identical.

    Raises:
        ValueError: If both the argument and ``self.original_components``
            are None.
    """
    originals = (
        original_components
        if original_components is not None
        else self.original_components
    )
    if originals is None:
        raise ValueError(
            "No original components available. "
            "Pass original_components or use a result that stores them."
        )
    return _build_diff(self.evolved_components, originals)

FrontierType

Bases: str, Enum


              flowchart TD
              gepa_adk.FrontierType[FrontierType]

              

              click gepa_adk.FrontierType href "" "gepa_adk.FrontierType"
            

Supported frontier tracking strategies for Pareto selection.

Notes

INSTANCE tracks per-example scores, OBJECTIVE aggregated objective scores, HYBRID both, and CARTESIAN per-example objective scores. Members are str values, so they compare equal to and serialize as their plain strings ("instance" and so on).

Examples:

frontier_type = FrontierType.INSTANCE
Source code in src/gepa_adk/domain/types.py
class FrontierType(str, Enum):  # noqa: UP042  # StrEnum would change str() of public members
    """Supported frontier tracking strategies for Pareto selection.

    Notes:
        ``INSTANCE`` tracks per-example scores, ``OBJECTIVE`` aggregated
        objective scores, ``HYBRID`` both, and ``CARTESIAN`` per-example
        objective scores. Members are ``str`` values, so they compare equal
        to and serialize as their plain strings (``"instance"`` and so on).

    Examples:
        ```python
        frontier_type = FrontierType.INSTANCE
        ```
    """

    INSTANCE = "instance"
    OBJECTIVE = "objective"
    HYBRID = "hybrid"
    CARTESIAN = "cartesian"

IterationRecord dataclass

Captures metrics for a single evolution iteration.

This is an immutable record of what happened during one iteration of the evolution process. Records are created by the engine and stored in EvolutionResult.iteration_history.

ATTRIBUTE DESCRIPTION
iteration_number

1-indexed iteration number for human readability.

TYPE: int

score

Score achieved in this iteration (typically in [0.0, 1.0]).

TYPE: float

component_text

The component_text that was evaluated in this iteration (e.g., the instruction text for the "instruction" component).

TYPE: str

evolved_component

The name of the component that was evolved in this iteration (e.g., "instruction", "output_schema"). Used for tracking which component changed in round-robin evolution strategies.

TYPE: str

accepted

Whether this proposal was accepted as the new best.

TYPE: bool

objective_scores

Optional per-example multi-objective scores from the valset evaluation. None when adapter does not provide objective scores. Each dict maps objective name to score value. Index-aligned with evaluation batch examples.

TYPE: list[dict[str, float]] | None

reflection_reasoning

Optional natural language reasoning from the reflection agent explaining why the mutation was proposed. None when reasoning is not available (e.g., model without thinking support or older data without this field).

TYPE: str | None

skip_reason

Why the iteration produced no evaluated proposal, or None for an ordinary iteration. "empty_proposal" marks an iteration whose reflection returned an empty response twice; such a record has score=0.0, component_text="" and accepted=False because nothing was proposed or evaluated. "duplicate" marks a proposal whose components equal a candidate already scored in the run; it is not evaluated again, and the record carries that candidate's acceptance score, the proposal's component_text and accepted=False. "reflection_timeout" marks an iteration whose reflection agent exceeded its timeout; like an empty proposal, the record has score=0.0, component_text="" and accepted=False. "reflection_error" marks an iteration whose reflection function raised a retryable provider error (quota, availability or connection) on both attempts; the record has the same shape. "incomplete_proposal" marks an iteration whose reflection output was cut off at the output-token limit or opened a reasoning tag it never closed; it is not evaluated, and the record has score=0.0, the truncated text as component_text and accepted=False. "minibatch_rejected" marks a proposal that did not beat its parent on the iteration's reflection minibatch (see EvolutionConfig.reflection_minibatch_size); it is evaluated on those rows only, so score is the acceptance aggregate over the minibatch rows only, and accepted=False. "schema_validation_failed" marks a proposal whose output_schema text failed validation; it is not evaluated, and the record has score=0.0, the invalid schema text as component_text, evolved_component="output_schema" and accepted=False. "proposal_rejected" marks a proposal that EvolutionConfig.proposal_validator rejected; it is not evaluated, and the record has score=0.0, the rejected text as component_text, the validator's reason in rejection_reason and accepted=False.

TYPE: str | None

failed_evaluations

Number of evaluated rows in this iteration whose agent run or scorer raised, or whose run returned a failed execution, summed over every evaluation the iteration made (trainset, valset and merge). Such a row still scores 0.0, so this count separates a crash from a wrong answer. Defaults to 0; an "empty_proposal" or "duplicate" skip evaluates nothing and reports 0, and a "minibatch_rejected" skip counts failures in the minibatch rows.

TYPE: int

token_usage

Token usage observed on the rows this iteration evaluated and its reflection calls, or None when not recorded (results saved before schema version 3). The rollup carries an evaluation and a reflection split; reflection is None when no reflection call was observed (baseline, merge, an adapter without the proposer, or results saved before schema version 6). evaluation is set on every rollup the engine wrote at version 6 and on migrated results. A skip that evaluates nothing reports zero evaluation usage; rows evaluated without traces are unknown.

TYPE: TokenRollup | None

candidate_id

Candidate.id of the proposal this iteration produced. Set on evaluated, "duplicate", "minibatch_rejected", "schema_validation_failed" and "proposal_rejected" records; None for skips without a proposal (empty proposal, reflection timeout or error) and for results saved before schema version 4.

TYPE: str | None

parent_ids

Candidate.id of each candidate the proposal was made from: one for a mutation (the candidate the reflector rewrote); merges write no record. None when the iteration has no proposal or the result was saved before schema version 4.

TYPE: list[str] | None

rejection_reason

The reason EvolutionConfig.proposal_validator returned when skip_reason == "proposal_rejected"; None for every other record and for results saved before schema version 5.

TYPE: str | None

rows_evaluated

Number of rows score aggregates over (summed or averaged per EvolutionConfig.acceptance_metric). On an evaluated record (accepted or rejected after its full evaluation) it is the number of rows in the scoring batch; on a "minibatch_rejected" record it is the number of minibatch rows scored. 0 means nothing was evaluated: the "empty_proposal", "reflection_timeout", "reflection_error", "incomplete_proposal", "schema_validation_failed" and "proposal_rejected" skips. None means not recorded: a "duplicate" skip, whose score was computed in an earlier iteration, and results saved before schema version 7. When acceptance_metric="sum", score / rows_evaluated is the per-row mean; guard the division, since the count may be 0 or None.

TYPE: int | None

Examples:

Creating an iteration record:

from gepa_adk.domain.models import IterationRecord

record = IterationRecord(
    iteration_number=1,
    score=0.85,
    component_text="Be helpful",
    evolved_component="instruction",
    accepted=True,
)
print(record.score)  # 0.85
print(record.evolved_component)  # "instruction"
print(record.accepted)  # True

Serialization round-trip:

d = record.to_dict()
restored = IterationRecord.from_dict(d)
assert restored.score == record.score
Notes

An immutable record that captures iteration metrics. Once created, IterationRecord instances cannot be modified, ensuring historical accuracy of the evolution trace.

Source code in src/gepa_adk/domain/models.py
@dataclass(slots=True, frozen=True, kw_only=True)
class IterationRecord:
    """Captures metrics for a single evolution iteration.

    This is an immutable record of what happened during one iteration
    of the evolution process. Records are created by the engine and
    stored in EvolutionResult.iteration_history.

    Attributes:
        iteration_number (int): 1-indexed iteration number for human
            readability.
        score (float): Score achieved in this iteration (typically in
            [0.0, 1.0]).
        component_text (str): The component_text that was evaluated in this
            iteration (e.g., the instruction text for the "instruction" component).
        evolved_component (str): The name of the component that was evolved
            in this iteration (e.g., "instruction", "output_schema"). Used for
            tracking which component changed in round-robin evolution strategies.
        accepted (bool): Whether this proposal was accepted as the new best.
        objective_scores (list[dict[str, float]] | None): Optional per-example
            multi-objective scores from the valset evaluation. None when adapter
            does not provide objective scores. Each dict maps objective name to
            score value. Index-aligned with evaluation batch examples.
        reflection_reasoning (str | None): Optional natural language reasoning
            from the reflection agent explaining why the mutation was proposed.
            None when reasoning is not available (e.g., model without thinking
            support or older data without this field).
        skip_reason (str | None): Why the iteration produced no evaluated
            proposal, or None for an ordinary iteration. ``"empty_proposal"``
            marks an iteration whose reflection returned an empty response
            twice; such a record has ``score=0.0``, ``component_text=""`` and
            ``accepted=False`` because nothing was proposed or evaluated.
            ``"duplicate"`` marks a proposal whose components equal a
            candidate already scored in the run; it is not evaluated again,
            and the record carries that candidate's acceptance score, the
            proposal's ``component_text`` and ``accepted=False``.
            ``"reflection_timeout"`` marks an iteration whose reflection
            agent exceeded its timeout; like an empty proposal, the record
            has ``score=0.0``, ``component_text=""`` and ``accepted=False``.
            ``"reflection_error"`` marks an iteration whose reflection
            function raised a retryable provider error (quota, availability
            or connection) on both attempts; the record has the same shape.
            ``"incomplete_proposal"`` marks an iteration whose reflection
            output was cut off at the output-token limit or opened a
            reasoning tag it never closed; it is not evaluated, and the
            record has ``score=0.0``, the truncated text as
            ``component_text`` and ``accepted=False``.
            ``"minibatch_rejected"`` marks a proposal that did not beat its
            parent on the iteration's reflection minibatch (see
            ``EvolutionConfig.reflection_minibatch_size``); it is evaluated
            on those rows only, so ``score`` is the acceptance aggregate over
            the minibatch rows only, and ``accepted=False``.
            ``"schema_validation_failed"`` marks a proposal whose
            ``output_schema`` text failed validation; it is not evaluated, and
            the record has ``score=0.0``, the invalid schema text as
            ``component_text``, ``evolved_component="output_schema"`` and
            ``accepted=False``.
            ``"proposal_rejected"`` marks a proposal that
            ``EvolutionConfig.proposal_validator`` rejected; it is not
            evaluated, and the record has ``score=0.0``, the rejected text
            as ``component_text``, the validator's reason in
            ``rejection_reason`` and ``accepted=False``.
        failed_evaluations (int): Number of evaluated rows in this iteration
            whose agent run or scorer raised, or whose run returned a failed
            execution, summed over
            every evaluation the iteration made (trainset, valset and merge).
            Such a row still scores 0.0, so this count separates a crash from
            a wrong answer. Defaults to 0; an ``"empty_proposal"`` or
            ``"duplicate"`` skip evaluates nothing and reports 0, and a
            ``"minibatch_rejected"`` skip counts failures in the minibatch
            rows.
        token_usage (TokenRollup | None): Token usage observed on the rows
            this iteration evaluated and its reflection calls, or None when
            not recorded (results saved before schema version 3). The
            rollup carries an ``evaluation`` and a ``reflection`` split;
            ``reflection`` is None when no reflection call was observed
            (baseline, merge, an adapter without the proposer, or results
            saved before schema version 6). ``evaluation`` is set on every
            rollup the engine wrote at version 6 and on migrated results. A
            skip that evaluates nothing
            reports zero evaluation usage; rows evaluated without traces
            are unknown.
        candidate_id (str | None): ``Candidate.id`` of the proposal this
            iteration produced. Set on evaluated, ``"duplicate"``,
            ``"minibatch_rejected"``, ``"schema_validation_failed"`` and
            ``"proposal_rejected"`` records; None for skips without a proposal (empty proposal,
            reflection timeout or error) and for results saved before
            schema version 4.
        parent_ids (list[str] | None): ``Candidate.id`` of each candidate
            the proposal was made from: one for a mutation (the candidate
            the reflector rewrote); merges write no record. None when the
            iteration has no proposal or the result was saved before
            schema version 4.
        rejection_reason (str | None): The reason
            ``EvolutionConfig.proposal_validator`` returned when
            ``skip_reason == "proposal_rejected"``; None for every other
            record and for results saved before schema version 5.
        rows_evaluated (int | None): Number of rows ``score`` aggregates
            over (summed or averaged per ``EvolutionConfig.acceptance_metric``).
            On an evaluated record (accepted or rejected after its full
            evaluation) it is the number of rows in the scoring batch; on a
            ``"minibatch_rejected"`` record it is the number of minibatch
            rows scored. ``0`` means nothing was evaluated: the
            ``"empty_proposal"``, ``"reflection_timeout"``,
            ``"reflection_error"``, ``"incomplete_proposal"``,
            ``"schema_validation_failed"`` and ``"proposal_rejected"``
            skips. ``None`` means not recorded: a ``"duplicate"`` skip,
            whose ``score`` was computed in an earlier iteration, and
            results saved before schema version 7. When
            ``acceptance_metric="sum"``, ``score / rows_evaluated`` is the
            per-row mean; guard the division, since the count may be ``0``
            or ``None``.

    Examples:
        Creating an iteration record:

        ```python
        from gepa_adk.domain.models import IterationRecord

        record = IterationRecord(
            iteration_number=1,
            score=0.85,
            component_text="Be helpful",
            evolved_component="instruction",
            accepted=True,
        )
        print(record.score)  # 0.85
        print(record.evolved_component)  # "instruction"
        print(record.accepted)  # True
        ```

        Serialization round-trip:

        ```python
        d = record.to_dict()
        restored = IterationRecord.from_dict(d)
        assert restored.score == record.score
        ```

    Notes:
        An immutable record that captures iteration metrics. Once created,
        IterationRecord instances cannot be modified, ensuring historical
        accuracy of the evolution trace.
    """

    iteration_number: int
    score: float
    component_text: str
    evolved_component: str
    accepted: bool
    objective_scores: list[dict[str, float]] | None = None
    reflection_reasoning: str | None = None
    skip_reason: str | None = None
    failed_evaluations: int = 0
    token_usage: TokenRollup | None = None
    candidate_id: str | None = None
    parent_ids: list[str] | None = None
    rejection_reason: str | None = None
    rows_evaluated: int | None = None

    def to_dict(self) -> dict[str, Any]:
        """Serialize this record to a stdlib-only dict.

        Returns:
            Dict containing all 14 fields; ``token_usage``, ``candidate_id``,
            ``parent_ids``, ``rejection_reason`` and ``rows_evaluated`` are
            always present and None when unset.
            ``parent_ids`` is copied to a new list. Output is directly
            ``json.dumps()``-compatible.
        """
        return {
            "iteration_number": self.iteration_number,
            "score": self.score,
            "component_text": self.component_text,
            "evolved_component": self.evolved_component,
            "accepted": self.accepted,
            "objective_scores": self.objective_scores,
            "reflection_reasoning": self.reflection_reasoning,
            "skip_reason": self.skip_reason,
            "failed_evaluations": self.failed_evaluations,
            "token_usage": _rollup_to_dict(self.token_usage),
            "candidate_id": self.candidate_id,
            "parent_ids": (
                list(self.parent_ids) if self.parent_ids is not None else None
            ),
            "rejection_reason": self.rejection_reason,
            "rows_evaluated": self.rows_evaluated,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "IterationRecord":
        """Reconstruct an IterationRecord from a dict.

        Unknown keys are silently ignored for forward compatibility,
        allowing older code to load records produced by newer versions.
        Optional fields (``objective_scores``, ``reflection_reasoning``,
        ``skip_reason``, ``token_usage``, ``candidate_id``, ``parent_ids``,
        ``rejection_reason``, ``rows_evaluated``) default to None and
        ``failed_evaluations`` to 0 when missing from
        the input dict. ``parent_ids`` is copied to a new list so the frozen
        record does not alias the input.

        Args:
            data: Dict containing iteration record fields.

        Returns:
            Reconstructed IterationRecord instance.

        Raises:
            KeyError: If a required field is missing from the dict.
        """
        return cls(
            iteration_number=data["iteration_number"],
            score=data["score"],
            component_text=data["component_text"],
            evolved_component=data["evolved_component"],
            accepted=data["accepted"],
            objective_scores=data.get("objective_scores"),
            reflection_reasoning=data.get("reflection_reasoning"),
            skip_reason=data.get("skip_reason"),
            failed_evaluations=data.get("failed_evaluations", 0),
            token_usage=_rollup_from_dict(data.get("token_usage")),
            candidate_id=data.get("candidate_id"),
            parent_ids=(
                list(parent_ids)
                if (parent_ids := data.get("parent_ids")) is not None
                else None
            ),
            rejection_reason=data.get("rejection_reason"),
            rows_evaluated=data.get("rows_evaluated"),
        )

to_dict

to_dict() -> dict[str, Any]

Serialize this record to a stdlib-only dict.

RETURNS DESCRIPTION
dict[str, Any]

Dict containing all 14 fields; token_usage, candidate_id,

dict[str, Any]

parent_ids, rejection_reason and rows_evaluated are

dict[str, Any]

always present and None when unset.

dict[str, Any]

parent_ids is copied to a new list. Output is directly

dict[str, Any]

json.dumps()-compatible.

Source code in src/gepa_adk/domain/models.py
def to_dict(self) -> dict[str, Any]:
    """Serialize this record to a stdlib-only dict.

    Returns:
        Dict containing all 14 fields; ``token_usage``, ``candidate_id``,
        ``parent_ids``, ``rejection_reason`` and ``rows_evaluated`` are
        always present and None when unset.
        ``parent_ids`` is copied to a new list. Output is directly
        ``json.dumps()``-compatible.
    """
    return {
        "iteration_number": self.iteration_number,
        "score": self.score,
        "component_text": self.component_text,
        "evolved_component": self.evolved_component,
        "accepted": self.accepted,
        "objective_scores": self.objective_scores,
        "reflection_reasoning": self.reflection_reasoning,
        "skip_reason": self.skip_reason,
        "failed_evaluations": self.failed_evaluations,
        "token_usage": _rollup_to_dict(self.token_usage),
        "candidate_id": self.candidate_id,
        "parent_ids": (
            list(self.parent_ids) if self.parent_ids is not None else None
        ),
        "rejection_reason": self.rejection_reason,
        "rows_evaluated": self.rows_evaluated,
    }

from_dict classmethod

from_dict(data: dict[str, Any]) -> IterationRecord

Reconstruct an IterationRecord from a dict.

Unknown keys are silently ignored for forward compatibility, allowing older code to load records produced by newer versions. Optional fields (objective_scores, reflection_reasoning, skip_reason, token_usage, candidate_id, parent_ids, rejection_reason, rows_evaluated) default to None and failed_evaluations to 0 when missing from the input dict. parent_ids is copied to a new list so the frozen record does not alias the input.

PARAMETER DESCRIPTION
data

Dict containing iteration record fields.

TYPE: dict[str, Any]

RETURNS DESCRIPTION
IterationRecord

Reconstructed IterationRecord instance.

RAISES DESCRIPTION
KeyError

If a required field is missing from the dict.

Source code in src/gepa_adk/domain/models.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "IterationRecord":
    """Reconstruct an IterationRecord from a dict.

    Unknown keys are silently ignored for forward compatibility,
    allowing older code to load records produced by newer versions.
    Optional fields (``objective_scores``, ``reflection_reasoning``,
    ``skip_reason``, ``token_usage``, ``candidate_id``, ``parent_ids``,
    ``rejection_reason``, ``rows_evaluated``) default to None and
    ``failed_evaluations`` to 0 when missing from
    the input dict. ``parent_ids`` is copied to a new list so the frozen
    record does not alias the input.

    Args:
        data: Dict containing iteration record fields.

    Returns:
        Reconstructed IterationRecord instance.

    Raises:
        KeyError: If a required field is missing from the dict.
    """
    return cls(
        iteration_number=data["iteration_number"],
        score=data["score"],
        component_text=data["component_text"],
        evolved_component=data["evolved_component"],
        accepted=data["accepted"],
        objective_scores=data.get("objective_scores"),
        reflection_reasoning=data.get("reflection_reasoning"),
        skip_reason=data.get("skip_reason"),
        failed_evaluations=data.get("failed_evaluations", 0),
        token_usage=_rollup_from_dict(data.get("token_usage")),
        candidate_id=data.get("candidate_id"),
        parent_ids=(
            list(parent_ids)
            if (parent_ids := data.get("parent_ids")) is not None
            else None
        ),
        rejection_reason=data.get("rejection_reason"),
        rows_evaluated=data.get("rows_evaluated"),
    )

MultiAgentEvolutionResult dataclass

Outcome of a completed multi-agent evolution run.

Contains evolved component_text for all agents in the group, along with performance metrics and evolution history.

ATTRIBUTE DESCRIPTION
schema_version

Schema version for forward-compatible serialization.

TYPE: int

stop_reason

Why the evolution run terminated.

TYPE: StopReason

evolved_components

Mapping of agent name to evolved component_text.

TYPE: dict[str, str]

original_score

Starting performance score (baseline).

TYPE: float

final_score

Ending performance score (best achieved).

TYPE: float

primary_agent

Name of the agent whose output was used for scoring.

TYPE: str

iteration_history

Chronological list of iteration records.

TYPE: list[IterationRecord]

total_iterations

Number of iterations performed.

TYPE: int

original_components

Optional snapshot of pre-evolution component values. When present, enables zero-arg show_diff() calls. None for results created before this field was added or when originals were not captured.

TYPE: dict[str, str] | None

baseline_failed_evaluations

Number of rows whose pipeline run or scorer raised, or whose run returned a failed execution, during the baseline evaluation. Defaults to 0.

TYPE: int

total_failed_evaluations

baseline_failed_evaluations plus the sum of failed_evaluations over iteration_history. Defaults to 0.

TYPE: int

token_usage

Token usage observed on the rows evaluated over the run, baseline included, and on its reflection calls. None when not recorded (results saved before schema version 3). The rollup carries an evaluation split, set on every result the engine wrote at version 6 and on migrated results, and a reflection split that is None when no reflection call was observed.

TYPE: TokenRollup | None

Examples:

Creating and analyzing a multi-agent result:

from gepa_adk.domain.models import MultiAgentEvolutionResult, IterationRecord

result = MultiAgentEvolutionResult(
    evolved_components={
        "generator": "Generate high-quality code",
        "critic": "Review code thoroughly",
    },
    original_components={
        "generator": "Generate code",
        "critic": "Review code",
    },
    original_score=0.60,
    final_score=0.85,
    primary_agent="generator",
    iteration_history=[],
    total_iterations=10,
)
print(result.improvement)  # 0.25
print(result.show_diff())  # unified diff of component changes
print(result.agent_names)  # ["critic", "generator"]
assert result.schema_version == 7

Serialization round-trip:

import json

d = result.to_dict()
restored = MultiAgentEvolutionResult.from_dict(json.loads(json.dumps(d)))
Notes

An immutable result container for multi-agent evolution. Once created, MultiAgentEvolutionResult instances cannot be modified. Use computed properties like improvement, improved, and agent_names to analyze results without modifying the underlying data.

Source code in src/gepa_adk/domain/models.py
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
@dataclass(slots=True, frozen=True, kw_only=True)
class MultiAgentEvolutionResult:
    """Outcome of a completed multi-agent evolution run.

    Contains evolved component_text for all agents in the group,
    along with performance metrics and evolution history.

    Attributes:
        schema_version (int): Schema version for forward-compatible serialization.
        stop_reason (StopReason): Why the evolution run terminated.
        evolved_components (dict[str, str]): Mapping of agent name to evolved
            component_text.
        original_score (float): Starting performance score (baseline).
        final_score (float): Ending performance score (best achieved).
        primary_agent (str): Name of the agent whose output was used for scoring.
        iteration_history (list[IterationRecord]): Chronological list of iteration records.
        total_iterations (int): Number of iterations performed.
        original_components (dict[str, str] | None): Optional snapshot of
            pre-evolution component values. When present, enables zero-arg
            ``show_diff()`` calls. None for results created before this field
            was added or when originals were not captured.
        baseline_failed_evaluations (int): Number of rows whose pipeline run or
            scorer raised, or whose run returned a failed execution, during
            the baseline evaluation. Defaults to 0.
        total_failed_evaluations (int): ``baseline_failed_evaluations`` plus
            the sum of ``failed_evaluations`` over ``iteration_history``.
            Defaults to 0.
        token_usage (TokenRollup | None): Token usage observed on the rows
            evaluated over the run, baseline included, and on its
            reflection calls. None when not recorded (results saved before
            schema version 3). The rollup carries an ``evaluation`` split,
            set on every result the engine wrote at version 6 and on
            migrated results, and a ``reflection`` split that is None when
            no reflection call was observed.

    Examples:
        Creating and analyzing a multi-agent result:

        ```python
        from gepa_adk.domain.models import MultiAgentEvolutionResult, IterationRecord

        result = MultiAgentEvolutionResult(
            evolved_components={
                "generator": "Generate high-quality code",
                "critic": "Review code thoroughly",
            },
            original_components={
                "generator": "Generate code",
                "critic": "Review code",
            },
            original_score=0.60,
            final_score=0.85,
            primary_agent="generator",
            iteration_history=[],
            total_iterations=10,
        )
        print(result.improvement)  # 0.25
        print(result.show_diff())  # unified diff of component changes
        print(result.agent_names)  # ["critic", "generator"]
        assert result.schema_version == 7
        ```

        Serialization round-trip:

        ```python
        import json

        d = result.to_dict()
        restored = MultiAgentEvolutionResult.from_dict(json.loads(json.dumps(d)))
        ```

    Notes:
        An immutable result container for multi-agent evolution. Once created,
        MultiAgentEvolutionResult instances cannot be modified. Use computed
        properties like `improvement`, `improved`, and `agent_names` to analyze
        results without modifying the underlying data.
    """

    schema_version: int = CURRENT_SCHEMA_VERSION
    stop_reason: StopReason = StopReason.COMPLETED
    evolved_components: dict[str, str]
    original_score: float
    final_score: float
    primary_agent: str
    iteration_history: list[IterationRecord]
    total_iterations: int
    original_components: dict[str, str] | None = None
    baseline_failed_evaluations: int = 0
    total_failed_evaluations: int = 0
    token_usage: TokenRollup | None = None

    def to_dict(self) -> dict[str, Any]:
        """Serialize this result to a stdlib-only dict.

        Returns:
            Dict containing all 12 fields. ``stop_reason`` is serialized
            as its string value. ``iteration_history`` is serialized as a
            list of dicts. Output is directly ``json.dumps()``-compatible.
        """
        return {
            "schema_version": self.schema_version,
            "stop_reason": self.stop_reason.value,
            "evolved_components": self.evolved_components,
            "original_score": self.original_score,
            "final_score": self.final_score,
            "primary_agent": self.primary_agent,
            "iteration_history": [r.to_dict() for r in self.iteration_history],
            "total_iterations": self.total_iterations,
            "original_components": self.original_components,
            "baseline_failed_evaluations": self.baseline_failed_evaluations,
            "total_failed_evaluations": self.total_failed_evaluations,
            "token_usage": _rollup_to_dict(self.token_usage),
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "MultiAgentEvolutionResult":
        """Reconstruct a MultiAgentEvolutionResult from a dict.

        Validates schema version, applies migration if needed, and
        reconstructs all nested objects including optional
        original_components. Version 1 dicts migrate through
        ``_migrate_v1_to_v2()``, so their failure counts read as 0, and
        version 1 and 2 dicts through ``_migrate_v2_to_v3()``, so their
        ``token_usage`` reads as None, and version 1 to 3 dicts through
        ``_migrate_v3_to_v4()``, so each record's ``candidate_id`` and
        ``parent_ids`` read as None, and version 1 to 4 dicts through
        ``_migrate_v4_to_v5()``, so each record's ``rejection_reason`` reads
        as None, and version 1 to 5 dicts through ``_migrate_v5_to_v6()``,
        so each ``token_usage`` loads as its ``evaluation`` split with
        ``reflection`` None, and version 1 to 6 dicts through
        ``_migrate_v6_to_v7()``, so each record's ``rows_evaluated`` reads
        as None.

        Args:
            data: Dict containing multi-agent evolution result fields.

        Returns:
            Reconstructed MultiAgentEvolutionResult instance.

        Raises:
            ConfigurationError: If ``schema_version`` exceeds the current
                version or ``stop_reason`` is not a valid enum value.
            KeyError: If a required field is missing from the dict.
        """
        version = data.get("schema_version", 1)
        if version > CURRENT_SCHEMA_VERSION:
            raise ConfigurationError(
                f"Cannot deserialize result with schema_version {version} "
                f"(current version is {CURRENT_SCHEMA_VERSION}). "
                f"Upgrade gepa-adk to load this result.",
                field="schema_version",
                value=version,
                constraint=f"<= {CURRENT_SCHEMA_VERSION}",
            )
        migrated = _migrate_result_dict(data, from_version=version)
        try:
            stop_reason = StopReason(migrated.get("stop_reason", "completed"))
        except ValueError:
            raw = migrated.get("stop_reason")
            raise ConfigurationError(
                f"Invalid stop_reason value: {raw!r}",
                field="stop_reason",
                value=raw,
                constraint=("one of: " + ", ".join(sr.value for sr in StopReason)),
            ) from None
        return cls(
            schema_version=migrated["schema_version"],
            stop_reason=stop_reason,
            evolved_components=migrated["evolved_components"],
            original_score=migrated["original_score"],
            final_score=migrated["final_score"],
            primary_agent=migrated["primary_agent"],
            iteration_history=[
                IterationRecord.from_dict(r)
                for r in migrated.get("iteration_history", [])
            ],
            total_iterations=migrated["total_iterations"],
            original_components=migrated.get("original_components"),
            baseline_failed_evaluations=migrated.get("baseline_failed_evaluations", 0),
            total_failed_evaluations=migrated.get("total_failed_evaluations", 0),
            token_usage=_rollup_from_dict(migrated.get("token_usage")),
        )

    @property
    def improvement(self) -> float:
        """Calculate the score improvement from original to final.

        Returns:
            The difference between final_score and original_score.
            Positive values indicate improvement, negative indicates degradation.

        Notes:
            Override is not needed since frozen dataclasses support properties.
        """
        return self.final_score - self.original_score

    @property
    def improved(self) -> bool:
        """Check if the final score is better than the original.

        Returns:
            True if final_score > original_score, False otherwise.

        Notes:
            Only returns True for strict improvement, not equal scores.
        """
        return self.final_score > self.original_score

    @property
    def agent_names(self) -> list[str]:
        """Get sorted list of evolved agent names.

        Returns:
            Sorted list of agent names from evolved_components keys.

        Notes:
            Outputs a new list each time, sorted alphabetically for
            consistent ordering regardless of insertion order.
        """
        return sorted(self.evolved_components.keys())

    def __repr__(self) -> str:
        """Narrative summary of the multi-agent evolution result.

        Returns:
            Human-readable multi-line summary with improvement percentage,
            iterations, stop reason, primary agent, agent names, and
            acceptance rate. Uses 2-space indent, no box-drawing characters,
            every line greppable. When the original score is zero the
            improvement is reported as an absolute value instead of a
            percentage.
        """
        if abs(self.original_score) < 1e-9:  # noqa: PLR2004  # zero guard before a percentage division
            imp_str = f"{self.improvement:+.4f} improvement"
        else:
            pct = self.improvement * 100 / abs(self.original_score)
            sign = "+" if pct > 0 else ""
            imp_str = f"{sign}{pct:.1f}% improvement"
        lines = [
            f"MultiAgentEvolutionResult: {imp_str} "
            f"({self.original_score:.2f} \u2192 {self.final_score:.2f})",
            f"  iterations: {self.total_iterations}, "
            f"stop_reason: {self.stop_reason.value}",
            f"  primary_agent: {self.primary_agent}",
            f"  agents: {', '.join(sorted(self.evolved_components))}",
        ]
        if self.total_iterations > 0:
            accepted = sum(1 for r in self.iteration_history if r.accepted)
            lines.append(f"  acceptance_rate: {accepted}/{self.total_iterations}")
        return "\n".join(lines)

    def show_diff(self, original_components: dict[str, str] | None = None) -> str:
        """Show unified diff between original and evolved components.

        Uses stored ``original_components`` if no explicit argument is
        provided. Produces git-diff-style output (``---``/``+++``/``@@``)
        for each component that changed.

        Args:
            original_components: Pre-evolution component values. If None,
                falls back to ``self.original_components``.

        Returns:
            Unified diff string, or ``"No changes detected."`` if all
            components are identical.

        Raises:
            ValueError: If both the argument and ``self.original_components``
                are None.
        """
        originals = (
            original_components
            if original_components is not None
            else self.original_components
        )
        if originals is None:
            raise ValueError(
                "No original components available. "
                "Pass original_components or use a result that stores them."
            )
        return _build_diff(self.evolved_components, originals)

    def _repr_html_(self) -> str:
        """Render an HTML summary for Jupyter notebooks.

        Returns:
            HTML string with summary and components tables. Uses semantic
            ``<th>`` header elements and inline CSS for portability across
            JupyterLab, Colab, and VS Code. Iteration history is wrapped
            in a collapsible ``<details>``/``<summary>`` block. The improvement
            is shown as a signed absolute value when the original score is
            zero, since a percentage is undefined there.
        """
        if abs(self.original_score) < 1e-9:  # noqa: PLR2004  # zero guard before a percentage division
            imp_html = f"{self.improvement:+.4f}"
        else:
            pct = self.improvement * 100 / abs(self.original_score)
            sign = "+" if pct > 0 else ""
            imp_html = f"{sign}{pct:.1f}%"
        style = (
            'style="border-collapse:collapse;'
            "font-family:monospace;font-size:13px;"
            'margin:8px 0"'
        )
        td = 'style="padding:4px 12px;border:1px solid #ddd"'
        th = 'style="padding:4px 12px;border:1px solid #ddd;background:#f5f5f5"'

        rows = [
            f"<tr><th {th}>Improvement</th><td {td}>{imp_html}</td></tr>",
            f"<tr><th {th}>Original Score</th>"
            f"<td {td}>{self.original_score:.4f}</td></tr>",
            f"<tr><th {th}>Final Score</th><td {td}>{self.final_score:.4f}</td></tr>",
            f"<tr><th {th}>Iterations</th><td {td}>{self.total_iterations}</td></tr>",
            f"<tr><th {th}>Stop Reason</th>"
            f"<td {td}>{html_mod.escape(self.stop_reason.value)}</td></tr>",
            f"<tr><th {th}>Primary Agent</th>"
            f"<td {td}>{html_mod.escape(self.primary_agent)}</td></tr>",
        ]
        summary_table = f"<table {style}>{''.join(rows)}</table>"

        comp_rows = []
        for name in sorted(self.evolved_components):
            val = html_mod.escape(_truncate(self.evolved_components[name], 200))
            comp_rows.append(
                f"<tr><td {td}>{html_mod.escape(name)}</td><td {td}>{val}</td></tr>"
            )
        comp_header = f"<tr><th {th}>Agent</th><th {th}>Evolved Value</th></tr>"
        comp_table = f"<table {style}>{comp_header}{''.join(comp_rows)}</table>"

        history_rows = []
        for rec in self.iteration_history:
            accepted_mark = "\u2713" if rec.accepted else ""
            history_rows.append(
                f"<tr><td {td}>{rec.iteration_number}</td>"
                f"<td {td}>{rec.score:.4f}</td>"
                f"<td {td}>{html_mod.escape(rec.evolved_component)}</td>"
                f"<td {td}>{accepted_mark}</td></tr>"
            )
        history_header = (
            f"<tr><th {th}>#</th><th {th}>Score</th>"
            f"<th {th}>Component</th><th {th}>Accepted</th></tr>"
        )
        history_table = (
            f"<table {style}>{history_header}{''.join(history_rows)}</table>"
        )
        history_section = (
            "<details><summary>Iteration History "
            f"({len(self.iteration_history)} records)</summary>"
            f"{history_table}</details>"
        )

        return (
            "<div>"
            "<strong>MultiAgentEvolutionResult</strong>"
            f"{summary_table}{comp_table}{history_section}"
            "</div>"
        )

improvement property

improvement: float

Calculate the score improvement from original to final.

RETURNS DESCRIPTION
float

The difference between final_score and original_score.

float

Positive values indicate improvement, negative indicates degradation.

Notes

Override is not needed since frozen dataclasses support properties.

improved property

improved: bool

Check if the final score is better than the original.

RETURNS DESCRIPTION
bool

True if final_score > original_score, False otherwise.

Notes

Only returns True for strict improvement, not equal scores.

agent_names property

agent_names: list[str]

Get sorted list of evolved agent names.

RETURNS DESCRIPTION
list[str]

Sorted list of agent names from evolved_components keys.

Notes

Outputs a new list each time, sorted alphabetically for consistent ordering regardless of insertion order.

to_dict

to_dict() -> dict[str, Any]

Serialize this result to a stdlib-only dict.

RETURNS DESCRIPTION
dict[str, Any]

Dict containing all 12 fields. stop_reason is serialized

dict[str, Any]

as its string value. iteration_history is serialized as a

dict[str, Any]

list of dicts. Output is directly json.dumps()-compatible.

Source code in src/gepa_adk/domain/models.py
def to_dict(self) -> dict[str, Any]:
    """Serialize this result to a stdlib-only dict.

    Returns:
        Dict containing all 12 fields. ``stop_reason`` is serialized
        as its string value. ``iteration_history`` is serialized as a
        list of dicts. Output is directly ``json.dumps()``-compatible.
    """
    return {
        "schema_version": self.schema_version,
        "stop_reason": self.stop_reason.value,
        "evolved_components": self.evolved_components,
        "original_score": self.original_score,
        "final_score": self.final_score,
        "primary_agent": self.primary_agent,
        "iteration_history": [r.to_dict() for r in self.iteration_history],
        "total_iterations": self.total_iterations,
        "original_components": self.original_components,
        "baseline_failed_evaluations": self.baseline_failed_evaluations,
        "total_failed_evaluations": self.total_failed_evaluations,
        "token_usage": _rollup_to_dict(self.token_usage),
    }

from_dict classmethod

from_dict(
    data: dict[str, Any],
) -> MultiAgentEvolutionResult

Reconstruct a MultiAgentEvolutionResult from a dict.

Validates schema version, applies migration if needed, and reconstructs all nested objects including optional original_components. Version 1 dicts migrate through _migrate_v1_to_v2(), so their failure counts read as 0, and version 1 and 2 dicts through _migrate_v2_to_v3(), so their token_usage reads as None, and version 1 to 3 dicts through _migrate_v3_to_v4(), so each record's candidate_id and parent_ids read as None, and version 1 to 4 dicts through _migrate_v4_to_v5(), so each record's rejection_reason reads as None, and version 1 to 5 dicts through _migrate_v5_to_v6(), so each token_usage loads as its evaluation split with reflection None, and version 1 to 6 dicts through _migrate_v6_to_v7(), so each record's rows_evaluated reads as None.

PARAMETER DESCRIPTION
data

Dict containing multi-agent evolution result fields.

TYPE: dict[str, Any]

RETURNS DESCRIPTION
MultiAgentEvolutionResult

Reconstructed MultiAgentEvolutionResult instance.

RAISES DESCRIPTION
ConfigurationError

If schema_version exceeds the current version or stop_reason is not a valid enum value.

KeyError

If a required field is missing from the dict.

Source code in src/gepa_adk/domain/models.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "MultiAgentEvolutionResult":
    """Reconstruct a MultiAgentEvolutionResult from a dict.

    Validates schema version, applies migration if needed, and
    reconstructs all nested objects including optional
    original_components. Version 1 dicts migrate through
    ``_migrate_v1_to_v2()``, so their failure counts read as 0, and
    version 1 and 2 dicts through ``_migrate_v2_to_v3()``, so their
    ``token_usage`` reads as None, and version 1 to 3 dicts through
    ``_migrate_v3_to_v4()``, so each record's ``candidate_id`` and
    ``parent_ids`` read as None, and version 1 to 4 dicts through
    ``_migrate_v4_to_v5()``, so each record's ``rejection_reason`` reads
    as None, and version 1 to 5 dicts through ``_migrate_v5_to_v6()``,
    so each ``token_usage`` loads as its ``evaluation`` split with
    ``reflection`` None, and version 1 to 6 dicts through
    ``_migrate_v6_to_v7()``, so each record's ``rows_evaluated`` reads
    as None.

    Args:
        data: Dict containing multi-agent evolution result fields.

    Returns:
        Reconstructed MultiAgentEvolutionResult instance.

    Raises:
        ConfigurationError: If ``schema_version`` exceeds the current
            version or ``stop_reason`` is not a valid enum value.
        KeyError: If a required field is missing from the dict.
    """
    version = data.get("schema_version", 1)
    if version > CURRENT_SCHEMA_VERSION:
        raise ConfigurationError(
            f"Cannot deserialize result with schema_version {version} "
            f"(current version is {CURRENT_SCHEMA_VERSION}). "
            f"Upgrade gepa-adk to load this result.",
            field="schema_version",
            value=version,
            constraint=f"<= {CURRENT_SCHEMA_VERSION}",
        )
    migrated = _migrate_result_dict(data, from_version=version)
    try:
        stop_reason = StopReason(migrated.get("stop_reason", "completed"))
    except ValueError:
        raw = migrated.get("stop_reason")
        raise ConfigurationError(
            f"Invalid stop_reason value: {raw!r}",
            field="stop_reason",
            value=raw,
            constraint=("one of: " + ", ".join(sr.value for sr in StopReason)),
        ) from None
    return cls(
        schema_version=migrated["schema_version"],
        stop_reason=stop_reason,
        evolved_components=migrated["evolved_components"],
        original_score=migrated["original_score"],
        final_score=migrated["final_score"],
        primary_agent=migrated["primary_agent"],
        iteration_history=[
            IterationRecord.from_dict(r)
            for r in migrated.get("iteration_history", [])
        ],
        total_iterations=migrated["total_iterations"],
        original_components=migrated.get("original_components"),
        baseline_failed_evaluations=migrated.get("baseline_failed_evaluations", 0),
        total_failed_evaluations=migrated.get("total_failed_evaluations", 0),
        token_usage=_rollup_from_dict(migrated.get("token_usage")),
    )

__repr__

__repr__() -> str

Narrative summary of the multi-agent evolution result.

RETURNS DESCRIPTION
str

Human-readable multi-line summary with improvement percentage,

str

iterations, stop reason, primary agent, agent names, and

str

acceptance rate. Uses 2-space indent, no box-drawing characters,

str

every line greppable. When the original score is zero the

str

improvement is reported as an absolute value instead of a

str

percentage.

Source code in src/gepa_adk/domain/models.py
def __repr__(self) -> str:
    """Narrative summary of the multi-agent evolution result.

    Returns:
        Human-readable multi-line summary with improvement percentage,
        iterations, stop reason, primary agent, agent names, and
        acceptance rate. Uses 2-space indent, no box-drawing characters,
        every line greppable. When the original score is zero the
        improvement is reported as an absolute value instead of a
        percentage.
    """
    if abs(self.original_score) < 1e-9:  # noqa: PLR2004  # zero guard before a percentage division
        imp_str = f"{self.improvement:+.4f} improvement"
    else:
        pct = self.improvement * 100 / abs(self.original_score)
        sign = "+" if pct > 0 else ""
        imp_str = f"{sign}{pct:.1f}% improvement"
    lines = [
        f"MultiAgentEvolutionResult: {imp_str} "
        f"({self.original_score:.2f} \u2192 {self.final_score:.2f})",
        f"  iterations: {self.total_iterations}, "
        f"stop_reason: {self.stop_reason.value}",
        f"  primary_agent: {self.primary_agent}",
        f"  agents: {', '.join(sorted(self.evolved_components))}",
    ]
    if self.total_iterations > 0:
        accepted = sum(1 for r in self.iteration_history if r.accepted)
        lines.append(f"  acceptance_rate: {accepted}/{self.total_iterations}")
    return "\n".join(lines)

show_diff

show_diff(
    original_components: dict[str, str] | None = None,
) -> str

Show unified diff between original and evolved components.

Uses stored original_components if no explicit argument is provided. Produces git-diff-style output (---/+++/@@) for each component that changed.

PARAMETER DESCRIPTION
original_components

Pre-evolution component values. If None, falls back to self.original_components.

TYPE: dict[str, str] | None DEFAULT: None

RETURNS DESCRIPTION
str

Unified diff string, or "No changes detected." if all

str

components are identical.

RAISES DESCRIPTION
ValueError

If both the argument and self.original_components are None.

Source code in src/gepa_adk/domain/models.py
def show_diff(self, original_components: dict[str, str] | None = None) -> str:
    """Show unified diff between original and evolved components.

    Uses stored ``original_components`` if no explicit argument is
    provided. Produces git-diff-style output (``---``/``+++``/``@@``)
    for each component that changed.

    Args:
        original_components: Pre-evolution component values. If None,
            falls back to ``self.original_components``.

    Returns:
        Unified diff string, or ``"No changes detected."`` if all
        components are identical.

    Raises:
        ValueError: If both the argument and ``self.original_components``
            are None.
    """
    originals = (
        original_components
        if original_components is not None
        else self.original_components
    )
    if originals is None:
        raise ValueError(
            "No original components available. "
            "Pass original_components or use a result that stores them."
        )
    return _build_diff(self.evolved_components, originals)

SchemaConstraints dataclass

Constraints for output schema evolution.

Controls which fields must be preserved during schema evolution, including field existence and type constraints. Used by the OutputSchemaHandler to validate proposed schema mutations.

ATTRIBUTE DESCRIPTION
required_fields

Field names that must exist in evolved schemas. Mutations removing these fields are rejected.

TYPE: tuple[str, ...]

preserve_types

Mapping of field names to allowed type(s). Mutations changing a field's type to an incompatible type are rejected.

TYPE: dict[str, type | tuple[type, ...]]

Examples:

Preserve required fields only:

from gepa_adk.domain.types import SchemaConstraints

constraints = SchemaConstraints(
    required_fields=("score", "feedback"),
)

Preserve required fields with type constraints:

constraints = SchemaConstraints(
    required_fields=("score",),
    preserve_types={
        "score": (float, int),  # Allow numeric types
        "order_id": str,  # Must stay string
    },
)
Notes

A frozen dataclass ensures immutability during evolution runs. Configuration is validated at evolution start.

Source code in src/gepa_adk/domain/types.py
@dataclass(frozen=True, slots=True)
class SchemaConstraints:
    """Constraints for output schema evolution.

    Controls which fields must be preserved during schema evolution,
    including field existence and type constraints. Used by the
    OutputSchemaHandler to validate proposed schema mutations.

    Attributes:
        required_fields (tuple[str, ...]): Field names that must exist
            in evolved schemas. Mutations removing these fields are rejected.
        preserve_types (dict[str, type | tuple[type, ...]]): Mapping of
            field names to allowed type(s). Mutations changing a field's
            type to an incompatible type are rejected.

    Examples:
        Preserve required fields only:

        ```python
        from gepa_adk.domain.types import SchemaConstraints

        constraints = SchemaConstraints(
            required_fields=("score", "feedback"),
        )
        ```

        Preserve required fields with type constraints:

        ```python
        constraints = SchemaConstraints(
            required_fields=("score",),
            preserve_types={
                "score": (float, int),  # Allow numeric types
                "order_id": str,  # Must stay string
            },
        )
        ```

    Notes:
        A frozen dataclass ensures immutability during evolution runs.
        Configuration is validated at evolution start.
    """

    required_fields: tuple[str, ...] = ()
    preserve_types: dict[str, type | tuple[type, ...]] = field(default_factory=dict)

StopReason

Bases: str, Enum


              flowchart TD
              gepa_adk.StopReason[StopReason]

              

              click gepa_adk.StopReason href "" "gepa_adk.StopReason"
            

Why an evolution run terminated.

Each value represents a distinct termination condition. The engine sets the appropriate value when building the final EvolutionResult. Members are str values, so a serialized result stores them as plain strings.

The engine sets KEYBOARD_INTERRUPT or CANCELLED when a run is interrupted or cancelled after the baseline is scored. TIMEOUT is reserved and not set by the engine yet.

MAX_ITERATIONS means the iteration cap was reached. PATIENCE means early stopping fired because stagnation_counter reached patience.

ERROR marks the partial result the engine attaches to an EvolutionError in partial_result when it aborts a run after the baseline was scored.

Examples:

reason = StopReason.MAX_ITERATIONS
assert reason == "max_iterations"
Source code in src/gepa_adk/domain/types.py
class StopReason(str, Enum):  # noqa: UP042  # StrEnum would change str() of public members
    """Why an evolution run terminated.

    Each value represents a distinct termination condition. The engine sets the
    appropriate value when building the final ``EvolutionResult``. Members are
    ``str`` values, so a serialized result stores them as plain strings.

    The engine sets ``KEYBOARD_INTERRUPT`` or ``CANCELLED`` when a run is
    interrupted or cancelled after the baseline is scored. ``TIMEOUT`` is
    reserved and not set by the engine yet.

    ``MAX_ITERATIONS`` means the iteration cap was reached. ``PATIENCE`` means
    early stopping fired because ``stagnation_counter`` reached ``patience``.

    ``ERROR`` marks the partial result the engine attaches to an
    ``EvolutionError`` in ``partial_result`` when it aborts a run after the
    baseline was scored.

    Examples:
        ```python
        reason = StopReason.MAX_ITERATIONS
        assert reason == "max_iterations"
        ```
    """

    COMPLETED = "completed"
    MAX_ITERATIONS = "max_iterations"
    PATIENCE = "patience"
    STOPPER_TRIGGERED = "stopper_triggered"
    KEYBOARD_INTERRUPT = "keyboard_interrupt"
    TIMEOUT = "timeout"
    CANCELLED = "cancelled"
    ERROR = "error"

TokenRollup dataclass

Token usage summed over evaluated rows and reflection calls.

Each counter is the sum over the rows that reported usage. A row that reported none is counted in rows_unknown, never as zero tokens. The counters are None only when no row reported usage and at least one row was unknown; nothing evaluated gives zeros. A rollup built with split() also carries its evaluation and reflection parts, and its counters cover both; a rollup built by from_batch() is a plain rollup without splits.

ATTRIBUTE DESCRIPTION
input_tokens

Prompt tokens over the counted rows, or None when unknown.

TYPE: int | None

output_tokens

Generated tokens over the counted rows, or None when unknown.

TYPE: int | None

total_tokens

Total tokens over the counted rows, or None when unknown.

TYPE: int | None

rows_counted

Rows that reported usage.

TYPE: int

rows_unknown

Rows that reported no usage.

TYPE: int

evaluation

The evaluated rows' part of a split rollup, or None for a rollup without splits.

TYPE: TokenRollup | None

reflection

The reflection calls' part of a split rollup, one row per call, or None when no reflection call was observed.

TYPE: TokenRollup | None

Examples:

Summing an evaluation batch and serializing the result:

from gepa_adk.domain.models import TokenRollup

rollup = TokenRollup.from_batch(batch)
print(rollup.total_tokens, rollup.rows_unknown)
data = rollup.to_dict()  # None counters become "unknown"
assert TokenRollup.from_dict(data) == rollup
Notes

The evaluation part covers the rows the adapters evaluated with traces; rows evaluated without traces, such as a separate valset pass, are not observed. The reflection part counts each reflection call that returned; a call whose executor captured no events is unknown. The parts are rollups without splits of their own.

Source code in src/gepa_adk/domain/models.py
@dataclass(slots=True, frozen=True, kw_only=True)
class TokenRollup:
    """Token usage summed over evaluated rows and reflection calls.

    Each counter is the sum over the rows that reported usage. A row that
    reported none is counted in ``rows_unknown``, never as zero tokens. The
    counters are None only when no row reported usage and at least one row
    was unknown; nothing evaluated gives zeros. A rollup built with
    ``split()`` also carries its ``evaluation`` and ``reflection`` parts,
    and its counters cover both; a rollup built by ``from_batch()`` is a
    plain rollup without splits.

    Attributes:
        input_tokens (int | None): Prompt tokens over the counted rows, or
            None when unknown.
        output_tokens (int | None): Generated tokens over the counted rows,
            or None when unknown.
        total_tokens (int | None): Total tokens over the counted rows, or
            None when unknown.
        rows_counted (int): Rows that reported usage.
        rows_unknown (int): Rows that reported no usage.
        evaluation (TokenRollup | None): The evaluated rows' part of a split
            rollup, or None for a rollup without splits.
        reflection (TokenRollup | None): The reflection calls' part of a
            split rollup, one row per call, or None when no reflection call
            was observed.

    Examples:
        Summing an evaluation batch and serializing the result:

        ```python
        from gepa_adk.domain.models import TokenRollup

        rollup = TokenRollup.from_batch(batch)
        print(rollup.total_tokens, rollup.rows_unknown)
        data = rollup.to_dict()  # None counters become "unknown"
        assert TokenRollup.from_dict(data) == rollup
        ```

    Notes:
        The evaluation part covers the rows the adapters evaluated with
        traces; rows evaluated without traces, such as a separate valset
        pass, are not observed. The reflection part counts each reflection
        call that returned; a call whose executor captured no events is
        unknown. The parts are rollups without splits of their own.
    """

    input_tokens: int | None
    output_tokens: int | None
    total_tokens: int | None
    rows_counted: int
    rows_unknown: int
    evaluation: "TokenRollup | None" = None
    reflection: "TokenRollup | None" = None

    @classmethod
    def _build(
        cls,
        sums: tuple[int, int, int],
        rows_counted: int,
        rows_unknown: int,
    ) -> "TokenRollup":
        """Build a rollup, making counters unknown only under the unknown rule.

        Args:
            sums: Input, output and total token sums over counted rows.
            rows_counted: Rows that reported usage.
            rows_unknown: Rows that reported no usage.

        Returns:
            The rollup, with None counters when no row was counted and at
            least one row was unknown.
        """
        if rows_counted == 0 and rows_unknown > 0:
            return cls(
                input_tokens=None,
                output_tokens=None,
                total_tokens=None,
                rows_counted=0,
                rows_unknown=rows_unknown,
            )
        return cls(
            input_tokens=sums[0],
            output_tokens=sums[1],
            total_tokens=sums[2],
            rows_counted=rows_counted,
            rows_unknown=rows_unknown,
        )

    @classmethod
    def from_batch(cls, batch: Any) -> "TokenRollup":
        """Sum the token usage reported by each row of an evaluation batch.

        Reads ``batch.trajectories[i]`` for each row of ``batch.scores``. A
        trajectory whose ``token_usage`` or ``total_token_usage`` is a
        ``TokenUsage`` counts its three numbers; any other row (no
        trajectories, a trajectory without usage, a dict) is unknown.

        Args:
            batch: An ``EvaluationBatch`` or any object with ``scores`` and
                an optional ``trajectories`` sequence.

        Returns:
            The rollup of the batch. An empty batch gives zeros.
        """
        trajectories = getattr(batch, "trajectories", None) or []
        sums = [0, 0, 0]
        counted = 0
        unknown = 0
        for i in range(len(batch.scores)):
            usage = _row_usage(trajectories[i]) if i < len(trajectories) else None
            if usage is None:
                unknown += 1
                continue
            counted += 1
            sums[0] += usage.input_tokens
            sums[1] += usage.output_tokens
            sums[2] += usage.total_tokens
        return cls._build((sums[0], sums[1], sums[2]), counted, unknown)

    @classmethod
    def split(
        cls,
        *,
        evaluation: "TokenRollup | None",
        reflection: "TokenRollup | None",
    ) -> "TokenRollup":
        """Build a rollup from its evaluation and reflection parts.

        Args:
            evaluation: The evaluated rows' rollup, or None when absent.
            reflection: The reflection calls' rollup, or None when no
                reflection call was observed.

        Returns:
            A rollup whose counters combine both parts (a None part adds
            nothing; counters are None only when nothing was counted and
            something was unknown) and whose ``evaluation`` and
            ``reflection`` are the parts as given.

        Raises:
            ValueError: If a part has splits of its own; parts are rollups
                without splits, and a nested split would be flattened on
                save and load.
        """
        for name, part in (("evaluation", evaluation), ("reflection", reflection)):
            if part is not None and part._has_split():
                raise ValueError(
                    f"TokenRollup.split() {name} part must not have splits of its own"
                )
        total = cls._leaf_sum(evaluation, reflection)
        return cls(
            input_tokens=total.input_tokens,
            output_tokens=total.output_tokens,
            total_tokens=total.total_tokens,
            rows_counted=total.rows_counted,
            rows_unknown=total.rows_unknown,
            evaluation=evaluation,
            reflection=reflection,
        )

    @classmethod
    def _leaf_sum(
        cls, first: "TokenRollup | None", second: "TokenRollup | None"
    ) -> "TokenRollup":
        """Add the counters of two optional rollups, ignoring their splits.

        Args:
            first: A rollup, or None to add nothing.
            second: A rollup, or None to add nothing.

        Returns:
            A rollup without splits; zeros when both are None.
        """
        parts = [part for part in (first, second) if part is not None]
        sums = (
            sum(part.input_tokens or 0 for part in parts),
            sum(part.output_tokens or 0 for part in parts),
            sum(part.total_tokens or 0 for part in parts),
        )
        return cls._build(
            sums,
            sum(part.rows_counted for part in parts),
            sum(part.rows_unknown for part in parts),
        )

    def combine(self, other: "TokenRollup") -> "TokenRollup":
        """Add another rollup to this one.

        Counted rows and both row counts add up. A None counter on either
        side is treated as absent. When either side has splits, the result
        combines split-wise: a side without splits counts as evaluation,
        and a split absent on both sides stays None.

        Args:
            other: The rollup to add.

        Returns:
            A new rollup; its counters are None only when no row was counted
            and at least one row was unknown.
        """
        if not (self._has_split() or other._has_split()):
            return self._leaf_sum(self, other)
        return TokenRollup.split(
            evaluation=_combine_optional(
                self._evaluation_part(), other._evaluation_part()
            ),
            reflection=_combine_optional(self.reflection, other.reflection),
        )

    def _has_split(self) -> bool:
        """Tell whether this rollup carries an evaluation or reflection part.

        Returns:
            True when either split is set.
        """
        return self.evaluation is not None or self.reflection is not None

    def _evaluation_part(self) -> "TokenRollup | None":
        """Return the part of this rollup that counts as evaluation.

        Returns:
            The ``evaluation`` split of a split rollup, or this whole rollup
            when it has no splits.
        """
        if self._has_split():
            return self.evaluation
        return self

    def to_dict(self) -> dict[str, Any]:
        """Serialize this rollup to a stdlib-only dict.

        Returns:
            Dict with the five counters, where a None counter is written as
            ``"unknown"``, plus ``"evaluation"`` and ``"reflection"``, each
            the part's dict or None when absent. Output is directly
            ``json.dumps()``-compatible.
        """
        data = self._counters_dict()
        data["evaluation"] = _rollup_to_dict(self.evaluation)
        data["reflection"] = _rollup_to_dict(self.reflection)
        return data

    def _counters_dict(self) -> dict[str, Any]:
        """Serialize the five counters of this rollup.

        Returns:
            Dict with the five counters; a None counter is ``"unknown"``.
        """
        return {
            "input_tokens": (
                "unknown" if self.input_tokens is None else self.input_tokens
            ),
            "output_tokens": (
                "unknown" if self.output_tokens is None else self.output_tokens
            ),
            "total_tokens": (
                "unknown" if self.total_tokens is None else self.total_tokens
            ),
            "rows_counted": self.rows_counted,
            "rows_unknown": self.rows_unknown,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "TokenRollup":
        """Reconstruct a TokenRollup from a dict.

        Args:
            data: Dict produced by ``to_dict()``. A counter may be an int,
                None or ``"unknown"``. ``"evaluation"`` and ``"reflection"``
                are read when present; absent or None means no split. The
                parts load as rollups without splits of their own.

        Returns:
            Reconstructed TokenRollup; ``"unknown"`` and None load as None.

        Raises:
            KeyError: If a row count is missing from the dict or a part.
        """
        evaluation = data.get("evaluation")
        reflection = data.get("reflection")
        return cls(
            **cls._counters_from_dict(data),
            evaluation=(
                None
                if evaluation is None
                else cls(**cls._counters_from_dict(evaluation))
            ),
            reflection=(
                None
                if reflection is None
                else cls(**cls._counters_from_dict(reflection))
            ),
        )

    @staticmethod
    def _counters_from_dict(data: dict[str, Any]) -> dict[str, Any]:
        """Read the five counters of a serialized rollup.

        Args:
            data: A serialized rollup.

        Returns:
            Keyword arguments for the five counters.

        Raises:
            KeyError: If a row count is missing from the dict.
        """
        return {
            "input_tokens": _unknown_or_int(data.get("input_tokens")),
            "output_tokens": _unknown_or_int(data.get("output_tokens")),
            "total_tokens": _unknown_or_int(data.get("total_tokens")),
            "rows_counted": data["rows_counted"],
            "rows_unknown": data["rows_unknown"],
        }

from_batch classmethod

from_batch(batch: Any) -> TokenRollup

Sum the token usage reported by each row of an evaluation batch.

Reads batch.trajectories[i] for each row of batch.scores. A trajectory whose token_usage or total_token_usage is a TokenUsage counts its three numbers; any other row (no trajectories, a trajectory without usage, a dict) is unknown.

PARAMETER DESCRIPTION
batch

An EvaluationBatch or any object with scores and an optional trajectories sequence.

TYPE: Any

RETURNS DESCRIPTION
TokenRollup

The rollup of the batch. An empty batch gives zeros.

Source code in src/gepa_adk/domain/models.py
@classmethod
def from_batch(cls, batch: Any) -> "TokenRollup":
    """Sum the token usage reported by each row of an evaluation batch.

    Reads ``batch.trajectories[i]`` for each row of ``batch.scores``. A
    trajectory whose ``token_usage`` or ``total_token_usage`` is a
    ``TokenUsage`` counts its three numbers; any other row (no
    trajectories, a trajectory without usage, a dict) is unknown.

    Args:
        batch: An ``EvaluationBatch`` or any object with ``scores`` and
            an optional ``trajectories`` sequence.

    Returns:
        The rollup of the batch. An empty batch gives zeros.
    """
    trajectories = getattr(batch, "trajectories", None) or []
    sums = [0, 0, 0]
    counted = 0
    unknown = 0
    for i in range(len(batch.scores)):
        usage = _row_usage(trajectories[i]) if i < len(trajectories) else None
        if usage is None:
            unknown += 1
            continue
        counted += 1
        sums[0] += usage.input_tokens
        sums[1] += usage.output_tokens
        sums[2] += usage.total_tokens
    return cls._build((sums[0], sums[1], sums[2]), counted, unknown)

split classmethod

split(
    *,
    evaluation: TokenRollup | None,
    reflection: TokenRollup | None,
) -> TokenRollup

Build a rollup from its evaluation and reflection parts.

PARAMETER DESCRIPTION
evaluation

The evaluated rows' rollup, or None when absent.

TYPE: TokenRollup | None

reflection

The reflection calls' rollup, or None when no reflection call was observed.

TYPE: TokenRollup | None

RETURNS DESCRIPTION
TokenRollup

A rollup whose counters combine both parts (a None part adds

TokenRollup

nothing; counters are None only when nothing was counted and

TokenRollup

something was unknown) and whose evaluation and

TokenRollup

reflection are the parts as given.

RAISES DESCRIPTION
ValueError

If a part has splits of its own; parts are rollups without splits, and a nested split would be flattened on save and load.

Source code in src/gepa_adk/domain/models.py
@classmethod
def split(
    cls,
    *,
    evaluation: "TokenRollup | None",
    reflection: "TokenRollup | None",
) -> "TokenRollup":
    """Build a rollup from its evaluation and reflection parts.

    Args:
        evaluation: The evaluated rows' rollup, or None when absent.
        reflection: The reflection calls' rollup, or None when no
            reflection call was observed.

    Returns:
        A rollup whose counters combine both parts (a None part adds
        nothing; counters are None only when nothing was counted and
        something was unknown) and whose ``evaluation`` and
        ``reflection`` are the parts as given.

    Raises:
        ValueError: If a part has splits of its own; parts are rollups
            without splits, and a nested split would be flattened on
            save and load.
    """
    for name, part in (("evaluation", evaluation), ("reflection", reflection)):
        if part is not None and part._has_split():
            raise ValueError(
                f"TokenRollup.split() {name} part must not have splits of its own"
            )
    total = cls._leaf_sum(evaluation, reflection)
    return cls(
        input_tokens=total.input_tokens,
        output_tokens=total.output_tokens,
        total_tokens=total.total_tokens,
        rows_counted=total.rows_counted,
        rows_unknown=total.rows_unknown,
        evaluation=evaluation,
        reflection=reflection,
    )

combine

combine(other: TokenRollup) -> TokenRollup

Add another rollup to this one.

Counted rows and both row counts add up. A None counter on either side is treated as absent. When either side has splits, the result combines split-wise: a side without splits counts as evaluation, and a split absent on both sides stays None.

PARAMETER DESCRIPTION
other

The rollup to add.

TYPE: TokenRollup

RETURNS DESCRIPTION
TokenRollup

A new rollup; its counters are None only when no row was counted

TokenRollup

and at least one row was unknown.

Source code in src/gepa_adk/domain/models.py
def combine(self, other: "TokenRollup") -> "TokenRollup":
    """Add another rollup to this one.

    Counted rows and both row counts add up. A None counter on either
    side is treated as absent. When either side has splits, the result
    combines split-wise: a side without splits counts as evaluation,
    and a split absent on both sides stays None.

    Args:
        other: The rollup to add.

    Returns:
        A new rollup; its counters are None only when no row was counted
        and at least one row was unknown.
    """
    if not (self._has_split() or other._has_split()):
        return self._leaf_sum(self, other)
    return TokenRollup.split(
        evaluation=_combine_optional(
            self._evaluation_part(), other._evaluation_part()
        ),
        reflection=_combine_optional(self.reflection, other.reflection),
    )

to_dict

to_dict() -> dict[str, Any]

Serialize this rollup to a stdlib-only dict.

RETURNS DESCRIPTION
dict[str, Any]

Dict with the five counters, where a None counter is written as

dict[str, Any]

"unknown", plus "evaluation" and "reflection", each

dict[str, Any]

the part's dict or None when absent. Output is directly

dict[str, Any]

json.dumps()-compatible.

Source code in src/gepa_adk/domain/models.py
def to_dict(self) -> dict[str, Any]:
    """Serialize this rollup to a stdlib-only dict.

    Returns:
        Dict with the five counters, where a None counter is written as
        ``"unknown"``, plus ``"evaluation"`` and ``"reflection"``, each
        the part's dict or None when absent. Output is directly
        ``json.dumps()``-compatible.
    """
    data = self._counters_dict()
    data["evaluation"] = _rollup_to_dict(self.evaluation)
    data["reflection"] = _rollup_to_dict(self.reflection)
    return data

from_dict classmethod

from_dict(data: dict[str, Any]) -> TokenRollup

Reconstruct a TokenRollup from a dict.

PARAMETER DESCRIPTION
data

Dict produced by to_dict(). A counter may be an int, None or "unknown". "evaluation" and "reflection" are read when present; absent or None means no split. The parts load as rollups without splits of their own.

TYPE: dict[str, Any]

RETURNS DESCRIPTION
TokenRollup

Reconstructed TokenRollup; "unknown" and None load as None.

RAISES DESCRIPTION
KeyError

If a row count is missing from the dict or a part.

Source code in src/gepa_adk/domain/models.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "TokenRollup":
    """Reconstruct a TokenRollup from a dict.

    Args:
        data: Dict produced by ``to_dict()``. A counter may be an int,
            None or ``"unknown"``. ``"evaluation"`` and ``"reflection"``
            are read when present; absent or None means no split. The
            parts load as rollups without splits of their own.

    Returns:
        Reconstructed TokenRollup; ``"unknown"`` and None load as None.

    Raises:
        KeyError: If a row count is missing from the dict or a part.
    """
    evaluation = data.get("evaluation")
    reflection = data.get("reflection")
    return cls(
        **cls._counters_from_dict(data),
        evaluation=(
            None
            if evaluation is None
            else cls(**cls._counters_from_dict(evaluation))
        ),
        reflection=(
            None
            if reflection is None
            else cls(**cls._counters_from_dict(reflection))
        ),
    )

TrajectoryConfig dataclass

Configuration for trajectory extraction behavior.

Controls which components are extracted from ADK event streams, whether sensitive data should be redacted, and whether large values should be truncated.

Notes

All configuration fields are immutable after instantiation, ensuring consistent extraction behavior throughout evolution.

ATTRIBUTE DESCRIPTION
include_tool_calls

Extract tool/function call records. Defaults to True.

TYPE: bool

include_state_deltas

Extract session state changes. Defaults to True.

TYPE: bool

include_token_usage

Extract LLM token consumption metrics. Defaults to True.

TYPE: bool

redact_sensitive

Apply sensitive data redaction. When True, fields matching sensitive_keys will be replaced with "[REDACTED]". Defaults to True for secure-by-default behavior.

TYPE: bool

sensitive_keys

Field names to redact via exact match. Case-sensitive. Defaults to DEFAULT_SENSITIVE_KEYS.

TYPE: tuple[str, ...]

max_string_length

Truncate strings longer than this with a marker indicating truncation. None disables truncation. Defaults to 10000 characters.

TYPE: int | None

Examples:

Default configuration (secure, with truncation):

config = TrajectoryConfig()  # All features enabled, redaction ON

Minimal configuration (tool calls only):

config = TrajectoryConfig(
    include_tool_calls=True,
    include_state_deltas=False,
    include_token_usage=False,
    redact_sensitive=False,
)

Custom sensitive keys (extends defaults):

config = TrajectoryConfig(
    sensitive_keys=DEFAULT_SENSITIVE_KEYS + ("ssn",),
    max_string_length=5000,  # Truncate DOM/screenshots earlier
)

Disable truncation (keep full values):

config = TrajectoryConfig(max_string_length=None)
Notes

Redaction takes precedence over truncation. Sensitive values are replaced with "[REDACTED]" first, then truncation applies to the remaining (non-sensitive) strings.

Source code in src/gepa_adk/domain/types.py
@dataclass(frozen=True, slots=True)
class TrajectoryConfig:
    """Configuration for trajectory extraction behavior.

    Controls which components are extracted from ADK event streams,
    whether sensitive data should be redacted, and whether large
    values should be truncated.

    Notes:
        All configuration fields are immutable after instantiation,
        ensuring consistent extraction behavior throughout evolution.

    Attributes:
        include_tool_calls (bool): Extract tool/function call records.
            Defaults to True.
        include_state_deltas (bool): Extract session state changes.
            Defaults to True.
        include_token_usage (bool): Extract LLM token consumption metrics.
            Defaults to True.
        redact_sensitive (bool): Apply sensitive data redaction. When True,
            fields matching sensitive_keys will be replaced with "[REDACTED]".
            Defaults to True for secure-by-default behavior.
        sensitive_keys (tuple[str, ...]): Field names to redact via exact
            match. Case-sensitive. Defaults to ``DEFAULT_SENSITIVE_KEYS``.
        max_string_length (int | None): Truncate strings longer than this
            with a marker indicating truncation. None disables truncation.
            Defaults to 10000 characters.

    Examples:
        Default configuration (secure, with truncation):

        ```python
        config = TrajectoryConfig()  # All features enabled, redaction ON
        ```

        Minimal configuration (tool calls only):

        ```python
        config = TrajectoryConfig(
            include_tool_calls=True,
            include_state_deltas=False,
            include_token_usage=False,
            redact_sensitive=False,
        )
        ```

        Custom sensitive keys (extends defaults):

        ```python
        config = TrajectoryConfig(
            sensitive_keys=DEFAULT_SENSITIVE_KEYS + ("ssn",),
            max_string_length=5000,  # Truncate DOM/screenshots earlier
        )
        ```

        Disable truncation (keep full values):

        ```python
        config = TrajectoryConfig(max_string_length=None)
        ```

    Notes:
        Redaction takes precedence over truncation. Sensitive values are
        replaced with "[REDACTED]" first, then truncation applies to the
        remaining (non-sensitive) strings.
    """

    include_tool_calls: bool = True
    include_state_deltas: bool = True
    include_token_usage: bool = True
    redact_sensitive: bool = True
    sensitive_keys: tuple[str, ...] = DEFAULT_SENSITIVE_KEYS
    max_string_length: int | None = 10000

VideoValidationError

Bases: ConfigurationError


              flowchart TD
              gepa_adk.VideoValidationError[VideoValidationError]
              gepa_adk.domain.exceptions.ConfigurationError[ConfigurationError]
              gepa_adk.domain.exceptions.EvolutionError[EvolutionError]

                              gepa_adk.domain.exceptions.ConfigurationError --> gepa_adk.VideoValidationError
                                gepa_adk.domain.exceptions.EvolutionError --> gepa_adk.domain.exceptions.ConfigurationError
                



              click gepa_adk.VideoValidationError href "" "gepa_adk.VideoValidationError"
              click gepa_adk.domain.exceptions.ConfigurationError href "" "gepa_adk.domain.exceptions.ConfigurationError"
              click gepa_adk.domain.exceptions.EvolutionError href "" "gepa_adk.domain.exceptions.EvolutionError"
            

Raised when video file validation fails.

This exception is raised during video file processing when a video file does not exist, exceeds size limits, or has an invalid MIME type.

ATTRIBUTE DESCRIPTION
video_path

The path to the video file that failed validation.

TYPE: str

field

The configuration field name (default "video").

TYPE: str

constraint

Description of the validation constraint that was violated.

TYPE: str

Examples:

Raising a video validation error:

from gepa_adk.domain.exceptions import VideoValidationError

raise VideoValidationError(
    "Video file not found",
    video_path="/path/to/missing.mp4",
    constraint="file must exist",
)

Handling video validation errors:

from gepa_adk.domain.exceptions import VideoValidationError

try:
    await video_service.prepare_video_parts(["/bad/path.mp4"])
except VideoValidationError as e:
    print(f"Invalid video: {e.video_path}")
    print(f"Constraint violated: {e.constraint}")
Notes

Arises from video file validation failures during multimodal input processing. File existence, size limits (2GB), and MIME type (video/*) are validated before loading video content.

Source code in src/gepa_adk/domain/exceptions.py
class VideoValidationError(ConfigurationError):
    """Raised when video file validation fails.

    This exception is raised during video file processing when a video
    file does not exist, exceeds size limits, or has an invalid MIME type.

    Attributes:
        video_path (str): The path to the video file that failed validation.
        field (str): The configuration field name (default "video").
        constraint (str): Description of the validation constraint that was violated.

    Examples:
        Raising a video validation error:

        ```python
        from gepa_adk.domain.exceptions import VideoValidationError

        raise VideoValidationError(
            "Video file not found",
            video_path="/path/to/missing.mp4",
            constraint="file must exist",
        )
        ```

        Handling video validation errors:

        ```python
        from gepa_adk.domain.exceptions import VideoValidationError

        try:
            await video_service.prepare_video_parts(["/bad/path.mp4"])
        except VideoValidationError as e:
            print(f"Invalid video: {e.video_path}")
            print(f"Constraint violated: {e.constraint}")
        ```

    Notes:
        Arises from video file validation failures during multimodal input
        processing. File existence, size limits (2GB), and MIME type
        (video/*) are validated before loading video content.
    """

    def __init__(
        self,
        message: str,
        *,
        video_path: str,
        field: str = "video",
        constraint: str,
    ) -> None:
        """Initialize VideoValidationError with video file context.

        Args:
            message: Human-readable error description.
            video_path: The path to the video file that failed validation.
            field: Name of the configuration field (default "video").
            constraint: Description of the validation constraint violated.

        """
        super().__init__(message, field=field, value=video_path, constraint=constraint)
        self.video_path = video_path

    def __str__(self) -> str:
        """Return string representation with video path context.

        Returns:
            Formatted error message including video_path and constraint.

        Notes:
            Outputs formatted error message with video_path for easy
            identification of the problematic file in error logs.
        """
        base = Exception.__str__(self)
        return (
            f"{base} [video_path={self.video_path!r}, constraint={self.constraint!r}]"
        )

__init__

__init__(
    message: str,
    *,
    video_path: str,
    field: str = "video",
    constraint: str,
) -> None

Initialize VideoValidationError with video file context.

PARAMETER DESCRIPTION
message

Human-readable error description.

TYPE: str

video_path

The path to the video file that failed validation.

TYPE: str

field

Name of the configuration field (default "video").

TYPE: str DEFAULT: 'video'

constraint

Description of the validation constraint violated.

TYPE: str

Source code in src/gepa_adk/domain/exceptions.py
def __init__(
    self,
    message: str,
    *,
    video_path: str,
    field: str = "video",
    constraint: str,
) -> None:
    """Initialize VideoValidationError with video file context.

    Args:
        message: Human-readable error description.
        video_path: The path to the video file that failed validation.
        field: Name of the configuration field (default "video").
        constraint: Description of the validation constraint violated.

    """
    super().__init__(message, field=field, value=video_path, constraint=constraint)
    self.video_path = video_path

__str__

__str__() -> str

Return string representation with video path context.

RETURNS DESCRIPTION
str

Formatted error message including video_path and constraint.

Notes

Outputs formatted error message with video_path for easy identification of the problematic file in error logs.

Source code in src/gepa_adk/domain/exceptions.py
def __str__(self) -> str:
    """Return string representation with video path context.

    Returns:
        Formatted error message including video_path and constraint.

    Notes:
        Outputs formatted error message with video_path for easy
        identification of the problematic file in error logs.
    """
    base = Exception.__str__(self)
    return (
        f"{base} [video_path={self.video_path!r}, constraint={self.constraint!r}]"
    )

AsyncGEPAEngine

Bases: Generic[DataInst, Trajectory, RolloutOutput]


              flowchart TD
              gepa_adk.AsyncGEPAEngine[AsyncGEPAEngine]

              

              click gepa_adk.AsyncGEPAEngine href "" "gepa_adk.AsyncGEPAEngine"
            

Async evolution engine orchestrating the GEPA loop.

This engine executes the core evolution algorithm: 1. Evaluate baseline candidate 2. For each iteration until max_iterations or convergence: a. Generate reflective dataset from traces b. Propose new candidate text (an empty reflection after one retry, or a reflection that times out, records a skipped iteration and moves on) c. Evaluate proposal d. Accept if improves above threshold e. Record iteration 3. Return frozen EvolutionResult

The engine is generic over the adapter's DataInst, Trajectory and RolloutOutput types.

ATTRIBUTE DESCRIPTION
adapter

Implementation of AsyncGEPAAdapter protocol.

TYPE: AsyncGEPAAdapter

config

Evolution parameters.

TYPE: EvolutionConfig

Examples:

Basic usage:

from gepa_adk.engine import AsyncGEPAEngine
from gepa_adk.domain.models import EvolutionConfig, Candidate

engine = AsyncGEPAEngine(
    adapter=my_adapter,
    config=EvolutionConfig(max_iterations=50),
    initial_candidate=Candidate(components={"instruction": "Be helpful"}),
    batch=training_data,
)
result = await engine.run()
print(f"Final score: {result.final_score}")
Notes

Avoid reusing engine instances after run() completes; a repeated run() still starts fresh through _reset_run_state. When a seeded rng is provided, it is used for the auto-created merge proposer. The API layer passes the same rng to candidate selectors for full determinism across stochastic components. Evaluation, acceptance, reuse, Pareto and merge log events carry candidate_id (Candidate.id) as the per-candidate correlation key. Rows named in EvaluationBatch.failed_indices are counted per iteration and reported on the result, and so is the token usage the evaluated rows' trajectories report and the reflection calls the adapter's proposer reports (TokenRollup with evaluation and reflection splits). A proposal whose Candidate.id was already scored in the run is not evaluated again; its iteration is recorded with skip_reason="duplicate". A proposal whose output_schema text fails validation is not evaluated; its iteration is recorded with skip_reason="schema_validation_failed" and counts toward stagnation, so max_iterations, patience and stoppers still end the run. A retryable ReflectionError (the proposer already retried once) is recorded the same way with skip_reason="reflection_error"; a non-retryable one aborts the run with the partial result attached to the error, and so do config.max_consecutive_reflection_errors consecutive ones with the same cause type and text. An IncompleteProposalError (truncated reflection output) is recorded with skip_reason="incomplete_proposal" and not evaluated. A proposal that config.proposal_validator rejects is recorded with skip_reason="proposal_rejected" and the validator's reason, counts toward stagnation and is not evaluated. A merge candidate that is already scored or has an invalid schema is not evaluated, and the iteration that scheduled it is still recorded. An evaluation policy takes effect only through the Pareto state that a candidate selector creates. No policy and no selector is full evaluation; a selector alone is full evaluation over the Pareto state; a selector with a policy uses the policy; a policy without a selector raises ConfigurationError at construction. With config.reflection_minibatch_size smaller than the trainset, a proposal runs first on a fresh seeded sample of the trainset rows its parent has scores for, and goes on only when its mean there beats the parent's cached scores on the same rows; otherwise its iteration is recorded with skip_reason="minibatch_rejected". When the valset is the trainset, a passing proposal is evaluated on the full trainset and that batch is reused for scoring. When the valset is separate, no full trainset pass runs: the minibatch batch is the proposal's reflection batch, the valset is scored, and on acceptance that batch and its trainset rows become the candidate's cached batch, so trainset_score is the mean over those rows. With config.checkpoint_path set, state is checkpointed after the baseline and after every recorded iteration, and config.resume continues a run from that file (see _resume_from_checkpoint).

Source code in src/gepa_adk/engine/async_engine.py
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
class AsyncGEPAEngine(Generic[DataInst, Trajectory, RolloutOutput]):  # noqa: UP046  # TypeVars are exported names shared across ports and engine
    """Async evolution engine orchestrating the GEPA loop.

    This engine executes the core evolution algorithm:
    1. Evaluate baseline candidate
    2. For each iteration until max_iterations or convergence:
       a. Generate reflective dataset from traces
       b. Propose new candidate text (an empty reflection after one retry,
          or a reflection that times out, records a skipped iteration and
          moves on)
       c. Evaluate proposal
       d. Accept if improves above threshold
       e. Record iteration
    3. Return frozen EvolutionResult

    The engine is generic over the adapter's ``DataInst``, ``Trajectory``
    and ``RolloutOutput`` types.

    Attributes:
        adapter (AsyncGEPAAdapter): Implementation of AsyncGEPAAdapter protocol.
        config (EvolutionConfig): Evolution parameters.

    Examples:
        Basic usage:

        ```python
        from gepa_adk.engine import AsyncGEPAEngine
        from gepa_adk.domain.models import EvolutionConfig, Candidate

        engine = AsyncGEPAEngine(
            adapter=my_adapter,
            config=EvolutionConfig(max_iterations=50),
            initial_candidate=Candidate(components={"instruction": "Be helpful"}),
            batch=training_data,
        )
        result = await engine.run()
        print(f"Final score: {result.final_score}")
        ```

    Notes:
        Avoid reusing engine instances after run() completes; a repeated
        run() still starts fresh through ``_reset_run_state``.
        When a seeded ``rng`` is provided, it is used for the auto-created
        merge proposer. The API layer passes the same ``rng`` to candidate
        selectors for full determinism across stochastic components.
        Evaluation, acceptance, reuse, Pareto and merge log events carry
        ``candidate_id`` (``Candidate.id``) as the per-candidate correlation
        key. Rows named in ``EvaluationBatch.failed_indices`` are counted per
        iteration and reported on the result, and so is the token usage the
        evaluated rows' trajectories report and the reflection calls the
        adapter's proposer reports (``TokenRollup`` with ``evaluation`` and
        ``reflection`` splits). A proposal whose
        ``Candidate.id`` was already scored in the run is not evaluated
        again; its iteration is recorded with ``skip_reason="duplicate"``.
        A proposal whose ``output_schema`` text fails validation is not
        evaluated; its iteration is recorded with
        ``skip_reason="schema_validation_failed"`` and counts toward
        stagnation, so ``max_iterations``, ``patience`` and stoppers still
        end the run. A retryable ``ReflectionError`` (the proposer already
        retried once) is recorded the same way with
        ``skip_reason="reflection_error"``; a non-retryable one aborts the
        run with the partial result attached to the error, and so do
        ``config.max_consecutive_reflection_errors`` consecutive ones with
        the same cause type and text. An
        ``IncompleteProposalError`` (truncated reflection output) is recorded
        with ``skip_reason="incomplete_proposal"`` and not evaluated.
        A proposal that ``config.proposal_validator`` rejects is recorded
        with ``skip_reason="proposal_rejected"`` and the validator's reason,
        counts toward stagnation and is not evaluated.
        A merge candidate that is already scored or has an invalid schema is
        not evaluated, and the iteration that scheduled it is still recorded.
        An evaluation policy takes effect only through the Pareto state that
        a candidate selector creates. No policy and no selector is full
        evaluation; a selector alone is full evaluation over the Pareto
        state; a selector with a policy uses the policy; a policy without a
        selector raises ``ConfigurationError`` at construction.
        With ``config.reflection_minibatch_size`` smaller than the trainset,
        a proposal runs first on a fresh seeded sample of the trainset rows
        its parent has scores for, and goes on only when its mean there
        beats the parent's cached scores on the same rows; otherwise its
        iteration is recorded with ``skip_reason="minibatch_rejected"``.
        When the valset is the trainset, a passing proposal is evaluated on
        the full trainset and that batch is reused for scoring. When the
        valset is separate, no full trainset pass runs: the minibatch batch
        is the proposal's reflection batch, the valset is scored, and on
        acceptance that batch and its trainset rows become the candidate's
        cached batch, so ``trainset_score`` is the mean over those rows.
        With ``config.checkpoint_path`` set, state is checkpointed after the
        baseline and after every recorded iteration, and ``config.resume``
        continues a run from that file (see ``_resume_from_checkpoint``).
    """

    def __init__(  # noqa: PLR0913, PLR0915  # public constructor; decomposition tracked in #507
        self,
        adapter: AsyncGEPAAdapter[DataInst, Trajectory, RolloutOutput],
        config: EvolutionConfig,
        initial_candidate: Candidate,
        batch: list[DataInst],
        valset: list[DataInst] | None = None,
        candidate_selector: CandidateSelectorProtocol | None = None,
        component_selector: ComponentSelectorProtocol | None = None,
        evaluation_policy: EvaluationPolicyProtocol | None = None,
        merge_proposer: ProposerProtocol | None = None,
        rng: random.Random | None = None,
    ) -> None:
        """Initialize the evolution engine.

        Args:
            adapter (AsyncGEPAAdapter[DataInst, Trajectory, RolloutOutput]):
                Implementation of AsyncGEPAAdapter protocol for evaluation and proposal
                generation.
            config (EvolutionConfig): Evolution parameters controlling iterations, thresholds,
                and early stopping.
            initial_candidate (Candidate): Starting candidate with at least one component.
            batch (list[DataInst]): Trainset data instances for reflection and mutation.
            valset (list[DataInst] | None): Optional validation data for scoring
                candidates. Defaults to trainset when omitted. Must be non-empty if
                provided.
            candidate_selector (CandidateSelectorProtocol | None): Optional selector
                strategy for Pareto-aware candidate sampling. When provided, initializes
                ParetoState for multi-objective tracking.
            component_selector (ComponentSelectorProtocol | None): Optional selector
                strategy for choosing which components to update. Defaults to
                RoundRobinComponentSelector.
            evaluation_policy (EvaluationPolicyProtocol | None): Optional policy for
                selecting which validation examples to evaluate per iteration. Defaults
                to FullEvaluationPolicy. Requires ``candidate_selector``, because the
                policy acts only through the Pareto state the selector creates.
            merge_proposer (ProposerProtocol | None): Optional proposer for merge
                operations. If provided and config.use_merge is True, merge proposals
                will be attempted after successful mutations.
            rng (random.Random | None): Optional seeded random.Random instance for
                deterministic engine decisions. When provided, used for the auto-created
                merge proposer and for drawing reflection minibatch rows. None preserves
                current random behavior, and minibatch rows are then drawn from a
                ``random.Random(config.seed)``.

        Raises:
            ValueError: If batch is empty, valset is provided but empty,
                or initial_candidate has no components.
            ConfigurationError: If evaluation_policy is provided without a
                candidate_selector, including an explicit FullEvaluationPolicy,
                or if ``config.checkpoint_path`` is set together with a
                candidate_selector (Pareto state is not checkpointed).

        Examples:
            Creating an engine:

            ```python
            engine = AsyncGEPAEngine(
                adapter=my_adapter,
                config=EvolutionConfig(max_iterations=50),
                initial_candidate=Candidate(components={"instruction": "Be helpful"}),
                batch=training_data,
                candidate_selector=selector,
            )
            ```

        Notes:
            Configures trainset and valset routing for reflection and scoring.
            A valset that is the same object as the batch (or omitted) marks
            the engine to reuse reflection batches for scoring.
            Initializes stopper lifecycle tracking for custom stop callbacks.
            Initializes the evaluation and pending failure counters, the
            pending and run token rollups, the pending and run reflection
            rollups (None until the proposer reports one), the last proposer
            rollup consumed (so an unchanged one is not recounted), the map
            from each scored
            candidate's id to its acceptance score, the random source for
            reflection minibatch rows, the slots holding the last mutation
            parent's trainset batch and the trainset rows it covers, the
            slots for the batch and rows the last minibatch gate evaluated,
            and the per-candidate map of cached batch rows (None for the
            full trainset).
            Initializes an in-memory streak of identical consecutive
            reflection errors (the cause's type name and text, the count, and
            the history index of the latest one), which ``run()`` resets and
            ``_record_reflection_error`` uses to enforce
            ``config.max_consecutive_reflection_errors``.
            Valid selector and policy combinations: neither is full
            evaluation; a selector alone is full evaluation over the Pareto
            state; a selector with a policy uses that policy; a policy alone
            is rejected. A checkpoint path with a candidate selector is
            rejected too, and the restored-state flag starts False.
        """
        # Validation
        if len(batch) == 0:
            raise ValueError("batch must contain at least one data instance")
        if valset is not None and len(valset) == 0:
            raise ValueError(
                "valset must contain at least one validation data instance"
            )

        if not initial_candidate.components:
            raise ValueError("initial_candidate must have at least one component")

        if evaluation_policy is not None and candidate_selector is None:
            raise ConfigurationError(
                "evaluation_policy requires a candidate_selector: a policy "
                "takes effect only through the Pareto state a candidate "
                "selector creates",
                field="evaluation_policy",
                value=type(evaluation_policy).__name__,
                constraint="candidate_selector is not None",
            )

        if config.checkpoint_path is not None and candidate_selector is not None:
            raise ConfigurationError(
                "checkpoint_path cannot be combined with a candidate_selector: "
                "Pareto state is not checkpointed yet",
                field="candidate_selector",
                value=type(candidate_selector).__name__,
                constraint="candidate_selector is None when checkpoint_path is set",
            )

        # Store dependencies
        self.adapter = adapter
        self.config = config
        self._initial_candidate = initial_candidate
        self._trainset = batch
        self._valset = valset if valset is not None else batch
        # Identity, not equality: an equal-but-separate valset is scored apart.
        self._valset_is_trainset = self._valset is self._trainset
        self._state: _EngineState | None = None
        self._rng = rng
        # Draws each iteration's reflection minibatch rows
        self._minibatch_rng = rng if rng is not None else random.Random(config.seed)  # noqa: S311  # evolution sampling, not security
        # Trainset batch of the parent the last mutation reflected on, and
        # the trainset rows it covers (None: the full trainset in order)
        self._mutation_parent_batch: EvaluationBatch | None = None
        self._mutation_parent_rows: list[int] | None = None
        # The batch and rows the last minibatch gate evaluated the proposal on
        self._gate_batch: EvaluationBatch | None = None
        self._gate_rows: list[int] | None = None
        self._candidate_selector = candidate_selector
        self._component_selector = component_selector or RoundRobinComponentSelector()
        self._pareto_state: ParetoState | None = None
        self._candidate_eval_batches: dict[int, EvaluationBatch] = {}
        # Trainset rows each cached batch covers (None: the full trainset)
        self._candidate_eval_rows: dict[int, list[int] | None] = {}
        # Acceptance score of every candidate scored this run, keyed by id
        self._scored: dict[str, float] = {}
        if merge_proposer is not None:
            self._merge_proposer = merge_proposer
        elif config.use_merge:
            self._merge_proposer = MergeProposer(rng=rng or random.Random())  # noqa: S311  # evolution sampling, not security
        else:
            self._merge_proposer = None
        self._merges_due: int = 0
        self._merge_invocations: int = 0
        # Stopper state tracking (T001, T002)
        self._start_time: float | None = None
        self._total_evaluations: int = 0
        # Failed rows evaluated since the last record (see _count_batch)
        self._pending_failed_evaluations: int = 0
        # Token usage since the last record and over the run (see _count_batch)
        self._pending_token_usage: TokenRollup = _ZERO_TOKENS
        self._run_token_usage: TokenRollup = _ZERO_TOKENS
        # Reflection usage of this iteration and of the run; None = not observed
        self._pending_reflection_usage: TokenRollup | None = None
        self._run_reflection_usage: TokenRollup | None = None
        # Last proposer rollup consumed, so an unchanged one is not recounted
        self._consumed_proposer_usage: TokenRollup | None = None
        self._active_stoppers: list[object] = []
        # Run of identical consecutive reflection errors (in memory only):
        # the cause's (type name, text), how many in a row, and the history
        # index of the latest one
        self._reflection_error_signature: tuple[str, str] | None = None
        self._reflection_error_streak: int = 0
        self._reflection_error_index: int = -1
        # True once run() restored state from a checkpoint (skips the baseline)
        self._restored: bool = False
        if evaluation_policy is None:
            self._evaluation_policy: EvaluationPolicyProtocol = FullEvaluationPolicy()
        else:
            self._evaluation_policy = evaluation_policy

    def _take_pending_failed_evaluations(self) -> int:
        """Return the pending failure count and reset it to zero.

        Returns:
            Failed rows counted by ``_count_batch`` since the last call.
        """
        count = self._pending_failed_evaluations
        self._pending_failed_evaluations = 0
        return count

    def _take_pending_token_usage(self) -> TokenRollup:
        """Return the pending token rollup and reset it to zero.

        Returns:
            Token usage folded in by ``_count_batch`` since the last call.
        """
        usage = self._pending_token_usage
        self._pending_token_usage = _ZERO_TOKENS
        return usage

    def _take_proposer_usage(self) -> None:
        """Store the reflection usage the adapter's proposer reported.

        Reads ``last_token_usage`` from the adapter's ``_proposer`` into
        ``_pending_reflection_usage``, so the next record carries it. An
        adapter without a proposer, or a proposer without the attribute,
        leaves it None (not observed). The proposer builds a new rollup
        object on every ``propose()`` and every reflection call, so a rollup
        that is the same object (``is``) as the last one consumed means
        ``propose()`` did not run this iteration; it is treated as not
        observed, and the previous iteration's reflection is not counted
        again. A rollup with no counted and no unknown row means
        ``propose()`` ran but made no reflection call (an empty reflective
        dataset), which is not observed reflection either.
        """
        usage = getattr(
            getattr(self.adapter, "_proposer", None), "last_token_usage", None
        )
        if not isinstance(usage, TokenRollup) or usage is self._consumed_proposer_usage:
            self._pending_reflection_usage = None
            return
        if usage.rows_counted == 0 and usage.rows_unknown == 0:
            self._consumed_proposer_usage = usage
            self._pending_reflection_usage = None
            return
        self._consumed_proposer_usage = usage
        self._pending_reflection_usage = usage

    def _take_record_token_usage(self) -> TokenRollup:
        """Build the next record's token rollup and reset the pending usage.

        Returns:
            A split rollup whose ``evaluation`` is the pending evaluation
            rollup and whose ``reflection`` is the pending reflection usage,
            or None when none was observed. The reflection usage is also
            folded into the run's reflection rollup.
        """
        evaluation = self._take_pending_token_usage()
        reflection = self._pending_reflection_usage
        self._pending_reflection_usage = None
        if reflection is not None:
            self._run_reflection_usage = (
                reflection
                if self._run_reflection_usage is None
                else self._run_reflection_usage.combine(reflection)
            )
        return TokenRollup.split(evaluation=evaluation, reflection=reflection)

    def _run_token_rollup(self) -> TokenRollup:
        """Return the run's token rollup with its evaluation and reflection splits.

        Returns:
            ``TokenRollup.split`` of the run's evaluation rollup and its
            reflection rollup, which is None when no reflection usage was
            observed.
        """
        return TokenRollup.split(
            evaluation=self._run_token_usage, reflection=self._run_reflection_usage
        )

    def _aggregate_acceptance_score(self, scores: list[float]) -> float:
        """Aggregate scores for acceptance decisions based on acceptance_metric.

        Args:
            scores: List of per-example scores from evaluation batch.

        Returns:
            Aggregated acceptance score (sum or mean based on config).

        Raises:
            InvalidScoreListError: If scores list is empty or contains
                non-finite values.

        Notes:
            Sums or averages acceptance scores after validating they are
            non-empty and finite. Uses sum or mean based on config.acceptance_metric.
        """
        # Validate scores are non-empty
        if not scores:
            raise InvalidScoreListError(
                "Cannot aggregate acceptance score from empty score list",
                scores=scores,
                reason="empty",
            )

        # Validate scores are finite
        if not all(math.isfinite(score) for score in scores):
            raise InvalidScoreListError(
                "Cannot aggregate acceptance score from non-finite values (NaN/inf)",
                scores=scores,
                reason="non-finite",
            )

        # Aggregate based on acceptance_metric
        if self.config.acceptance_metric == "sum":
            return sum(scores)
        else:  # acceptance_metric == "mean"
            return sum(scores) / len(scores)

    def _setup_stoppers(self) -> list[object]:
        """Call setup() on stoppers that have lifecycle methods.

        Returns:
            List of stoppers that had setup() called (for cleanup in reverse order).

        Notes:
            Only invokes setup() on stoppers implementing the lifecycle method.
            Stoppers that fail setup() are excluded from stop_callbacks for the
            remainder of execution to prevent inconsistent state.
        """
        setup_stoppers: list[object] = []
        active_stoppers: list[object] = []
        stop_callbacks = self.config.stop_callbacks
        if stop_callbacks:
            for stopper in stop_callbacks:
                setup_method = getattr(stopper, "setup", None)
                if setup_method is not None and callable(setup_method):
                    try:
                        setup_method()
                        setup_stoppers.append(stopper)
                        active_stoppers.append(stopper)
                    except Exception:
                        logger.exception(
                            "stopper.setup_error",
                            stopper=type(stopper).__name__,
                        )
                        # Stopper excluded from active list due to setup failure
                else:
                    # Stopper has no setup() method, still active
                    active_stoppers.append(stopper)
        # Store active stoppers for _should_stop() to use
        self._active_stoppers = active_stoppers
        return setup_stoppers

    def _cleanup_stoppers(self, setup_stoppers: list[object]) -> None:
        """Call cleanup() on stoppers in reverse order of setup.

        Args:
            setup_stoppers: List of stoppers that had setup() called.

        Notes:
            Follows the reverse-order cleanup contract (T025).
            When cleanup() raises, logs the error and continues (T026).
        """
        for stopper in reversed(setup_stoppers):
            cleanup_method = getattr(stopper, "cleanup", None)
            if cleanup_method is not None and callable(cleanup_method):
                try:
                    cleanup_method()
                except Exception:
                    logger.exception(
                        "stopper.cleanup_error",
                        stopper=type(stopper).__name__,
                    )

    def _build_stopper_state(self) -> StopperState:
        """Build a StopperState snapshot from current engine state.

        Constructs an immutable snapshot of evolution state for stopper
        callbacks to evaluate. Captures all metrics needed by stoppers
        including elapsed time, total evaluations and the latest score.

        Returns:
            Frozen StopperState containing current iteration, best score,
            stagnation counter, total evaluations, candidates count, elapsed
            time, and the latest iteration's score.

        Notes:
            Obtains elapsed_seconds from monotonic time since run() started.
            Uses zero if _start_time has not yet been set. Takes latest_score
            from the last ``iteration_history`` record, accepted or not, and
            leaves it ``None`` while the history is empty. Must run after
            run() created the engine state, which the method asserts.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        start, history = self._start_time, self._state.iteration_history
        elapsed = time.monotonic() - start if start is not None else 0.0
        candidates_count = (
            len(self._pareto_state.candidates) if self._pareto_state is not None else 0
        )
        return StopperState(
            iteration=self._state.iteration,
            best_score=self._state.best_score,
            stagnation_counter=self._state.stagnation_counter,
            total_evaluations=self._total_evaluations,
            candidates_count=candidates_count,
            elapsed_seconds=elapsed,
            latest_score=history[-1].score if history else None,
        )

    @property
    def pareto_state(self) -> ParetoState | None:
        """Return the current Pareto state, if initialized."""
        return self._pareto_state

    def _build_component_list(self, candidate: Candidate) -> list[str]:
        """Build list of available component keys from candidate.

        Excludes generic 'instruction' alias if agent-specific keys exist
        (e.g., 'agent1_instruction').

        Args:
            candidate: Candidate to extract component keys from.

        Returns:
            List of component keys to consider for update.

        Notes:
            Selects component keys, filtering out the default component name when
            more specific per-agent component keys are present.
        """
        keys = list(candidate.components.keys())
        if len(keys) > 1 and DEFAULT_COMPONENT_NAME in keys:
            # If multiple keys exist, assume default component might be an alias/proxy
            # or simply one of many.
            # For now, simplistic rule: if other keys exist, exclude default.
            return [k for k in keys if k != DEFAULT_COMPONENT_NAME]
        return keys

    def _count_batch(
        self, candidate: Candidate, batch: EvaluationBatch, phase: str
    ) -> None:
        """Count one adapter evaluation and log it under the candidate's id.

        Adds the batch's rows to the evaluation counter that stoppers read
        and its ``failed_indices`` to the failures pending for the next
        record (the baseline or the current iteration), folds the batch's
        ``TokenRollup`` into the pending and the run token rollups, then logs
        ``evaluation.completed`` with the candidate's id, the phase, the row
        count and that same failure count.

        Args:
            candidate: Candidate whose components were evaluated.
            batch: Evaluation batch the adapter just returned.
            phase: ``"reflection"`` for a trainset evaluation with traces,
                ``"scoring"`` for a valset evaluation.

        Notes:
            Called once per adapter ``evaluate()`` call, so a reused batch
            is neither counted nor logged, because no row was evaluated
            again. An adapter that leaves ``failed_indices`` as None reports
            zero failures.
        """
        failed = len(batch.failed_indices or [])
        self._total_evaluations += len(batch.scores)
        self._pending_failed_evaluations += failed
        usage = TokenRollup.from_batch(batch)
        self._pending_token_usage = self._pending_token_usage.combine(usage)
        self._run_token_usage = self._run_token_usage.combine(usage)
        logger.debug(
            "evaluation.completed",
            candidate_id=candidate.id,
            phase=phase,
            n=len(batch.scores),
            failed=failed,
            iteration=self._state.iteration if self._state is not None else 0,
        )

    async def _initialize_baseline(self) -> None:
        """Initialize baseline evaluation.

        Evaluates the initial candidate on trainset for reflection and
        on valset for scoring. Caches the reflection batch for use in
        the first mutation proposal. When the valset is the trainset, the
        reflection batch also serves scoring, so the baseline is evaluated
        once.

        Notes:
            Sets up both reflection and scoring baselines up front. With a
            candidate selector and objective scores, the baseline's Pareto
            entry gets per-objective means (``statistics.fmean``) for the
            OBJECTIVE, HYBRID and CARTESIAN frontiers, and per-example
            objective scores as well for CARTESIAN. The
            baseline evaluation is counted and logged via ``_count_batch``.
            Rows that failed during these evaluations are stored on the state
            as ``baseline_failed_evaluations``. The baseline's token usage
            stays in the run rollup and is cleared from the pending one, so
            the first iteration's record does not include it. The baseline's
            cached batch covers the full trainset, so its rows are None on
            the state and in the Pareto row map. Ends by writing
            a checkpoint through ``_write_checkpoint`` when
            ``config.checkpoint_path`` is set.
        """
        # Create pareto_state before evaluation if candidate_selector exists
        # so that _evaluate_scoring can use evaluation_policy
        if self._candidate_selector is not None:
            self._pareto_state = ParetoState(frontier_type=self.config.frontier_type)

        reflection_batch = await self.adapter.evaluate(
            self._trainset,
            self._initial_candidate.components,
            capture_traces=True,
        )
        self._count_batch(self._initial_candidate, reflection_batch, "reflection")
        # Use _evaluate_scoring for baseline to get eval_indices
        (
            baseline_score,
            scoring_batch,
            baseline_eval_indices,
        ) = await self._evaluate_scoring(
            self._initial_candidate, reflection_batch=reflection_batch
        )
        baseline_reflection_score = sum(reflection_batch.scores) / len(
            reflection_batch.scores
        )
        baseline_valset_mean = (
            sum(scoring_batch.scores) / len(scoring_batch.scores)
            if scoring_batch.scores
            else 0.0
        )
        self._state = _EngineState(
            best_candidate=self._initial_candidate,
            best_score=baseline_score,
            original_score=baseline_score,
            iteration=0,
            stagnation_counter=0,
            iteration_history=[],
            last_eval_batch=reflection_batch,
            best_reflection_score=baseline_reflection_score,
            best_valset_mean=baseline_valset_mean,
            best_objective_scores=scoring_batch.objective_scores,
            baseline_failed_evaluations=self._take_pending_failed_evaluations(),
        )
        self._take_pending_token_usage()
        if self._candidate_selector is not None:
            # Prepare objective scores for baseline if needed
            objective_scores: dict[str, float] | None = None
            per_example_objective_scores: dict[int, dict[str, float]] | None = None

            if scoring_batch.objective_scores is not None:
                if self.config.frontier_type in (
                    FrontierType.OBJECTIVE,
                    FrontierType.HYBRID,
                ):
                    objective_scores_by_name: dict[str, list[float]] = {}
                    for obj_scores in scoring_batch.objective_scores:
                        for obj_name, obj_score in obj_scores.items():
                            objective_scores_by_name.setdefault(obj_name, []).append(
                                obj_score
                            )
                    objective_scores = {
                        obj_name: fmean(scores)
                        for obj_name, scores in objective_scores_by_name.items()
                    }

                if self.config.frontier_type == FrontierType.CARTESIAN:
                    per_example_objective_scores = {
                        baseline_eval_indices[i]: scoring_batch.objective_scores[i]
                        for i in range(len(baseline_eval_indices))
                    }
                    objective_scores_by_name: dict[str, list[float]] = {}
                    for obj_scores in scoring_batch.objective_scores:
                        for obj_name, obj_score in obj_scores.items():
                            objective_scores_by_name.setdefault(obj_name, []).append(
                                obj_score
                            )
                    objective_scores = {
                        obj_name: fmean(scores)
                        for obj_name, scores in objective_scores_by_name.items()
                    }

            assert self._pareto_state is not None, "Pareto state not initialized"  # noqa: S101  # narrows state that run() sets before this call
            candidate_idx = self._pareto_state.add_candidate(
                self._initial_candidate,
                scoring_batch.scores,
                score_indices=baseline_eval_indices,
                objective_scores=objective_scores,
                per_example_objective_scores=per_example_objective_scores,
                logger=logger,
            )
            self._candidate_eval_batches[candidate_idx] = reflection_batch
            self._candidate_eval_rows[candidate_idx] = None
        self._write_checkpoint()

    async def _evaluate_reflection(
        self, candidate: Candidate
    ) -> tuple[float, EvaluationBatch]:
        """Evaluate a candidate on the trainset for reflection.

        Args:
            candidate: Candidate to evaluate.

        Returns:
            Tuple of (mean score across trainset examples, evaluation batch).

        Notes:
            Supplies trajectories for reflective dataset construction.
            The evaluation is counted and logged via ``_count_batch``.
        """
        eval_batch = await self.adapter.evaluate(
            self._trainset,
            candidate.components,
            capture_traces=True,
        )
        self._count_batch(candidate, eval_batch, "reflection")
        score = sum(eval_batch.scores) / len(eval_batch.scores)
        return score, eval_batch

    async def _evaluate_scoring(
        self,
        candidate: Candidate,
        reflection_batch: EvaluationBatch | None = None,
    ) -> tuple[float, EvaluationBatch, list[int]]:
        """Evaluate a candidate on the valset for scoring decisions.

        Args:
            candidate: Candidate to evaluate on the validation set.
            reflection_batch: The candidate's trainset reflection batch. When
                the valset is the trainset, scoring reuses this batch (or its
                entries at the selected indices) instead of calling the
                adapter again.

        Returns:
            Tuple of (aggregated acceptance score, evaluation batch, eval_indices).
            Score is aggregated using acceptance_metric (sum or mean).
            eval_indices are the valset indices that were actually evaluated.

        Notes:
            Supplies scores without traces for acceptance decisions.
            Aggregation method (sum/mean) is determined by config.acceptance_metric.
            Uses evaluation_policy to determine which examples to evaluate.
            Only the canonical ordered index list counts as a full evaluation;
            any other selection, including a permutation, is built row by row
            in the policy's order.
            A reused batch adds nothing to the evaluation or failure
            counters, because no example is evaluated again; its reuse log
            names the ``candidate_id``. The acceptance score is stored under
            the candidate's id so a later duplicate proposal is not
            evaluated again.
        """
        # Get indices to evaluate from evaluation policy
        valset_ids = list(range(len(self._valset)))
        if self._pareto_state is not None:
            eval_indices = self._evaluation_policy.get_eval_batch(
                valset_ids, self._pareto_state
            )
        else:
            # Fallback to all indices if no pareto state yet
            eval_indices = valset_ids

        # Filter valset to only include selected indices
        # Only the canonical ordered list counts as a full evaluation, so a
        # policy that permutes the indices gets a batch built in its order.
        is_full_eval = list(eval_indices) == valset_ids

        if reflection_batch is not None and self._valset_is_trainset:
            logger.debug(
                "evaluation.reuse_trainset_batch",
                candidate_id=candidate.id,
                components=sorted(candidate.components),
                reason="valset_is_trainset",
                full=is_full_eval,
                n=len(eval_indices),
            )
            eval_batch = (
                reflection_batch
                if is_full_eval
                else _select_batch_rows(reflection_batch, eval_indices)
            )
        else:
            eval_valset = (
                self._valset
                if is_full_eval
                else [self._valset[i] for i in eval_indices]
            )
            eval_batch = await self.adapter.evaluate(
                eval_valset,
                candidate.components,
                capture_traces=False,
            )
            self._count_batch(candidate, eval_batch, "scoring")
        score = self._aggregate_acceptance_score(eval_batch.scores)
        self._scored[candidate.id] = score
        return score, eval_batch, eval_indices

    async def _propose_mutation(
        self,
    ) -> tuple[Candidate, list[str], int | None]:
        """Propose a new candidate via reflective mutation.

        Uses the cached evaluation batch from the most recent best candidate
        evaluation to generate the reflective dataset, avoiding redundant
        adapter calls.

        Returns:
            Tuple of (new candidate with proposed component updates,
            list of component names that were updated, Pareto index of the
            parent the reflector rewrote). The index is the selector's draw;
            without a selector (or when the selector found no candidate) it
            is the index of the current best candidate in the Pareto state,
            searched from the end, and None when there is no Pareto state or
            the parent is absent from it.

        Raises:
            ConfigurationError: If the candidate selector returns an index
                with no cached reflection batch, such as a negative index.
                An empty frontier (``NoCandidateAvailableError``) is not an
                error: the most recent cached batch is used instead.

        Notes:
            Spawns a new candidate with updated components based on reflective
            dataset analysis and component selector strategy. The selected
            parent is logged with its ``candidate_id``. Every candidate's
            reflection batch is cached when it is added to the Pareto state,
            so the selected parent is never re-evaluated. The parent's
            trainset batch it reflected on, and the trainset rows that batch
            covers (None for the full trainset), are kept on the engine as
            ``_mutation_parent_batch`` and ``_mutation_parent_rows`` for the
            reflection minibatch gate. The reflective dataset is built from
            whatever batch the parent has, which is its sampled minibatch
            rows when it was accepted with a separate valset. The parent is drawn
            once per iteration: the proposal carries ``parent_id`` set to the
            parent's ``Candidate.id`` and ``generation`` set to the parent's
            plus one, and the returned index is the one the loop passes to
            ``ParetoState.add_candidate`` as ``parent_indices``.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        assert self._state.last_eval_batch is not None, "No eval batch cached"  # noqa: S101  # narrows state that run() sets before this call

        selected_candidate = self._state.best_candidate
        selected_idx: int | None = None
        eval_batch = self._state.last_eval_batch
        eval_rows = self._state.last_eval_rows

        if self._candidate_selector is not None and self._pareto_state is not None:
            try:
                selected_idx = await self._candidate_selector.select_candidate(
                    self._pareto_state
                )
                selected_candidate = self._pareto_state.candidates[selected_idx]
                eval_batch = self._candidate_eval_batches.get(selected_idx)
                eval_rows = self._candidate_eval_rows.get(selected_idx)
                logger.info(
                    "pareto_selection.mutation_parent_selected",
                    candidate_idx=selected_idx,
                    candidate_id=selected_candidate.id,
                    iteration=self._state.iteration,
                    selector_type=type(self._candidate_selector).__name__,
                )
            except NoCandidateAvailableError as exc:
                logger.info(
                    "pareto_selection.empty_frontier_fallback",
                    iteration=self._state.iteration,
                    selector_type=type(self._candidate_selector).__name__,
                    error=str(exc),
                )
                eval_batch = self._state.last_eval_batch
                eval_rows = self._state.last_eval_rows

        if eval_batch is None:
            # Every added candidate is cached at once; a miss is a bad index
            raise ConfigurationError(
                f"candidate selector returned index {selected_idx}, which has "
                "no cached reflection batch",
                field="candidate_selector",
                value=selected_idx,
                constraint="0 <= index < len(pareto_state.candidates)",
            )
        # The minibatch gate compares the proposal with this parent batch
        self._mutation_parent_batch = eval_batch
        self._mutation_parent_rows = eval_rows

        # Build component list
        available_components = self._build_component_list(selected_candidate)

        # Select components to update
        components_to_update = await self._component_selector.select_components(
            components=available_components,
            iteration=self._state.iteration,
            candidate_idx=selected_idx if selected_idx is not None else 0,
        )

        logger.info(
            "mutation.components_selected",
            iteration=self._state.iteration,
            components=components_to_update,
            selector=type(self._component_selector).__name__,
        )

        # Build reflective dataset
        reflective_dataset = await self.adapter.make_reflective_dataset(
            selected_candidate.components,
            eval_batch,
            components_to_update,
        )

        # Propose new texts
        proposed_components = await self.adapter.propose_new_texts(
            selected_candidate.components,
            reflective_dataset,
            components_to_update,
        )

        # The parent's Pareto index, for the genealogy edge
        parent_idx = selected_idx
        if parent_idx is None and self._pareto_state is not None:
            parent_idx = next(
                (
                    idx
                    for idx in range(len(self._pareto_state.candidates) - 1, -1, -1)
                    if self._pareto_state.candidates[idx].id == selected_candidate.id
                ),
                None,
            )

        # Create new candidate with proposed components and its lineage
        new_components = dict(selected_candidate.components)
        new_components.update(proposed_components)
        return (
            Candidate(
                components=new_components,
                generation=selected_candidate.generation + 1,
                parent_id=selected_candidate.id,
            ),
            components_to_update,
            parent_idx,
        )

    async def _record_iteration(  # noqa: PLR0913  # one argument per recorded field; private helper
        self,
        score: float,
        component_text: str,
        evolved_component: str,
        accepted: bool,
        objective_scores: list[dict[str, float]] | None = None,
        reflection_reasoning: str | None = None,
        skip_reason: str | None = None,
        candidate_id: str | None = None,
        parent_ids: list[str] | None = None,
        rejection_reason: str | None = None,
        *,
        rows_evaluated: int | None = None,
    ) -> None:
        """Record iteration outcome and notify the ``on_iteration`` callback.

        Args:
            score (float): Score achieved in this iteration.
            component_text (str): The text of the component that was evaluated.
            evolved_component (str): The name of the component that was evolved
                (e.g., "instruction", "output_schema").
            accepted (bool): Whether proposal was accepted.
            objective_scores (list[dict[str, float]] | None): Optional objective scores
                from this iteration's evaluation. None when adapter does not provide
                objective scores.
            reflection_reasoning (str | None): Optional natural language reasoning from
                the reflection agent explaining the mutation. None when
                reasoning is not available.
            skip_reason (str | None): Why the iteration produced no evaluated proposal
                (e.g., ``"empty_proposal"``). None for ordinary iterations.
            candidate_id (str | None): Id of the candidate the record concerns, stored on
                the record and passed to the callback. None when nothing was
                proposed.
            parent_ids (list[str] | None): Ids of the candidates the proposal was made from,
                stored on the record. None when nothing was proposed.
            rejection_reason (str | None): The reason ``config.proposal_validator``
                returned for a ``"proposal_rejected"`` skip, stored on the
                record. None for every other iteration.
            rows_evaluated (int | None): Number of rows ``score`` aggregates over, stored
                on the record: the scoring batch's row count on an evaluated
                record, the minibatch's on a ``"minibatch_rejected"`` skip,
                0 on a skip that evaluated nothing and None on a
                ``"duplicate"`` skip, whose score is not this iteration's.
                Defaults to None (not recorded); every engine path passes it.

        Notes:
            Appends an IterationRecord to ``state.iteration_history`` so the
            chronological evolution trace is preserved for analysis. The
            record's ``failed_evaluations`` takes the failures counted since
            the iteration began, including any merge evaluation, and its
            ``token_usage`` the token rollup of the same evaluations (zeros
            when the iteration evaluated nothing), split into ``evaluation``
            and ``reflection``; ``reflection`` is None unless the proposer
            reported the iteration's reflection usage. Every record
            path goes through here, so ``config.on_iteration``, when set, is
            called with the record and ``candidate_id`` right after the
            append; an awaitable return value is awaited. Exceptions from the
            callback are not caught. Ends by writing a checkpoint through
            ``_write_checkpoint`` when ``config.checkpoint_path`` is set.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        record = IterationRecord(
            iteration_number=self._state.iteration,
            score=score,
            component_text=component_text,
            evolved_component=evolved_component,
            accepted=accepted,
            objective_scores=objective_scores,
            reflection_reasoning=reflection_reasoning,
            skip_reason=skip_reason,
            failed_evaluations=self._take_pending_failed_evaluations(),
            token_usage=self._take_record_token_usage(),
            candidate_id=candidate_id,
            parent_ids=parent_ids,
            rejection_reason=rejection_reason,
            rows_evaluated=rows_evaluated,
        )
        self._state.iteration_history.append(record)
        callback = self.config.on_iteration
        if callback is not None:
            outcome = callback(record, candidate_id)
            if inspect.isawaitable(outcome):
                await outcome
        self._write_checkpoint()

    async def _record_empty_proposal(self, error: EmptyProposalError) -> None:
        """Record an iteration whose reflection returned an empty proposal.

        Args:
            error (EmptyProposalError): The error raised while proposing; its ``component`` names
                the component the reflection was working on.

        Notes:
            Logs ``evolution.proposal_skipped`` with
            ``reason="empty_proposal"``, counts the iteration toward
            stagnation and appends a not-accepted IterationRecord with
            ``score=0.0``, empty ``component_text`` and
            ``skip_reason="empty_proposal"``. Nothing is evaluated, so the
            record's ``rows_evaluated`` is 0. The
            ``on_iteration`` callback receives ``None`` as the candidate id.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        logger.debug(
            "evolution.proposal_skipped",
            iteration=self._state.iteration,
            reason="empty_proposal",
            component=error.component,
        )
        self._state.stagnation_counter += 1
        await self._record_iteration(
            score=0.0,
            component_text="",
            evolved_component=error.component,
            accepted=False,
            skip_reason="empty_proposal",
            rows_evaluated=0,
        )

    async def _record_reflection_timeout(self, error: ReflectionTimeoutError) -> None:
        """Record an iteration whose reflection agent timed out.

        Args:
            error (ReflectionTimeoutError): The error raised while proposing; its
                ``component`` names the component the reflection was working on and its
                ``timeout_seconds`` the timeout it ran under.

        Notes:
            Logs ``evolution.proposal_skipped`` with
            ``reason="reflection_timeout"`` and ``timeout_seconds``, counts
            the iteration toward stagnation and appends a not-accepted
            IterationRecord with ``score=0.0``, empty ``component_text`` and
            ``skip_reason="reflection_timeout"``. Nothing is evaluated
            (``rows_evaluated=0``), and accepted candidates and the Pareto state are untouched.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        logger.debug(
            "evolution.proposal_skipped",
            iteration=self._state.iteration,
            reason="reflection_timeout",
            component=error.component,
            timeout_seconds=error.timeout_seconds,
        )
        self._state.stagnation_counter += 1
        await self._record_iteration(
            score=0.0,
            component_text="",
            evolved_component=error.component,
            accepted=False,
            skip_reason="reflection_timeout",
            rows_evaluated=0,
        )

    async def _record_reflection_error(self, error: ReflectionError) -> None:
        """Record an iteration whose reflection function kept raising.

        Args:
            error (ReflectionError): The retryable error the proposer raised after its retry;
                its ``component`` names the component the reflection was
                working on, ``cause`` the provider exception and
                ``attempts`` the number of reflection calls made.

        Notes:
            Logs ``evolution.proposal_skipped`` at warning level with
            ``reason="reflection_error"``, the cause's ``error_type`` and
            text and ``attempts``, counts the iteration toward stagnation
            and appends a not-accepted IterationRecord with ``score=0.0``,
            empty ``component_text`` and ``skip_reason="reflection_error"``.
            Nothing is evaluated (``rows_evaluated=0``), and accepted candidates and the Pareto
            state are untouched.

            When the record just appended is the
            ``config.max_consecutive_reflection_errors``-th in a row whose
            cause has the same exception type name and text, raises a
            non-retryable ``ReflectionError``; ``run()`` attaches the
            partial result. Any other record in between, from a successful
            reflection or another skip, or a different cause, restarts the
            count.

        Raises:
            ReflectionError: With ``retryable=False``, the last cause and its
                attempts, once the identical errors reach the limit.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        error_type = type(error.cause).__name__
        logger.warning(
            "evolution.proposal_skipped",
            iteration=self._state.iteration,
            reason="reflection_error",
            component=error.component,
            error_type=error_type,
            error=str(error.cause),
            attempts=error.attempts,
        )
        self._state.stagnation_counter += 1
        signature = (error_type, str(error.cause))
        index = len(self._state.iteration_history)
        if (
            signature == self._reflection_error_signature
            and self._reflection_error_index == index - 1
        ):
            self._reflection_error_streak += 1
        else:
            self._reflection_error_signature = signature
            self._reflection_error_streak = 1
        self._reflection_error_index = index
        await self._record_iteration(
            score=0.0,
            component_text="",
            evolved_component=error.component,
            accepted=False,
            skip_reason="reflection_error",
            rows_evaluated=0,
        )
        limit = self.config.max_consecutive_reflection_errors
        if self._reflection_error_streak >= limit:
            logger.error(
                "evolution.reflection_errors_exhausted",
                component=error.component,
                error_type=error_type,
                error=str(error.cause),
                consecutive=self._reflection_error_streak,
            )
            raise ReflectionError(
                error.component,
                cause=error.cause,
                retryable=False,
                attempts=error.attempts,
            ) from error

    async def _record_incomplete_proposal(self, error: IncompleteProposalError) -> None:
        """Record an iteration whose reflection output was incomplete.

        Args:
            error (IncompleteProposalError): The error the reflection function raised; its
                ``component`` names the component the reflection was working
                on, ``finish_reason`` why the output is incomplete and
                ``raw_text`` the truncated text.

        Notes:
            Logs ``evolution.proposal_skipped`` at warning level with
            ``reason="incomplete_proposal"``, ``finish_reason`` and
            ``response_length``, counts the iteration toward stagnation and
            appends a not-accepted IterationRecord with ``score=0.0``, the
            truncated text as ``component_text`` and
            ``skip_reason="incomplete_proposal"``. Nothing is evaluated
            (``rows_evaluated=0``), and accepted candidates and the Pareto state are untouched.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        logger.warning(
            "evolution.proposal_skipped",
            iteration=self._state.iteration,
            reason="incomplete_proposal",
            component=error.component,
            finish_reason=error.finish_reason,
            response_length=error.output_length,
        )
        self._state.stagnation_counter += 1
        await self._record_iteration(
            score=0.0,
            component_text=error.raw_text,
            evolved_component=error.component,
            accepted=False,
            skip_reason="incomplete_proposal",
            rows_evaluated=0,
        )

    async def _record_schema_validation_skip(
        self, proposal: Candidate, evolved_components: list[str]
    ) -> None:
        """Record an iteration whose proposed output schema failed validation.

        Args:
            proposal (Candidate): The proposal whose ``output_schema`` text is invalid.
            evolved_components (list[str]): Names of the components the proposal evolved,
                logged with the skip.

        Notes:
            Logs ``evolution.proposal_skipped`` with
            ``reason="schema_validation_failed"``, counts the iteration toward
            stagnation and appends a not-accepted IterationRecord with
            ``score=0.0``, the invalid schema text as ``component_text``,
            ``evolved_component="output_schema"`` and
            ``skip_reason="schema_validation_failed"``. Nothing is evaluated
            (``rows_evaluated=0``).
            The ``on_iteration`` callback receives the proposal's id, and the
            record carries it as ``candidate_id`` with ``parent_ids`` naming
            the proposal's parent.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        logger.debug(
            "evolution.proposal_skipped",
            iteration=self._state.iteration,
            reason="schema_validation_failed",
            candidate_id=proposal.id,
            components=evolved_components,
        )
        self._state.stagnation_counter += 1
        await self._record_iteration(
            score=0.0,
            component_text=proposal.components["output_schema"],
            evolved_component="output_schema",
            accepted=False,
            skip_reason="schema_validation_failed",
            rows_evaluated=0,
            candidate_id=proposal.id,
            parent_ids=_parent_ids(proposal),
        )

    async def _record_if_rejected(
        self, proposal: Candidate, evolved_components: list[str]
    ) -> bool:
        """Record a skipped iteration when the proposal validator rejects it.

        Args:
            proposal (Candidate): The candidate proposed this iteration.
            evolved_components (list[str]): Names of the components evolved this
                iteration; each is passed to the validator in order. When
                empty, every component of the proposal is checked.

        Returns:
            True when ``config.proposal_validator`` returned a reason for a
            component and the iteration was recorded as rejected; False when
            no validator is set or it accepted every component.

        Notes:
            The validator is called with the component name and the proposed
            text; the first non-None return is the reason. A rejection logs
            ``evolution.proposal_skipped`` at warning level with
            ``reason="proposal_rejected"`` and the ``rejection_reason``,
            counts toward stagnation and appends a not-accepted
            IterationRecord with ``score=0.0``, the rejected text, the
            proposal's ``candidate_id`` and ``parent_ids`` and the reason.
            No adapter call is made, so the record's ``rows_evaluated`` is
            0. Exceptions from the validator are not caught and propagate
            out of ``run()``.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        validator = self.config.proposal_validator
        if validator is None:
            return False
        names = evolved_components or list(proposal.components)
        for name in names:
            text = proposal.components[name]
            reason = validator(name, text)
            if reason is None:
                continue
            logger.warning(
                "evolution.proposal_skipped",
                iteration=self._state.iteration,
                reason="proposal_rejected",
                rejection_reason=reason,
                component=name,
                candidate_id=proposal.id,
            )
            self._state.stagnation_counter += 1
            await self._record_iteration(
                score=0.0,
                component_text=text,
                evolved_component=name,
                accepted=False,
                skip_reason="proposal_rejected",
                rows_evaluated=0,
                candidate_id=proposal.id,
                parent_ids=_parent_ids(proposal),
                rejection_reason=reason,
            )
            return True
        return False

    async def _record_if_duplicate(
        self, proposal: Candidate, evolved_components: list[str]
    ) -> bool:
        """Record a skipped iteration when the proposal was already scored.

        Args:
            proposal (Candidate): The candidate proposed this iteration.
            evolved_components (list[str]): Names of the components evolved this
                iteration; the first one names the record's component.

        Returns:
            True when the proposal's id was already scored and the iteration
            was recorded as a duplicate; False when it must be evaluated.

        Notes:
            A duplicate logs ``proposal.duplicate`` with the stored
            acceptance score, counts toward stagnation and appends a
            not-accepted IterationRecord carrying that score, the proposal's
            text for the evolved component and ``skip_reason="duplicate"``.
            No adapter call is made. The record's ``rows_evaluated`` is None
            because its score comes from an earlier evaluation. The
            ``on_iteration`` callback receives the duplicate proposal's id,
            and the record carries it as
            ``candidate_id`` with ``parent_ids`` naming the proposal's parent.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        score = self._scored.get(proposal.id)
        if score is None:
            return False
        component = (
            evolved_components[0]
            if evolved_components
            else next(iter(proposal.components))
        )
        logger.info(
            "proposal.duplicate",
            iteration=self._state.iteration,
            candidate_id=proposal.id,
            score=score,
        )
        self._state.stagnation_counter += 1
        await self._record_iteration(
            score=score,
            component_text=proposal.components.get(component, ""),
            evolved_component=component,
            accepted=False,
            skip_reason="duplicate",
            candidate_id=proposal.id,
            parent_ids=_parent_ids(proposal),
            rows_evaluated=None,
        )
        return True

    def _effective_minibatch_size(self) -> int | None:
        """Return the reflection minibatch size in effect for this run.

        Returns:
            ``min(reflection_minibatch_size, len(trainset))`` when that is
            smaller than the trainset, otherwise None, meaning every proposal
            is evaluated on the full trainset.
        """
        size = self.config.reflection_minibatch_size
        if size is None:
            return None
        k = min(size, len(self._trainset))
        return k if k < len(self._trainset) else None

    async def _gate_on_minibatch(
        self, proposal: Candidate, evolved_components: list[str]
    ) -> bool:
        """Decide whether a proposal earns its full evaluation.

        Samples trainset rows from the rows the mutation parent has scores
        for, evaluates the proposal on them with traces, and compares the
        mean of its scores with the mean of the parent's cached scores on the
        same rows.

        Args:
            proposal (Candidate): The candidate proposed this iteration.
            evolved_components (list[str]): Names of the components evolved this
                iteration; the first one names the record's component.

        Returns:
            True when the minibatch is disabled or the proposal's mean is
            strictly greater than the parent's; False when the proposal was
            rejected and its iteration recorded.

        Notes:
            The parent's known rows are ``_mutation_parent_rows``, or every
            trainset row when that is None. When the parent has at most
            ``k`` known rows, all of them are used (sorted) and the seeded
            random source is not drawn from; otherwise ``k`` of them are
            drawn with it and sorted. Each sampled trainset index is mapped
            to its position in the parent's batch, so the comparison is
            always on rows both sides scored. The evaluated batch and its
            trainset indices are stored as ``_gate_batch`` and
            ``_gate_rows`` (both None when the minibatch is disabled), so the
            loop can reuse them as the proposal's reflection batch.
            The minibatch evaluation is counted through ``_count_batch`` as a
            ``"reflection"`` evaluation. A rejection logs
            ``proposal.minibatch_rejected``, counts toward stagnation and
            appends a not-accepted IterationRecord whose score is the
            acceptance aggregate over the minibatch rows, whose
            ``rows_evaluated`` is the number of minibatch rows and whose
            ``skip_reason`` is ``"minibatch_rejected"``, with the proposal's
            ``candidate_id`` and its parent in ``parent_ids``. A rejected proposal
            is not stored as scored, so an identical later proposal draws a
            fresh sample. A pass logs ``proposal.minibatch_passed``. Both
            log events carry ``parent_rows``, the number of rows the parent
            has scores for. A full parent batch is sampled from ``range(n)``
            and read by trainset row, so no per-iteration row list or
            position map is built for it; only a sampled parent batch maps
            row to position.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        self._gate_batch = None
        self._gate_rows = None
        k = self._effective_minibatch_size()
        if k is None:
            return True
        parent_batch = self._mutation_parent_batch
        assert parent_batch is not None, "No parent batch cached"  # noqa: S101  # set by the mutation step that precedes the gate
        # A full batch is indexed by trainset row, so no position map or
        # row list is built for it; a sampled batch maps row to position.
        known: list[int] | range = (
            self._mutation_parent_rows
            if self._mutation_parent_rows is not None
            else range(len(self._trainset))
        )
        position = (
            {row: pos for pos, row in enumerate(known)}
            if self._mutation_parent_rows is not None
            else None
        )
        indices = (
            sorted(known)
            if len(known) <= k
            else sorted(self._minibatch_rng.sample(known, k))
        )
        minibatch = await self.adapter.evaluate(
            [self._trainset[i] for i in indices],
            proposal.components,
            capture_traces=True,
        )
        self._count_batch(proposal, minibatch, "reflection")
        self._gate_batch = minibatch
        self._gate_rows = indices
        proposal_mean = sum(minibatch.scores) / len(minibatch.scores)
        parent_mean = sum(
            parent_batch.scores[i if position is None else position[i]] for i in indices
        ) / len(indices)
        fields = {
            "iteration": self._state.iteration,
            "candidate_id": proposal.id,
            "indices": indices,
            "parent_rows": len(known),
            "proposal_mean": proposal_mean,
            "parent_mean": parent_mean,
        }
        if proposal_mean > parent_mean:
            logger.info("proposal.minibatch_passed", **fields)
            return True
        logger.info("proposal.minibatch_rejected", **fields)
        component = (
            evolved_components[0]
            if evolved_components
            else next(iter(proposal.components))
        )
        self._state.stagnation_counter += 1
        await self._record_iteration(
            score=self._aggregate_acceptance_score(minibatch.scores),
            component_text=proposal.components.get(component, ""),
            evolved_component=component,
            accepted=False,
            skip_reason="minibatch_rejected",
            candidate_id=proposal.id,
            parent_ids=_parent_ids(proposal),
            rows_evaluated=len(minibatch.scores),
        )
        return False

    async def _evaluate_passed_proposal(
        self, proposal: Candidate
    ) -> tuple[float, EvaluationBatch, list[int] | None]:
        """Build the reflection batch of a proposal that passed the gate.

        Args:
            proposal: The candidate that passed the minibatch gate (or ran
                with the minibatch disabled).

        Returns:
            Tuple of (mean score over the batch, the batch, the trainset
            indices its rows cover or None for the full trainset in order).

        Notes:
            With a reflection minibatch in effect and a valset separate from
            the trainset, the gate's batch (``_gate_batch``, evaluated with
            traces) is the reflection batch and no full trainset pass runs;
            its rows are ``_gate_rows``. Otherwise the proposal is evaluated
            on the full trainset through ``_evaluate_reflection``, so a
            valset that is the trainset can reuse that batch for scoring.
        """
        if (
            self._effective_minibatch_size() is not None
            and not self._valset_is_trainset
            and self._gate_batch is not None
        ):
            batch = self._gate_batch
            return sum(batch.scores) / len(batch.scores), batch, self._gate_rows
        score, batch = await self._evaluate_reflection(proposal)
        return score, batch, None

    def _start_merge(self, merge_result: ProposalResult) -> bool:
        """Count a merge attempt and decide whether to evaluate it.

        Args:
            merge_result (ProposalResult): The merge proposer's result for this iteration.

        Returns:
            True when the merge candidate should be evaluated; False when it
            is skipped for an invalid schema or an already scored id.

        Notes:
            Decrements ``merges_due``, increments the invocation count and
            logs ``merge_scheduling.merge_attempted`` for every attempt,
            skipped or not.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        self._merges_due -= 1
        self._merge_invocations += 1
        logger.info(
            "merge_scheduling.merge_attempted",
            iteration=self._state.iteration,
            candidate_id=merge_result.candidate.id,
            parent_indices=merge_result.parent_indices,
            ancestor_idx=merge_result.metadata.get("ancestor_idx"),
            merges_due=self._merges_due,
            total_invocations=self._merge_invocations,
        )
        return not self._skip_merge_candidate(merge_result.candidate)

    def _skip_merge_candidate(self, candidate: Candidate) -> bool:
        """Decide whether a merge candidate must not be evaluated.

        Args:
            candidate (Candidate): The candidate produced by the merge proposer.

        Returns:
            True when the candidate has an invalid output schema or its id
            was already scored; False when it should be evaluated.

        Notes:
            Logs ``merge.proposal_skipped`` with
            ``reason="schema_validation_failed"`` or ``reason="duplicate"``.
            No iteration record is written for a skipped merge; the
            iteration's own proposal record is unaffected.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        if not self._validate_schema_component(candidate):
            reason = "schema_validation_failed"
        elif candidate.id in self._scored:
            reason = "duplicate"
        else:
            return False
        logger.debug(
            "merge.proposal_skipped",
            iteration=self._state.iteration,
            candidate_id=candidate.id,
            reason=reason,
        )
        return True

    def _should_stop(self) -> StopReason | None:
        """Check if evolution should terminate.

        Returns:
            The ``StopReason`` that triggered termination, or ``None`` if
            evolution should continue. Conditions are checked in priority
            order: max iterations, early stopping patience, custom stoppers.

        Notes:
            Only active stoppers (those that passed setup or have no setup
            method) are invoked. Patience-based early stopping returns
            ``PATIENCE`` and is off when ``config.patience`` is 0;
            ``MAX_ITERATIONS`` means only the iteration cap.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        # Condition 1: Max iterations reached (built-in, fast path)
        if self._state.iteration >= self.config.max_iterations:
            return StopReason.MAX_ITERATIONS

        # Condition 2: Early stopping (patience exhausted, built-in)
        if (
            self.config.patience > 0
            and self._state.stagnation_counter >= self.config.patience
        ):
            return StopReason.PATIENCE

        # Condition 3: Custom stoppers (T010-T013)
        # Use _active_stoppers which excludes stoppers that failed setup
        active_stoppers = getattr(self, "_active_stoppers", None)
        if active_stoppers:
            stopper_state = self._build_stopper_state()
            for stopper in active_stoppers:
                try:
                    if stopper(stopper_state):
                        # Log stopper trigger (T013)
                        logger.info(
                            "stopper.triggered",
                            stopper=type(stopper).__name__,
                            iteration=self._state.iteration,
                        )
                        return StopReason.STOPPER_TRIGGERED
                except Exception:
                    # T032: Handle stopper exception gracefully
                    logger.exception(
                        "stopper.error",
                        stopper=type(stopper).__name__,
                        iteration=self._state.iteration,
                    )
                    # Continue checking other stoppers

        return None

    def _should_accept(self, proposal_score: float, best_score: float) -> bool:
        """Check if proposal should be accepted.

        Args:
            proposal_score: Score of the proposed candidate.
            best_score: Current best score.

        Returns:
            True if proposal_score > best_score + min_improvement_threshold.

        Notes:
            Signals True when proposal exceeds best score by the configured
            improvement threshold, enabling configurable acceptance sensitivity.
        """
        threshold = self.config.min_improvement_threshold
        return proposal_score > best_score + threshold

    def _validate_schema_component(self, proposal: Candidate) -> bool:
        """Validate output_schema component if present.

        Validates that proposed schema text is syntactically correct and
        structurally valid (inherits from BaseModel, no imports/functions).
        Invalid schemas are rejected to prevent evolution from accepting
        non-functional schema proposals.

        Args:
            proposal (Candidate): Candidate containing components to validate.

        Returns:
            True if valid or no output_schema component present.
            False if output_schema validation fails.

        Notes:
            This validation runs before expensive evaluation to reject
            invalid schemas early. Security checks (no imports, no functions)
            are enforced to prevent code injection.
        """
        if "output_schema" not in proposal.components:
            return True

        schema_text = proposal.components["output_schema"]

        try:
            validate_schema_text(schema_text)
        except SchemaValidationError as e:
            logger.warning(
                "schema_validation.rejected",
                iteration=self._state.iteration if self._state else None,
                validation_stage=e.validation_stage,
                line_number=e.line_number,
                error=e.validation_error,
            )
            return False
        else:
            logger.debug(
                "schema_validation.passed",
                iteration=self._state.iteration if self._state else None,
            )
            return True

    def _accept_proposal(  # noqa: PLR0913  # optional fields are keyword-only; private helper
        self,
        proposal: Candidate,
        score: float,
        eval_batch: EvaluationBatch,
        *,
        candidate_idx: int | None = None,
        eval_rows: list[int] | None = None,
        reflection_score: float | None = None,
        valset_mean: float | None = None,
        objective_scores: list[dict[str, float]] | None = None,
    ) -> None:
        """Accept a proposal and update state.

        Args:
            proposal (Candidate): Proposed candidate to accept.
            score (float): Acceptance score of the proposed candidate (sum or mean).
            eval_batch (EvaluationBatch): Reflection batch from proposal evaluation (cached for
                next iteration's reflective dataset generation).
            candidate_idx (int | None): Optional ParetoState candidate index to update with
                lineage metadata.
            eval_rows (list[int] | None): Trainset indices the rows of ``eval_batch`` cover, in
                batch order; None means the full trainset in order.
            reflection_score (float | None): Optional trainset score to store with best
                candidate metadata: the mean over ``eval_batch``.
            valset_mean (float | None): Optional valset mean score to track separately from
                acceptance score.
            objective_scores (list[dict[str, float]] | None): Optional objective scores
                from scoring batch. None when adapter does not provide objective scores.

        Notes:
            Replaces the cached reflection batch and its trainset rows for
            the next proposal iteration and tracks acceptance score and valset mean separately.
            The proposal is stored as it is: a mutation already carries its
            parent's id and generation from ``_propose_mutation``, and a
            merge candidate keeps the lineage the merge proposer set.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        if candidate_idx is not None and self._pareto_state is not None:
            self._pareto_state.candidates[candidate_idx] = proposal
        self._state.best_candidate = proposal
        self._state.best_score = score
        self._state.stagnation_counter = 0
        self._state.last_eval_batch = eval_batch
        self._state.last_eval_rows = eval_rows
        if reflection_score is not None:
            self._state.best_reflection_score = reflection_score
        if valset_mean is not None:
            self._state.best_valset_mean = valset_mean
        self._state.best_objective_scores = objective_scores

    def _build_result(
        self, stop_reason: StopReason = StopReason.COMPLETED
    ) -> EvolutionResult:
        """Build final result from current state.

        Args:
            stop_reason (StopReason): Why the evolution run terminated.

        Returns:
            Frozen EvolutionResult with all metrics and original_components.

        Notes:
            Synthesizes a frozen EvolutionResult containing all evolution metrics,
            history, and original_components snapshot, suitable for immutable
            result reporting. The evolved_components dict contains all component
            values from the best candidate. ``total_failed_evaluations`` is
            the baseline count plus the sum over ``iteration_history``, and
            ``token_usage`` is the run token rollup, baseline included,
            with its ``evaluation`` split and a ``reflection`` split that is
            None when no reflection usage was observed.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        baseline_failed = self._state.baseline_failed_evaluations
        return EvolutionResult(
            stop_reason=stop_reason,
            original_score=self._state.original_score,
            final_score=self._state.best_score,
            evolved_components=dict(self._state.best_candidate.components),
            iteration_history=self._state.iteration_history,
            total_iterations=self._state.iteration,
            valset_score=self._state.best_valset_mean,
            trainset_score=self._state.best_reflection_score,
            objective_scores=self._state.best_objective_scores,
            original_components=dict(self._initial_candidate.components),
            baseline_failed_evaluations=baseline_failed,
            total_failed_evaluations=baseline_failed
            + sum(r.failed_evaluations for r in self._state.iteration_history),
            token_usage=self._run_token_rollup(),
        )

    def _write_checkpoint(self) -> None:
        """Write the engine's state to ``config.checkpoint_path``.

        Does nothing when no checkpoint path is configured. Otherwise
        builds the checkpoint dict (version, iteration, stagnation counter,
        best candidate and scores, history, scored map, evaluation counter,
        best reflection batch, random states and run identity) and writes it
        atomically through ``write_checkpoint``.

        Raises:
            TypeError: If a batch field holds a value JSON cannot encode.
            OSError: If the file cannot be written. Nothing is swallowed.

        Notes:
            ``rng_state`` is the engine ``rng``'s state when one was given,
            else null; ``minibatch_rng_state`` is always written.
            ``valset_size`` is null when the valset is the trainset.
            ``last_eval_rows`` holds the trainset indices the cached batch
            covers, null for the full trainset.
            ``run_token_usage`` is the run's token rollup so far, with its
            ``evaluation`` and ``reflection`` splits, so a resumed run
            reports the whole run's usage.
        """
        if self.config.checkpoint_path is None or self._state is None:
            return
        path = Path(self.config.checkpoint_path)
        state = self._state
        data = {
            "checkpoint_version": CHECKPOINT_VERSION,
            "iteration": state.iteration,
            "stagnation_counter": state.stagnation_counter,
            "best_candidate": state.best_candidate.to_dict(),
            "best_score": state.best_score,
            "original_score": state.original_score,
            "best_reflection_score": state.best_reflection_score,
            "best_valset_mean": state.best_valset_mean,
            "best_objective_scores": state.best_objective_scores,
            "baseline_failed_evaluations": state.baseline_failed_evaluations,
            "iteration_history": [r.to_dict() for r in state.iteration_history],
            "scored": dict(self._scored),
            "total_evaluations": self._total_evaluations,
            "last_eval_batch": batch_to_dict(state.last_eval_batch),
            "last_eval_rows": state.last_eval_rows,
            "rng_state": (
                None if self._rng is None else rng_state_to_json(self._rng.getstate())
            ),
            "minibatch_rng_state": rng_state_to_json(self._minibatch_rng.getstate()),
            "initial_candidate_id": self._initial_candidate.id,
            "trainset_size": len(self._trainset),
            "valset_size": None if self._valset_is_trainset else len(self._valset),
            "seed": self.config.seed,
            "run_token_usage": self._run_token_rollup().to_dict(),
            "written_at": datetime.now(UTC).isoformat(),
        }
        write_checkpoint(path, data)
        logger.debug("checkpoint.written", iteration=state.iteration, path=str(path))

    def _load_checkpoint(self) -> dict[str, Any]:
        """Read the checkpoint and refuse one from a different run.

        Returns:
            The checkpoint dict.

        Raises:
            ConfigurationError: If ``config.checkpoint_path`` is unset, the
                file is missing, its version is not ``CHECKPOINT_VERSION``,
                or its initial candidate, trainset size or valset size
                differs from this engine's.
        """
        path = self.config.checkpoint_path
        if path is None:
            raise ConfigurationError(
                "resume=True requires checkpoint_path",
                field="checkpoint_path",
                value=None,
                constraint="not None when resume=True",
            )
        data = read_checkpoint(Path(path))
        check_run_matches(
            data,
            initial_candidate_id=self._initial_candidate.id,
            trainset_size=len(self._trainset),
            valset_size=None if self._valset_is_trainset else len(self._valset),
        )
        return data

    def _resume_from_checkpoint(self) -> None:
        """Restore engine state from ``config.checkpoint_path``.

        Rebuilds ``_EngineState``, the scored map, the evaluation counter,
        the run token rollup, the mutation parent batch and the random
        states from the file, and marks the engine restored so the loop
        skips the baseline.

        Raises:
            ConfigurationError: If ``_load_checkpoint`` refuses the file.

        Notes:
            The engine ``rng`` is restored only when one was given and a
            state was stored; the minibatch random source always is. A
            checkpoint without ``run_token_usage`` restores an unknown
            rollup whose ``rows_unknown`` is the checkpointed evaluation
            count, so the pre-resume rows read as unknown, not free. A
            checkpoint without ``last_eval_rows`` restores the cached batch
            as covering the full trainset; ``_propose_mutation`` reads the
            parent's rows from the restored state. A
            stored rollup without splits restores as evaluation usage with
            reflection not observed; a split one restores its ``evaluation``
            split (zeros when absent) as evaluation and its ``reflection``
            split as reflection, so reflection usage never enters the
            evaluation total. The
            ``on_iteration`` callback is not called for restored history.
            Logs ``checkpoint.resumed`` with the iteration, the stagnation
            counter, the evaluation counter and the path.
        """
        data = self._load_checkpoint()
        last_eval_batch = batch_from_dict(data["last_eval_batch"])
        # Absent in older files: the cached batch is the full trainset
        last_eval_rows = data.get("last_eval_rows")
        self._state = _EngineState(
            best_candidate=Candidate.from_dict(data["best_candidate"]),
            best_score=data["best_score"],
            original_score=data["original_score"],
            iteration=data["iteration"],
            stagnation_counter=data["stagnation_counter"],
            iteration_history=[
                IterationRecord.from_dict(r) for r in data["iteration_history"]
            ],
            last_eval_batch=last_eval_batch,
            last_eval_rows=last_eval_rows,
            best_reflection_score=data["best_reflection_score"],
            best_valset_mean=data["best_valset_mean"],
            best_objective_scores=data["best_objective_scores"],
            baseline_failed_evaluations=data["baseline_failed_evaluations"],
        )
        self._scored = dict(data["scored"])
        self._total_evaluations = data["total_evaluations"]
        stored_usage = data.get("run_token_usage")
        loaded = (
            TokenRollup.from_dict(stored_usage)
            if stored_usage is not None
            else _unknown_tokens(data["total_evaluations"])
        )
        has_split = loaded.evaluation is not None or loaded.reflection is not None
        self._run_token_usage = (
            (loaded.evaluation or _ZERO_TOKENS) if has_split else loaded
        )
        self._run_reflection_usage = loaded.reflection
        self._mutation_parent_batch = last_eval_batch
        if self._rng is not None and data["rng_state"] is not None:
            self._rng.setstate(rng_state_from_json(data["rng_state"]))
        self._minibatch_rng.setstate(rng_state_from_json(data["minibatch_rng_state"]))
        self._restored = True
        logger.info(
            "checkpoint.resumed",
            iteration=self._state.iteration,
            stagnation_counter=self._state.stagnation_counter,
            total_evaluations=self._total_evaluations,
            path=str(self.config.checkpoint_path),
        )

    def _reset_run_state(self) -> None:
        """Clear per-run state so a repeated ``run()`` starts fresh.

        Resets the stopper timer, the evaluation and pending failure
        counters, the pending and run token and reflection rollups, the
        reflection error streak, the scored-candidate map, the engine state
        and the restored flag.
        """
        # Initialize stopper state tracking (T004)
        self._start_time = time.monotonic()
        self._total_evaluations = 0
        self._pending_failed_evaluations = 0
        self._pending_token_usage = self._run_token_usage = _ZERO_TOKENS
        self._pending_reflection_usage = self._run_reflection_usage = None
        self._reflection_error_signature = None
        self._reflection_error_streak = 0
        self._reflection_error_index = -1
        # A fresh run scores every candidate again; stale ids would read
        # first-iteration proposals as duplicates.
        self._scored.clear()
        # State from an earlier run must not leak into this run's partial
        # result; the baseline or the checkpoint rebuilds it.
        self._state = None
        self._restored = False

    async def run(self) -> EvolutionResult:
        """Execute the evolution loop.

        Runs the core evolution loop:
        1. Evaluate baseline candidate
        2. For each iteration until max_iterations or convergence:
           a. Generate reflective dataset from traces
           b. Propose new candidate text
           c. Evaluate proposal
           d. Accept if improves above threshold
           e. Record iteration
        3. Return frozen EvolutionResult
        4. On ``KeyboardInterrupt`` or ``asyncio.CancelledError``, return
           partial result with best-so-far components
        5. On an ``EvolutionError`` after the baseline, attach a partial
           result to the error and re-raise it

        Returns:
            EvolutionResult containing evolution metrics and components.
            On normal completion, ``stop_reason`` reflects the termination
            condition. On interrupt, a partial result is returned with
            ``StopReason.KEYBOARD_INTERRUPT`` or ``StopReason.CANCELLED``.

        Raises:
            KeyboardInterrupt: Re-raised if interrupt occurs before baseline
                evaluation completes (no meaningful partial result possible).
            asyncio.CancelledError: Re-raised if cancellation occurs before
                baseline evaluation completes.
            EvolutionError: Re-raised when it aborts the run, for example a
                non-retryable ``ReflectionError`` (also raised after
                ``config.max_consecutive_reflection_errors`` identical
                reflection errors in a row). When the baseline was
                scored, its ``partial_result`` holds an ``EvolutionResult``
                with ``StopReason.ERROR``, the recorded iterations and the
                best candidate so far; otherwise it stays ``None``.
            Exception: Adapter ``Exception`` subclasses propagate unchanged.

        Examples:
            Running evolution:

            ```python
            result = await engine.run()
            print(f"Improved: {result.improved}")
            print(f"Best score: {result.final_score}")
            ```

        Notes:
            Outputs a frozen EvolutionResult after completing the evolution
            loop. Engine instance should not be reused after run() completes.
            Method is idempotent if called multiple times (restarts fresh):
            the evaluation counters and the scored-candidate map used for
            duplicate detection are cleared before the baseline runs.
            Fail-fast behavior: adapter ``Exception`` subclasses propagate
            unchanged. ``KeyboardInterrupt`` and ``asyncio.CancelledError``
            (``BaseException`` subclasses) are caught and converted to partial
            results with appropriate ``StopReason``. An ``EvolutionError`` is
            logged as ``evolution.aborted`` at error level, without a
            traceback, and re-raised; after the
            baseline it carries ``partial_result`` whose
            ``total_iterations`` counts recorded iterations only, since the
            aborted iteration was never recorded. Logs seed value at start
            for reproducibility tracking, and logs
            ``reflection.minibatch.enabled`` when a reflection minibatch
            smaller than the trainset is in effect; with a separate valset a
            proposal that passes that gate is scored on the valset without a
            full trainset pass, and ``trainset_score`` is then the mean over
            the accepted candidate's sampled rows. ``_reset_run_state``
            resets the evaluation counter, the pending failure counter, the
            engine state, the pending and run token rollups and the pending
            and run reflection rollups at start, so a reused engine
            never attaches an earlier run's state to ``partial_result``.
            With ``config.resume`` it then restores
            the checkpoint through ``_resume_from_checkpoint``, which sets
            the counters and scored map from the file, and the loop skips
            the baseline. An exception raised by
            ``config.proposal_validator`` is not an ``EvolutionError``, so it
            propagates unchanged without a partial result; a rejection it
            returns is recorded with ``skip_reason="proposal_rejected"``.
        """
        logger.info("engine.start", seed=self.config.seed)
        if self._effective_minibatch_size() is not None:
            logger.info(
                "reflection.minibatch.enabled",
                size=self.config.reflection_minibatch_size,
                trainset_size=len(self._trainset),
            )

        self._reset_run_state()
        if self.config.resume:
            self._resume_from_checkpoint()

        # Setup stopper lifecycle (T023)
        setup_stoppers = self._setup_stoppers()

        try:
            return await self._run_evolution_loop()
        except EvolutionError as error:
            logger.error(  # noqa: TRY400  # re-raised below with partial_result attached
                "evolution.aborted",
                iteration=self._state.iteration if self._state else 0,
                error_type=type(error).__name__,
                error=str(error),
            )
            if self._state is not None:
                # The aborted iteration was never recorded
                self._state.iteration = len(self._state.iteration_history)
                error.partial_result = self._build_result(stop_reason=StopReason.ERROR)
            raise
        except KeyboardInterrupt:
            logger.info(
                "evolution.interrupted",
                iteration=self._state.iteration if self._state else 0,
            )
            if self._state is None:
                raise
            return self._build_result(stop_reason=StopReason.KEYBOARD_INTERRUPT)
        except asyncio.CancelledError:
            logger.info(
                "evolution.cancelled",
                iteration=self._state.iteration if self._state else 0,
            )
            if self._state is None:
                raise
            return self._build_result(stop_reason=StopReason.CANCELLED)
        finally:
            # Cleanup stopper lifecycle (T024)
            self._cleanup_stoppers(setup_stoppers)

    async def _run_evolution_loop(self) -> EvolutionResult:  # noqa: C901, PLR0912, PLR0915  # decomposition tracked in #507
        """Run propose-evaluate-accept iterations until a stop condition holds.

        This method contains the actual evolution loop logic, separated
        from lifecycle management for clean try/finally handling.

        Returns:
            EvolutionResult with evolution outcomes.

        Raises:
            ReflectionError: If proposing raises a non-retryable
                ``ReflectionError``, or after
                ``config.max_consecutive_reflection_errors`` identical
                retryable ones in a row; ``run()`` attaches the partial result.

        Notes:
            Only called from run(). Handles the evolution loop body
            while run() manages stopper lifecycle. The loop tracks
            ``StopReason`` to report why evolution terminated.
            The parent index ``_propose_mutation`` returns is the proposal's
            only genealogy edge in the Pareto state; the selector is not
            asked a second time.
            Each iteration records ``reflection_reasoning`` from the
            adapter's proposer via a ``getattr`` chain when available.
            Each proposal's reflection batch is handed to scoring so a
            valset that is the trainset is not evaluated twice.
            An ``EmptyProposalError`` from proposing is recorded as a
            skipped iteration (``skip_reason="empty_proposal"``) that counts
            toward stagnation; the loop then checks stop conditions and
            continues. A ``ReflectionTimeoutError`` is handled the same way
            with ``skip_reason="reflection_timeout"``, and a retryable
            ``ReflectionError`` with ``skip_reason="reflection_error"``; a
            non-retryable ``ReflectionError`` propagates to ``run()``,
            which attaches the partial result. An
            ``IncompleteProposalError`` is recorded with
            ``skip_reason="incomplete_proposal"``, counts toward stagnation
            and is followed by the stop check. A proposal that
            ``config.proposal_validator`` rejects is recorded, before schema
            validation, with ``skip_reason="proposal_rejected"`` and the
            validator's reason, counts toward stagnation and is followed by
            the stop check; an exception from the validator propagates. A
            proposal whose
            ``output_schema`` text fails validation is recorded the same way
            with ``skip_reason="schema_validation_failed"``, counts toward
            stagnation and is followed by the stop check. A proposal whose
            ``Candidate.id`` was already scored (baseline, proposal or merge)
            is recorded with
            ``skip_reason="duplicate"`` and its stored score instead of being
            evaluated. With a reflection minibatch in effect, a proposal
            that does not beat its parent on the iteration's sampled rows is
            recorded with ``skip_reason="minibatch_rejected"`` and not
            evaluated further. An evaluated proposal's record carries the
            scoring batch's row count as ``rows_evaluated``. A proposal that
            passes is evaluated on the
            full trainset when the valset is the trainset (that batch is
            reused for scoring); with a separate valset the gate's batch is
            its reflection batch and only the valset is evaluated (see
            ``_evaluate_passed_proposal``). The trainset rows of each cached
            batch are stored beside it for the next gate and reflection. A
            merge candidate is always evaluated on the full trainset, so
            its rows are None. A merge candidate that is a duplicate or has an
            invalid schema is not evaluated, and the iteration's own record
            and stop check still follow. Each iteration starts with no
            pending failures, a zero pending token rollup and no pending
            reflection usage, so an unrecorded iteration's failures and tokens
            do not carry into the next record. After the proposal returns,
            or raises one of the skip errors, the adapter proposer's
            ``last_token_usage`` is read through ``_take_proposer_usage`` so
            the iteration's record carries its reflection cost.
            Each accept decision is logged as ``proposal.accepted`` or
            ``proposal.rejected`` with the proposal's ``candidate_id``.
            Every recorded iteration, skipped or not, reaches
            ``config.on_iteration`` before the stop check.
            State restored from a checkpoint replaces the baseline
            evaluation.
        """
        # A restored checkpoint already holds the baseline
        if not self._restored:
            await self._initialize_baseline()
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call

        # Evolution loop
        stop_reason = self._should_stop()
        while stop_reason is None:
            self._state.iteration += 1
            # Failures from an unrecorded previous iteration do not carry over
            self._pending_failed_evaluations = 0
            self._pending_token_usage = _ZERO_TOKENS
            self._pending_reflection_usage = None

            # Propose mutation (returns candidate and list of components evolved)
            try:
                (
                    proposal,
                    evolved_components_list,
                    parent_idx,
                ) = await self._propose_mutation()
            except EmptyProposalError as error:
                # Empty reflection after retry: failed iteration, not fatal
                self._take_proposer_usage()
                await self._record_empty_proposal(error)
                stop_reason = self._should_stop()
                continue
            except ReflectionTimeoutError as error:
                # Reflection timed out: skipped iteration, not fatal
                self._take_proposer_usage()
                await self._record_reflection_timeout(error)
                stop_reason = self._should_stop()
                continue
            except ReflectionError as error:
                # A programming or client error will not clear on its own
                if not error.retryable:
                    raise
                # Provider error survived the proposer's retry: skip it
                self._take_proposer_usage()
                await self._record_reflection_error(error)
                stop_reason = self._should_stop()
                continue
            except IncompleteProposalError as error:
                # Truncated reflection output: skipped iteration, not fatal
                self._take_proposer_usage()
                await self._record_incomplete_proposal(error)
                stop_reason = self._should_stop()
                continue
            self._take_proposer_usage()

            # Caller's validator rejected the text: record it, no evaluation
            if await self._record_if_rejected(proposal, evolved_components_list):
                stop_reason = self._should_stop()
                continue

            # Validate schema component if present (reject invalid early)
            if not self._validate_schema_component(proposal):
                # Invalid schema: record the skip, no evaluation
                await self._record_schema_validation_skip(
                    proposal, evolved_components_list
                )
                stop_reason = self._should_stop()
                continue

            # Already scored this run: reuse its score, no evaluation
            if await self._record_if_duplicate(proposal, evolved_components_list):
                stop_reason = self._should_stop()
                continue

            # Beat the parent on a trainset minibatch before the full pass
            if not await self._gate_on_minibatch(proposal, evolved_components_list):
                stop_reason = self._should_stop()
                continue

            # Reflection batch: the gate's rows with a separate valset,
            # otherwise the full trainset pass
            (
                reflection_score,
                reflection_batch,
                reflection_rows,
            ) = await self._evaluate_passed_proposal(proposal)
            proposal_score, scoring_batch, eval_indices = await self._evaluate_scoring(
                proposal,
                reflection_batch=reflection_batch if reflection_rows is None else None,
            )

            candidate_idx = None
            if self._pareto_state is not None:
                # Use eval_indices returned from _evaluate_scoring (T066)
                # Prepare objective scores if available (T068)
                objective_scores: dict[str, float] | None = None
                per_example_objective_scores: dict[int, dict[str, float]] | None = None

                if scoring_batch.objective_scores is not None:
                    if self.config.frontier_type in (
                        FrontierType.OBJECTIVE,
                        FrontierType.HYBRID,
                    ):
                        # Aggregate objective scores across evaluated examples
                        objective_scores_accum: dict[str, list[float]] = {}
                        for obj_scores in scoring_batch.objective_scores:
                            for obj_name, obj_score in obj_scores.items():
                                objective_scores_accum.setdefault(obj_name, []).append(
                                    obj_score
                                )
                        # Take mean per objective
                        objective_scores = {
                            obj_name: fmean(scores)
                            for obj_name, scores in objective_scores_accum.items()
                        }

                    if self.config.frontier_type == FrontierType.CARTESIAN:
                        # For CARTESIAN, need per-example objective scores mapped to valset indices
                        per_example_objective_scores = {
                            eval_indices[i]: scoring_batch.objective_scores[i]
                            for i in range(len(eval_indices))
                        }
                        # Also need aggregated for validation
                        objective_scores_by_name: dict[str, list[float]] = {}
                        for obj_scores in scoring_batch.objective_scores:
                            for obj_name, obj_score in obj_scores.items():
                                objective_scores_by_name.setdefault(
                                    obj_name, []
                                ).append(obj_score)
                        objective_scores = {
                            obj_name: fmean(scores)
                            for obj_name, scores in objective_scores_by_name.items()
                        }

                # The genealogy edge is the parent the reflector rewrote
                parent_indices = [parent_idx] if parent_idx is not None else None

                # Pass scores with correct index mapping (T066)
                candidate_idx = self._pareto_state.add_candidate(
                    proposal,
                    scoring_batch.scores,
                    score_indices=eval_indices,
                    objective_scores=objective_scores,
                    per_example_objective_scores=per_example_objective_scores,
                    parent_indices=parent_indices,
                    logger=logger,
                )
                self._candidate_eval_batches[candidate_idx] = reflection_batch
                self._candidate_eval_rows[candidate_idx] = reflection_rows
                logger.info(
                    "pareto_frontier.candidate_added",
                    candidate_idx=candidate_idx,
                    candidate_id=proposal.id,
                    iteration=self._state.iteration,
                )

            # Calculate valset mean using only evaluated scores (T067)
            valset_mean = (
                sum(scoring_batch.scores) / len(scoring_batch.scores)
                if scoring_batch.scores
                else 0.0
            )

            # Accept if improves above threshold
            accepted = self._should_accept(proposal_score, self._state.best_score)
            if accepted:
                logger.info(
                    "proposal.accepted",
                    iteration=self._state.iteration,
                    candidate_id=proposal.id,
                    score=proposal_score,
                    best_score=self._state.best_score,
                )
                self._accept_proposal(
                    proposal,
                    proposal_score,
                    reflection_batch,
                    candidate_idx=candidate_idx,
                    eval_rows=reflection_rows,
                    reflection_score=reflection_score,
                    valset_mean=valset_mean,
                    objective_scores=scoring_batch.objective_scores,
                )
                # Schedule merge if enabled
                if (
                    self.config.use_merge
                    and self._merge_proposer is not None
                    and self._merge_invocations < self.config.max_merge_invocations
                ):
                    self._merges_due += 1
                    logger.debug(
                        "merge_scheduling.merge_scheduled",
                        iteration=self._state.iteration,
                        candidate_id=proposal.id,
                        merges_due=self._merges_due,
                    )
            else:
                logger.debug(
                    "proposal.rejected",
                    iteration=self._state.iteration,
                    candidate_id=proposal.id,
                    score=proposal_score,
                    best_score=self._state.best_score,
                )
                # Increment stagnation counter on rejection
                self._state.stagnation_counter += 1

            # Attempt merge if scheduled
            if (
                self._merges_due > 0
                and self._merge_proposer is not None
                and self._pareto_state is not None
                and self._merge_invocations < self.config.max_merge_invocations
            ):
                merge_result = await self._merge_proposer.propose(self._pareto_state)
                # A skipped merge (invalid schema or already scored) is not
                # evaluated; the iteration is still recorded below
                if merge_result is not None and self._start_merge(merge_result):
                    # Evaluate merge proposal
                    (
                        merge_reflection_score,
                        merge_reflection_batch,
                    ) = await self._evaluate_reflection(merge_result.candidate)
                    (
                        merge_proposal_score,
                        merge_scoring_batch,
                        merge_eval_indices,
                    ) = await self._evaluate_scoring(
                        merge_result.candidate,
                        reflection_batch=merge_reflection_batch,
                    )

                    # Add merge candidate to ParetoState
                    merge_candidate_idx = None
                    assert self._pareto_state is not None, (  # noqa: S101  # narrows state that run() sets before this call
                        "Pareto state not initialized"
                    )
                    merge_objective_scores: dict[str, float] | None = None
                    merge_per_example_objective_scores: (
                        dict[int, dict[str, float]] | None
                    ) = None

                    if merge_scoring_batch.objective_scores is not None:
                        if self.config.frontier_type in (
                            FrontierType.OBJECTIVE,
                            FrontierType.HYBRID,
                        ):
                            merge_objective_scores_accum: dict[str, list[float]] = {}
                            for obj_scores in merge_scoring_batch.objective_scores:
                                for obj_name, obj_score in obj_scores.items():
                                    merge_objective_scores_accum.setdefault(
                                        obj_name, []
                                    ).append(obj_score)
                            merge_objective_scores = {
                                obj_name: fmean(scores)
                                for obj_name, scores in merge_objective_scores_accum.items()
                            }

                        if self.config.frontier_type == FrontierType.CARTESIAN:
                            merge_per_example_objective_scores = {
                                merge_eval_indices[
                                    i
                                ]: merge_scoring_batch.objective_scores[i]
                                for i in range(len(merge_eval_indices))
                            }
                            merge_objective_scores_by_name: dict[str, list[float]] = {}
                            for obj_scores in merge_scoring_batch.objective_scores:
                                for obj_name, obj_score in obj_scores.items():
                                    merge_objective_scores_by_name.setdefault(
                                        obj_name, []
                                    ).append(obj_score)
                            merge_objective_scores = {
                                obj_name: fmean(scores)
                                for obj_name, scores in merge_objective_scores_by_name.items()
                            }

                    merge_candidate_idx = self._pareto_state.add_candidate(
                        merge_result.candidate,
                        merge_scoring_batch.scores,
                        score_indices=merge_eval_indices,
                        objective_scores=merge_objective_scores,
                        per_example_objective_scores=merge_per_example_objective_scores,
                        parent_indices=merge_result.parent_indices,
                        logger=logger,
                    )
                    self._candidate_eval_batches[merge_candidate_idx] = (
                        merge_reflection_batch
                    )
                    self._candidate_eval_rows[merge_candidate_idx] = None

                    merge_valset_mean = (
                        sum(merge_scoring_batch.scores)
                        / len(merge_scoring_batch.scores)
                        if merge_scoring_batch.scores
                        else 0.0
                    )

                    # Accept merge if improves
                    merge_accepted = self._should_accept(
                        merge_proposal_score, self._state.best_score
                    )
                    if merge_accepted:
                        self._accept_proposal(
                            merge_result.candidate,
                            merge_proposal_score,
                            merge_reflection_batch,
                            candidate_idx=merge_candidate_idx,
                            reflection_score=merge_reflection_score,
                            valset_mean=merge_valset_mean,
                            objective_scores=merge_scoring_batch.objective_scores,
                        )
                        logger.info(
                            "merge_scheduling.merge_accepted",
                            iteration=self._state.iteration,
                            candidate_id=merge_result.candidate.id,
                            merge_score=merge_proposal_score,
                        )
                    else:
                        logger.debug(
                            "merge_scheduling.merge_rejected",
                            iteration=self._state.iteration,
                            candidate_id=merge_result.candidate.id,
                            merge_score=merge_proposal_score,
                            best_score=self._state.best_score,
                        )
                elif merge_result is None:
                    # Merge not possible, decrement counter
                    if self._merges_due > 0:
                        self._merges_due -= 1

            # Record iteration with actual evolved component name (T033)
            # For single-component evolution, use the first (and only) component.
            # For multi-component round-robin, this tracks which component was
            # evolved in this iteration.
            if evolved_components_list:
                evolved_component_name = evolved_components_list[0]
            else:
                # Empty list indicates logic error - use first key from proposal
                logger.warning(
                    "engine.empty_evolved_components_list",
                    iteration=self._state.iteration,
                    proposal_keys=list(proposal.components.keys()),
                )
                evolved_component_name = next(iter(proposal.components.keys()))
            await self._record_iteration(
                score=proposal_score,
                component_text=proposal.components.get(evolved_component_name, ""),
                evolved_component=evolved_component_name,
                accepted=accepted,
                objective_scores=scoring_batch.objective_scores,
                # TODO: Surface last_reasoning through adapter protocol instead
                # of reaching into private _proposer attribute. Safe (defaults
                # to None) but couples engine to adapter internals.
                reflection_reasoning=getattr(
                    getattr(self.adapter, "_proposer", None),
                    "last_reasoning",
                    None,
                ),
                candidate_id=proposal.id,
                parent_ids=_parent_ids(proposal),
                rows_evaluated=len(scoring_batch.scores),
            )

            stop_reason = self._should_stop()

        # Build and return result
        return self._build_result(stop_reason=stop_reason)

pareto_state property

pareto_state: ParetoState | None

Return the current Pareto state, if initialized.

__init__

__init__(
    adapter: AsyncGEPAAdapter[
        DataInst, Trajectory, RolloutOutput
    ],
    config: EvolutionConfig,
    initial_candidate: Candidate,
    batch: list[DataInst],
    valset: list[DataInst] | None = None,
    candidate_selector: CandidateSelectorProtocol
    | None = None,
    component_selector: ComponentSelectorProtocol
    | None = None,
    evaluation_policy: EvaluationPolicyProtocol
    | None = None,
    merge_proposer: ProposerProtocol | None = None,
    rng: Random | None = None,
) -> None

Initialize the evolution engine.

PARAMETER DESCRIPTION
adapter

Implementation of AsyncGEPAAdapter protocol for evaluation and proposal generation.

TYPE: AsyncGEPAAdapter[DataInst, Trajectory, RolloutOutput]

config

Evolution parameters controlling iterations, thresholds, and early stopping.

TYPE: EvolutionConfig

initial_candidate

Starting candidate with at least one component.

TYPE: Candidate

batch

Trainset data instances for reflection and mutation.

TYPE: list[DataInst]

valset

Optional validation data for scoring candidates. Defaults to trainset when omitted. Must be non-empty if provided.

TYPE: list[DataInst] | None DEFAULT: None

candidate_selector

Optional selector strategy for Pareto-aware candidate sampling. When provided, initializes ParetoState for multi-objective tracking.

TYPE: CandidateSelectorProtocol | None DEFAULT: None

component_selector

Optional selector strategy for choosing which components to update. Defaults to RoundRobinComponentSelector.

TYPE: ComponentSelectorProtocol | None DEFAULT: None

evaluation_policy

Optional policy for selecting which validation examples to evaluate per iteration. Defaults to FullEvaluationPolicy. Requires candidate_selector, because the policy acts only through the Pareto state the selector creates.

TYPE: EvaluationPolicyProtocol | None DEFAULT: None

merge_proposer

Optional proposer for merge operations. If provided and config.use_merge is True, merge proposals will be attempted after successful mutations.

TYPE: ProposerProtocol | None DEFAULT: None

rng

Optional seeded random.Random instance for deterministic engine decisions. When provided, used for the auto-created merge proposer and for drawing reflection minibatch rows. None preserves current random behavior, and minibatch rows are then drawn from a random.Random(config.seed).

TYPE: Random | None DEFAULT: None

RAISES DESCRIPTION
ValueError

If batch is empty, valset is provided but empty, or initial_candidate has no components.

ConfigurationError

If evaluation_policy is provided without a candidate_selector, including an explicit FullEvaluationPolicy, or if config.checkpoint_path is set together with a candidate_selector (Pareto state is not checkpointed).

Examples:

Creating an engine:

engine = AsyncGEPAEngine(
    adapter=my_adapter,
    config=EvolutionConfig(max_iterations=50),
    initial_candidate=Candidate(components={"instruction": "Be helpful"}),
    batch=training_data,
    candidate_selector=selector,
)
Notes

Configures trainset and valset routing for reflection and scoring. A valset that is the same object as the batch (or omitted) marks the engine to reuse reflection batches for scoring. Initializes stopper lifecycle tracking for custom stop callbacks. Initializes the evaluation and pending failure counters, the pending and run token rollups, the pending and run reflection rollups (None until the proposer reports one), the last proposer rollup consumed (so an unchanged one is not recounted), the map from each scored candidate's id to its acceptance score, the random source for reflection minibatch rows, the slots holding the last mutation parent's trainset batch and the trainset rows it covers, the slots for the batch and rows the last minibatch gate evaluated, and the per-candidate map of cached batch rows (None for the full trainset). Initializes an in-memory streak of identical consecutive reflection errors (the cause's type name and text, the count, and the history index of the latest one), which run() resets and _record_reflection_error uses to enforce config.max_consecutive_reflection_errors. Valid selector and policy combinations: neither is full evaluation; a selector alone is full evaluation over the Pareto state; a selector with a policy uses that policy; a policy alone is rejected. A checkpoint path with a candidate selector is rejected too, and the restored-state flag starts False.

Source code in src/gepa_adk/engine/async_engine.py
def __init__(  # noqa: PLR0913, PLR0915  # public constructor; decomposition tracked in #507
    self,
    adapter: AsyncGEPAAdapter[DataInst, Trajectory, RolloutOutput],
    config: EvolutionConfig,
    initial_candidate: Candidate,
    batch: list[DataInst],
    valset: list[DataInst] | None = None,
    candidate_selector: CandidateSelectorProtocol | None = None,
    component_selector: ComponentSelectorProtocol | None = None,
    evaluation_policy: EvaluationPolicyProtocol | None = None,
    merge_proposer: ProposerProtocol | None = None,
    rng: random.Random | None = None,
) -> None:
    """Initialize the evolution engine.

    Args:
        adapter (AsyncGEPAAdapter[DataInst, Trajectory, RolloutOutput]):
            Implementation of AsyncGEPAAdapter protocol for evaluation and proposal
            generation.
        config (EvolutionConfig): Evolution parameters controlling iterations, thresholds,
            and early stopping.
        initial_candidate (Candidate): Starting candidate with at least one component.
        batch (list[DataInst]): Trainset data instances for reflection and mutation.
        valset (list[DataInst] | None): Optional validation data for scoring
            candidates. Defaults to trainset when omitted. Must be non-empty if
            provided.
        candidate_selector (CandidateSelectorProtocol | None): Optional selector
            strategy for Pareto-aware candidate sampling. When provided, initializes
            ParetoState for multi-objective tracking.
        component_selector (ComponentSelectorProtocol | None): Optional selector
            strategy for choosing which components to update. Defaults to
            RoundRobinComponentSelector.
        evaluation_policy (EvaluationPolicyProtocol | None): Optional policy for
            selecting which validation examples to evaluate per iteration. Defaults
            to FullEvaluationPolicy. Requires ``candidate_selector``, because the
            policy acts only through the Pareto state the selector creates.
        merge_proposer (ProposerProtocol | None): Optional proposer for merge
            operations. If provided and config.use_merge is True, merge proposals
            will be attempted after successful mutations.
        rng (random.Random | None): Optional seeded random.Random instance for
            deterministic engine decisions. When provided, used for the auto-created
            merge proposer and for drawing reflection minibatch rows. None preserves
            current random behavior, and minibatch rows are then drawn from a
            ``random.Random(config.seed)``.

    Raises:
        ValueError: If batch is empty, valset is provided but empty,
            or initial_candidate has no components.
        ConfigurationError: If evaluation_policy is provided without a
            candidate_selector, including an explicit FullEvaluationPolicy,
            or if ``config.checkpoint_path`` is set together with a
            candidate_selector (Pareto state is not checkpointed).

    Examples:
        Creating an engine:

        ```python
        engine = AsyncGEPAEngine(
            adapter=my_adapter,
            config=EvolutionConfig(max_iterations=50),
            initial_candidate=Candidate(components={"instruction": "Be helpful"}),
            batch=training_data,
            candidate_selector=selector,
        )
        ```

    Notes:
        Configures trainset and valset routing for reflection and scoring.
        A valset that is the same object as the batch (or omitted) marks
        the engine to reuse reflection batches for scoring.
        Initializes stopper lifecycle tracking for custom stop callbacks.
        Initializes the evaluation and pending failure counters, the
        pending and run token rollups, the pending and run reflection
        rollups (None until the proposer reports one), the last proposer
        rollup consumed (so an unchanged one is not recounted), the map
        from each scored
        candidate's id to its acceptance score, the random source for
        reflection minibatch rows, the slots holding the last mutation
        parent's trainset batch and the trainset rows it covers, the
        slots for the batch and rows the last minibatch gate evaluated,
        and the per-candidate map of cached batch rows (None for the
        full trainset).
        Initializes an in-memory streak of identical consecutive
        reflection errors (the cause's type name and text, the count, and
        the history index of the latest one), which ``run()`` resets and
        ``_record_reflection_error`` uses to enforce
        ``config.max_consecutive_reflection_errors``.
        Valid selector and policy combinations: neither is full
        evaluation; a selector alone is full evaluation over the Pareto
        state; a selector with a policy uses that policy; a policy alone
        is rejected. A checkpoint path with a candidate selector is
        rejected too, and the restored-state flag starts False.
    """
    # Validation
    if len(batch) == 0:
        raise ValueError("batch must contain at least one data instance")
    if valset is not None and len(valset) == 0:
        raise ValueError(
            "valset must contain at least one validation data instance"
        )

    if not initial_candidate.components:
        raise ValueError("initial_candidate must have at least one component")

    if evaluation_policy is not None and candidate_selector is None:
        raise ConfigurationError(
            "evaluation_policy requires a candidate_selector: a policy "
            "takes effect only through the Pareto state a candidate "
            "selector creates",
            field="evaluation_policy",
            value=type(evaluation_policy).__name__,
            constraint="candidate_selector is not None",
        )

    if config.checkpoint_path is not None and candidate_selector is not None:
        raise ConfigurationError(
            "checkpoint_path cannot be combined with a candidate_selector: "
            "Pareto state is not checkpointed yet",
            field="candidate_selector",
            value=type(candidate_selector).__name__,
            constraint="candidate_selector is None when checkpoint_path is set",
        )

    # Store dependencies
    self.adapter = adapter
    self.config = config
    self._initial_candidate = initial_candidate
    self._trainset = batch
    self._valset = valset if valset is not None else batch
    # Identity, not equality: an equal-but-separate valset is scored apart.
    self._valset_is_trainset = self._valset is self._trainset
    self._state: _EngineState | None = None
    self._rng = rng
    # Draws each iteration's reflection minibatch rows
    self._minibatch_rng = rng if rng is not None else random.Random(config.seed)  # noqa: S311  # evolution sampling, not security
    # Trainset batch of the parent the last mutation reflected on, and
    # the trainset rows it covers (None: the full trainset in order)
    self._mutation_parent_batch: EvaluationBatch | None = None
    self._mutation_parent_rows: list[int] | None = None
    # The batch and rows the last minibatch gate evaluated the proposal on
    self._gate_batch: EvaluationBatch | None = None
    self._gate_rows: list[int] | None = None
    self._candidate_selector = candidate_selector
    self._component_selector = component_selector or RoundRobinComponentSelector()
    self._pareto_state: ParetoState | None = None
    self._candidate_eval_batches: dict[int, EvaluationBatch] = {}
    # Trainset rows each cached batch covers (None: the full trainset)
    self._candidate_eval_rows: dict[int, list[int] | None] = {}
    # Acceptance score of every candidate scored this run, keyed by id
    self._scored: dict[str, float] = {}
    if merge_proposer is not None:
        self._merge_proposer = merge_proposer
    elif config.use_merge:
        self._merge_proposer = MergeProposer(rng=rng or random.Random())  # noqa: S311  # evolution sampling, not security
    else:
        self._merge_proposer = None
    self._merges_due: int = 0
    self._merge_invocations: int = 0
    # Stopper state tracking (T001, T002)
    self._start_time: float | None = None
    self._total_evaluations: int = 0
    # Failed rows evaluated since the last record (see _count_batch)
    self._pending_failed_evaluations: int = 0
    # Token usage since the last record and over the run (see _count_batch)
    self._pending_token_usage: TokenRollup = _ZERO_TOKENS
    self._run_token_usage: TokenRollup = _ZERO_TOKENS
    # Reflection usage of this iteration and of the run; None = not observed
    self._pending_reflection_usage: TokenRollup | None = None
    self._run_reflection_usage: TokenRollup | None = None
    # Last proposer rollup consumed, so an unchanged one is not recounted
    self._consumed_proposer_usage: TokenRollup | None = None
    self._active_stoppers: list[object] = []
    # Run of identical consecutive reflection errors (in memory only):
    # the cause's (type name, text), how many in a row, and the history
    # index of the latest one
    self._reflection_error_signature: tuple[str, str] | None = None
    self._reflection_error_streak: int = 0
    self._reflection_error_index: int = -1
    # True once run() restored state from a checkpoint (skips the baseline)
    self._restored: bool = False
    if evaluation_policy is None:
        self._evaluation_policy: EvaluationPolicyProtocol = FullEvaluationPolicy()
    else:
        self._evaluation_policy = evaluation_policy

run async

run() -> EvolutionResult

Execute the evolution loop.

Runs the core evolution loop: 1. Evaluate baseline candidate 2. For each iteration until max_iterations or convergence: a. Generate reflective dataset from traces b. Propose new candidate text c. Evaluate proposal d. Accept if improves above threshold e. Record iteration 3. Return frozen EvolutionResult 4. On KeyboardInterrupt or asyncio.CancelledError, return partial result with best-so-far components 5. On an EvolutionError after the baseline, attach a partial result to the error and re-raise it

RETURNS DESCRIPTION
EvolutionResult

EvolutionResult containing evolution metrics and components.

EvolutionResult

On normal completion, stop_reason reflects the termination

EvolutionResult

condition. On interrupt, a partial result is returned with

EvolutionResult

StopReason.KEYBOARD_INTERRUPT or StopReason.CANCELLED.

RAISES DESCRIPTION
KeyboardInterrupt

Re-raised if interrupt occurs before baseline evaluation completes (no meaningful partial result possible).

CancelledError

Re-raised if cancellation occurs before baseline evaluation completes.

EvolutionError

Re-raised when it aborts the run, for example a non-retryable ReflectionError (also raised after config.max_consecutive_reflection_errors identical reflection errors in a row). When the baseline was scored, its partial_result holds an EvolutionResult with StopReason.ERROR, the recorded iterations and the best candidate so far; otherwise it stays None.

Exception

Adapter Exception subclasses propagate unchanged.

Examples:

Running evolution:

result = await engine.run()
print(f"Improved: {result.improved}")
print(f"Best score: {result.final_score}")
Notes

Outputs a frozen EvolutionResult after completing the evolution loop. Engine instance should not be reused after run() completes. Method is idempotent if called multiple times (restarts fresh): the evaluation counters and the scored-candidate map used for duplicate detection are cleared before the baseline runs. Fail-fast behavior: adapter Exception subclasses propagate unchanged. KeyboardInterrupt and asyncio.CancelledError (BaseException subclasses) are caught and converted to partial results with appropriate StopReason. An EvolutionError is logged as evolution.aborted at error level, without a traceback, and re-raised; after the baseline it carries partial_result whose total_iterations counts recorded iterations only, since the aborted iteration was never recorded. Logs seed value at start for reproducibility tracking, and logs reflection.minibatch.enabled when a reflection minibatch smaller than the trainset is in effect; with a separate valset a proposal that passes that gate is scored on the valset without a full trainset pass, and trainset_score is then the mean over the accepted candidate's sampled rows. _reset_run_state resets the evaluation counter, the pending failure counter, the engine state, the pending and run token rollups and the pending and run reflection rollups at start, so a reused engine never attaches an earlier run's state to partial_result. With config.resume it then restores the checkpoint through _resume_from_checkpoint, which sets the counters and scored map from the file, and the loop skips the baseline. An exception raised by config.proposal_validator is not an EvolutionError, so it propagates unchanged without a partial result; a rejection it returns is recorded with skip_reason="proposal_rejected".

Source code in src/gepa_adk/engine/async_engine.py
async def run(self) -> EvolutionResult:
    """Execute the evolution loop.

    Runs the core evolution loop:
    1. Evaluate baseline candidate
    2. For each iteration until max_iterations or convergence:
       a. Generate reflective dataset from traces
       b. Propose new candidate text
       c. Evaluate proposal
       d. Accept if improves above threshold
       e. Record iteration
    3. Return frozen EvolutionResult
    4. On ``KeyboardInterrupt`` or ``asyncio.CancelledError``, return
       partial result with best-so-far components
    5. On an ``EvolutionError`` after the baseline, attach a partial
       result to the error and re-raise it

    Returns:
        EvolutionResult containing evolution metrics and components.
        On normal completion, ``stop_reason`` reflects the termination
        condition. On interrupt, a partial result is returned with
        ``StopReason.KEYBOARD_INTERRUPT`` or ``StopReason.CANCELLED``.

    Raises:
        KeyboardInterrupt: Re-raised if interrupt occurs before baseline
            evaluation completes (no meaningful partial result possible).
        asyncio.CancelledError: Re-raised if cancellation occurs before
            baseline evaluation completes.
        EvolutionError: Re-raised when it aborts the run, for example a
            non-retryable ``ReflectionError`` (also raised after
            ``config.max_consecutive_reflection_errors`` identical
            reflection errors in a row). When the baseline was
            scored, its ``partial_result`` holds an ``EvolutionResult``
            with ``StopReason.ERROR``, the recorded iterations and the
            best candidate so far; otherwise it stays ``None``.
        Exception: Adapter ``Exception`` subclasses propagate unchanged.

    Examples:
        Running evolution:

        ```python
        result = await engine.run()
        print(f"Improved: {result.improved}")
        print(f"Best score: {result.final_score}")
        ```

    Notes:
        Outputs a frozen EvolutionResult after completing the evolution
        loop. Engine instance should not be reused after run() completes.
        Method is idempotent if called multiple times (restarts fresh):
        the evaluation counters and the scored-candidate map used for
        duplicate detection are cleared before the baseline runs.
        Fail-fast behavior: adapter ``Exception`` subclasses propagate
        unchanged. ``KeyboardInterrupt`` and ``asyncio.CancelledError``
        (``BaseException`` subclasses) are caught and converted to partial
        results with appropriate ``StopReason``. An ``EvolutionError`` is
        logged as ``evolution.aborted`` at error level, without a
        traceback, and re-raised; after the
        baseline it carries ``partial_result`` whose
        ``total_iterations`` counts recorded iterations only, since the
        aborted iteration was never recorded. Logs seed value at start
        for reproducibility tracking, and logs
        ``reflection.minibatch.enabled`` when a reflection minibatch
        smaller than the trainset is in effect; with a separate valset a
        proposal that passes that gate is scored on the valset without a
        full trainset pass, and ``trainset_score`` is then the mean over
        the accepted candidate's sampled rows. ``_reset_run_state``
        resets the evaluation counter, the pending failure counter, the
        engine state, the pending and run token rollups and the pending
        and run reflection rollups at start, so a reused engine
        never attaches an earlier run's state to ``partial_result``.
        With ``config.resume`` it then restores
        the checkpoint through ``_resume_from_checkpoint``, which sets
        the counters and scored map from the file, and the loop skips
        the baseline. An exception raised by
        ``config.proposal_validator`` is not an ``EvolutionError``, so it
        propagates unchanged without a partial result; a rejection it
        returns is recorded with ``skip_reason="proposal_rejected"``.
    """
    logger.info("engine.start", seed=self.config.seed)
    if self._effective_minibatch_size() is not None:
        logger.info(
            "reflection.minibatch.enabled",
            size=self.config.reflection_minibatch_size,
            trainset_size=len(self._trainset),
        )

    self._reset_run_state()
    if self.config.resume:
        self._resume_from_checkpoint()

    # Setup stopper lifecycle (T023)
    setup_stoppers = self._setup_stoppers()

    try:
        return await self._run_evolution_loop()
    except EvolutionError as error:
        logger.error(  # noqa: TRY400  # re-raised below with partial_result attached
            "evolution.aborted",
            iteration=self._state.iteration if self._state else 0,
            error_type=type(error).__name__,
            error=str(error),
        )
        if self._state is not None:
            # The aborted iteration was never recorded
            self._state.iteration = len(self._state.iteration_history)
            error.partial_result = self._build_result(stop_reason=StopReason.ERROR)
        raise
    except KeyboardInterrupt:
        logger.info(
            "evolution.interrupted",
            iteration=self._state.iteration if self._state else 0,
        )
        if self._state is None:
            raise
        return self._build_result(stop_reason=StopReason.KEYBOARD_INTERRUPT)
    except asyncio.CancelledError:
        logger.info(
            "evolution.cancelled",
            iteration=self._state.iteration if self._state else 0,
        )
        if self._state is None:
            raise
        return self._build_result(stop_reason=StopReason.CANCELLED)
    finally:
        # Cleanup stopper lifecycle (T024)
        self._cleanup_stoppers(setup_stoppers)

MergeProposer

Proposer that combines two Pareto-optimal candidates via genetic crossover.

Selects two candidates from the frontier that share a common ancestor, identifies which components each improved, and creates a merged candidate that combines improvements from both branches.

ATTRIBUTE DESCRIPTION
rng

Random number generator for candidate selection.

TYPE: Random

val_overlap_floor

Minimum overlapping validation coverage required.

TYPE: int

max_attempts

Maximum merge attempts before giving up.

TYPE: int

attempted_merges

Set of attempted merge triplets to prevent duplicates.

TYPE: set[AncestorLog]

Examples:

Creating a merge proposer:

proposer = MergeProposer(rng=random.Random(42))
result = await proposer.propose(state)
if result:
    print(f"Merged from parents {result.parent_indices}")

Notes: A proposer that combines two Pareto-optimal candidates via genetic crossover. Selects candidates from the frontier that share a common ancestor and merges their complementary component improvements.

Source code in src/gepa_adk/engine/merge_proposer.py
class MergeProposer:
    """Proposer that combines two Pareto-optimal candidates via genetic crossover.

    Selects two candidates from the frontier that share a common ancestor,
    identifies which components each improved, and creates a merged candidate
    that combines improvements from both branches.

    Attributes:
        rng (random.Random): Random number generator for candidate selection.
        val_overlap_floor (int): Minimum overlapping validation coverage required.
        max_attempts (int): Maximum merge attempts before giving up.
        attempted_merges (set[AncestorLog]): Set of attempted merge triplets to prevent duplicates.

    Examples:
        Creating a merge proposer:

        ```python
        proposer = MergeProposer(rng=random.Random(42))
        result = await proposer.propose(state)
        if result:
            print(f"Merged from parents {result.parent_indices}")
        ```
    Notes:
        A proposer that combines two Pareto-optimal candidates via genetic crossover.
        Selects candidates from the frontier that share a common ancestor and merges
        their complementary component improvements.
    """

    def __init__(
        self,
        rng: random.Random,
        val_overlap_floor: int = 5,
        max_attempts: int = 10,
    ) -> None:
        """Initialize MergeProposer.

        Args:
            rng: Random number generator for candidate selection.
            val_overlap_floor: Minimum overlapping validation examples required.
            max_attempts: Maximum merge attempts before giving up.

        Notes:
            The ``attempted_merges`` set starts empty so previously tried
            parent pairs can be skipped on subsequent calls.
        """
        self.rng = rng
        self.val_overlap_floor = val_overlap_floor
        self.max_attempts = max_attempts
        self.attempted_merges: set[AncestorLog] = set()

    async def propose(
        self,
        state: ParetoState,
        eval_batch: object | None = None,  # Ignored for merge proposals
    ) -> ProposalResult | None:
        """Attempt to merge two frontier candidates.

        Args:
            state (ParetoState): Current Pareto state with candidates, scores, and genealogy.
                Must contain at least 2 candidates on the Pareto frontier with a shared
                common ancestor for merge to succeed.
            eval_batch (object | None): Ignored for merge proposals. Merge operations
                do not require evaluation batch data as they combine existing candidates.

        Returns:
            ProposalResult | None: ProposalResult with merged candidate and both parent indices,
            or None if merge not possible (e.g., no common ancestor, insufficient frontier,
            or no complementary component changes).

        Examples:
            Proposing a merge from evolution state:

            ```python
            proposer = MergeProposer(rng=random.Random(42))
            result = await proposer.propose(state)
            if result:
                print(f"Merged from parents {result.parent_indices}")
                print(f"Ancestor: {result.metadata['ancestor_idx']}")
            ```

        Notes:
            Operations select candidates from Pareto frontier only. Requires common ancestor
            and complementary component changes for successful merge. Validates
            minimum validation overlap before merging. The common ancestor is
            found over ``state.parent_indices``, where each mutation's edge
            points at the candidate the reflector actually rewrote.
        """
        # Find suitable merge candidates
        merge_candidates = self._find_merge_candidates(state)
        if merge_candidates is None:
            logger.debug("merge_proposer.no_candidates", reason="no_suitable_pair")
            return None

        parent1_idx, parent2_idx, ancestor_idx = merge_candidates

        # Get candidate components
        ancestor = state.candidates[ancestor_idx]
        parent1 = state.candidates[parent1_idx]
        parent2 = state.candidates[parent2_idx]

        # Check if merge is desirable (complementary changes)
        if not has_desirable_predictors(
            ancestor.components, parent1.components, parent2.components
        ):
            logger.debug(
                "merge_proposer.no_desirable_predictors",
                parent1_idx=parent1_idx,
                parent2_idx=parent2_idx,
                ancestor_idx=ancestor_idx,
            )
            return None

        # Check validation overlap
        scores1 = state.candidate_scores.get(parent1_idx, {})
        scores2 = state.candidate_scores.get(parent2_idx, {})
        overlap = set(scores1.keys()) & set(scores2.keys())
        valid_overlap = {idx for idx in overlap if isinstance(idx, int) and idx >= 0}
        if len(valid_overlap) < self.val_overlap_floor:
            logger.debug(
                "merge_proposer.insufficient_overlap",
                parent1_idx=parent1_idx,
                parent2_idx=parent2_idx,
                overlap_count=len(valid_overlap),
                required=self.val_overlap_floor,
            )
            return None

        # Calculate average scores for component selection
        avg_score1 = fmean(scores1.values()) if scores1 else 0.0
        avg_score2 = fmean(scores2.values()) if scores2 else 0.0

        # Merge components
        merged_components = self._merge_components(
            ancestor.components,
            parent1.components,
            parent2.components,
            avg_score1,
            avg_score2,
        )

        # Create merged candidate
        merged_candidate = Candidate(
            components=merged_components,
            generation=max(parent1.generation, parent2.generation) + 1,
            parent_ids=[parent1_idx, parent2_idx],
        )

        # Log successful merge
        logger.info(
            "merge_proposer.merge_success",
            parent1_idx=parent1_idx,
            parent2_idx=parent2_idx,
            ancestor_idx=ancestor_idx,
            components_merged=list(merged_components.keys()),
        )

        return ProposalResult(
            candidate=merged_candidate,
            parent_indices=[parent1_idx, parent2_idx],
            tag="merge",
            metadata={"ancestor_idx": ancestor_idx},
        )

    def _find_merge_candidates(
        self,
        state: ParetoState,
    ) -> tuple[int, int, int] | None:
        """Find two candidates suitable for merging.

        Args:
            state (ParetoState): Current Pareto state.

        Returns:
            Tuple of (parent1_idx, parent2_idx, ancestor_idx), or None when
            the frontier holds fewer than two candidates or no suitable pair
            is found within ``max_attempts`` samples.

        Notes:
            Searches for suitable merge candidates from the Pareto frontier.
            Requires common ancestor and prevents duplicate merge attempts.
        """
        # Get frontier candidates (non-dominated)
        frontier_candidates = state.frontier.get_non_dominated()

        if len(frontier_candidates) < 2:  # noqa: PLR2004  # a merge needs two distinct parents
            logger.debug(
                "merge_proposer.insufficient_frontier",
                frontier_size=len(frontier_candidates),
            )
            return None

        # Try to find a suitable pair
        for _ in range(self.max_attempts):
            # Sample two different candidates from frontier
            candidate_list = list(frontier_candidates)
            if len(candidate_list) < 2:  # noqa: PLR2004  # a merge needs two distinct parents
                return None

            parent1_idx = self.rng.choice(candidate_list)
            parent2_idx = self.rng.choice(candidate_list)

            # Must be different candidates
            if parent1_idx == parent2_idx:
                continue

            # Ensure consistent ordering for deduplication
            if parent1_idx > parent2_idx:
                parent1_idx, parent2_idx = parent2_idx, parent1_idx

            # Find common ancestor
            ancestor_idx = find_common_ancestor(
                parent1_idx, parent2_idx, state.parent_indices
            )

            if ancestor_idx is None:
                continue

            # Check if already attempted
            merge_log: AncestorLog = (parent1_idx, parent2_idx, ancestor_idx)
            if merge_log in self.attempted_merges:
                continue

            # Check ancestor score constraint (ancestor should not be better than descendants)
            ancestor_scores = state.candidate_scores.get(ancestor_idx, {})
            parent1_scores = state.candidate_scores.get(parent1_idx, {})
            parent2_scores = state.candidate_scores.get(parent2_idx, {})

            if ancestor_scores and parent1_scores and parent2_scores:
                ancestor_avg = fmean(ancestor_scores.values())
                parent1_avg = fmean(parent1_scores.values())
                parent2_avg = fmean(parent2_scores.values())

                # Ancestor should not be better than both descendants
                if ancestor_avg > max(parent1_avg, parent2_avg):
                    logger.debug(
                        "merge_proposer.ancestor_too_good",
                        ancestor_idx=ancestor_idx,
                        ancestor_avg=ancestor_avg,
                        parent1_avg=parent1_avg,
                        parent2_avg=parent2_avg,
                    )
                    continue

            # Mark as attempted
            self.attempted_merges.add(merge_log)

            return (parent1_idx, parent2_idx, ancestor_idx)

        logger.debug("merge_proposer.max_attempts_exceeded", attempts=self.max_attempts)
        return None

    def _merge_components(
        self,
        ancestor: dict[str, str],
        parent1: dict[str, str],
        parent2: dict[str, str],
        score1: float,
        score2: float,
    ) -> dict[str, str]:
        """Merge components from two parents based on ancestor divergence.

        Args:
            ancestor (dict[str, str]): Component dictionary from common ancestor.
            parent1 (dict[str, str]): Component dictionary from first parent.
            parent2 (dict[str, str]): Component dictionary from second parent.
            score1 (float): Average score of first parent.
            score2 (float): Average score of second parent.

        Returns:
            Merged component dictionary.

        Notes:
            Strategy for merging components:
            - If both parents same → take either
            - If one unchanged from ancestor, other changed → take changed value
            - If both changed differently → take higher scorer's value
              (the first parent's on a tie)
            Components present only in parents (not ancestor) are ignored.
        """
        merged: dict[str, str] = {}

        for key, anc_val in ancestor.items():
            p1_val = parent1.get(key, anc_val)
            p2_val = parent2.get(key, anc_val)

            if p1_val == p2_val:
                # Both same - take either
                merged[key] = p1_val
            elif p1_val == anc_val and p2_val != anc_val:
                # P1 unchanged, P2 changed - take P2's innovation
                merged[key] = p2_val
            elif p2_val == anc_val and p1_val != anc_val:
                # P2 unchanged, P1 changed - take P1's innovation
                merged[key] = p1_val
            else:
                # Both changed differently - take higher scorer's value
                merged[key] = p1_val if score1 >= score2 else p2_val

        return merged

__init__

__init__(
    rng: Random,
    val_overlap_floor: int = 5,
    max_attempts: int = 10,
) -> None

Initialize MergeProposer.

PARAMETER DESCRIPTION
rng

Random number generator for candidate selection.

TYPE: Random

val_overlap_floor

Minimum overlapping validation examples required.

TYPE: int DEFAULT: 5

max_attempts

Maximum merge attempts before giving up.

TYPE: int DEFAULT: 10

Notes

The attempted_merges set starts empty so previously tried parent pairs can be skipped on subsequent calls.

Source code in src/gepa_adk/engine/merge_proposer.py
def __init__(
    self,
    rng: random.Random,
    val_overlap_floor: int = 5,
    max_attempts: int = 10,
) -> None:
    """Initialize MergeProposer.

    Args:
        rng: Random number generator for candidate selection.
        val_overlap_floor: Minimum overlapping validation examples required.
        max_attempts: Maximum merge attempts before giving up.

    Notes:
        The ``attempted_merges`` set starts empty so previously tried
        parent pairs can be skipped on subsequent calls.
    """
    self.rng = rng
    self.val_overlap_floor = val_overlap_floor
    self.max_attempts = max_attempts
    self.attempted_merges: set[AncestorLog] = set()

propose async

propose(
    state: ParetoState, eval_batch: object | None = None
) -> ProposalResult | None

Attempt to merge two frontier candidates.

PARAMETER DESCRIPTION
state

Current Pareto state with candidates, scores, and genealogy. Must contain at least 2 candidates on the Pareto frontier with a shared common ancestor for merge to succeed.

TYPE: ParetoState

eval_batch

Ignored for merge proposals. Merge operations do not require evaluation batch data as they combine existing candidates.

TYPE: object | None DEFAULT: None

RETURNS DESCRIPTION
ProposalResult | None

ProposalResult | None: ProposalResult with merged candidate and both parent indices,

ProposalResult | None

or None if merge not possible (e.g., no common ancestor, insufficient frontier,

ProposalResult | None

or no complementary component changes).

Examples:

Proposing a merge from evolution state:

proposer = MergeProposer(rng=random.Random(42))
result = await proposer.propose(state)
if result:
    print(f"Merged from parents {result.parent_indices}")
    print(f"Ancestor: {result.metadata['ancestor_idx']}")
Notes

Operations select candidates from Pareto frontier only. Requires common ancestor and complementary component changes for successful merge. Validates minimum validation overlap before merging. The common ancestor is found over state.parent_indices, where each mutation's edge points at the candidate the reflector actually rewrote.

Source code in src/gepa_adk/engine/merge_proposer.py
async def propose(
    self,
    state: ParetoState,
    eval_batch: object | None = None,  # Ignored for merge proposals
) -> ProposalResult | None:
    """Attempt to merge two frontier candidates.

    Args:
        state (ParetoState): Current Pareto state with candidates, scores, and genealogy.
            Must contain at least 2 candidates on the Pareto frontier with a shared
            common ancestor for merge to succeed.
        eval_batch (object | None): Ignored for merge proposals. Merge operations
            do not require evaluation batch data as they combine existing candidates.

    Returns:
        ProposalResult | None: ProposalResult with merged candidate and both parent indices,
        or None if merge not possible (e.g., no common ancestor, insufficient frontier,
        or no complementary component changes).

    Examples:
        Proposing a merge from evolution state:

        ```python
        proposer = MergeProposer(rng=random.Random(42))
        result = await proposer.propose(state)
        if result:
            print(f"Merged from parents {result.parent_indices}")
            print(f"Ancestor: {result.metadata['ancestor_idx']}")
        ```

    Notes:
        Operations select candidates from Pareto frontier only. Requires common ancestor
        and complementary component changes for successful merge. Validates
        minimum validation overlap before merging. The common ancestor is
        found over ``state.parent_indices``, where each mutation's edge
        points at the candidate the reflector actually rewrote.
    """
    # Find suitable merge candidates
    merge_candidates = self._find_merge_candidates(state)
    if merge_candidates is None:
        logger.debug("merge_proposer.no_candidates", reason="no_suitable_pair")
        return None

    parent1_idx, parent2_idx, ancestor_idx = merge_candidates

    # Get candidate components
    ancestor = state.candidates[ancestor_idx]
    parent1 = state.candidates[parent1_idx]
    parent2 = state.candidates[parent2_idx]

    # Check if merge is desirable (complementary changes)
    if not has_desirable_predictors(
        ancestor.components, parent1.components, parent2.components
    ):
        logger.debug(
            "merge_proposer.no_desirable_predictors",
            parent1_idx=parent1_idx,
            parent2_idx=parent2_idx,
            ancestor_idx=ancestor_idx,
        )
        return None

    # Check validation overlap
    scores1 = state.candidate_scores.get(parent1_idx, {})
    scores2 = state.candidate_scores.get(parent2_idx, {})
    overlap = set(scores1.keys()) & set(scores2.keys())
    valid_overlap = {idx for idx in overlap if isinstance(idx, int) and idx >= 0}
    if len(valid_overlap) < self.val_overlap_floor:
        logger.debug(
            "merge_proposer.insufficient_overlap",
            parent1_idx=parent1_idx,
            parent2_idx=parent2_idx,
            overlap_count=len(valid_overlap),
            required=self.val_overlap_floor,
        )
        return None

    # Calculate average scores for component selection
    avg_score1 = fmean(scores1.values()) if scores1 else 0.0
    avg_score2 = fmean(scores2.values()) if scores2 else 0.0

    # Merge components
    merged_components = self._merge_components(
        ancestor.components,
        parent1.components,
        parent2.components,
        avg_score1,
        avg_score2,
    )

    # Create merged candidate
    merged_candidate = Candidate(
        components=merged_components,
        generation=max(parent1.generation, parent2.generation) + 1,
        parent_ids=[parent1_idx, parent2_idx],
    )

    # Log successful merge
    logger.info(
        "merge_proposer.merge_success",
        parent1_idx=parent1_idx,
        parent2_idx=parent2_idx,
        ancestor_idx=ancestor_idx,
        components_merged=list(merged_components.keys()),
    )

    return ProposalResult(
        candidate=merged_candidate,
        parent_indices=[parent1_idx, parent2_idx],
        tag="merge",
        metadata={"ancestor_idx": ancestor_idx},
    )

CheckpointSummary dataclass

Read-only summary of an engine checkpoint file.

ATTRIBUTE DESCRIPTION
best_candidate

Best candidate found so far.

TYPE: Candidate

best_score

Score of the best candidate.

TYPE: float

original_score

Score of the initial candidate.

TYPE: float

best_valset_mean

Valset mean of the best candidate, or None when none was recorded.

TYPE: float | None

best_objective_scores

Per-row objective scores of the best candidate, or None.

TYPE: list[dict[str, float]] | None

iteration

Last completed iteration number.

TYPE: int

iteration_history

One record per recorded iteration, in order.

TYPE: tuple[IterationRecord, ...]

total_evaluations

Rows evaluated so far.

TYPE: int

token_usage

The run's token rollup so far; unknown counters with rows_unknown=total_evaluations when the file stores none.

TYPE: TokenRollup

Examples:

summary = inspect_checkpoint("runs/checkpoint.json")
print(summary.best_candidate.components, summary.best_score)
Source code in src/gepa_adk/engine/checkpoint.py
@dataclass(frozen=True, slots=True)
class CheckpointSummary:
    """Read-only summary of an engine checkpoint file.

    Attributes:
        best_candidate (Candidate): Best candidate found so far.
        best_score (float): Score of the best candidate.
        original_score (float): Score of the initial candidate.
        best_valset_mean (float | None): Valset mean of the best candidate,
            or None when none was recorded.
        best_objective_scores (list[dict[str, float]] | None): Per-row
            objective scores of the best candidate, or None.
        iteration (int): Last completed iteration number.
        iteration_history (tuple[IterationRecord, ...]): One record per
            recorded iteration, in order.
        total_evaluations (int): Rows evaluated so far.
        token_usage (TokenRollup): The run's token rollup so far; unknown
            counters with ``rows_unknown=total_evaluations`` when the file
            stores none.

    Examples:
        ```python
        summary = inspect_checkpoint("runs/checkpoint.json")
        print(summary.best_candidate.components, summary.best_score)
        ```
    """

    best_candidate: Candidate
    best_score: float
    original_score: float
    best_valset_mean: float | None
    best_objective_scores: list[dict[str, float]] | None
    iteration: int
    iteration_history: tuple[IterationRecord, ...]
    total_evaluations: int
    token_usage: TokenRollup

AgentProvider

Bases: Protocol


              flowchart TD
              gepa_adk.AgentProvider[AgentProvider]

              

              click gepa_adk.AgentProvider href "" "gepa_adk.AgentProvider"
            

Protocol for loading and persisting agents.

Implementations provide agent configuration storage and retrieval, enabling the evolution system to load agents and persist evolved instructions.

Examples:

Implement a minimal in-memory provider:

class InMemoryProvider:
    def __init__(self):
        self._agents = {}

    def get_agent(self, name: str) -> LlmAgent:
        if not name:
            raise ValueError("Agent name cannot be empty")
        if name not in self._agents:
            raise KeyError(f"Agent not found: {name}")
        return self._agents[name]

    def save_instruction(self, name: str, instruction: str) -> None:
        if not name:
            raise ValueError("Agent name cannot be empty")
        if name not in self._agents:
            raise KeyError(f"Agent not found: {name}")
        self._agents[name].instruction = instruction

    def list_agents(self) -> list[str]:
        return list(self._agents.keys())

Verify protocol compliance:

from gepa_adk.ports import AgentProvider

provider = InMemoryProvider()
assert isinstance(provider, AgentProvider)  # Runtime check works
Notes

All implementations must provide get_agent(), save_instruction(), and list_agents() methods. Use @runtime_checkable to enable isinstance() checks for protocol compliance.

Source code in src/gepa_adk/ports/agent_provider.py
@runtime_checkable
class AgentProvider(Protocol):
    """Protocol for loading and persisting agents.

    Implementations provide agent configuration storage and retrieval,
    enabling the evolution system to load agents and persist evolved
    instructions.

    Examples:
        Implement a minimal in-memory provider:

        ```python
        class InMemoryProvider:
            def __init__(self):
                self._agents = {}

            def get_agent(self, name: str) -> LlmAgent:
                if not name:
                    raise ValueError("Agent name cannot be empty")
                if name not in self._agents:
                    raise KeyError(f"Agent not found: {name}")
                return self._agents[name]

            def save_instruction(self, name: str, instruction: str) -> None:
                if not name:
                    raise ValueError("Agent name cannot be empty")
                if name not in self._agents:
                    raise KeyError(f"Agent not found: {name}")
                self._agents[name].instruction = instruction

            def list_agents(self) -> list[str]:
                return list(self._agents.keys())
        ```

        Verify protocol compliance:

        ```python
        from gepa_adk.ports import AgentProvider

        provider = InMemoryProvider()
        assert isinstance(provider, AgentProvider)  # Runtime check works
        ```

    Notes:
        All implementations must provide get_agent(), save_instruction(),
        and list_agents() methods. Use @runtime_checkable to enable
        isinstance() checks for protocol compliance.
    """

    def get_agent(self, name: str) -> LlmAgent:
        """Load an agent by its unique name.

        Args:
            name (str): The unique identifier for the agent.

        Returns:
            The configured LlmAgent instance ready for use.

        Raises:
            KeyError: If no agent with the given name exists.
            ValueError: If name is empty or invalid.

        Examples:
            Load a named agent:

            ```python
            provider = MyAgentProvider()
            agent = provider.get_agent("my_agent")
            print(agent.instruction)
            ```

        Notes:
            Only non-empty names are accepted. Configured agents ready
            for use with the evolution system should be returned.
        """
        ...

    def save_instruction(self, name: str, instruction: str) -> None:
        """Persist an evolved instruction for a named agent.

        Args:
            name: The unique identifier for the agent.
            instruction: The new instruction text to persist.

        Raises:
            KeyError: If no agent with the given name exists.
            ValueError: If name is empty or invalid.
            IOError: If persistence fails (implementation-specific).

        Examples:
            Save an evolved instruction:

            ```python
            provider.save_instruction(
                "my_agent", "You are a helpful assistant specialized in coding."
            )
            ```

        Notes:
            Only after successful persistence should subsequent calls to
            get_agent() return an agent with the updated instruction.
            Implementations should validate empty names before processing.
        """
        ...

    def list_agents(self) -> list[str]:
        """List all available agent names.

        Returns:
            A list of agent name strings. Empty list if no agents.

        Examples:
            Discover available agents:

            ```python
            provider = MyAgentProvider()
            for name in provider.list_agents():
                print(f"Found agent: {name}")
            ```

        Notes:
            Ordering of returned names is not guaranteed by the protocol.
            Some implementations may return names in a specific order,
            but callers should not rely upon any particular sequence.
        """
        ...

get_agent

get_agent(name: str) -> LlmAgent

Load an agent by its unique name.

PARAMETER DESCRIPTION
name

The unique identifier for the agent.

TYPE: str

RETURNS DESCRIPTION
LlmAgent

The configured LlmAgent instance ready for use.

RAISES DESCRIPTION
KeyError

If no agent with the given name exists.

ValueError

If name is empty or invalid.

Examples:

Load a named agent:

provider = MyAgentProvider()
agent = provider.get_agent("my_agent")
print(agent.instruction)
Notes

Only non-empty names are accepted. Configured agents ready for use with the evolution system should be returned.

Source code in src/gepa_adk/ports/agent_provider.py
def get_agent(self, name: str) -> LlmAgent:
    """Load an agent by its unique name.

    Args:
        name (str): The unique identifier for the agent.

    Returns:
        The configured LlmAgent instance ready for use.

    Raises:
        KeyError: If no agent with the given name exists.
        ValueError: If name is empty or invalid.

    Examples:
        Load a named agent:

        ```python
        provider = MyAgentProvider()
        agent = provider.get_agent("my_agent")
        print(agent.instruction)
        ```

    Notes:
        Only non-empty names are accepted. Configured agents ready
        for use with the evolution system should be returned.
    """
    ...

save_instruction

save_instruction(name: str, instruction: str) -> None

Persist an evolved instruction for a named agent.

PARAMETER DESCRIPTION
name

The unique identifier for the agent.

TYPE: str

instruction

The new instruction text to persist.

TYPE: str

RAISES DESCRIPTION
KeyError

If no agent with the given name exists.

ValueError

If name is empty or invalid.

IOError

If persistence fails (implementation-specific).

Examples:

Save an evolved instruction:

provider.save_instruction(
    "my_agent", "You are a helpful assistant specialized in coding."
)
Notes

Only after successful persistence should subsequent calls to get_agent() return an agent with the updated instruction. Implementations should validate empty names before processing.

Source code in src/gepa_adk/ports/agent_provider.py
def save_instruction(self, name: str, instruction: str) -> None:
    """Persist an evolved instruction for a named agent.

    Args:
        name: The unique identifier for the agent.
        instruction: The new instruction text to persist.

    Raises:
        KeyError: If no agent with the given name exists.
        ValueError: If name is empty or invalid.
        IOError: If persistence fails (implementation-specific).

    Examples:
        Save an evolved instruction:

        ```python
        provider.save_instruction(
            "my_agent", "You are a helpful assistant specialized in coding."
        )
        ```

    Notes:
        Only after successful persistence should subsequent calls to
        get_agent() return an agent with the updated instruction.
        Implementations should validate empty names before processing.
    """
    ...

list_agents

list_agents() -> list[str]

List all available agent names.

RETURNS DESCRIPTION
list[str]

A list of agent name strings. Empty list if no agents.

Examples:

Discover available agents:

provider = MyAgentProvider()
for name in provider.list_agents():
    print(f"Found agent: {name}")
Notes

Ordering of returned names is not guaranteed by the protocol. Some implementations may return names in a specific order, but callers should not rely upon any particular sequence.

Source code in src/gepa_adk/ports/agent_provider.py
def list_agents(self) -> list[str]:
    """List all available agent names.

    Returns:
        A list of agent name strings. Empty list if no agents.

    Examples:
        Discover available agents:

        ```python
        provider = MyAgentProvider()
        for name in provider.list_agents():
            print(f"Found agent: {name}")
        ```

    Notes:
        Ordering of returned names is not guaranteed by the protocol.
        Some implementations may return names in a specific order,
        but callers should not rely upon any particular sequence.
    """
    ...

AsyncGEPAAdapter

Bases: Protocol[DataInst, Trajectory, RolloutOutput]


              flowchart TD
              gepa_adk.AsyncGEPAAdapter[AsyncGEPAAdapter]

              

              click gepa_adk.AsyncGEPAAdapter href "" "gepa_adk.AsyncGEPAAdapter"
            

Protocol for async GEPA adapters used by the evolution engine.

Implementations provide evaluation, reflection dataset generation, and proposal updates for candidate component texts.

Examples:

Implement a minimal adapter:

class MyAdapter:
    async def evaluate(self, batch, candidate, capture_traces=False):
        return EvaluationBatch(outputs=[], scores=[])

    async def make_reflective_dataset(
        self, candidate, eval_batch, components_to_update
    ):
        return {component: [] for component in components_to_update}

    async def propose_new_texts(
        self, candidate, reflective_dataset, components_to_update
    ):
        return {
            component: candidate[component]
            for component in components_to_update
        }
Notes

Adapters must implement all three async methods to satisfy the protocol. Use runtime_checkable for isinstance() checks.

Source code in src/gepa_adk/ports/adapter.py
@runtime_checkable
class AsyncGEPAAdapter(Protocol[DataInst, Trajectory, RolloutOutput]):
    """Protocol for async GEPA adapters used by the evolution engine.

    Implementations provide evaluation, reflection dataset generation, and
    proposal updates for candidate component texts.

    Examples:
        Implement a minimal adapter:

        ```python
        class MyAdapter:
            async def evaluate(self, batch, candidate, capture_traces=False):
                return EvaluationBatch(outputs=[], scores=[])

            async def make_reflective_dataset(
                self, candidate, eval_batch, components_to_update
            ):
                return {component: [] for component in components_to_update}

            async def propose_new_texts(
                self, candidate, reflective_dataset, components_to_update
            ):
                return {
                    component: candidate[component]
                    for component in components_to_update
                }
        ```

    Notes:
        Adapters must implement all three async methods to satisfy
        the protocol. Use runtime_checkable for isinstance() checks.
    """

    async def evaluate(
        self,
        batch: list[DataInst],
        candidate: dict[str, str],
        capture_traces: bool = False,
    ) -> EvaluationBatch[Trajectory, RolloutOutput]:
        """Evaluate a candidate over a batch of inputs.

        Args:
            batch: Input data instances to evaluate.
            candidate: Component name to text mapping.
            capture_traces: Whether to capture execution traces.

        Returns:
            Evaluation results with outputs, scores, and optional traces.

        Examples:
            Basic evaluation:

            ```python
            result = await adapter.evaluate(batch, candidate)
            assert len(result.scores) == len(batch)
            ```

        Notes:
            Output and score lists must have the same length as the
            input batch. Set capture_traces=True to enable reflection.
        """

    async def make_reflective_dataset(
        self,
        candidate: dict[str, str],
        eval_batch: EvaluationBatch[Trajectory, RolloutOutput],
        components_to_update: list[str],
    ) -> Mapping[str, Sequence[Mapping[str, Any]]]:
        """Build reflective datasets from evaluation traces.

        Args:
            candidate: Current candidate components.
            eval_batch: Evaluation results with traces.
            components_to_update: Components to generate datasets for.

        Returns:
            Mapping of component name to reflective examples.

        Examples:
            Build datasets for specific components:

            ```python
            dataset = await adapter.make_reflective_dataset(
                candidate,
                eval_batch,
                ["instruction"],
            )
            ```

        Notes:
            Only call this method when eval_batch contains trajectories.
            Each component receives its own list of reflective examples.
        """

    async def propose_new_texts(
        self,
        candidate: dict[str, str],
        reflective_dataset: Mapping[str, Sequence[Mapping[str, Any]]],
        components_to_update: list[str],
    ) -> dict[str, str]:
        """Propose updated component texts from reflective datasets.

        Args:
            candidate: Current candidate components.
            reflective_dataset: Reflective examples per component.
            components_to_update: Components to propose updates for.

        Returns:
            Mapping of component name to new proposed text.

        Examples:
            Generate new component texts:

            ```python
            proposals = await adapter.propose_new_texts(
                candidate,
                reflective_dataset,
                ["instruction"],
            )
            ```

        Notes:
            Outputs should contain improved text for each requested
            component. The evolution engine uses these as mutation candidates.
        """

evaluate async

evaluate(
    batch: list[DataInst],
    candidate: dict[str, str],
    capture_traces: bool = False,
) -> EvaluationBatch[Trajectory, RolloutOutput]

Evaluate a candidate over a batch of inputs.

PARAMETER DESCRIPTION
batch

Input data instances to evaluate.

TYPE: list[DataInst]

candidate

Component name to text mapping.

TYPE: dict[str, str]

capture_traces

Whether to capture execution traces.

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
EvaluationBatch[Trajectory, RolloutOutput]

Evaluation results with outputs, scores, and optional traces.

Examples:

Basic evaluation:

result = await adapter.evaluate(batch, candidate)
assert len(result.scores) == len(batch)
Notes

Output and score lists must have the same length as the input batch. Set capture_traces=True to enable reflection.

Source code in src/gepa_adk/ports/adapter.py
async def evaluate(
    self,
    batch: list[DataInst],
    candidate: dict[str, str],
    capture_traces: bool = False,
) -> EvaluationBatch[Trajectory, RolloutOutput]:
    """Evaluate a candidate over a batch of inputs.

    Args:
        batch: Input data instances to evaluate.
        candidate: Component name to text mapping.
        capture_traces: Whether to capture execution traces.

    Returns:
        Evaluation results with outputs, scores, and optional traces.

    Examples:
        Basic evaluation:

        ```python
        result = await adapter.evaluate(batch, candidate)
        assert len(result.scores) == len(batch)
        ```

    Notes:
        Output and score lists must have the same length as the
        input batch. Set capture_traces=True to enable reflection.
    """

make_reflective_dataset async

make_reflective_dataset(
    candidate: dict[str, str],
    eval_batch: EvaluationBatch[Trajectory, RolloutOutput],
    components_to_update: list[str],
) -> Mapping[str, Sequence[Mapping[str, Any]]]

Build reflective datasets from evaluation traces.

PARAMETER DESCRIPTION
candidate

Current candidate components.

TYPE: dict[str, str]

eval_batch

Evaluation results with traces.

TYPE: EvaluationBatch[Trajectory, RolloutOutput]

components_to_update

Components to generate datasets for.

TYPE: list[str]

RETURNS DESCRIPTION
Mapping[str, Sequence[Mapping[str, Any]]]

Mapping of component name to reflective examples.

Examples:

Build datasets for specific components:

dataset = await adapter.make_reflective_dataset(
    candidate,
    eval_batch,
    ["instruction"],
)
Notes

Only call this method when eval_batch contains trajectories. Each component receives its own list of reflective examples.

Source code in src/gepa_adk/ports/adapter.py
async def make_reflective_dataset(
    self,
    candidate: dict[str, str],
    eval_batch: EvaluationBatch[Trajectory, RolloutOutput],
    components_to_update: list[str],
) -> Mapping[str, Sequence[Mapping[str, Any]]]:
    """Build reflective datasets from evaluation traces.

    Args:
        candidate: Current candidate components.
        eval_batch: Evaluation results with traces.
        components_to_update: Components to generate datasets for.

    Returns:
        Mapping of component name to reflective examples.

    Examples:
        Build datasets for specific components:

        ```python
        dataset = await adapter.make_reflective_dataset(
            candidate,
            eval_batch,
            ["instruction"],
        )
        ```

    Notes:
        Only call this method when eval_batch contains trajectories.
        Each component receives its own list of reflective examples.
    """

propose_new_texts async

propose_new_texts(
    candidate: dict[str, str],
    reflective_dataset: Mapping[
        str, Sequence[Mapping[str, Any]]
    ],
    components_to_update: list[str],
) -> dict[str, str]

Propose updated component texts from reflective datasets.

PARAMETER DESCRIPTION
candidate

Current candidate components.

TYPE: dict[str, str]

reflective_dataset

Reflective examples per component.

TYPE: Mapping[str, Sequence[Mapping[str, Any]]]

components_to_update

Components to propose updates for.

TYPE: list[str]

RETURNS DESCRIPTION
dict[str, str]

Mapping of component name to new proposed text.

Examples:

Generate new component texts:

proposals = await adapter.propose_new_texts(
    candidate,
    reflective_dataset,
    ["instruction"],
)
Notes

Outputs should contain improved text for each requested component. The evolution engine uses these as mutation candidates.

Source code in src/gepa_adk/ports/adapter.py
async def propose_new_texts(
    self,
    candidate: dict[str, str],
    reflective_dataset: Mapping[str, Sequence[Mapping[str, Any]]],
    components_to_update: list[str],
) -> dict[str, str]:
    """Propose updated component texts from reflective datasets.

    Args:
        candidate: Current candidate components.
        reflective_dataset: Reflective examples per component.
        components_to_update: Components to propose updates for.

    Returns:
        Mapping of component name to new proposed text.

    Examples:
        Generate new component texts:

        ```python
        proposals = await adapter.propose_new_texts(
            candidate,
            reflective_dataset,
            ["instruction"],
        )
        ```

    Notes:
        Outputs should contain improved text for each requested
        component. The evolution engine uses these as mutation candidates.
    """

ComponentHandler

Bases: Protocol


              flowchart TD
              gepa_adk.ComponentHandler[ComponentHandler]

              

              click gepa_adk.ComponentHandler href "" "gepa_adk.ComponentHandler"
            

Protocol for component serialization and application.

Handles the serialize/apply/restore cycle for one component type during evolution. Implementations must be stateless and thread-safe.

The protocol defines three operations: 1. serialize: Extract current component value as string 2. apply: Set new value, return original for restoration 3. restore: Reinstate original value after evaluation

Examples:

Implement a custom handler:

class TemperatureHandler:
    def serialize(self, agent: LlmAgent) -> str:
        config = getattr(agent, "generate_content_config", None)
        if config and hasattr(config, "temperature"):
            return str(config.temperature)
        return "1.0"

    def apply(self, agent: LlmAgent, value: str) -> Any:
        config = getattr(agent, "generate_content_config", None)
        original = config.temperature if config else 1.0
        if config:
            config.temperature = float(value)
        return original

    def restore(self, agent: LlmAgent, original: Any) -> None:
        config = getattr(agent, "generate_content_config", None)
        if config:
            config.temperature = original
See Also
Notes

All methods are synchronous - no I/O operations should be performed. Apply() should log warnings and keep the original value rather than raising exceptions on invalid inputs for error safety.

Source code in src/gepa_adk/ports/component_handler.py
@runtime_checkable
class ComponentHandler(Protocol):
    """Protocol for component serialization and application.

    Handles the serialize/apply/restore cycle for one component type during
    evolution. Implementations must be stateless and thread-safe.

    The protocol defines three operations:
    1. **serialize**: Extract current component value as string
    2. **apply**: Set new value, return original for restoration
    3. **restore**: Reinstate original value after evaluation

    Examples:
        Implement a custom handler:

        ```python
        class TemperatureHandler:
            def serialize(self, agent: LlmAgent) -> str:
                config = getattr(agent, "generate_content_config", None)
                if config and hasattr(config, "temperature"):
                    return str(config.temperature)
                return "1.0"

            def apply(self, agent: LlmAgent, value: str) -> Any:
                config = getattr(agent, "generate_content_config", None)
                original = config.temperature if config else 1.0
                if config:
                    config.temperature = float(value)
                return original

            def restore(self, agent: LlmAgent, original: Any) -> None:
                config = getattr(agent, "generate_content_config", None)
                if config:
                    config.temperature = original
        ```

    See Also:
        - [`InstructionHandler`]\
[gepa_adk.adapters.components.component_handlers.InstructionHandler]:
            Handler for agent instruction.
        - [`OutputSchemaHandler`]\
[gepa_adk.adapters.components.component_handlers.OutputSchemaHandler]:
            Handler for agent output schema.

    Notes:
        All methods are synchronous - no I/O operations should be performed.
        Apply() should log warnings and keep the original value rather than
        raising exceptions on invalid inputs for error safety.
    """

    def serialize(self, agent: LlmAgent) -> str:
        """Extract component value from agent as string for evolution.

        Args:
            agent (LlmAgent): The agent to extract the component from.

        Returns:
            String representation of the component value.
            Returns empty string if component is not set.

        Examples:
            ```python
            handler = InstructionHandler()
            text = handler.serialize(agent)
            # text == "You are a helpful assistant."
            ```

        Notes:
            Operations must never raise exceptions for missing values.
            Return empty string or sensible default instead.
        """
        ...

    def apply(self, agent: LlmAgent, value: str) -> Any:
        """Apply evolved value to agent, return original for restore.

        Args:
            agent (LlmAgent): The agent to modify.
            value (str): The new component value as string.

        Returns:
            The original component value (type depends on component).
            This value will be passed to restore() later.

        Examples:
            ```python
            handler = InstructionHandler()
            original = handler.apply(agent, "New instruction")
            # agent.instruction is now "New instruction"
            # original contains previous instruction value
            ```

        Notes:
            On application failure (e.g., invalid schema), log warning
            and return original without modifying agent. Never raise
            exceptions - graceful degradation is required.
        """
        ...

    def restore(self, agent: LlmAgent, original: Any) -> None:
        """Restore original value after evaluation.

        Args:
            agent (LlmAgent): The agent to restore.
            original (Any): The value apply() returned, passed back unchanged.

        Examples:
            ```python
            handler = InstructionHandler()
            original = handler.apply(agent, "Temp instruction")
            # ... run evaluation ...
            handler.restore(agent, original)
            # agent.instruction is back to original value
            ```

        Notes:
            Original value restoration always succeeds - never raises exceptions.
            None values reset to component default.
        """
        ...

serialize

serialize(agent: LlmAgent) -> str

Extract component value from agent as string for evolution.

PARAMETER DESCRIPTION
agent

The agent to extract the component from.

TYPE: LlmAgent

RETURNS DESCRIPTION
str

String representation of the component value.

str

Returns empty string if component is not set.

Examples:

handler = InstructionHandler()
text = handler.serialize(agent)
# text == "You are a helpful assistant."
Notes

Operations must never raise exceptions for missing values. Return empty string or sensible default instead.

Source code in src/gepa_adk/ports/component_handler.py
def serialize(self, agent: LlmAgent) -> str:
    """Extract component value from agent as string for evolution.

    Args:
        agent (LlmAgent): The agent to extract the component from.

    Returns:
        String representation of the component value.
        Returns empty string if component is not set.

    Examples:
        ```python
        handler = InstructionHandler()
        text = handler.serialize(agent)
        # text == "You are a helpful assistant."
        ```

    Notes:
        Operations must never raise exceptions for missing values.
        Return empty string or sensible default instead.
    """
    ...

apply

apply(agent: LlmAgent, value: str) -> Any

Apply evolved value to agent, return original for restore.

PARAMETER DESCRIPTION
agent

The agent to modify.

TYPE: LlmAgent

value

The new component value as string.

TYPE: str

RETURNS DESCRIPTION
Any

The original component value (type depends on component).

Any

This value will be passed to restore() later.

Examples:

handler = InstructionHandler()
original = handler.apply(agent, "New instruction")
# agent.instruction is now "New instruction"
# original contains previous instruction value
Notes

On application failure (e.g., invalid schema), log warning and return original without modifying agent. Never raise exceptions - graceful degradation is required.

Source code in src/gepa_adk/ports/component_handler.py
def apply(self, agent: LlmAgent, value: str) -> Any:
    """Apply evolved value to agent, return original for restore.

    Args:
        agent (LlmAgent): The agent to modify.
        value (str): The new component value as string.

    Returns:
        The original component value (type depends on component).
        This value will be passed to restore() later.

    Examples:
        ```python
        handler = InstructionHandler()
        original = handler.apply(agent, "New instruction")
        # agent.instruction is now "New instruction"
        # original contains previous instruction value
        ```

    Notes:
        On application failure (e.g., invalid schema), log warning
        and return original without modifying agent. Never raise
        exceptions - graceful degradation is required.
    """
    ...

restore

restore(agent: LlmAgent, original: Any) -> None

Restore original value after evaluation.

PARAMETER DESCRIPTION
agent

The agent to restore.

TYPE: LlmAgent

original

The value apply() returned, passed back unchanged.

TYPE: Any

Examples:

handler = InstructionHandler()
original = handler.apply(agent, "Temp instruction")
# ... run evaluation ...
handler.restore(agent, original)
# agent.instruction is back to original value
Notes

Original value restoration always succeeds - never raises exceptions. None values reset to component default.

Source code in src/gepa_adk/ports/component_handler.py
def restore(self, agent: LlmAgent, original: Any) -> None:
    """Restore original value after evaluation.

    Args:
        agent (LlmAgent): The agent to restore.
        original (Any): The value apply() returned, passed back unchanged.

    Examples:
        ```python
        handler = InstructionHandler()
        original = handler.apply(agent, "Temp instruction")
        # ... run evaluation ...
        handler.restore(agent, original)
        # agent.instruction is back to original value
        ```

    Notes:
        Original value restoration always succeeds - never raises exceptions.
        None values reset to component default.
    """
    ...

EvaluationBatch dataclass

Bases: Generic[Trajectory, RolloutOutput]


              flowchart TD
              gepa_adk.EvaluationBatch[EvaluationBatch]

              

              click gepa_adk.EvaluationBatch href "" "gepa_adk.EvaluationBatch"
            

Container for evaluation outputs and scores.

A frozen, slotted dataclass generic over the Trajectory and RolloutOutput TypeVars; every per-example list is index-aligned.

ATTRIBUTE DESCRIPTION
outputs

Per-example outputs produced during evaluation.

TYPE: list[RolloutOutput]

scores

Per-example normalized scores (higher is better).

TYPE: list[Score]

trajectories

Optional per-example execution traces.

TYPE: list[Trajectory] | None

objective_scores

Optional multi-objective scores per example.

TYPE: list[dict[ComponentName, Score]] | None

metadata

Optional per-example scorer metadata. When provided, metadata[i] corresponds to outputs[i] and scores[i] (index-aligned). Metadata dicts may contain scorer-specific fields like 'feedback', 'actionable_guidance', or 'dimension_scores' from CriticScorer implementations.

TYPE: list[dict[str, Any]] | None

inputs

Optional per-example input text that was used to generate each output. Used by make_reflective_dataset to provide context for reflection. When provided, inputs[i] corresponds to the input that produced outputs[i].

TYPE: list[str] | None

failed_indices

Optional batch indices whose agent run or scorer raised, or whose run returned a failed execution, in index order. Those rows still carry a score (usually 0.0); this field lets callers tell a failed run from a wrong answer. None means the adapter does not report failures and counts as zero failures.

TYPE: list[int] | None

Examples:

Create a batch with optional traces:

batch = EvaluationBatch(
    outputs=["ok", "ok"],
    scores=[0.9, 0.8],
    trajectories=[{"trace": 1}, {"trace": 2}],
)

Create a batch with inputs for reflection:

batch = EvaluationBatch(
    outputs=["Hello!", "Good morrow!"],
    scores=[0.3, 0.9],
    inputs=["I am the King", "I am your friend"],
)
Notes

All fields are immutable once created due to frozen=True. Use this as the standard return type from adapter evaluations. When metadata is not None, len(metadata) must equal len(outputs) and len(scores).

Source code in src/gepa_adk/ports/adapter.py
@dataclass(frozen=True, slots=True)
class EvaluationBatch(Generic[Trajectory, RolloutOutput]):  # noqa: UP046  # TypeVars are exported names shared across ports and engine
    """Container for evaluation outputs and scores.

    A frozen, slotted dataclass generic over the ``Trajectory`` and
    ``RolloutOutput`` TypeVars; every per-example list is index-aligned.

    Attributes:
        outputs (list[RolloutOutput]): Per-example outputs produced during evaluation.
        scores (list[Score]): Per-example normalized scores (higher is better).
        trajectories (list[Trajectory] | None): Optional per-example execution traces.
        objective_scores (list[dict[ComponentName, Score]] | None): Optional
            multi-objective scores per example.
        metadata (list[dict[str, Any]] | None): Optional per-example scorer metadata.
            When provided, metadata[i] corresponds to outputs[i] and scores[i]
            (index-aligned). Metadata dicts may contain scorer-specific fields like
            'feedback', 'actionable_guidance', or 'dimension_scores' from
            CriticScorer implementations.
        inputs (list[str] | None): Optional per-example input text that was used
            to generate each output. Used by make_reflective_dataset to provide
            context for reflection. When provided, inputs[i] corresponds to the
            input that produced outputs[i].
        failed_indices (list[int] | None): Optional batch indices whose agent
            run or scorer raised, or whose run returned a failed execution, in
            index order. Those
            rows still carry a score (usually 0.0); this field lets callers
            tell a failed run from a wrong answer. ``None`` means the adapter
            does not report failures and counts as zero failures.

    Examples:
        Create a batch with optional traces:

        ```python
        batch = EvaluationBatch(
            outputs=["ok", "ok"],
            scores=[0.9, 0.8],
            trajectories=[{"trace": 1}, {"trace": 2}],
        )
        ```

        Create a batch with inputs for reflection:

        ```python
        batch = EvaluationBatch(
            outputs=["Hello!", "Good morrow!"],
            scores=[0.3, 0.9],
            inputs=["I am the King", "I am your friend"],
        )
        ```

    Notes:
        All fields are immutable once created due to frozen=True.
        Use this as the standard return type from adapter evaluations.
        When metadata is not None, len(metadata) must equal len(outputs) and len(scores).
    """

    outputs: list[RolloutOutput]
    scores: list[Score]
    trajectories: list[Trajectory] | None = None
    objective_scores: list[dict[ComponentName, Score]] | None = None
    metadata: list[dict[str, Any]] | None = None
    inputs: list[str] | None = None
    failed_indices: list[int] | None = None

Scorer

Bases: Protocol


              flowchart TD
              gepa_adk.Scorer[Scorer]

              

              click gepa_adk.Scorer href "" "gepa_adk.Scorer"
            

Protocol for scoring agent outputs.

Implementations provide scoring logic that evaluates how well an agent's output matches expected results or quality criteria.

Both synchronous and asynchronous methods are defined. Implementations should provide both, though callers may use only one based on context.

Examples:

Implement a simple fixed scorer for testing:

class FixedScorer:
    def score(
        self,
        input_text: str,
        output: str,
        expected: str | None = None,
    ) -> tuple[float, dict]:
        return 0.5, {"note": "Fixed score for testing"}

    async def async_score(
        self,
        input_text: str,
        output: str,
        expected: str | None = None,
    ) -> tuple[float, dict]:
        return self.score(input_text, output, expected)

Verify protocol compliance:

from gepa_adk import Scorer

scorer = FixedScorer()
assert isinstance(scorer, Scorer)  # Runtime check works
Notes

All implementations must provide both score() and async_score() methods to satisfy the protocol. Score values should be normalized between 0.0 and 1.0 by convention, with higher values indicating better performance. The protocol does not enforce this range.

An implementation may add an optional parameter named trajectory (trajectory: ADKTrajectory | None = None, keyword-only or positional-or-keyword) to both methods. The adapters send the row's trajectory only to a scorer whose async_score declares that parameter by name; see scorer_accepts_trajectory.

Source code in src/gepa_adk/ports/scorer.py
@runtime_checkable
class Scorer(Protocol):
    """Protocol for scoring agent outputs.

    Implementations provide scoring logic that evaluates how well
    an agent's output matches expected results or quality criteria.

    Both synchronous and asynchronous methods are defined. Implementations
    should provide both, though callers may use only one based on context.

    Examples:
        Implement a simple fixed scorer for testing:

        ```python
        class FixedScorer:
            def score(
                self,
                input_text: str,
                output: str,
                expected: str | None = None,
            ) -> tuple[float, dict]:
                return 0.5, {"note": "Fixed score for testing"}

            async def async_score(
                self,
                input_text: str,
                output: str,
                expected: str | None = None,
            ) -> tuple[float, dict]:
                return self.score(input_text, output, expected)
        ```

        Verify protocol compliance:

        ```python
        from gepa_adk import Scorer

        scorer = FixedScorer()
        assert isinstance(scorer, Scorer)  # Runtime check works
        ```

    Notes:
        All implementations must provide both score() and async_score()
        methods to satisfy the protocol. Score values should be normalized
        between 0.0 and 1.0 by convention, with higher values indicating
        better performance. The protocol does not enforce this range.

        An implementation may add an optional parameter named ``trajectory``
        (``trajectory: ADKTrajectory | None = None``, keyword-only or
        positional-or-keyword) to both methods. The
        adapters send the row's trajectory only to a scorer whose
        ``async_score`` declares that parameter by name; see
        ``scorer_accepts_trajectory``.
    """

    def score(
        self,
        input_text: str,
        output: str,
        expected: str | None = None,
    ) -> tuple[float, dict[str, Any]]:
        """Score an agent output synchronously.

        Args:
            input_text: The input provided to the agent.
            output: The agent's generated output to score.
            expected: Optional expected/reference output for comparison.
                Pass None for open-ended evaluation without expected output.

        Returns:
            A tuple of (score, metadata) where:

            - score: Float value, conventionally 0.0-1.0, higher is better
            - metadata: Dict with arbitrary scoring details (e.g., feedback,
                dimension_scores, reasoning). Should be JSON-serializable.

        Examples:
            Basic usage:

            ```python
            score, meta = scorer.score("What is 2+2?", "4", "4")
            assert score == 1.0
            assert meta.get("exact_match") is True
            ```

            Scoring without expected output:

            ```python
            score, meta = scorer.score("Explain gravity", response)
            # Scorer evaluates based on quality criteria, not exact match
            ```

        Notes:
            Operations complete synchronously and block until scoring finishes.
            Use async_score() for I/O-bound operations like LLM calls.
        """
        ...

    async def async_score(
        self,
        input_text: str,
        output: str,
        expected: str | None = None,
    ) -> tuple[float, dict[str, Any]]:
        """Score an agent output asynchronously.

        Args:
            input_text: The input provided to the agent.
            output: The agent's generated output to score.
            expected: Optional expected/reference output for comparison.
                Pass None for open-ended evaluation without expected output.

        Returns:
            A tuple of (score, metadata) where:

            - score: Float value, conventionally 0.0-1.0, higher is better
            - metadata: Dict with arbitrary scoring details (e.g., feedback,
                dimension_scores, reasoning). Should be JSON-serializable.

        Examples:
            Async usage:

            ```python
            score, meta = await scorer.async_score("Explain gravity", response)
            print(f"Quality: {score:.2f} - {meta.get('feedback')}")
            ```

            Concurrent scoring:

            ```python
            import asyncio

            tasks = [
                scorer.async_score(input, output, expected)
                for input, output, expected in batch
            ]
            scores = await asyncio.gather(*tasks)
            ```

        Notes:
            Operations run asynchronously and can be executed concurrently.
            Prefer this method for I/O-bound scoring operations such as
            LLM-based evaluation or external API calls.
        """
        ...

score

score(
    input_text: str,
    output: str,
    expected: str | None = None,
) -> tuple[float, dict[str, Any]]

Score an agent output synchronously.

PARAMETER DESCRIPTION
input_text

The input provided to the agent.

TYPE: str

output

The agent's generated output to score.

TYPE: str

expected

Optional expected/reference output for comparison. Pass None for open-ended evaluation without expected output.

TYPE: str | None DEFAULT: None

RETURNS DESCRIPTION
float

A tuple of (score, metadata) where:

dict[str, Any]
  • score: Float value, conventionally 0.0-1.0, higher is better
tuple[float, dict[str, Any]]
  • metadata: Dict with arbitrary scoring details (e.g., feedback, dimension_scores, reasoning). Should be JSON-serializable.

Examples:

Basic usage:

score, meta = scorer.score("What is 2+2?", "4", "4")
assert score == 1.0
assert meta.get("exact_match") is True

Scoring without expected output:

score, meta = scorer.score("Explain gravity", response)
# Scorer evaluates based on quality criteria, not exact match
Notes

Operations complete synchronously and block until scoring finishes. Use async_score() for I/O-bound operations like LLM calls.

Source code in src/gepa_adk/ports/scorer.py
def score(
    self,
    input_text: str,
    output: str,
    expected: str | None = None,
) -> tuple[float, dict[str, Any]]:
    """Score an agent output synchronously.

    Args:
        input_text: The input provided to the agent.
        output: The agent's generated output to score.
        expected: Optional expected/reference output for comparison.
            Pass None for open-ended evaluation without expected output.

    Returns:
        A tuple of (score, metadata) where:

        - score: Float value, conventionally 0.0-1.0, higher is better
        - metadata: Dict with arbitrary scoring details (e.g., feedback,
            dimension_scores, reasoning). Should be JSON-serializable.

    Examples:
        Basic usage:

        ```python
        score, meta = scorer.score("What is 2+2?", "4", "4")
        assert score == 1.0
        assert meta.get("exact_match") is True
        ```

        Scoring without expected output:

        ```python
        score, meta = scorer.score("Explain gravity", response)
        # Scorer evaluates based on quality criteria, not exact match
        ```

    Notes:
        Operations complete synchronously and block until scoring finishes.
        Use async_score() for I/O-bound operations like LLM calls.
    """
    ...

async_score async

async_score(
    input_text: str,
    output: str,
    expected: str | None = None,
) -> tuple[float, dict[str, Any]]

Score an agent output asynchronously.

PARAMETER DESCRIPTION
input_text

The input provided to the agent.

TYPE: str

output

The agent's generated output to score.

TYPE: str

expected

Optional expected/reference output for comparison. Pass None for open-ended evaluation without expected output.

TYPE: str | None DEFAULT: None

RETURNS DESCRIPTION
float

A tuple of (score, metadata) where:

dict[str, Any]
  • score: Float value, conventionally 0.0-1.0, higher is better
tuple[float, dict[str, Any]]
  • metadata: Dict with arbitrary scoring details (e.g., feedback, dimension_scores, reasoning). Should be JSON-serializable.

Examples:

Async usage:

score, meta = await scorer.async_score("Explain gravity", response)
print(f"Quality: {score:.2f} - {meta.get('feedback')}")

Concurrent scoring:

import asyncio

tasks = [
    scorer.async_score(input, output, expected)
    for input, output, expected in batch
]
scores = await asyncio.gather(*tasks)
Notes

Operations run asynchronously and can be executed concurrently. Prefer this method for I/O-bound scoring operations such as LLM-based evaluation or external API calls.

Source code in src/gepa_adk/ports/scorer.py
async def async_score(
    self,
    input_text: str,
    output: str,
    expected: str | None = None,
) -> tuple[float, dict[str, Any]]:
    """Score an agent output asynchronously.

    Args:
        input_text: The input provided to the agent.
        output: The agent's generated output to score.
        expected: Optional expected/reference output for comparison.
            Pass None for open-ended evaluation without expected output.

    Returns:
        A tuple of (score, metadata) where:

        - score: Float value, conventionally 0.0-1.0, higher is better
        - metadata: Dict with arbitrary scoring details (e.g., feedback,
            dimension_scores, reasoning). Should be JSON-serializable.

    Examples:
        Async usage:

        ```python
        score, meta = await scorer.async_score("Explain gravity", response)
        print(f"Quality: {score:.2f} - {meta.get('feedback')}")
        ```

        Concurrent scoring:

        ```python
        import asyncio

        tasks = [
            scorer.async_score(input, output, expected)
            for input, output, expected in batch
        ]
        scores = await asyncio.gather(*tasks)
        ```

    Notes:
        Operations run asynchronously and can be executed concurrently.
        Prefer this method for I/O-bound scoring operations such as
        LLM-based evaluation or external API calls.
    """
    ...

ComponentSelectorProtocol

Bases: Protocol


              flowchart TD
              gepa_adk.ComponentSelectorProtocol[ComponentSelectorProtocol]

              

              click gepa_adk.ComponentSelectorProtocol href "" "gepa_adk.ComponentSelectorProtocol"
            

Async protocol for component selection strategies.

Notes

Adapters implementing this protocol determine which candidate components to update during mutation, enabling flexible evolution strategies.

Examples:

class MySelector:
    async def select_components(
        self, components: list[str], iteration: int, candidate_idx: int
    ) -> list[str]:
        return components[:1]
Source code in src/gepa_adk/ports/component_selector.py
@runtime_checkable
class ComponentSelectorProtocol(Protocol):
    """Async protocol for component selection strategies.

    Notes:
        Adapters implementing this protocol determine which candidate components
        to update during mutation, enabling flexible evolution strategies.

    Examples:
        ```python
        class MySelector:
            async def select_components(
                self, components: list[str], iteration: int, candidate_idx: int
            ) -> list[str]:
                return components[:1]
        ```
    """

    async def select_components(
        self, components: list[str], iteration: int, candidate_idx: int
    ) -> list[str]:
        """Select components to update for the current iteration.

        Args:
            components: List of available component keys (e.g. ["instruction", "input_schema"]).
            iteration: Current global iteration number (0-based).
            candidate_idx: Index of the candidate being evolved.

        Returns:
            List of component keys to update.

        Raises:
            ValueError: If components list is empty.

        Notes:
            Outputs a list of component keys to update, enabling selective
            mutation of specific candidate components.

        Examples:
            ```python
            selected = await selector.select_components(
                components=["instruction", "schema"], iteration=1, candidate_idx=0
            )
            ```
        """
        ...

select_components async

select_components(
    components: list[str],
    iteration: int,
    candidate_idx: int,
) -> list[str]

Select components to update for the current iteration.

PARAMETER DESCRIPTION
components

List of available component keys (e.g. ["instruction", "input_schema"]).

TYPE: list[str]

iteration

Current global iteration number (0-based).

TYPE: int

candidate_idx

Index of the candidate being evolved.

TYPE: int

RETURNS DESCRIPTION
list[str]

List of component keys to update.

RAISES DESCRIPTION
ValueError

If components list is empty.

Notes

Outputs a list of component keys to update, enabling selective mutation of specific candidate components.

Examples:

selected = await selector.select_components(
    components=["instruction", "schema"], iteration=1, candidate_idx=0
)
Source code in src/gepa_adk/ports/component_selector.py
async def select_components(
    self, components: list[str], iteration: int, candidate_idx: int
) -> list[str]:
    """Select components to update for the current iteration.

    Args:
        components: List of available component keys (e.g. ["instruction", "input_schema"]).
        iteration: Current global iteration number (0-based).
        candidate_idx: Index of the candidate being evolved.

    Returns:
        List of component keys to update.

    Raises:
        ValueError: If components list is empty.

    Notes:
        Outputs a list of component keys to update, enabling selective
        mutation of specific candidate components.

    Examples:
        ```python
        selected = await selector.select_components(
            components=["instruction", "schema"], iteration=1, candidate_idx=0
        )
        ```
    """
    ...

register_mapping_components

register_mapping_components(
    mapping: MutableMapping[str, str],
    *,
    registry: ComponentHandlerRegistry | None = None,
) -> list[str]

Register a handler for every key of a mapping.

Each key becomes a component name backed by a MappingComponentHandler. Every key is validated before any handler is registered, so a rejected mapping leaves the registry unchanged.

PARAMETER DESCRIPTION
mapping

The caller-owned mapping of prompt strings.

TYPE: MutableMapping[str, str]

registry

The registry to register into. Defaults to the default component_handlers registry that evolve() consults.

TYPE: ComponentHandlerRegistry | None DEFAULT: None

RETURNS DESCRIPTION
list[str]

The registered component names, in mapping order.

RAISES DESCRIPTION
ValueError

If the mapping is empty, a key is not a Python identifier, or a key equals instruction or output_schema.

TypeError

If a value is not a str.

Examples:

prompts = {"greeting": "hello", "farewell": "bye"}
names = register_mapping_components(prompts)
# names == ["greeting", "farewell"]
See Also
Notes

Registering a name that already has a handler replaces it.

Source code in src/gepa_adk/adapters/components/mapping_handler.py
def register_mapping_components(
    mapping: MutableMapping[str, str],
    *,
    registry: ComponentHandlerRegistry | None = None,
) -> list[str]:
    """Register a handler for every key of a mapping.

    Each key becomes a component name backed by a
    ``MappingComponentHandler``. Every key is validated before any handler
    is registered, so a rejected mapping leaves the registry unchanged.

    Args:
        mapping: The caller-owned mapping of prompt strings.
        registry: The registry to register into. Defaults to the default
            ``component_handlers`` registry that ``evolve()`` consults.

    Returns:
        The registered component names, in mapping order.

    Raises:
        ValueError: If the mapping is empty, a key is not a Python
            identifier, or a key equals ``instruction`` or ``output_schema``.
        TypeError: If a value is not a ``str``.

    Examples:
        ```python
        prompts = {"greeting": "hello", "farewell": "bye"}
        names = register_mapping_components(prompts)
        # names == ["greeting", "farewell"]
        ```

    See Also:
        - [`MappingComponentHandler`][gepa_adk.adapters.components.MappingComponentHandler]:
          The handler registered for each key.

    Notes:
        Registering a name that already has a handler replaces it.
    """
    if not mapping:
        raise ValueError("Cannot register components from an empty mapping")
    handlers: list[tuple[str, MappingComponentHandler]] = []
    for key in mapping:
        if not key.isidentifier():
            raise ValueError(
                f"Mapping key {key!r} must be a Python identifier to be a "
                "component name"
            )
        if key in _RESERVED_NAMES:
            raise ValueError(f"Mapping key {key!r} shadows a built-in component name")
        handlers.append((key, MappingComponentHandler(mapping, key)))

    target = registry if registry is not None else component_handlers
    for key, handler in handlers:
        target.register(key, handler)
    return [key for key, _ in handlers]

create_critic

create_critic(
    name: str, *, model: str | None = None
) -> LlmAgent

Create a pre-configured critic agent by preset name.

PARAMETER DESCRIPTION
name

Preset name. Must be a key in _PRESET_INSTRUCTIONS.

TYPE: str

model

Optional model override. When None, ADK uses its default.

TYPE: str | None DEFAULT: None

RETURNS DESCRIPTION
LlmAgent

Configured LlmAgent with CriticOutput schema and preset instruction.

RAISES DESCRIPTION
ConfigurationError

If name is not a valid preset.

Source code in src/gepa_adk/adapters/scoring/critic_scorer.py
def create_critic(name: str, *, model: str | None = None) -> LlmAgent:
    """Create a pre-configured critic agent by preset name.

    Args:
        name: Preset name. Must be a key in ``_PRESET_INSTRUCTIONS``.
        model: Optional model override. When None, ADK uses its default.

    Returns:
        Configured LlmAgent with CriticOutput schema and preset instruction.

    Raises:
        ConfigurationError: If name is not a valid preset.
    """
    if name not in _PRESET_INSTRUCTIONS:
        valid_presets = ", ".join(sorted(_PRESET_INSTRUCTIONS))
        raise ConfigurationError(
            f"Unknown critic preset '{name}'. Valid presets: {valid_presets}",
            constraint=f"Must be one of: {valid_presets}",
            value=name,
            field="name",
        )

    model_kwargs: dict[str, Any] = {}
    if model is not None:
        model_kwargs["model"] = model

    return LlmAgent(
        name=f"{name}_critic",
        instruction=_PRESET_INSTRUCTIONS[name],
        output_schema=CriticOutput,
        **model_kwargs,
    )

normalize_feedback

normalize_feedback(
    score: float, metadata: dict[str, Any] | None
) -> dict[str, Any]

Normalize critic feedback to consistent trial format.

Converts both simple and advanced critic outputs to a standardized format for use in trial records. This enables the reflection agent to receive consistent feedback regardless of which critic schema was used.

PARAMETER DESCRIPTION
score

The numeric score from the critic (0.0-1.0).

TYPE: float

metadata

Optional metadata dict from critic output. May contain: - feedback (str): Simple feedback text - dimension_scores (dict): Per-dimension scores - actionable_guidance (str): Improvement suggestions - Any additional fields from critic output

TYPE: dict[str, Any] | None

RETURNS DESCRIPTION
dict[str, Any]

Normalized feedback dict with structure:

dict[str, Any]

```python

dict[str, Any]

{ "score": 0.75, "feedback_text": "Main feedback message", "dimension_scores": {...}, # Optional "actionable_guidance": "...", # Optional

dict[str, Any]

}

dict[str, Any]

```

Examples:

Normalize simple feedback:

normalized = normalize_feedback(0.8, {"feedback": "Good job"})
# {"score": 0.8, "feedback_text": "Good job"}

Normalize advanced feedback:

normalized = normalize_feedback(
    0.6,
    {
        "feedback": "Needs work",
        "dimension_scores": {"clarity": 0.5},
        "actionable_guidance": "Add examples",
    },
)
# {
#     "score": 0.6,
#     "feedback_text": "Needs work",
#     "dimension_scores": {"clarity": 0.5},
#     "actionable_guidance": "Add examples",
# }

Handle missing feedback:

normalized = normalize_feedback(0.5, None)
# {"score": 0.5, "feedback_text": ""}
Notes

Supports both SimpleCriticOutput and CriticOutput schemas for flexible critic integration. Extracts the "feedback" field and renames it to "feedback_text" for consistent trial structure. Additional fields like dimension_scores are preserved when present.

Source code in src/gepa_adk/adapters/scoring/critic_scorer.py
def normalize_feedback(
    score: float,
    metadata: dict[str, Any] | None,
) -> dict[str, Any]:
    """Normalize critic feedback to consistent trial format.

    Converts both simple and advanced critic outputs to a standardized
    format for use in trial records. This enables the reflection agent
    to receive consistent feedback regardless of which critic schema
    was used.

    Args:
        score: The numeric score from the critic (0.0-1.0).
        metadata: Optional metadata dict from critic output. May contain:
            - feedback (str): Simple feedback text
            - dimension_scores (dict): Per-dimension scores
            - actionable_guidance (str): Improvement suggestions
            - Any additional fields from critic output

    Returns:
        Normalized feedback dict with structure:
        ```python
        {
            "score": 0.75,
            "feedback_text": "Main feedback message",
            "dimension_scores": {...},  # Optional
            "actionable_guidance": "...",  # Optional
        }
        ```

    Examples:
        Normalize simple feedback:

        ```python
        normalized = normalize_feedback(0.8, {"feedback": "Good job"})
        # {"score": 0.8, "feedback_text": "Good job"}
        ```

        Normalize advanced feedback:

        ```python
        normalized = normalize_feedback(
            0.6,
            {
                "feedback": "Needs work",
                "dimension_scores": {"clarity": 0.5},
                "actionable_guidance": "Add examples",
            },
        )
        # {
        #     "score": 0.6,
        #     "feedback_text": "Needs work",
        #     "dimension_scores": {"clarity": 0.5},
        #     "actionable_guidance": "Add examples",
        # }
        ```

        Handle missing feedback:

        ```python
        normalized = normalize_feedback(0.5, None)
        # {"score": 0.5, "feedback_text": ""}
        ```

    Notes:
        Supports both SimpleCriticOutput and CriticOutput schemas for flexible
        critic integration. Extracts the "feedback" field and renames it to
        "feedback_text" for consistent trial structure. Additional fields
        like dimension_scores are preserved when present.
    """
    result: dict[str, Any] = {"score": score}

    if metadata is None:
        result["feedback_text"] = ""
        return result

    # Extract feedback text - handle both "feedback" and "feedback_text" keys
    feedback_text = metadata.get("feedback_text") or metadata.get("feedback") or ""
    if isinstance(feedback_text, str) and feedback_text.strip():
        result["feedback_text"] = feedback_text.strip()
    else:
        result["feedback_text"] = ""

    # Preserve dimension_scores if present
    dimension_scores = metadata.get("dimension_scores")
    if dimension_scores and isinstance(dimension_scores, dict):
        result["dimension_scores"] = dimension_scores

    # Preserve actionable_guidance if present
    actionable_guidance = metadata.get("actionable_guidance")
    if actionable_guidance and isinstance(actionable_guidance, str):
        guidance_str = actionable_guidance.strip()
        if guidance_str:
            result["actionable_guidance"] = guidance_str

    return result

create_component_selector

create_component_selector(
    selector_type: str,
) -> ComponentSelectorProtocol

Create a component selector strategy from a string alias.

PARAMETER DESCRIPTION
selector_type

Name of the selector strategy. Supported values: - 'round_robin', 'roundrobin': Round-robin cycling. - 'all', 'all_components': All components simultaneously.

TYPE: str

RETURNS DESCRIPTION
ComponentSelectorProtocol

Instance of requested component selector.

RAISES DESCRIPTION
ValueError

If selector_type is unknown.

Examples:

# Create round-robin selector
selector = create_component_selector("round_robin")

# Create all-components selector
selector = create_component_selector("all")
Notes

Supports flexible string aliases with normalization for common variations (underscores, hyphens, case-insensitive).

Source code in src/gepa_adk/adapters/selection/component_selector.py
def create_component_selector(selector_type: str) -> ComponentSelectorProtocol:
    """Create a component selector strategy from a string alias.

    Args:
        selector_type: Name of the selector strategy.
            Supported values:
            - 'round_robin', 'roundrobin': Round-robin cycling.
            - 'all', 'all_components': All components simultaneously.

    Returns:
        Instance of requested component selector.

    Raises:
        ValueError: If selector_type is unknown.

    Examples:
        ```python
        # Create round-robin selector
        selector = create_component_selector("round_robin")

        # Create all-components selector
        selector = create_component_selector("all")
        ```

    Notes:
        Supports flexible string aliases with normalization for common
        variations (underscores, hyphens, case-insensitive).
    """
    normalized = selector_type.lower().replace("_", "").replace("-", "")

    if normalized == "roundrobin":
        return RoundRobinComponentSelector()
    elif normalized in ("all", "allcomponents"):
        return AllComponentSelector()

    raise ValueError(f"Unknown component selector: {selector_type}")

evolve async

evolve(
    agent: LlmAgent,
    trainset: list[dict[str, Any]],
    *,
    valset: list[dict[str, Any]] | None = None,
    critic: LlmAgent | None = None,
    scorer: Scorer | None = None,
    reflection_agent: LlmAgent | None = None,
    config: EvolutionConfig | None = None,
    trajectory_config: TrajectoryConfig | None = None,
    state_guard: StateGuard | None = None,
    candidate_selector: CandidateSelectorProtocol
    | str
    | None = None,
    component_selector: ComponentSelectorProtocol
    | str
    | None = None,
    executor: AgentExecutorProtocol | None = None,
    components: list[str] | None = None,
    schema_constraints: SchemaConstraints | None = None,
    app: App | None = None,
    runner: Runner | None = None,
    registry: ComponentHandlerRegistry | None = None,
) -> EvolutionResult

Evolve an ADK agent's instruction.

Optimizes the instruction for a single ADK agent using evolutionary optimization. The agent's instruction is iteratively improved based on performance on the training set.

PARAMETER DESCRIPTION
agent

The ADK LlmAgent to evolve. When critic is not provided, agent.output_schema must be a pydantic BaseModel subclass (other schema types accepted by ADK 1.34+ are not supported here).

TYPE: LlmAgent

trainset

Training examples [{"input": "...", "expected": "..."}].

TYPE: list[dict[str, Any]]

PARAMETER DESCRIPTION
valset

Optional validation examples used for scoring and acceptance. Defaults to the trainset when omitted.

TYPE: list[dict[str, Any]] | None

critic

Optional ADK agent for scoring (uses schema scoring if None and no scorer is given). Mutually exclusive with scorer.

TYPE: LlmAgent | None

scorer

Optional object implementing the Scorer protocol (score and async_score). When given, it is used as-is and takes precedence over the agent's output_schema. Mutually exclusive with critic.

TYPE: Scorer | None

reflection_agent

Optional ADK agent for proposals. If None, creates a default reflection agent using config.reflection_model (a model string, or a BaseLlm instance passed through unchanged).

TYPE: LlmAgent | None

config

Evolution configuration (uses defaults if None). Its reflection_max_trials and reflection_max_trial_chars bound the trials each reflection call sees, and reflection_timeout_seconds bounds each reflection call's run time; a timed-out reflection skips the iteration. max_concurrent_evals is passed to ADKAdapter to limit concurrent evaluation rows within each batch.

TYPE: EvolutionConfig | None

trajectory_config

Trajectory capture settings (uses defaults if None).

TYPE: TrajectoryConfig | None

state_guard

Optional state token preservation settings.

TYPE: StateGuard | None

candidate_selector

Optional selector instance or selector name.

TYPE: CandidateSelectorProtocol | str | None

component_selector

Optional selector instance or selector name for choosing which components to update.

TYPE: ComponentSelectorProtocol | str | None

executor

Optional AgentExecutorProtocol implementation for unified agent execution. When provided, both the ADKAdapter and CriticScorer use this executor for consistent session management and execution. If None, creates an AgentExecutor automatically. An AgentExecutor retries a transient database is locked session error under its RetryPolicy (three attempts by default). SqliteSessionService sets no busy timeout or WAL of its own, so on a loaded disk a write can wait past the five-second default of Python's sqlite3 connection (which aiosqlite uses) and raise that error; to change the policy, pass AgentExecutor(session_service=..., retry_policy=RetryPolicy(...)).

TYPE: AgentExecutorProtocol | None

components

List of component names to include in evolution. Supported: - "instruction": The agent's instruction text (default if None). - "output_schema": The agent's Pydantic output_schema (serialized). - Any other name with a handler in the default component handler registry, such as "generate_content_config" or the keys added by register_mapping_components(); the candidate is seeded with that handler's serialize(agent). When None, defaults to ["instruction"]. Use ["output_schema"] with a schema reflection agent to evolve the output schema.

TYPE: list[str] | None

schema_constraints

Optional SchemaConstraints for output_schema evolution. When provided, proposed schema mutations are validated against these constraints. Mutations that violate constraints (e.g., remove required fields) are rejected and the original schema is preserved.

TYPE: SchemaConstraints | None

app

Optional ADK App instance. When provided, evolution uses the app's configuration. Note that App does not hold services directly; pass a Runner for service extraction, or combine with session_service param. See the App/Runner integration guide for details.

TYPE: App | None

runner

Optional ADK Runner instance. When provided, evolution extracts and uses the runner's session_service for all agent executions (evolved agents, critic, and reflection agent). Takes precedence over both app and executor parameters. This enables seamless integration with existing ADK infrastructure.

TYPE: Runner | None

registry

Optional ComponentHandlerRegistry that resolves every component name; defaults to the default registry that register_handler() and register_mapping_components() fill.

TYPE: ComponentHandlerRegistry | None

RETURNS DESCRIPTION
EvolutionResult

The engine's EvolutionResult with evolved_components (after state

EvolutionResult

guard validation), valset_score, trainset_score and

EvolutionResult

original_components (each component's seeded text) replaced. Every

EvolutionResult

other field, including the failed-evaluation counters, stop_reason,

EvolutionResult

token_usage and objective_scores, comes from the engine unchanged.

RAISES DESCRIPTION
ConfigurationError

If invalid parameters provided, including pre-flight validation failures: non-LlmAgent agent or critic, both critic and scorer given, a scorer that does not implement Scorer, empty trainset, duplicate or empty component names, missing critic, scorer and output_schema, EvolutionConfig consistency errors, or a component name the resolved registry holds no handler for.

EvolutionError

If evolution fails during execution. A reflection call that keeps raising a retryable provider error is skipped with skip_reason="reflection_error"; a non-retryable one raises ReflectionError. A reflection cut off at the output-token limit, or one that opens a reasoning tag it never closes, is skipped with skip_reason="incomplete_proposal". An error raised after the baseline was scored carries the engine's EvolutionResult for the recorded iterations in partial_result.

Notes

Pre-flight validation runs synchronously before any LLM calls. When SchemaBasedScorer is selected and trainset rows carry expected, a scorer.schema_based_over_labelled_trainset warning is logged; the selected scorer does not change. Single-agent evolution with trainset reflection and valset scoring.

For reproducible evolution, pass a seeded config: config=EvolutionConfig(seed=42).

Examples:

Basic usage with output_schema:

from pydantic import BaseModel, Field
from google.adk.agents import LlmAgent
from google.adk.models.lite_llm import LiteLlm
from gepa_adk import evolve


class OutputSchema(BaseModel):
    answer: str
    score: float = Field(ge=0.0, le=1.0)


agent = LlmAgent(
    name="assistant",
    model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
    instruction="You are a helpful assistant.",
    output_schema=OutputSchema,
)

trainset = [
    {"input": "What is 2+2?", "expected": "4"},
    {"input": "What is the capital of France?", "expected": "Paris"},
]

result = await evolve(agent, trainset)
print(f"Evolved: {result.evolved_components['instruction']}")

With critic agent:

from pydantic import BaseModel, Field
from google.adk.agents import LlmAgent
from google.adk.models.lite_llm import LiteLlm
from gepa_adk import evolve


class CriticOutput(BaseModel):
    score: float = Field(ge=0.0, le=1.0)


critic = LlmAgent(
    name="critic",
    model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
    instruction="Score the response quality.",
    output_schema=CriticOutput,
)

result = await evolve(agent, trainset, critic=critic)

Evolving output_schema with schema reflection:

from gepa_adk.adapters.agents.reflection_agents import (
    create_schema_reflection_agent,
)

# Create schema reflection agent with validation tool
schema_reflector = create_schema_reflection_agent("gemini-3.8-flash")

# Evolve output_schema component
result = await evolve(
    agent,
    trainset,
    critic=critic,
    reflection_agent=schema_reflector,
    components=["output_schema"],  # Evolve schema, not instruction
)
print(f"Evolved schema: {result.evolved_components['output_schema']}")

Using App/Runner for existing infrastructure integration:

from google.adk.apps.app import App
from google.adk.runners import Runner

# Requires the gepa-adk[db] extra (google-adk[db]) on ADK 2.x
from google.adk.sessions import DatabaseSessionService

# Configure Runner with your production session service
session_service = DatabaseSessionService(connection_string="...")
runner = Runner(
    app_name="my_app",
    agent=agent,
    session_service=session_service,
)

# Evolution uses your Runner's session_service for all operations
result = await evolve(
    agent,
    trainset,
    runner=runner,  # Services extracted from runner
)
Source code in src/gepa_adk/api.py
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
async def evolve(  # noqa: C901, PLR0912, PLR0913, PLR0915  # decomposition tracked in #506; public API keyword arguments
    agent: LlmAgent,
    trainset: list[dict[str, Any]],
    *,
    valset: list[dict[str, Any]] | None = None,
    critic: LlmAgent | None = None,
    scorer: Scorer | None = None,
    reflection_agent: LlmAgent | None = None,
    config: EvolutionConfig | None = None,
    trajectory_config: TrajectoryConfig | None = None,
    state_guard: StateGuard | None = None,
    candidate_selector: CandidateSelectorProtocol | str | None = None,
    component_selector: ComponentSelectorProtocol | str | None = None,
    executor: AgentExecutorProtocol | None = None,
    components: list[str] | None = None,
    schema_constraints: SchemaConstraints | None = None,
    app: App | None = None,
    runner: Runner | None = None,
    registry: ComponentHandlerRegistry | None = None,
) -> EvolutionResult:
    """Evolve an ADK agent's instruction.

    Optimizes the instruction for a single ADK agent using evolutionary
    optimization. The agent's instruction is iteratively improved based on
    performance on the training set.

    Args:
        agent (LlmAgent): The ADK LlmAgent to evolve. When ``critic`` is not provided,
            ``agent.output_schema`` must be a pydantic ``BaseModel`` subclass
            (other schema types accepted by ADK 1.34+ are not supported here).
        trainset (list[dict[str, Any]]): Training examples [{"input": "...", "expected": "..."}].

    Keyword Args:
        valset (list[dict[str, Any]] | None): Optional validation examples used for
            scoring and acceptance. Defaults to the trainset when omitted.
        critic (LlmAgent | None): Optional ADK agent for scoring (uses schema scoring if None
            and no scorer is given). Mutually exclusive with scorer.
        scorer (Scorer | None): Optional object implementing the Scorer protocol
            (``score`` and ``async_score``). When given, it is used as-is
            and takes precedence over the agent's output_schema. Mutually
            exclusive with critic.
        reflection_agent (LlmAgent | None): Optional ADK agent for proposals. If None, creates a
            default reflection agent using config.reflection_model (a model
            string, or a ``BaseLlm`` instance passed through unchanged).
        config (EvolutionConfig | None): Evolution configuration (uses defaults if None). Its
            ``reflection_max_trials`` and ``reflection_max_trial_chars``
            bound the trials each reflection call sees, and
            ``reflection_timeout_seconds`` bounds each reflection call's run
            time; a timed-out reflection skips the iteration.
            ``max_concurrent_evals`` is passed to ADKAdapter to limit
            concurrent evaluation rows within each batch.
        trajectory_config (TrajectoryConfig | None): Trajectory capture settings (uses
            defaults if None).
        state_guard (StateGuard | None): Optional state token preservation settings.
        candidate_selector (CandidateSelectorProtocol | str | None): Optional selector
            instance or selector name.
        component_selector (ComponentSelectorProtocol | str | None): Optional selector
            instance or selector name for choosing which components to update.
        executor (AgentExecutorProtocol | None): Optional AgentExecutorProtocol
            implementation for unified agent execution. When provided, both the
            ADKAdapter and CriticScorer use this executor for consistent session
            management and execution. If None, creates an AgentExecutor automatically.
            An ``AgentExecutor`` retries a transient ``database is locked`` session
            error under its ``RetryPolicy`` (three attempts by default).
            ``SqliteSessionService`` sets no busy timeout or WAL of its own, so on a
            loaded disk a write can wait past the five-second default of Python's
            sqlite3 connection (which aiosqlite uses) and raise that error; to change
            the policy, pass ``AgentExecutor(session_service=...,
            retry_policy=RetryPolicy(...))``.
        components (list[str] | None): List of component names to include in evolution. Supported:
            - "instruction": The agent's instruction text (default if None).
            - "output_schema": The agent's Pydantic output_schema (serialized).
            - Any other name with a handler in the default component handler
              registry, such as "generate_content_config" or the keys added
              by ``register_mapping_components()``; the candidate is seeded
              with that handler's ``serialize(agent)``.
            When None, defaults to ["instruction"]. Use ["output_schema"] with
            a schema reflection agent to evolve the output schema.
        schema_constraints (SchemaConstraints | None): Optional SchemaConstraints for
            output_schema evolution. When provided, proposed schema mutations are
            validated against these constraints. Mutations that violate constraints
            (e.g., remove required fields) are rejected and the original schema is
            preserved.
        app (App | None): Optional ADK App instance. When provided, evolution uses the app's
            configuration. Note that App does not hold services directly; pass
            a Runner for service extraction, or combine with session_service param.
            See the App/Runner integration guide for details.
        runner (Runner | None): Optional ADK Runner instance. When provided, evolution extracts
            and uses the runner's session_service for all agent executions
            (evolved agents, critic, and reflection agent). Takes precedence
            over both app and executor parameters. This enables seamless
            integration with existing ADK infrastructure.
        registry (ComponentHandlerRegistry | None): Optional ComponentHandlerRegistry
            that resolves every component name; defaults to the default registry that
            ``register_handler()`` and ``register_mapping_components()`` fill.

    Returns:
        The engine's EvolutionResult with evolved_components (after state
        guard validation), valset_score, trainset_score and
        original_components (each component's seeded text) replaced. Every
        other field, including the failed-evaluation counters, stop_reason,
        token_usage and objective_scores, comes from the engine unchanged.

    Raises:
        ConfigurationError: If invalid parameters provided, including
            pre-flight validation failures: non-LlmAgent agent or critic,
            both critic and scorer given, a scorer that does not implement
            Scorer, empty trainset, duplicate or empty component names,
            missing critic, scorer and output_schema, EvolutionConfig
            consistency errors, or a component name the resolved registry
            holds no handler for.
        EvolutionError: If evolution fails during execution. A reflection
            call that keeps raising a retryable provider error is skipped
            with ``skip_reason="reflection_error"``; a non-retryable one
            raises ``ReflectionError``. A reflection cut off at the
            output-token limit, or one that opens a reasoning tag it never
            closes, is skipped with ``skip_reason="incomplete_proposal"``.
            An error raised after the baseline
            was scored carries the engine's ``EvolutionResult`` for the
            recorded iterations in ``partial_result``.

    Notes:
        Pre-flight validation runs synchronously before any LLM calls.
        When ``SchemaBasedScorer`` is selected and trainset rows carry
        ``expected``, a ``scorer.schema_based_over_labelled_trainset``
        warning is logged; the selected scorer does not change.
        Single-agent evolution with trainset reflection and valset scoring.

        For reproducible evolution, pass a seeded config:
        ``config=EvolutionConfig(seed=42)``.

    Examples:
        Basic usage with output_schema:

        ```python
        from pydantic import BaseModel, Field
        from google.adk.agents import LlmAgent
        from google.adk.models.lite_llm import LiteLlm
        from gepa_adk import evolve


        class OutputSchema(BaseModel):
            answer: str
            score: float = Field(ge=0.0, le=1.0)


        agent = LlmAgent(
            name="assistant",
            model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
            instruction="You are a helpful assistant.",
            output_schema=OutputSchema,
        )

        trainset = [
            {"input": "What is 2+2?", "expected": "4"},
            {"input": "What is the capital of France?", "expected": "Paris"},
        ]

        result = await evolve(agent, trainset)
        print(f"Evolved: {result.evolved_components['instruction']}")
        ```

        With critic agent:

        ```python
        from pydantic import BaseModel, Field
        from google.adk.agents import LlmAgent
        from google.adk.models.lite_llm import LiteLlm
        from gepa_adk import evolve


        class CriticOutput(BaseModel):
            score: float = Field(ge=0.0, le=1.0)


        critic = LlmAgent(
            name="critic",
            model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
            instruction="Score the response quality.",
            output_schema=CriticOutput,
        )

        result = await evolve(agent, trainset, critic=critic)
        ```

        Evolving output_schema with schema reflection:

        ```python
        from gepa_adk.adapters.agents.reflection_agents import (
            create_schema_reflection_agent,
        )

        # Create schema reflection agent with validation tool
        schema_reflector = create_schema_reflection_agent("gemini-3.8-flash")

        # Evolve output_schema component
        result = await evolve(
            agent,
            trainset,
            critic=critic,
            reflection_agent=schema_reflector,
            components=["output_schema"],  # Evolve schema, not instruction
        )
        print(f"Evolved schema: {result.evolved_components['output_schema']}")
        ```

        Using App/Runner for existing infrastructure integration:

        ```python
        from google.adk.apps.app import App
        from google.adk.runners import Runner

        # Requires the gepa-adk[db] extra (google-adk[db]) on ADK 2.x
        from google.adk.sessions import DatabaseSessionService

        # Configure Runner with your production session service
        session_service = DatabaseSessionService(connection_string="...")
        runner = Runner(
            app_name="my_app",
            agent=agent,
            session_service=session_service,
        )

        # Evolution uses your Runner's session_service for all operations
        result = await evolve(
            agent,
            trainset,
            runner=runner,  # Services extracted from runner
        )
        ```
    """
    # Pre-flight validation (Story 2.5)
    _pre_flight_validate_evolve(agent, trainset, critic, components, scorer=scorer)
    required_keys = (
        set(trainset[0].keys()) if trainset and len(trainset) > 0 else {"input"}
    )

    resolved_valset = valset if valset is not None else trainset
    if valset is not None:
        _validate_dataset(
            valset,
            "valset",
            allow_empty=False,
            required_keys=required_keys,
        )

    # Log reflection_agent configuration if provided
    if reflection_agent is not None:
        logger.debug(
            "evolve.reflection_agent.configured",
            agent_name=agent.name,
            reflection_agent_name=reflection_agent.name,
            message="Using ADK reflection agent for instruction improvement",
        )

    # Capture original instruction for StateGuard validation
    original_instruction = str(agent.instruction)

    # Resolve every component name through one registry for this round (#451)
    resolved_registry = registry if registry is not None else component_handlers
    for comp_name in components or [DEFAULT_COMPONENT_NAME]:
        _resolve_handler(comp_name, resolved_registry)

    candidate_selector_label = (
        candidate_selector
        if isinstance(candidate_selector, str)
        else type(candidate_selector).__name__
        if candidate_selector is not None
        else None
    )

    component_selector_label = (
        component_selector
        if isinstance(component_selector, str)
        else type(component_selector).__name__
        if component_selector is not None
        else None
    )

    # Log evolution start
    logger.info(
        "evolve.start",
        agent_name=agent.name,
        trainset_size=len(trainset),
        valset_size=len(resolved_valset),
        valset_defaulted=valset is None,
        has_critic=critic is not None,
        has_reflection_agent=reflection_agent is not None,
        has_state_guard=state_guard is not None,
        candidate_selector=candidate_selector_label,
        component_selector=component_selector_label,
    )

    # Resolve services from runner/app with precedence warnings (#227)
    # Log precedence warnings if multiple config sources provided (T009)
    if runner is not None and app is not None:
        logger.warning(
            "evolve.precedence.runner_over_app",
            message="Both runner and app provided; using runner (runner takes precedence)",
            runner_app_name=runner.app_name,
            app_name=app.name,
        )
    if runner is not None and executor is not None:
        logger.warning(
            "evolve.precedence.runner_over_executor",
            message="Both runner and executor provided; using runner's session_service",
            runner_app_name=runner.app_name,
        )

    # Extract services using precedence rules (T006)
    resolved_session_service, _artifact_service = _resolve_evolution_services(
        runner=runner,
        app=app,
        session_service=None,  # evolve() doesn't have direct session_service param
    )

    # Resolve app_name for session isolation (#239)
    resolved_app_name = _resolve_app_name(runner=runner, app=app)

    # Create executor with resolved session_service (T007)
    # Runner takes precedence over user-provided executor
    if runner is not None:
        resolved_executor = AgentExecutor(
            session_service=resolved_session_service,
            app_name=resolved_app_name,
        )
    else:
        resolved_executor = executor or AgentExecutor(
            session_service=resolved_session_service,
            app_name=resolved_app_name,
        )

    # Build scorer: explicit scorer > critic > output_schema
    resolved_scorer: Scorer
    if scorer is not None:
        resolved_scorer = scorer
    elif critic:
        resolved_scorer = CriticScorer(critic_agent=critic, executor=resolved_executor)
    elif hasattr(agent, "output_schema") and agent.output_schema is not None:
        # Use schema-based scorer when agent has output_schema
        _warn_if_self_grading_labelled_rows(trainset)
        resolved_scorer = SchemaBasedScorer(
            output_schema=cast(type[BaseModel], agent.output_schema)
        )
    else:
        raise ConfigurationError(
            "Either critic must be provided or agent must have output_schema",
            field="critic",
            value=None,
            constraint="must provide critic or agent.output_schema",
        )

    # Resolve config
    resolved_config = config or EvolutionConfig()
    rng = (
        random.Random(resolved_config.seed)  # noqa: S311  # evolution sampling, not security
        if resolved_config.seed is not None
        else None
    )

    # Create reflection agent if not provided
    resolved_reflection_agent = reflection_agent
    if resolved_reflection_agent is None:
        # Create default reflection agent with config settings
        resolved_reflection_agent = LlmAgent(
            name="reflection_agent",
            model=_resolve_model_for_agent(resolved_config.reflection_model),
            instruction=resolved_config.reflection_prompt or REFLECTION_INSTRUCTION,
        )
        logger.debug(
            "evolve.reflection_agent.default",
            reflection_model=_describe_reflection_model(
                resolved_config.reflection_model
            ),
        )

    # Build proposer chain in the composition root (api.py)
    adk_reflection_fn = create_adk_reflection_fn(
        resolved_reflection_agent,
        executor=resolved_executor,
        timeout_seconds=resolved_config.reflection_timeout_seconds,
    )
    proposer = AsyncReflectiveMutationProposer(
        adk_reflection_fn=adk_reflection_fn,
        max_trials=resolved_config.reflection_max_trials,
        max_trial_chars=resolved_config.reflection_max_trial_chars,
    )

    # Create adapter with resolved session_service (T008)
    adapter = ADKAdapter(
        agent=agent,
        scorer=resolved_scorer,
        trajectory_config=trajectory_config,
        proposer=proposer,
        executor=resolved_executor,
        schema_constraints=schema_constraints,
        max_concurrent_evals=resolved_config.max_concurrent_evals,
        session_service=resolved_session_service,
        registry=resolved_registry,
    )

    # Build initial candidate components based on requested components
    resolved_components = components if components else [DEFAULT_COMPONENT_NAME]
    initial_components: dict[str, str] = {}

    for comp_name in resolved_components:
        if comp_name == DEFAULT_COMPONENT_NAME:
            initial_components[comp_name] = original_instruction
        elif comp_name == COMPONENT_OUTPUT_SCHEMA:
            if not hasattr(agent, "output_schema") or agent.output_schema is None:
                raise ConfigurationError(
                    f"Cannot evolve '{COMPONENT_OUTPUT_SCHEMA}': agent has no output_schema",
                    field="components",
                    value=comp_name,
                    constraint="agent must have output_schema to evolve it",
                )
            schema_text = serialize_pydantic_schema(
                cast(type[BaseModel], agent.output_schema)
            )
            initial_components[comp_name] = schema_text
        else:
            handler_text = _serialize_registered_component(
                agent, comp_name, resolved_registry
            )
            initial_components[comp_name] = handler_text

    logger.debug(
        "evolve.components.resolved",
        agent_name=agent.name,
        components=resolved_components,
    )

    original_component_values = initial_components.copy()
    initial_candidate = Candidate(components=initial_components)

    # Create engine
    resolved_candidate_selector: CandidateSelectorProtocol | None = None
    if candidate_selector is not None:
        if isinstance(candidate_selector, str):
            resolved_candidate_selector = create_candidate_selector(
                candidate_selector, rng=rng
            )
        else:
            resolved_candidate_selector = candidate_selector

    resolved_component_selector: ComponentSelectorProtocol | None = None
    if component_selector is not None:
        if isinstance(component_selector, str):
            resolved_component_selector = create_component_selector(component_selector)
        else:
            resolved_component_selector = component_selector

    engine = AsyncGEPAEngine(
        adapter=adapter,
        config=resolved_config,
        initial_candidate=initial_candidate,
        batch=trainset,
        valset=resolved_valset,
        candidate_selector=resolved_candidate_selector,
        component_selector=resolved_component_selector,
        rng=rng,
    )

    # Run evolution with cleanup
    try:
        result = await engine.run()

        valset_score = result.valset_score
        trainset_score = result.trainset_score

        if trainset_score is not None:
            logger.info(
                "evolve.trainset.scored",
                agent_name=agent.name,
                trainset_size=len(trainset),
                trainset_score=trainset_score,
            )
        if valset_score is not None:
            logger.info(
                "evolve.valset.scored",
                agent_name=agent.name,
                valset_size=len(resolved_valset),
                valset_score=valset_score,
                valset_defaulted=valset is None,
            )

        # Apply state guard validation if provided (for token preservation)
        # Only applies to text components (instruction), not to output_schema
        validated_components = dict(result.evolved_components)
        for comp_name in resolved_components:
            if comp_name in result.evolved_components:
                if comp_name == DEFAULT_COMPONENT_NAME and state_guard is not None:
                    validated_components[comp_name] = _apply_state_guard_validation(
                        state_guard=state_guard,
                        original_component_text=original_component_values[comp_name],
                        evolved_component_text=result.evolved_components[comp_name],
                        agent_name=agent.name,
                    )
                else:
                    validated_components[comp_name] = result.evolved_components[
                        comp_name
                    ]

        # Log evolution completion
        logger.info(
            "evolve.complete",
            agent_name=agent.name,
            original_score=result.original_score,
            final_score=result.final_score,
            improvement=result.improvement,
            total_iterations=result.total_iterations,
            valset_score=valset_score,
            trainset_score=trainset_score,
            components=resolved_components,
        )

        # replace() rather than a rebuilt EvolutionResult, so engine-owned
        # fields (failure counters, stop_reason, token_usage) are not reset
        # to their defaults.
        return replace(
            result,
            evolved_components=validated_components,
            valset_score=valset_score,
            trainset_score=trainset_score,
            original_components=original_component_values,
        )
    finally:
        # Clean up adapter resources (clears handler constraints)
        adapter.cleanup()

evolve_group async

evolve_group(
    agents: dict[str, LlmAgent],
    primary: str,
    trainset: list[dict[str, Any]],
    *,
    components: dict[str, list[str]] | None = None,
    critic: LlmAgent | None = None,
    scorer: Scorer | None = None,
    share_session: bool = True,
    config: EvolutionConfig | None = None,
    state_guard: StateGuard | None = None,
    component_selector: ComponentSelectorProtocol
    | str
    | None = None,
    reflection_agent: LlmAgent | None = None,
    trajectory_config: TrajectoryConfig | None = None,
    workflow: SequentialAgent
    | LoopAgent
    | ParallelAgent
    | None = None,
    session_service: BaseSessionService | None = None,
    app: App | None = None,
    runner: Runner | None = None,
    registry: ComponentHandlerRegistry | None = None,
) -> MultiAgentEvolutionResult

Evolve multiple agents together with per-agent component configuration.

Optimizes specified components for each agent by targeting the primary agent's output score. When share_session=True, agents execute sequentially with shared session state, enabling later agents to access earlier agents' outputs via template strings.

PARAMETER DESCRIPTION
agents

Named ADK agents to evolve together as dict mapping agent names to LlmAgent instances. Must have at least one agent.

TYPE: dict[str, LlmAgent]

primary

Name of the agent whose output is used for scoring. Must match one of the agent names in the dict.

TYPE: str

trainset

Training examples for evaluation. Each example should have an "input" key and optionally an "expected" key.

TYPE: list[dict[str, Any]]

PARAMETER DESCRIPTION
components

Per-agent component configuration mapping agent names to lists of component names to evolve. If None, defaults to evolving "instruction" for all agents. Use empty list to exclude an agent from evolution. Available component names: "instruction", "output_schema", "generate_content_config".

TYPE: dict[str, list[str]] | None

critic

Optional critic agent for scoring. If None and no scorer is given, the primary agent must have an output_schema for schema-based scoring. Mutually exclusive with scorer.

TYPE: LlmAgent | None

scorer

Optional object implementing the Scorer protocol (score and async_score). When given, it scores the primary agent's output directly and takes precedence over schema-based scoring. Mutually exclusive with critic.

TYPE: Scorer | None

share_session

Whether agents share session state during execution. When True (default), uses SequentialAgent. When False, only the primary agent runs in its own session; other agents are not executed.

TYPE: bool

config

Evolution configuration. If None, uses EvolutionConfig defaults. Its reflection_max_trials and reflection_max_trial_chars bound the trials each reflection call sees, and its reflection_timeout_seconds bounds each reflection call's run time. max_concurrent_evals does not apply here: MultiAgentAdapter has no corresponding limit.

TYPE: EvolutionConfig | None

state_guard

Optional StateGuard instance for validating and repairing state injection tokens in evolved instructions.

TYPE: StateGuard | None

component_selector

Optional selector instance or selector name for choosing which components to update.

TYPE: ComponentSelectorProtocol | str | None

reflection_agent

Optional ADK agent for proposals. If None, creates a default reflection agent using config.reflection_model (a model string, or a BaseLlm instance passed through unchanged).

TYPE: LlmAgent | None

trajectory_config

Trajectory capture settings (uses defaults if None).

TYPE: TrajectoryConfig | None

workflow

Optional original workflow structure to preserve during evaluation. When provided, LoopAgent iterations and ParallelAgent concurrency are preserved instead of flattening to SequentialAgent. Used internally by evolve_workflow(); not typically set directly.

TYPE: SequentialAgent | LoopAgent | ParallelAgent | None

session_service

Optional ADK session service for state management. If None (default), creates an InMemorySessionService internally. Pass a custom service (e.g., SqliteSessionService, DatabaseSessionService; on ADK 2.x SQL-backed services need the gepa-adk[db] extra, which installs google-adk[db]) to persist sessions alongside other agent executions in a shared database. SqliteSessionService sets no busy timeout or WAL of its own, so on a loaded disk a write can wait past the five-second default of Python's sqlite3 connection (which aiosqlite uses) and raise database is locked. The internal executor retries that error under its default RetryPolicy (three attempts, 0.5 s backoff, doubling) before the row counts as a failed evaluation.

TYPE: BaseSessionService | None

app

Optional ADK App instance. When provided, evolution uses the app's configuration. Note that App does not hold services directly; pass a Runner for service extraction, or combine with session_service param.

TYPE: App | None

runner

Optional ADK Runner instance. When provided, evolution extracts and uses the runner's session_service for all agent executions (evolved agents, critic, and reflection agent). Takes precedence over both app and session_service parameters. This enables seamless integration with existing ADK infrastructure.

TYPE: Runner | None

registry

Optional ComponentHandlerRegistry that resolves every component name; defaults to the default registry that register_handler() and register_mapping_components() fill.

TYPE: ComponentHandlerRegistry | None

RETURNS DESCRIPTION
MultiAgentEvolutionResult

MultiAgentEvolutionResult containing evolved_components dict

MultiAgentEvolutionResult

mapping qualified component names (agent.component format) to their

MultiAgentEvolutionResult

optimized values, along with score metrics, iteration history

MultiAgentEvolutionResult

(each record names its candidate_id and parent_ids),

MultiAgentEvolutionResult

original_components (filtered to the qualified keyspace),

MultiAgentEvolutionResult

schema_version (4), and stop_reason propagated from the engine

MultiAgentEvolutionResult

result.

RAISES DESCRIPTION
ConfigurationError

If pre-flight validation fails: invalid agent names, non-LlmAgent critic, both critic and scorer given, a scorer that does not implement Scorer, empty trainset, duplicate or empty component names per agent, EvolutionConfig consistency errors, or a component name the resolved registry holds no handler for.

MultiAgentValidationError

If agents dict is empty, primary agent not found, or no scorer and primary lacks output_schema.

ValueError

If components mapping contains unknown agents or is missing entries for agents.

EvolutionError

If evolution fails during execution. A reflection call that keeps raising a retryable provider error is skipped with skip_reason="reflection_error"; a non-retryable one raises ReflectionError. A reflection cut off at the output-token limit, or one that opens a reasoning tag it never closes, is skipped with skip_reason="incomplete_proposal". An error raised after the baseline was scored carries the engine's EvolutionResult for the recorded iterations in partial_result.

Examples:

Basic usage with per-agent components (API v0.3.x):

from google.adk.agents import LlmAgent
from google.adk.models.lite_llm import LiteLlm
from gepa_adk import evolve_group

generator = LlmAgent(
    name="generator",
    model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
    instruction="Generate code based on the requirement.",
)
critic = LlmAgent(
    name="critic",
    model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
    instruction="Review the code in {generator_output}.",
)
validator = LlmAgent(
    name="validator",
    model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
    instruction="Validate the reviewed code.",
    output_schema=ValidationResult,
)

result = await evolve_group(
    agents={
        "generator": generator,
        "critic": critic,
        "validator": validator,
    },
    primary="validator",
    trainset=training_data,
    components={
        "generator": ["instruction", "output_schema"],
        "critic": ["instruction"],
        "validator": ["instruction"],
    },
)

# Access evolved components using qualified names
print(result.evolved_components["generator.instruction"])
print(result.evolved_components["critic.instruction"])
print(result.evolved_components["validator.instruction"])

Exclude an agent from evolution:

result = await evolve_group(
    agents={"generator": gen, "static_validator": val},
    primary="generator",
    trainset=training_data,
    components={
        "generator": ["instruction"],
        "static_validator": [],  # Excluded from evolution
    },
)

Using custom session service for persistence:

# Requires the gepa-adk[db] extra (google-adk[db]) on ADK 2.x
from google.adk.sessions import SqliteSessionService

# Use SQLite for session persistence
session_service = SqliteSessionService(db_path="evolution_sessions.db")

result = await evolve_group(
    agents={"generator": gen, "critic": critic},
    primary="critic",
    trainset=training_data,
    session_service=session_service,  # Sessions persisted to SQLite
)

Using App/Runner for existing infrastructure integration:

from google.adk.runners import Runner

# Requires the gepa-adk[db] extra (google-adk[db]) on ADK 2.x
from google.adk.sessions import DatabaseSessionService

# Configure Runner with your production session service
runner = Runner(
    app_name="my_app",
    agent=generator,  # Any agent from the group
    session_service=DatabaseSessionService(connection_string="..."),
)

# Evolution uses Runner's session_service for all operations
result = await evolve_group(
    agents={"generator": gen, "refiner": ref},
    primary="refiner",
    trainset=training_data,
    runner=runner,  # Services extracted from runner
)
Notes

Breaking change in v0.3.x: The agents parameter changed from list[LlmAgent] to dict[str, LlmAgent]. Candidate keys now use qualified names (agent.component) instead of {agent_name}_instruction.

For reproducible evolution, pass a seeded config: config=EvolutionConfig(seed=42).

The returned result copies baseline_failed_evaluations, total_failed_evaluations and token_usage from the engine result.

Source code in src/gepa_adk/api.py
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
async def evolve_group(  # noqa: C901, PLR0912, PLR0913  # decomposition tracked in #506; public API keyword arguments
    agents: dict[str, LlmAgent],
    primary: str,
    trainset: list[dict[str, Any]],
    *,
    components: dict[str, list[str]] | None = None,
    critic: LlmAgent | None = None,
    scorer: Scorer | None = None,
    share_session: bool = True,
    config: EvolutionConfig | None = None,
    state_guard: StateGuard | None = None,
    component_selector: ComponentSelectorProtocol | str | None = None,
    reflection_agent: LlmAgent | None = None,
    trajectory_config: TrajectoryConfig | None = None,
    workflow: SequentialAgent | LoopAgent | ParallelAgent | None = None,
    session_service: BaseSessionService | None = None,
    app: App | None = None,
    runner: Runner | None = None,
    registry: ComponentHandlerRegistry | None = None,
) -> MultiAgentEvolutionResult:
    """Evolve multiple agents together with per-agent component configuration.

    Optimizes specified components for each agent by targeting the primary
    agent's output score. When share_session=True, agents execute sequentially
    with shared session state, enabling later agents to access earlier
    agents' outputs via template strings.

    Args:
        agents (dict[str, LlmAgent]): Named ADK agents to evolve together as dict mapping agent
            names to LlmAgent instances. Must have at least one agent.
        primary (str): Name of the agent whose output is used for scoring.
            Must match one of the agent names in the dict.
        trainset (list[dict[str, Any]]): Training examples for evaluation. Each example should
            have an "input" key and optionally an "expected" key.

    Keyword Args:
        components (dict[str, list[str]] | None): Per-agent component configuration
            mapping agent names to lists of component names to evolve. If None, defaults
            to evolving "instruction" for all agents. Use empty list to exclude an agent
            from evolution. Available component names: "instruction", "output_schema",
            "generate_content_config".
        critic (LlmAgent | None): Optional critic agent for scoring. If None and no scorer is
            given, the primary agent must have an output_schema for
            schema-based scoring. Mutually exclusive with scorer.
        scorer (Scorer | None): Optional object implementing the Scorer protocol
            (``score`` and ``async_score``). When given, it scores the
            primary agent's output directly and takes precedence over
            schema-based scoring. Mutually exclusive with critic.
        share_session (bool): Whether agents share session state during
            execution. When True (default), uses SequentialAgent.
            When False, only the primary agent runs in its own session; other
            agents are not executed.
        config (EvolutionConfig | None): Evolution configuration. If None, uses EvolutionConfig
            defaults. Its ``reflection_max_trials`` and
            ``reflection_max_trial_chars`` bound the trials each
            reflection call sees, and its ``reflection_timeout_seconds``
            bounds each reflection call's run time. ``max_concurrent_evals``
            does not apply here: MultiAgentAdapter has no corresponding limit.
        state_guard (StateGuard | None): Optional StateGuard instance for validating and
            repairing state injection tokens in evolved instructions.
        component_selector (ComponentSelectorProtocol | str | None): Optional selector
            instance or selector name for choosing which components to update.
        reflection_agent (LlmAgent | None): Optional ADK agent for proposals. If None, creates a
            default reflection agent using config.reflection_model (a model
            string, or a ``BaseLlm`` instance passed through unchanged).
        trajectory_config (TrajectoryConfig | None): Trajectory capture settings (uses
            defaults if None).
        workflow (SequentialAgent | LoopAgent | ParallelAgent | None): Optional original
            workflow structure to preserve during evaluation. When provided, LoopAgent
            iterations and ParallelAgent concurrency are preserved instead of flattening
            to SequentialAgent. Used internally by evolve_workflow(); not typically set
            directly.
        session_service (BaseSessionService | None): Optional ADK session service for
            state management. If None (default), creates an InMemorySessionService
            internally. Pass a custom service (e.g., SqliteSessionService,
            DatabaseSessionService; on ADK 2.x SQL-backed services need the
            ``gepa-adk[db]`` extra, which installs ``google-adk[db]``) to persist
            sessions alongside other agent executions in a shared database.
            ``SqliteSessionService`` sets no busy timeout or WAL of its own, so on a
            loaded disk a write can wait past the five-second default of Python's
            sqlite3 connection (which aiosqlite uses) and raise ``database is
            locked``. The internal executor retries that error under its default
            ``RetryPolicy`` (three attempts, 0.5 s backoff, doubling) before the row
            counts as a failed evaluation.
        app (App | None): Optional ADK App instance. When provided, evolution uses the app's
            configuration. Note that App does not hold services directly; pass
            a Runner for service extraction, or combine with session_service param.
        runner (Runner | None): Optional ADK Runner instance. When provided, evolution extracts
            and uses the runner's session_service for all agent executions
            (evolved agents, critic, and reflection agent). Takes precedence
            over both app and session_service parameters. This enables seamless
            integration with existing ADK infrastructure.
        registry (ComponentHandlerRegistry | None): Optional ComponentHandlerRegistry
            that resolves every component name; defaults to the default registry that
            ``register_handler()`` and ``register_mapping_components()`` fill.

    Returns:
        MultiAgentEvolutionResult containing evolved_components dict
        mapping qualified component names (agent.component format) to their
        optimized values, along with score metrics, iteration history
        (each record names its ``candidate_id`` and ``parent_ids``),
        original_components (filtered to the qualified keyspace),
        schema_version (4), and stop_reason propagated from the engine
        result.

    Raises:
        ConfigurationError: If pre-flight validation fails: invalid agent
            names, non-LlmAgent critic, both critic and scorer given, a
            scorer that does not implement Scorer, empty trainset, duplicate
            or empty component names per agent, EvolutionConfig
            consistency errors, or a component name the resolved registry
            holds no handler for.
        MultiAgentValidationError: If agents dict is empty, primary agent
            not found, or no scorer and primary lacks output_schema.
        ValueError: If components mapping contains unknown agents or is
            missing entries for agents.
        EvolutionError: If evolution fails during execution. A reflection
            call that keeps raising a retryable provider error is skipped
            with ``skip_reason="reflection_error"``; a non-retryable one
            raises ``ReflectionError``. A reflection cut off at the
            output-token limit, or one that opens a reasoning tag it never
            closes, is skipped with ``skip_reason="incomplete_proposal"``.
            An error raised after the baseline
            was scored carries the engine's ``EvolutionResult`` for the
            recorded iterations in ``partial_result``.

    Examples:
        Basic usage with per-agent components (API v0.3.x):

        ```python
        from google.adk.agents import LlmAgent
        from google.adk.models.lite_llm import LiteLlm
        from gepa_adk import evolve_group

        generator = LlmAgent(
            name="generator",
            model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
            instruction="Generate code based on the requirement.",
        )
        critic = LlmAgent(
            name="critic",
            model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
            instruction="Review the code in {generator_output}.",
        )
        validator = LlmAgent(
            name="validator",
            model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
            instruction="Validate the reviewed code.",
            output_schema=ValidationResult,
        )

        result = await evolve_group(
            agents={
                "generator": generator,
                "critic": critic,
                "validator": validator,
            },
            primary="validator",
            trainset=training_data,
            components={
                "generator": ["instruction", "output_schema"],
                "critic": ["instruction"],
                "validator": ["instruction"],
            },
        )

        # Access evolved components using qualified names
        print(result.evolved_components["generator.instruction"])
        print(result.evolved_components["critic.instruction"])
        print(result.evolved_components["validator.instruction"])
        ```

        Exclude an agent from evolution:

        ```python
        result = await evolve_group(
            agents={"generator": gen, "static_validator": val},
            primary="generator",
            trainset=training_data,
            components={
                "generator": ["instruction"],
                "static_validator": [],  # Excluded from evolution
            },
        )
        ```

        Using custom session service for persistence:

        ```python
        # Requires the gepa-adk[db] extra (google-adk[db]) on ADK 2.x
        from google.adk.sessions import SqliteSessionService

        # Use SQLite for session persistence
        session_service = SqliteSessionService(db_path="evolution_sessions.db")

        result = await evolve_group(
            agents={"generator": gen, "critic": critic},
            primary="critic",
            trainset=training_data,
            session_service=session_service,  # Sessions persisted to SQLite
        )
        ```

        Using App/Runner for existing infrastructure integration:

        ```python
        from google.adk.runners import Runner

        # Requires the gepa-adk[db] extra (google-adk[db]) on ADK 2.x
        from google.adk.sessions import DatabaseSessionService

        # Configure Runner with your production session service
        runner = Runner(
            app_name="my_app",
            agent=generator,  # Any agent from the group
            session_service=DatabaseSessionService(connection_string="..."),
        )

        # Evolution uses Runner's session_service for all operations
        result = await evolve_group(
            agents={"generator": gen, "refiner": ref},
            primary="refiner",
            trainset=training_data,
            runner=runner,  # Services extracted from runner
        )
        ```

    Notes:
        Breaking change in v0.3.x: The `agents` parameter changed from
        `list[LlmAgent]` to `dict[str, LlmAgent]`. Candidate keys now use
        qualified names (agent.component) instead of {agent_name}_instruction.

        For reproducible evolution, pass a seeded config:
        ``config=EvolutionConfig(seed=42)``.

        The returned result copies ``baseline_failed_evaluations``,
        ``total_failed_evaluations`` and ``token_usage`` from the engine
        result.
    """
    # Pre-flight validation (T012a + Story 2.5)
    _pre_flight_validate_group(agents, trainset, critic, components, scorer=scorer)

    # Default components: evolve "instruction" for all agents
    if components is None:
        components = {name: ["instruction"] for name in agents}

    # Resolve every component name through one registry for this round (#451)
    resolved_registry = registry if registry is not None else component_handlers
    for comp_list in components.values():
        for comp_name in comp_list:
            _resolve_handler(comp_name, resolved_registry)

    # Capture original instructions for StateGuard validation
    original_instructions = {
        name: str(agent.instruction) for name, agent in agents.items()
    }

    # Log precedence warnings if multiple config sources provided (#227 T009)
    if runner is not None and app is not None:
        logger.warning(
            "evolve_group.precedence.runner_over_app",
            message="Both runner and app provided; using runner (runner takes precedence)",
            runner_app_name=runner.app_name,
            app_name=app.name,
        )

    # Resolve services using precedence rules: runner > app > session_service > default (#227)
    resolved_session_service, _artifact_service = _resolve_evolution_services(
        runner=runner,
        app=app,
        session_service=session_service,
    )

    # Resolve app_name for session isolation (#239)
    resolved_app_name = _resolve_app_name(runner=runner, app=app)

    # Create unified executor for consistent session management (FR-003)
    executor = AgentExecutor(
        session_service=resolved_session_service,
        app_name=resolved_app_name,
    )

    # Build scorer with executor (FR-005); an explicit scorer wins
    if scorer is None and critic:
        scorer = CriticScorer(critic_agent=critic, executor=executor)

    # Resolve config for reflection_model
    resolved_config = config or EvolutionConfig()
    rng = (
        random.Random(resolved_config.seed)  # noqa: S311  # evolution sampling, not security
        if resolved_config.seed is not None
        else None
    )

    # Create reflection-based proposer with executor (FR-006)
    # Use provided reflection_agent or create a default one
    if reflection_agent is None:
        reflection_agent = LlmAgent(
            name="reflection_agent",
            model=_resolve_model_for_agent(resolved_config.reflection_model),
            instruction=resolved_config.reflection_prompt or REFLECTION_INSTRUCTION,
        )
    adk_reflection_fn = create_adk_reflection_fn(
        reflection_agent,
        executor=executor,
        timeout_seconds=resolved_config.reflection_timeout_seconds,
    )
    proposer = AsyncReflectiveMutationProposer(
        adk_reflection_fn=adk_reflection_fn,
        max_trials=resolved_config.reflection_max_trials,
        max_trial_chars=resolved_config.reflection_max_trial_chars,
    )

    # Create adapter with executor (FR-004)
    adapter = MultiAgentAdapter(
        agents=agents,
        primary=primary,
        components=components,
        scorer=scorer,
        share_session=share_session,
        session_service=resolved_session_service,
        trajectory_config=trajectory_config,
        proposer=proposer,
        executor=executor,
        workflow=workflow,  # Preserve workflow structure (#215)
        registry=resolved_registry,
    )

    # Build seed candidate using qualified names (agent.component format per ADR-012)
    primary_agent = agents[primary]
    # Extract all configured components using their handlers
    seed_candidate_components: dict[str, str] = {}
    for agent_name, comp_list in components.items():
        agent = agents[agent_name]
        for comp_name in comp_list:
            qualified_name = f"{agent_name}.{comp_name}"
            handler = _resolve_handler(comp_name, resolved_registry)
            seed_candidate_components[qualified_name] = handler.serialize(agent)
    # Add required "instruction" key for engine compatibility
    seed_candidate_components["instruction"] = str(primary_agent.instruction)
    initial_candidate = Candidate(components=seed_candidate_components)

    # Create engine
    resolved_component_selector: ComponentSelectorProtocol | None = None
    if component_selector is not None:
        if isinstance(component_selector, str):
            resolved_component_selector = create_component_selector(component_selector)
        else:
            resolved_component_selector = component_selector

    engine = AsyncGEPAEngine(
        adapter=adapter,
        config=resolved_config,
        initial_candidate=initial_candidate,
        batch=trainset,
        component_selector=resolved_component_selector,
        rng=rng,
    )

    # Run evolution
    evolution_result = await engine.run()

    # Extract best candidate components from engine state using qualified names
    # The engine stores evolved_components from the candidate, which now uses
    # qualified names (agent.component format per ADR-012)
    evolved_components = _extract_evolved_components(
        evolution_result=evolution_result,
        seed_components=seed_candidate_components,
        agents=agents,
        components=components,
        primary=primary,
    )

    # Apply StateGuard validation to instruction components only
    if state_guard is not None:
        validated_components = {}
        for qualified_name, evolved_value in evolved_components.items():
            # Only apply StateGuard to instruction components
            if qualified_name.endswith(".instruction"):
                agent_name = qualified_name.rsplit(".", 1)[0]
                original_instruction = original_instructions.get(agent_name, "")
                validated_components[qualified_name] = _apply_state_guard_validation(
                    state_guard=state_guard,
                    original_component_text=original_instruction,
                    evolved_component_text=evolved_value,
                    agent_name=agent_name,
                )
            else:
                # Non-instruction components pass through unchanged
                validated_components[qualified_name] = evolved_value
        evolved_components = validated_components

    # Filter original_components to the qualified keyspace used by
    # evolved_components, stripping engine-internal keys (e.g. bare
    # "instruction" added for engine compatibility).
    orig = evolution_result.original_components
    if orig is not None:
        orig = {k: v for k, v in orig.items() if k in evolved_components}

    # Convert EvolutionResult to MultiAgentEvolutionResult
    return MultiAgentEvolutionResult(
        schema_version=evolution_result.schema_version,
        stop_reason=evolution_result.stop_reason,
        evolved_components=evolved_components,
        original_score=evolution_result.original_score,
        final_score=evolution_result.final_score,
        primary_agent=primary,
        iteration_history=evolution_result.iteration_history,
        total_iterations=evolution_result.total_iterations,
        original_components=orig,
        baseline_failed_evaluations=evolution_result.baseline_failed_evaluations,
        total_failed_evaluations=evolution_result.total_failed_evaluations,
        token_usage=evolution_result.token_usage,
    )

evolve_sync

evolve_sync(
    agent: LlmAgent,
    trainset: list[dict[str, Any]],
    **kwargs: Any,
) -> EvolutionResult

Synchronous wrapper for evolve().

.. deprecated:: Use run_sync(evolve(agent, trainset, ...)) instead.

Runs the async evolve() function in a blocking manner. Handles nested event loops automatically.

PARAMETER DESCRIPTION
agent

The ADK LlmAgent to evolve.

TYPE: LlmAgent

trainset

Training examples.

TYPE: list[dict[str, Any]]

**kwargs

Optional keyword arguments passed to evolve().

TYPE: Any DEFAULT: {}

PARAMETER DESCRIPTION
valset

Optional validation examples for held-out evaluation.

TYPE: list[dict[str, Any]] | None

critic

Optional ADK agent for scoring.

TYPE: LlmAgent | None

reflection_agent

Optional ADK agent for proposals (not yet implemented).

TYPE: LlmAgent | None

config

EvolutionConfig for customizing evolution parameters.

TYPE: EvolutionConfig | None

trajectory_config

TrajectoryConfig for trace capture settings.

TYPE: TrajectoryConfig | None

state_guard

Optional state token preservation settings.

TYPE: StateGuard | None

candidate_selector

Optional selector instance or selector name.

TYPE: CandidateSelectorProtocol | str | None

executor

Optional unified agent executor for consistent session management across all agent types.

TYPE: AgentExecutorProtocol | None

RETURNS DESCRIPTION
EvolutionResult

EvolutionResult with evolved_components dict and metrics.

RAISES DESCRIPTION
ConfigurationError

If invalid parameters provided.

EvolutionError

If evolution fails during execution. A reflection call that keeps raising a retryable provider error is skipped with skip_reason="reflection_error"; a non-retryable one raises ReflectionError. A reflection cut off at the output-token limit, or one that opens a reasoning tag it never closes, is skipped with skip_reason="incomplete_proposal". An error raised after the baseline was scored carries the engine's EvolutionResult for the recorded iterations in partial_result.

WARNS DESCRIPTION
DeprecationWarning

Always emitted when called. Use run_sync(evolve(...)) instead.

Examples:

Basic usage in a script:

from pydantic import BaseModel, Field
from google.adk.agents import LlmAgent
from google.adk.models.lite_llm import LiteLlm
from gepa_adk import evolve_sync


class OutputSchema(BaseModel):
    answer: str
    score: float = Field(ge=0.0, le=1.0)


agent = LlmAgent(
    name="assistant",
    model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
    instruction="You are a helpful assistant.",
    output_schema=OutputSchema,
)

trainset = [
    {"input": "What is 2+2?", "expected": "4"},
]

result = evolve_sync(agent, trainset)
print(f"Evolved: {result.evolved_components['instruction']}")

With configuration:

from gepa_adk import evolve_sync, EvolutionConfig

config = EvolutionConfig(max_iterations=50)
result = evolve_sync(agent, trainset, config=config)
Notes

Deprecated. Use run_sync(evolve(agent, trainset, ...)) instead. run_sync is a universal wrapper that works with all async evolution functions.

Source code in src/gepa_adk/api.py
def evolve_sync(
    agent: LlmAgent,
    trainset: list[dict[str, Any]],
    **kwargs: Any,
) -> EvolutionResult:
    """Synchronous wrapper for evolve().

    .. deprecated::
        Use ``run_sync(evolve(agent, trainset, ...))`` instead.

    Runs the async evolve() function in a blocking manner.
    Handles nested event loops automatically.

    Args:
        agent: The ADK LlmAgent to evolve.
        trainset: Training examples.
        **kwargs: Optional keyword arguments passed to evolve().

    Other Parameters:
        valset (list[dict[str, Any]] | None): Optional validation examples for
            held-out evaluation.
        critic (LlmAgent | None): Optional ADK agent for scoring.
        reflection_agent (LlmAgent | None): Optional ADK agent for proposals
            (not yet implemented).
        config (EvolutionConfig | None): EvolutionConfig for customizing
            evolution parameters.
        trajectory_config (TrajectoryConfig | None): TrajectoryConfig for trace
            capture settings.
        state_guard (StateGuard | None): Optional state token preservation
            settings.
        candidate_selector (CandidateSelectorProtocol | str | None): Optional
            selector instance or selector name.
        executor (AgentExecutorProtocol | None): Optional unified agent executor
            for consistent session management across all agent types.

    Returns:
        EvolutionResult with evolved_components dict and metrics.

    Raises:
        ConfigurationError: If invalid parameters provided.
        EvolutionError: If evolution fails during execution. A reflection
            call that keeps raising a retryable provider error is skipped
            with ``skip_reason="reflection_error"``; a non-retryable one
            raises ``ReflectionError``. A reflection cut off at the
            output-token limit, or one that opens a reasoning tag it never
            closes, is skipped with ``skip_reason="incomplete_proposal"``.
            An error raised after the baseline
            was scored carries the engine's ``EvolutionResult`` for the
            recorded iterations in ``partial_result``.

    Warns:
        DeprecationWarning: Always emitted when called. Use
            ``run_sync(evolve(...))`` instead.

    Examples:
        Basic usage in a script:

        ```python
        from pydantic import BaseModel, Field
        from google.adk.agents import LlmAgent
        from google.adk.models.lite_llm import LiteLlm
        from gepa_adk import evolve_sync


        class OutputSchema(BaseModel):
            answer: str
            score: float = Field(ge=0.0, le=1.0)


        agent = LlmAgent(
            name="assistant",
            model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
            instruction="You are a helpful assistant.",
            output_schema=OutputSchema,
        )

        trainset = [
            {"input": "What is 2+2?", "expected": "4"},
        ]

        result = evolve_sync(agent, trainset)
        print(f"Evolved: {result.evolved_components['instruction']}")
        ```

        With configuration:

        ```python
        from gepa_adk import evolve_sync, EvolutionConfig

        config = EvolutionConfig(max_iterations=50)
        result = evolve_sync(agent, trainset, config=config)
        ```

    Notes:
        Deprecated. Use ``run_sync(evolve(agent, trainset, ...))`` instead.
        ``run_sync`` is a universal wrapper that works with all async
        evolution functions.
    """
    warnings.warn(
        "evolve_sync() is deprecated, use run_sync(evolve(...)) instead",
        DeprecationWarning,
        stacklevel=2,
    )
    return run_sync(evolve(agent, trainset, **kwargs))

evolve_workflow async

evolve_workflow(
    workflow: SequentialAgent | LoopAgent | ParallelAgent,
    trainset: list[dict[str, Any]],
    *,
    critic: LlmAgent | None = None,
    scorer: Scorer | None = None,
    primary: str | None = None,
    max_depth: int = 5,
    config: EvolutionConfig | None = None,
    state_guard: StateGuard | None = None,
    component_selector: ComponentSelectorProtocol
    | str
    | None = None,
    round_robin: bool = False,
    components: dict[str, list[str]] | None = None,
    session_service: BaseSessionService | None = None,
    app: App | None = None,
    runner: Runner | None = None,
    registry: ComponentHandlerRegistry | None = None,
) -> MultiAgentEvolutionResult

Evolve LlmAgents within a workflow agent structure.

Discovers all LlmAgent instances within a workflow (SequentialAgent, LoopAgent, or ParallelAgent) and evolves them while preserving the workflow structure. Uses shared session state to maintain workflow context during evaluation.

PARAMETER DESCRIPTION
workflow

Workflow agent containing LlmAgents to evolve. Must be SequentialAgent, LoopAgent, or ParallelAgent.

TYPE: SequentialAgent | LoopAgent | ParallelAgent

trainset

Training examples for evaluation. Each example should have an "input" key and optionally an "expected" key.

TYPE: list[dict[str, Any]]

PARAMETER DESCRIPTION
critic

Optional critic agent for scoring. If None and no scorer is given, the primary agent must have an output_schema for schema-based scoring. Mutually exclusive with scorer.

TYPE: LlmAgent | None

scorer

Optional object implementing the Scorer protocol (score and async_score). Forwarded to evolve_group() and takes precedence over schema-based scoring. Mutually exclusive with critic.

TYPE: Scorer | None

primary

Name of the agent to score. Defaults to the last LlmAgent found in the workflow (for sequential workflows, this is typically the final output producer).

TYPE: str | None

max_depth

Maximum recursion depth for nested workflows (default: 5). Limits how deeply nested workflow structures are traversed.

TYPE: int

config

Evolution configuration. If None, uses EvolutionConfig defaults.

TYPE: EvolutionConfig | None

state_guard

Optional StateGuard instance for validating and repairing state injection tokens in evolved component_text.

TYPE: StateGuard | None

component_selector

Optional selector instance or selector name for choosing which components to update.

TYPE: ComponentSelectorProtocol | str | None

round_robin

If False (default), only the first discovered agent's instruction is evolved across all iterations. If True, all agents' instructions are evolved in round-robin fashion (the engine cycles through agents each iteration). Ignored when components is provided.

TYPE: bool

components

Optional per-agent component configuration mapping agent names to lists of component names to evolve. When provided, takes precedence over round_robin. Use empty list to exclude an agent.

TYPE: dict[str, list[str]] | None

session_service

Optional ADK session service for state management. If None (default), creates an InMemorySessionService internally. Pass a custom service (e.g., SqliteSessionService, DatabaseSessionService; on ADK 2.x SQL-backed services need the gepa-adk[db] extra, which installs google-adk[db]) to persist sessions alongside other agent executions in a shared database. SqliteSessionService sets no busy timeout or WAL of its own, so on a loaded disk a write can wait past the five-second default of Python's sqlite3 connection (which aiosqlite uses) and raise database is locked. The internal executor retries that error under its default RetryPolicy (three attempts, 0.5 s backoff, doubling) before the row counts as a failed evaluation.

TYPE: BaseSessionService | None

app

Optional ADK App instance supplying the session app name when runner is absent. Services come from runner when provided; otherwise evolution uses session_service or InMemorySessionService().

TYPE: App | None

runner

Optional ADK Runner instance. When provided, evolution extracts and uses the runner's session_service for all agent executions (evolved agents, critic, and reflection agent). Takes precedence over both app and session_service parameters. This enables seamless integration with existing ADK infrastructure.

TYPE: Runner | None

registry

Optional ComponentHandlerRegistry that resolves every component name; defaults to the default registry that register_handler() and register_mapping_components() fill.

TYPE: ComponentHandlerRegistry | None

RETURNS DESCRIPTION
MultiAgentEvolutionResult

MultiAgentEvolutionResult containing evolved_components dict mapping

MultiAgentEvolutionResult

qualified agent.component names (e.g., "writer.instruction") to their

MultiAgentEvolutionResult

optimized component_text, along with score

MultiAgentEvolutionResult

metrics and iteration history (each record names its

MultiAgentEvolutionResult

candidate_id and parent_ids).

RAISES DESCRIPTION
ConfigurationError

If pre-flight validation fails: non-LlmAgent critic, both critic and scorer given, a scorer that does not implement Scorer, empty trainset, duplicate or empty component names, EvolutionConfig consistency errors, or a component name the resolved registry holds no handler for.

WorkflowEvolutionError

If workflow contains no LlmAgents.

MultiAgentValidationError

If primary agent not found or no scorer available.

EvolutionError

If evolution fails during execution. A reflection call that keeps raising a retryable provider error is skipped with skip_reason="reflection_error"; a non-retryable one raises ReflectionError. A reflection cut off at the output-token limit, or one that opens a reasoning tag it never closes, is skipped with skip_reason="incomplete_proposal". An error raised after the baseline was scored carries the engine's EvolutionResult for the recorded iterations in partial_result.

Examples:

Default behavior (evolve first agent only):

from google.adk.agents import LlmAgent, SequentialAgent
from gepa_adk import evolve_workflow

generator = LlmAgent(name="generator", instruction="Generate code")
refiner = LlmAgent(name="refiner", instruction="Refine code")
writer = LlmAgent(name="writer", instruction="Write docs")
pipeline = SequentialAgent(
    name="Pipeline", sub_agents=[generator, refiner, writer]
)

# Only generator.instruction is evolved across all iterations
result = await evolve_workflow(workflow=pipeline, trainset=trainset)

Round-robin evolution (evolve all agents):

# All agents are evolved in round-robin: generator -> refiner -> writer -> ...
result = await evolve_workflow(
    workflow=pipeline,
    trainset=trainset,
    round_robin=True,
)

Explicit components override (takes precedence over round_robin):

# Only generator and writer are evolved; refiner is excluded
result = await evolve_workflow(
    workflow=pipeline,
    trainset=trainset,
    components={
        "generator": ["instruction"],
        "writer": ["instruction"],
        "refiner": [],  # Excluded
    },
)

Using custom session service for persistence:

# Requires the gepa-adk[db] extra (google-adk[db]) on ADK 2.x
from google.adk.sessions import SqliteSessionService

# Persist workflow evolution sessions to SQLite
session_service = SqliteSessionService(db_path="workflow_sessions.db")

result = await evolve_workflow(
    workflow=pipeline,
    trainset=trainset,
    session_service=session_service,
)

Using App/Runner for existing infrastructure integration:

from google.adk.runners import Runner

# Requires the gepa-adk[db] extra (google-adk[db]) on ADK 2.x
from google.adk.sessions import DatabaseSessionService

# Configure Runner with your production session service
runner = Runner(
    app_name="my_workflow_app",
    agent=pipeline,  # The workflow agent
    session_service=DatabaseSessionService(connection_string="..."),
)

# Evolution uses Runner's session_service for all operations
result = await evolve_workflow(
    workflow=pipeline,
    trainset=trainset,
    runner=runner,  # Services extracted from runner
)
Notes

Pre-flight validation runs synchronously before any LLM calls. Supports workflow agents (SequentialAgent, LoopAgent, ParallelAgent) with recursive traversal and depth limiting via max_depth parameter. Handles nested structures. LoopAgent and ParallelAgent configurations (max_iterations, etc.) are preserved during evolution. Always uses share_session=True to maintain workflow context (FR-010).

For reproducible evolution, pass a seeded config: config=EvolutionConfig(seed=42).

Source code in src/gepa_adk/api.py
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
async def evolve_workflow(  # noqa: PLR0913  # public API; each option is a keyword argument
    workflow: SequentialAgent | LoopAgent | ParallelAgent,
    trainset: list[dict[str, Any]],
    *,
    critic: LlmAgent | None = None,
    scorer: Scorer | None = None,
    primary: str | None = None,
    max_depth: int = 5,
    config: EvolutionConfig | None = None,
    state_guard: StateGuard | None = None,
    component_selector: ComponentSelectorProtocol | str | None = None,
    round_robin: bool = False,
    components: dict[str, list[str]] | None = None,
    session_service: BaseSessionService | None = None,
    app: App | None = None,
    runner: Runner | None = None,
    registry: ComponentHandlerRegistry | None = None,
) -> MultiAgentEvolutionResult:
    """Evolve LlmAgents within a workflow agent structure.

    Discovers all LlmAgent instances within a workflow (SequentialAgent,
    LoopAgent, or ParallelAgent) and evolves them while preserving the
    workflow structure. Uses shared session state to maintain workflow
    context during evaluation.

    Args:
        workflow (SequentialAgent | LoopAgent | ParallelAgent): Workflow agent
            containing LlmAgents to evolve. Must be SequentialAgent, LoopAgent, or
            ParallelAgent.
        trainset (list[dict[str, Any]]): Training examples for evaluation. Each example should have
            an "input" key and optionally an "expected" key.

    Keyword Args:
        critic (LlmAgent | None): Optional critic agent for scoring. If None and no scorer is
            given, the primary agent must have an output_schema for
            schema-based scoring. Mutually exclusive with scorer.
        scorer (Scorer | None): Optional object implementing the Scorer protocol
            (``score`` and ``async_score``). Forwarded to evolve_group() and
            takes precedence over schema-based scoring. Mutually exclusive
            with critic.
        primary (str | None): Name of the agent to score. Defaults to the last LlmAgent
            found in the workflow (for sequential workflows, this is typically
            the final output producer).
        max_depth (int): Maximum recursion depth for nested workflows (default: 5).
            Limits how deeply nested workflow structures are traversed.
        config (EvolutionConfig | None): Evolution configuration. If None, uses
            EvolutionConfig defaults.
        state_guard (StateGuard | None): Optional StateGuard instance for validating and
            repairing state injection tokens in evolved component_text.
        component_selector (ComponentSelectorProtocol | str | None): Optional selector
            instance or selector name for choosing which components to update.
        round_robin (bool): If False (default), only the first discovered agent's
            instruction is evolved across all iterations. If True, all agents'
            instructions are evolved in round-robin fashion (the engine cycles
            through agents each iteration). Ignored when components is provided.
        components (dict[str, list[str]] | None): Optional per-agent component
            configuration mapping agent names to lists of component names to evolve.
            When provided, takes precedence over round_robin. Use empty list to exclude
            an agent.
        session_service (BaseSessionService | None): Optional ADK session service for
            state management. If None (default), creates an InMemorySessionService
            internally. Pass a custom service (e.g., SqliteSessionService,
            DatabaseSessionService; on ADK 2.x SQL-backed services need the
            ``gepa-adk[db]`` extra, which installs ``google-adk[db]``) to persist
            sessions alongside other agent executions in a shared database.
            ``SqliteSessionService`` sets no busy timeout or WAL of its own, so on a
            loaded disk a write can wait past the five-second default of Python's
            sqlite3 connection (which aiosqlite uses) and raise ``database is
            locked``. The internal executor retries that error under its default
            ``RetryPolicy`` (three attempts, 0.5 s backoff, doubling) before the row
            counts as a failed evaluation.
        app (App | None): Optional ADK App instance supplying the session app name
            when runner is absent. Services come from runner when provided;
            otherwise evolution uses session_service or InMemorySessionService().
        runner (Runner | None): Optional ADK Runner instance. When provided, evolution extracts
            and uses the runner's session_service for all agent executions
            (evolved agents, critic, and reflection agent). Takes precedence
            over both app and session_service parameters. This enables seamless
            integration with existing ADK infrastructure.
        registry (ComponentHandlerRegistry | None): Optional ComponentHandlerRegistry
            that resolves every component name; defaults to the default registry that
            ``register_handler()`` and ``register_mapping_components()`` fill.

    Returns:
        MultiAgentEvolutionResult containing evolved_components dict mapping
        qualified agent.component names (e.g., "writer.instruction") to their
        optimized component_text, along with score
        metrics and iteration history (each record names its
        ``candidate_id`` and ``parent_ids``).

    Raises:
        ConfigurationError: If pre-flight validation fails: non-LlmAgent
            critic, both critic and scorer given, a scorer that does not
            implement Scorer, empty trainset, duplicate or empty component
            names, EvolutionConfig consistency errors, or a component name
            the resolved registry holds no handler for.
        WorkflowEvolutionError: If workflow contains no LlmAgents.
        MultiAgentValidationError: If primary agent not found or no scorer
            available.
        EvolutionError: If evolution fails during execution. A reflection
            call that keeps raising a retryable provider error is skipped
            with ``skip_reason="reflection_error"``; a non-retryable one
            raises ``ReflectionError``. A reflection cut off at the
            output-token limit, or one that opens a reasoning tag it never
            closes, is skipped with ``skip_reason="incomplete_proposal"``.
            An error raised after the baseline
            was scored carries the engine's ``EvolutionResult`` for the
            recorded iterations in ``partial_result``.

    Examples:
        Default behavior (evolve first agent only):

        ```python
        from google.adk.agents import LlmAgent, SequentialAgent
        from gepa_adk import evolve_workflow

        generator = LlmAgent(name="generator", instruction="Generate code")
        refiner = LlmAgent(name="refiner", instruction="Refine code")
        writer = LlmAgent(name="writer", instruction="Write docs")
        pipeline = SequentialAgent(
            name="Pipeline", sub_agents=[generator, refiner, writer]
        )

        # Only generator.instruction is evolved across all iterations
        result = await evolve_workflow(workflow=pipeline, trainset=trainset)
        ```

        Round-robin evolution (evolve all agents):

        ```python
        # All agents are evolved in round-robin: generator -> refiner -> writer -> ...
        result = await evolve_workflow(
            workflow=pipeline,
            trainset=trainset,
            round_robin=True,
        )
        ```

        Explicit components override (takes precedence over round_robin):

        ```python
        # Only generator and writer are evolved; refiner is excluded
        result = await evolve_workflow(
            workflow=pipeline,
            trainset=trainset,
            components={
                "generator": ["instruction"],
                "writer": ["instruction"],
                "refiner": [],  # Excluded
            },
        )
        ```

        Using custom session service for persistence:

        ```python
        # Requires the gepa-adk[db] extra (google-adk[db]) on ADK 2.x
        from google.adk.sessions import SqliteSessionService

        # Persist workflow evolution sessions to SQLite
        session_service = SqliteSessionService(db_path="workflow_sessions.db")

        result = await evolve_workflow(
            workflow=pipeline,
            trainset=trainset,
            session_service=session_service,
        )
        ```

        Using App/Runner for existing infrastructure integration:

        ```python
        from google.adk.runners import Runner

        # Requires the gepa-adk[db] extra (google-adk[db]) on ADK 2.x
        from google.adk.sessions import DatabaseSessionService

        # Configure Runner with your production session service
        runner = Runner(
            app_name="my_workflow_app",
            agent=pipeline,  # The workflow agent
            session_service=DatabaseSessionService(connection_string="..."),
        )

        # Evolution uses Runner's session_service for all operations
        result = await evolve_workflow(
            workflow=pipeline,
            trainset=trainset,
            runner=runner,  # Services extracted from runner
        )
        ```

    Notes:
        Pre-flight validation runs synchronously before any LLM calls.
        Supports workflow agents (SequentialAgent, LoopAgent, ParallelAgent)
        with recursive traversal and depth limiting via max_depth parameter.
        Handles nested structures. LoopAgent and ParallelAgent configurations
        (max_iterations, etc.) are preserved during evolution. Always uses
        share_session=True to maintain workflow context (FR-010).

        For reproducible evolution, pass a seeded config:
        ``config=EvolutionConfig(seed=42)``.
    """
    # Pre-flight validation (Story 2.5)
    _pre_flight_validate_workflow(trainset, critic, components, scorer=scorer)

    logger.info(
        "Starting workflow evolution",
        workflow_name=workflow.name,
        workflow_type=type(workflow).__name__,
    )

    # Find all LlmAgents in the workflow recursively up to max_depth (US3)
    llm_agents = find_llm_agents(workflow, max_depth=max_depth)

    # Validate that at least one LlmAgent was found
    if not llm_agents:
        error_msg = (
            f"No LlmAgents found in workflow '{workflow.name}'. "
            "Workflow must contain at least one LlmAgent to evolve."
        )
        logger.error(
            "Workflow evolution failed", workflow_name=workflow.name, error=error_msg
        )
        raise WorkflowEvolutionError(
            error_msg,
            workflow_name=workflow.name,
        )

    logger.info(
        "Found LlmAgents in workflow",
        workflow_name=workflow.name,
        agent_count=len(llm_agents),
        agent_names=[agent.name for agent in llm_agents],
    )

    # Determine primary agent (default to last agent for sequential workflows)
    if primary is None:
        primary = llm_agents[-1].name
        logger.debug(
            "Using default primary agent",
            workflow_name=workflow.name,
            primary=primary,
        )

    # Convert list to dict for evolve_group
    agents_dict = {agent.name: agent for agent in llm_agents}

    # Build components dict based on round_robin flag
    # Explicit components parameter takes precedence over round_robin
    resolved_components: dict[str, list[str]] | None = None
    if components is not None:
        # Explicit components provided - use as-is
        resolved_components = components
        logger.debug(
            "Using explicit components",
            workflow_name=workflow.name,
            components=list(components.keys()),
        )
    elif round_robin:
        # round_robin=True: evolve all agents
        resolved_components = {agent.name: ["instruction"] for agent in llm_agents}
        logger.debug(
            "Using round_robin mode - evolving all agents",
            workflow_name=workflow.name,
            agents=[agent.name for agent in llm_agents],
        )
    else:
        # Default: evolve only the first agent
        first_agent = llm_agents[0]
        resolved_components = {first_agent.name: ["instruction"]}
        # Add empty lists for other agents (excluded from evolution)
        for agent in llm_agents[1:]:
            resolved_components[agent.name] = []
        logger.debug(
            "Using default mode - evolving first agent only",
            workflow_name=workflow.name,
            first_agent=first_agent.name,
        )

    # Delegate to evolve_group with share_session=True (FR-010)
    logger.debug(
        "Delegating to evolve_group",
        workflow_name=workflow.name,
        agent_count=len(llm_agents),
        primary=primary,
        share_session=True,
        round_robin=round_robin,
    )

    return await evolve_group(
        agents=agents_dict,
        primary=primary,
        trainset=trainset,
        components=resolved_components,
        critic=critic,
        scorer=scorer,
        share_session=True,  # FR-010: Always use shared session for workflow context
        config=config,
        state_guard=state_guard,
        component_selector=component_selector,
        workflow=workflow,  # Preserve workflow structure (#215)
        session_service=session_service,  # Pass through for persistence (#226)
        app=app,  # Pass through for App/Runner pattern (#227)
        runner=runner,  # Pass through for App/Runner pattern (#227)
        registry=registry,  # Resolve component handlers per round (#451)
    )

run_sync

run_sync(coro: Coroutine[Any, Any, T]) -> T

Run an async coroutine synchronously and return its result.

Universal sync wrapper that accepts any coroutine (e.g., evolve(), evolve_group(), evolve_workflow()) and runs it in a blocking manner. Uses asyncio.run() as the primary mechanism, with nest_asyncio as a fallback for environments with a running event loop. The fallback saves and restores the original event loop to avoid polluting the event loop policy state.

PARAMETER DESCRIPTION
coro

A coroutine object to execute (e.g., evolve(agent, trainset)). Must be a coroutine, not a function or other awaitable.

TYPE: Coroutine[Any, Any, T]

RETURNS DESCRIPTION
T

The result of the coroutine execution. The return type matches

T

the coroutine's return type (e.g., EvolutionResult for evolve(),

T

MultiAgentEvolutionResult for evolve_group()).

RAISES DESCRIPTION
TypeError

If coro is not a coroutine object.

RuntimeError

If a running event loop is detected and nest_asyncio is not installed. Any other RuntimeError from asyncio.run() propagates unchanged.

Examples:

Single-agent evolution:

from gepa_adk import run_sync, evolve

result = run_sync(evolve(agent, trainset=trainset))

Multi-agent group evolution:

from gepa_adk import run_sync, evolve_group

result = run_sync(evolve_group(agents, "primary", trainset=trainset))
Notes

In Jupyter notebooks or IPython, the event loop is already running. Use await evolve(...) directly instead of run_sync(evolve(...)). The nest_asyncio fallback may work but await is preferred.

Source code in src/gepa_adk/api.py
def run_sync[T](coro: Coroutine[Any, Any, T]) -> T:
    """Run an async coroutine synchronously and return its result.

    Universal sync wrapper that accepts any coroutine (e.g., evolve(),
    evolve_group(), evolve_workflow()) and runs it in a blocking manner.
    Uses ``asyncio.run()`` as the primary mechanism, with ``nest_asyncio``
    as a fallback for environments with a running event loop.  The fallback
    saves and restores the original event loop to avoid polluting the
    event loop policy state.

    Args:
        coro (Coroutine[Any, Any, T]): A coroutine object to execute (e.g.,
            ``evolve(agent, trainset)``). Must be a coroutine, not a function
            or other awaitable.

    Returns:
        The result of the coroutine execution. The return type matches
        the coroutine's return type (e.g., EvolutionResult for evolve(),
        MultiAgentEvolutionResult for evolve_group()).

    Raises:
        TypeError: If ``coro`` is not a coroutine object.
        RuntimeError: If a running event loop is detected and ``nest_asyncio``
            is not installed. Any other ``RuntimeError`` from ``asyncio.run()``
            propagates unchanged.

    Examples:
        Single-agent evolution:

        ```python
        from gepa_adk import run_sync, evolve

        result = run_sync(evolve(agent, trainset=trainset))
        ```

        Multi-agent group evolution:

        ```python
        from gepa_adk import run_sync, evolve_group

        result = run_sync(evolve_group(agents, "primary", trainset=trainset))
        ```

    Notes:
        In Jupyter notebooks or IPython, the event loop is already running.
        Use ``await evolve(...)`` directly instead of ``run_sync(evolve(...))``.
        The ``nest_asyncio`` fallback may work but ``await`` is preferred.
    """
    if not asyncio.iscoroutine(coro):
        raise TypeError(
            f"run_sync() requires a coroutine object, got {type(coro).__name__}. "
            "Call the async function first: run_sync(evolve(...)) not run_sync(evolve)"
        )

    try:
        return asyncio.run(coro)
    except RuntimeError as e:
        if "asyncio.run() cannot be called from a running event loop" in str(e):
            try:
                import nest_asyncio  # noqa: PLC0415  # optional dependency, needed only inside a running loop

                nest_asyncio.apply()
                try:
                    old_loop = asyncio.get_running_loop()
                except RuntimeError:
                    old_loop = None
                loop = asyncio.new_event_loop()
                asyncio.set_event_loop(loop)
                try:
                    return loop.run_until_complete(coro)
                finally:
                    loop.close()
                    if old_loop is not None:
                        asyncio.set_event_loop(old_loop)
            except ImportError:
                raise RuntimeError(
                    "A running event loop was detected and nest_asyncio is not "
                    "installed. Install it with: uv add nest-asyncio\n"
                    "Alternatively, use 'await evolve(...)' directly in async "
                    "contexts like Jupyter notebooks."
                ) from e
        raise

inspect_checkpoint

inspect_checkpoint(path: str | Path) -> CheckpointSummary

Summarise a checkpoint file without building an engine.

Reads the file through read_checkpoint and rebuilds the best candidate, the iteration history and the run token rollup. Nothing is written and no run state is checked.

PARAMETER DESCRIPTION
path

Checkpoint file to read, as a str or Path.

TYPE: str | Path

RETURNS DESCRIPTION
CheckpointSummary

A frozen summary of the checkpoint. A file without

CheckpointSummary

run_token_usage reports an unknown rollup whose rows_unknown

CheckpointSummary

is the checkpointed evaluation count, as resume does.

RAISES DESCRIPTION
ConfigurationError

If the file does not exist or its checkpoint_version is not CHECKPOINT_VERSION.

JSONDecodeError

If the file is not valid JSON.

KeyError

If a field the summary needs is missing.

Examples:

summary = inspect_checkpoint("runs/checkpoint.json")
print(summary.iteration, len(summary.iteration_history))
Source code in src/gepa_adk/engine/checkpoint.py
def inspect_checkpoint(path: str | Path) -> CheckpointSummary:
    """Summarise a checkpoint file without building an engine.

    Reads the file through ``read_checkpoint`` and rebuilds the best
    candidate, the iteration history and the run token rollup. Nothing is
    written and no run state is checked.

    Args:
        path: Checkpoint file to read, as a str or Path.

    Returns:
        A frozen summary of the checkpoint. A file without
        ``run_token_usage`` reports an unknown rollup whose ``rows_unknown``
        is the checkpointed evaluation count, as resume does.

    Raises:
        ConfigurationError: If the file does not exist or its
            ``checkpoint_version`` is not ``CHECKPOINT_VERSION``.
        json.JSONDecodeError: If the file is not valid JSON.
        KeyError: If a field the summary needs is missing.

    Examples:
        ```python
        summary = inspect_checkpoint("runs/checkpoint.json")
        print(summary.iteration, len(summary.iteration_history))
        ```
    """
    data = read_checkpoint(Path(path))
    total_evaluations = data["total_evaluations"]
    stored_usage = data.get("run_token_usage")
    token_usage = (
        TokenRollup.from_dict(stored_usage)
        if stored_usage is not None
        else TokenRollup(
            input_tokens=None,
            output_tokens=None,
            total_tokens=None,
            rows_counted=0,
            rows_unknown=total_evaluations,
        )
    )
    return CheckpointSummary(
        best_candidate=Candidate.from_dict(data["best_candidate"]),
        best_score=data["best_score"],
        original_score=data["original_score"],
        best_valset_mean=data["best_valset_mean"],
        best_objective_scores=data["best_objective_scores"],
        iteration=data["iteration"],
        iteration_history=tuple(
            IterationRecord.from_dict(r) for r in data["iteration_history"]
        ),
        total_evaluations=total_evaluations,
        token_usage=token_usage,
    )