Skip to content

Models

models

Domain models for the gepa-adk evolution engine.

This module contains the core domain models used throughout the evolution engine, including result types with schema versioning and serialization support. Configuration validation enforces field constraints and finite-float checks. All models are dataclasses following hexagonal architecture principles with no runtime dependencies beyond structlog and the Python standard library. BaseLlm (for EvolutionConfig.reflection_model) is imported for type checking only, so the domain keeps no runtime google import.

Terminology
  • component: An evolvable unit with a name and text (e.g., instruction)
  • component_text: The current text content of a component being evolved
  • trial: One performance record {feedback, trajectory}
  • feedback: Critic evaluation {score, feedback_text, feedback_*} (stochastic)
  • trajectory: Execution record {input, output, trace} (deterministic)
ATTRIBUTE DESCRIPTION
EvolutionConfig

Configuration parameters for evolution runs, including an optional per-iteration callback (on_iteration), an optional check on each proposed text (proposal_validator) and checkpoint and resume settings (checkpoint_path, resume).

TYPE: class

TokenRollup

Token usage summed over evaluated rows or reflection calls, with rows that reported no usage counted as unknown, and optional evaluation and reflection splits.

TYPE: class

IterationRecord

Immutable record of a single iteration.

TYPE: class

EvolutionResult

Immutable outcome of a completed evolution run.

TYPE: class

Candidate

Mutable candidate holding components being evolved, identified by a short content hash of its components (id) and serialisable with to_dict()/from_dict().

TYPE: class

CURRENT_SCHEMA_VERSION

Current result schema version constant.

TYPE: int

MultiAgentEvolutionResult

Outcome of a completed multi-agent evolution run.

TYPE: class

VideoFileInfo

Metadata for a validated video file.

TYPE: class

Examples:

Creating configuration and result objects:

from gepa_adk.domain.models import EvolutionConfig, EvolutionResult

config = EvolutionConfig(max_iterations=20)
result = EvolutionResult(
    original_score=0.5,
    final_score=0.8,
    evolved_components={"instruction": "Be helpful"},
    iteration_history=[],
    total_iterations=10,
)
assert result.schema_version == 7

Serializing and deserializing results:

import json
from gepa_adk.domain.models import EvolutionResult

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

Display methods for inspecting results:

from gepa_adk.domain.models import EvolutionResult

result = EvolutionResult(
    original_score=0.5,
    final_score=0.8,
    evolved_components={"instruction": "Be helpful and concise"},
    original_components={"instruction": "Be helpful"},
    iteration_history=[],
    total_iterations=10,
)
print(repr(result))  # narrative summary with improvement %
print(result.show_diff())  # unified diff of component changes
See Also
Notes

These models are pure data containers with validation logic. They have no knowledge of infrastructure concerns like databases or APIs. Results serialize at schema version 7, which adds IterationRecord.rows_evaluated. Version 6 dicts migrate through _migrate_v6_to_v7(), version 5 dicts first through _migrate_v5_to_v6(), which splits each token_usage into evaluation and reflection, version 4 dicts first through _migrate_v4_to_v5(), version 3 dicts first through _migrate_v3_to_v4(), version 2 dicts first through _migrate_v2_to_v3() and version 1 dicts first through _migrate_v1_to_v2() on load.

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().

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))
        ),
    )

VideoFileInfo dataclass

Metadata for a validated video file.

This is an immutable record containing validated metadata about a video file. Created by VideoBlobService.validate_video_file() after checking that the file exists, is within size limits, and has a valid MIME type.

ATTRIBUTE DESCRIPTION
path

Absolute path to the video file.

TYPE: str

size_bytes

File size in bytes.

TYPE: int

mime_type

MIME type of the video (e.g., "video/mp4").

TYPE: str

Examples:

Creating video file info:

from gepa_adk.domain.models import VideoFileInfo

info = VideoFileInfo(
    path="/data/video.mp4",
    size_bytes=1024000,
    mime_type="video/mp4",
)
print(f"File: {info.path}, Size: {info.size_bytes}, Type: {info.mime_type}")
Notes

A frozen dataclass ensuring immutability after validation. Instances cannot be modified once created, guaranteeing consistency of validated file metadata.

Source code in src/gepa_adk/domain/models.py
@dataclass(slots=True, frozen=True)
class VideoFileInfo:
    """Metadata for a validated video file.

    This is an immutable record containing validated metadata about a video
    file. Created by VideoBlobService.validate_video_file() after checking
    that the file exists, is within size limits, and has a valid MIME type.

    Attributes:
        path (str): Absolute path to the video file.
        size_bytes (int): File size in bytes.
        mime_type (str): MIME type of the video (e.g., "video/mp4").

    Examples:
        Creating video file info:

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

        info = VideoFileInfo(
            path="/data/video.mp4",
            size_bytes=1024000,
            mime_type="video/mp4",
        )
        print(f"File: {info.path}, Size: {info.size_bytes}, Type: {info.mime_type}")
        ```

    Notes:
        A frozen dataclass ensuring immutability after validation.
        Instances cannot be modified once created, guaranteeing
        consistency of validated file metadata.
    """

    path: str
    size_bytes: int
    mime_type: str

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()

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"),
    )

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)

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 {}),
    )

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)