ADR-015: Result Schema Versioning — Domain-Layer Serialization¶
Status: Accepted Date: 2026-03-04 Deciders: gepa-adk maintainers
Context¶
Evolution results (EvolutionResult, MultiAgentEvolutionResult) are frozen dataclasses that capture the outcome of an evolution run. As GEPA-ADK moves toward cross-session result comparison and experiment tracking, serialized results loaded from previous sessions require a forward-compatible schema — a plain JSON round-trip is not sufficient.
Additionally, the engine terminates evolution runs for several reasons (max iterations reached, patience exhausted, custom stopper triggered), but this information was lost — the caller could only see the final score, not why the run stopped.
Decision¶
Schema Versioning¶
Add a frozen schema_version: int = CURRENT_SCHEMA_VERSION field to both result types, where CURRENT_SCHEMA_VERSION = 1 is a module-level constant in domain/models.py.
Design rules: - to_dict() outputs {"schema_version": N, ...} — version is always included - from_dict() accepts schema_version <= CURRENT_VERSION — explicit migration per version step - from_dict() always returns the current-version type — missing fields use their declared field defaults (e.g., CURRENT_SCHEMA_VERSION for schema_version) - Output schema_version is always CURRENT_VERSION regardless of input version - Unknown schema_version > CURRENT_VERSION raises ConfigurationError with migration guidance
Note: Serialization (to_dict() / from_dict()) is implemented on all three result types: IterationRecord, EvolutionResult, and MultiAgentEvolutionResult.
Stop Reason Tracking¶
Add StopReason(str, Enum) to domain/types.py with six values:
| Value | Meaning |
|---|---|
COMPLETED | Default — normal completion |
MAX_ITERATIONS | Reached max iterations or patience exhausted |
STOPPER_TRIGGERED | Custom stopper (via StopperProtocol) returned True |
KEYBOARD_INTERRUPT | User interrupted (future: Story 2.4) |
TIMEOUT | Time limit exceeded (future) |
CANCELLED | Programmatic cancellation (future) |
Add a frozen stop_reason: StopReason = StopReason.COMPLETED field to both result types.
Stop Reason Mapping¶
| Termination Condition | StopReason Value |
|---|---|
iteration >= max_iterations | MAX_ITERATIONS |
stagnation_counter >= patience | MAX_ITERATIONS |
| Custom stopper returns True | STOPPER_TRIGGERED |
| Normal loop completion | COMPLETED (default) |
max_iterations=0 (baseline-only) | MAX_ITERATIONS |
Patience maps to MAX_ITERATIONS, not STOPPER_TRIGGERED, because patience-based early stopping is a built-in convergence criterion, not a user-provided custom stopper. STOPPER_TRIGGERED is reserved exclusively for objects implementing StopperProtocol passed via stop_callbacks.
Rationale¶
- Stdlib only: All serialization logic stays in the domain layer with no external dependencies (respects ADR-000 hexagonal boundaries)
- Frozen record: Version is part of the immutable result — cannot be accidentally modified after creation
- Forward compatible: Explicit per-version migration ensures old results can always be loaded by newer code
strenum:StopReason(str, Enum)enables direct JSON serialization (json.dumpsworks without custom encoder)- Protocol alignment: Both new fields are added to
EvolutionResultProtocol(ADR-013), maintaining the shared interface via structural subtyping
Consequences¶
Positive¶
- Cross-session comparison: Serialized results carry their schema version, enabling safe deserialization across GEPA-ADK versions
- Diagnostic clarity: Callers can inspect
result.stop_reasonto understand why evolution terminated, enabling better experiment analysis - Test strategy: Migration test fixtures per schema version (
tests/fixtures/evolution_result_v1.json) ensure backward compatibility as the schema evolves
Negative¶
- Field overhead: Two additional fields on every result instance (minimal memory impact for frozen dataclasses)
- Migration overhead: Each new schema version requires a corresponding migration function (
_migrate_vN_to_vN+1) and a test fixture file
Neutral¶
- Default values: Both fields have defaults (
schema_version=1,stop_reason=COMPLETED), so existing code that constructs result types without these fields continues to work unchanged
Alternatives Considered¶
1. Pydantic Model Versioning¶
Use Pydantic's model validators for schema migration. Rejected because it introduces an external dependency in the domain layer, violating ADR-000 (hexagonal architecture — domain uses stdlib only).
2. Unversioned Serialization¶
Serialize results as plain dicts without version metadata. Rejected because it provides no forward-compatibility guarantee — adding or renaming a field in a future version would silently break deserialization of old results.
3. Separate Metadata Wrapper¶
Wrap results in a ResultEnvelope(version=1, data=result) container. Rejected because it adds indirection — the version belongs to the result itself (frozen record), not an external wrapper.