Skip to content

Engine

engine

Async evolution engine for gepa-adk.

This package provides the AsyncGEPAEngine class that orchestrates the core evolution loop, along with mutation proposers, merge proposers, genealogy utilities, and ADK reflection helpers.

ATTRIBUTE DESCRIPTION
AsyncGEPAEngine

Main evolution engine class.

TYPE: class

AsyncReflectiveMutationProposer

Mutation proposer using LLM reflection.

TYPE: class

MergeProposer

Proposer for merging Pareto-optimal candidates.

TYPE: class

ReflectionFn

Type alias for reflection callables.

TYPE: type

create_adk_reflection_fn

Factory for ADK-based reflection functions.

TYPE: function

is_length_stop

Whether a finish reason marks output cut off at the token limit.

TYPE: function

REFLECTION_INSTRUCTION

Default reflection instruction template.

TYPE: str

SESSION_STATE_KEYS

Session state keys the reflection agent reads.

TYPE: dict

detect_component_divergence

Detect which components have diverged from ancestor to parent.

TYPE: function

filter_ancestors_by_score

Filter ancestors by minimum average score constraint.

TYPE: function

find_common_ancestor

Find the most recent common ancestor of two candidates.

TYPE: function

get_ancestors

Return all ancestor indices for a candidate.

TYPE: function

has_desirable_predictors

Check if merge has desirable complementary component changes.

TYPE: function

Examples:

Basic usage:

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

engine = AsyncGEPAEngine(
    adapter=my_adapter,
    config=EvolutionConfig(max_iterations=50),
    initial_candidate=Candidate(components={"instruction": "Be helpful"}),
    batch=training_data,
)
result = await engine.run()
See Also

REFLECTION_INSTRUCTION module-attribute

REFLECTION_INSTRUCTION = "## Component Text to Improve\n{component_text}\n\n## Trials\n{trials}\n\nPropose an improved version of the component text based on the trials above.\nReturn ONLY the improved component text, nothing else."

Default instruction template for reflection agents.

Uses ADK's native template substitution syntax ({key}) to inject session state values. ADK automatically replaces these placeholders with values from session.state[key] during instruction processing.

The template contains two placeholders:

  • {component_text}: The current text being evolved (str)
  • {trials}: JSON-serialized list of trial records (str)

The instruction is processed by ADK's inject_session_state() function before being sent to the LLM.

Examples:

Use the default instruction with a custom agent:

from google.adk.agents import LlmAgent
from google.adk.models.lite_llm import LiteLlm
from gepa_adk.domain.types import REFLECTION_INSTRUCTION

agent = LlmAgent(
    name="reflector",
    model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
    instruction=REFLECTION_INSTRUCTION,
)
Notes

This replaces the previous workaround of embedding data in user messages via Python f-strings.

SESSION_STATE_KEYS module-attribute

SESSION_STATE_KEYS = {'component_text': str, 'trials': str}

Expected keys and types in ADK session state for reflection.

The reflection agent accesses these keys via {key} template syntax in its instruction. ADK's inject_session_state() automatically substitutes placeholders with session state values.

Keys

component_text: The text content being evolved (str). trials: JSON-serialized list of trial records (str). Each trial contains {input, output, feedback, trajectory}.

ReflectionFn module-attribute

ReflectionFn = Callable[
    [str, list[dict[str, Any]], str],
    Awaitable[
        tuple[str, str | None]
        | tuple[str, str | None, TokenUsage | None]
    ],
]

Async callable for reflection.

(component_text: str, trials: list[dict], component_name: str)

-> tuple[str, str | None] | tuple[str, str | None, TokenUsage | None]

Takes current component text, trials, and component name. Returns a tuple of (proposed_component_text, reasoning), optionally followed by the call's token usage. The reasoning is None when the model does not provide thought/reasoning output; a missing or None usage means unknown.

AsyncGEPAEngine

Bases: Generic[DataInst, Trajectory, RolloutOutput]


              flowchart TD
              gepa_adk.engine.AsyncGEPAEngine[AsyncGEPAEngine]

              

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

Async evolution engine orchestrating the GEPA loop.

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

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

ATTRIBUTE DESCRIPTION
adapter

Implementation of AsyncGEPAAdapter protocol.

TYPE: AsyncGEPAAdapter

config

Evolution parameters.

TYPE: EvolutionConfig

Examples:

Basic usage:

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

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

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

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

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

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

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

    Examples:
        Basic usage:

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

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

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

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

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

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

        Examples:
            Creating an engine:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        Args:
            scores: List of per-example scores from evaluation batch.

        Returns:
            Aggregated acceptance score (sum or mean based on config).

        Raises:
            InvalidScoreListError: If scores list is empty or contains
                non-finite values.

        Notes:
            Sums or averages acceptance scores after validating they are
            non-empty and finite. Uses sum or mean based on config.acceptance_metric.
        """
        # Validate scores are non-empty
        if not scores:
            raise InvalidScoreListError(
                "Cannot aggregate acceptance score from empty score list",
                scores=scores,
                reason="empty",
            )

        # Validate scores are finite
        if not all(math.isfinite(score) for score in scores):
            raise InvalidScoreListError(
                "Cannot aggregate acceptance score from non-finite values (NaN/inf)",
                scores=scores,
                reason="non-finite",
            )

        # Aggregate based on acceptance_metric
        if self.config.acceptance_metric == "sum":
            return sum(scores)
        else:  # acceptance_metric == "mean"
            return sum(scores) / len(scores)

    def _setup_stoppers(self) -> list[object]:
        """Call setup() on stoppers that have lifecycle methods.

        Returns:
            List of stoppers that had setup() called (for cleanup in reverse order).

        Notes:
            Only invokes setup() on stoppers implementing the lifecycle method.
            Stoppers that fail setup() are excluded from stop_callbacks for the
            remainder of execution to prevent inconsistent state.
        """
        setup_stoppers: list[object] = []
        active_stoppers: list[object] = []
        stop_callbacks = self.config.stop_callbacks
        if stop_callbacks:
            for stopper in stop_callbacks:
                setup_method = getattr(stopper, "setup", None)
                if setup_method is not None and callable(setup_method):
                    try:
                        setup_method()
                        setup_stoppers.append(stopper)
                        active_stoppers.append(stopper)
                    except Exception:
                        logger.exception(
                            "stopper.setup_error",
                            stopper=type(stopper).__name__,
                        )
                        # Stopper excluded from active list due to setup failure
                else:
                    # Stopper has no setup() method, still active
                    active_stoppers.append(stopper)
        # Store active stoppers for _should_stop() to use
        self._active_stoppers = active_stoppers
        return setup_stoppers

    def _cleanup_stoppers(self, setup_stoppers: list[object]) -> None:
        """Call cleanup() on stoppers in reverse order of setup.

        Args:
            setup_stoppers: List of stoppers that had setup() called.

        Notes:
            Follows the reverse-order cleanup contract (T025).
            When cleanup() raises, logs the error and continues (T026).
        """
        for stopper in reversed(setup_stoppers):
            cleanup_method = getattr(stopper, "cleanup", None)
            if cleanup_method is not None and callable(cleanup_method):
                try:
                    cleanup_method()
                except Exception:
                    logger.exception(
                        "stopper.cleanup_error",
                        stopper=type(stopper).__name__,
                    )

    def _build_stopper_state(self) -> StopperState:
        """Build a StopperState snapshot from current engine state.

        Constructs an immutable snapshot of evolution state for stopper
        callbacks to evaluate. Captures all metrics needed by stoppers
        including elapsed time, total evaluations and the latest score.

        Returns:
            Frozen StopperState containing current iteration, best score,
            stagnation counter, total evaluations, candidates count, elapsed
            time, and the latest iteration's score.

        Notes:
            Obtains elapsed_seconds from monotonic time since run() started.
            Uses zero if _start_time has not yet been set. Takes latest_score
            from the last ``iteration_history`` record, accepted or not, and
            leaves it ``None`` while the history is empty. Must run after
            run() created the engine state, which the method asserts.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        start, history = self._start_time, self._state.iteration_history
        elapsed = time.monotonic() - start if start is not None else 0.0
        candidates_count = (
            len(self._pareto_state.candidates) if self._pareto_state is not None else 0
        )
        return StopperState(
            iteration=self._state.iteration,
            best_score=self._state.best_score,
            stagnation_counter=self._state.stagnation_counter,
            total_evaluations=self._total_evaluations,
            candidates_count=candidates_count,
            elapsed_seconds=elapsed,
            latest_score=history[-1].score if history else None,
        )

    @property
    def pareto_state(self) -> ParetoState | None:
        """Return the current Pareto state, if initialized."""
        return self._pareto_state

    def _build_component_list(self, candidate: Candidate) -> list[str]:
        """Build list of available component keys from candidate.

        Excludes generic 'instruction' alias if agent-specific keys exist
        (e.g., 'agent1_instruction').

        Args:
            candidate: Candidate to extract component keys from.

        Returns:
            List of component keys to consider for update.

        Notes:
            Selects component keys, filtering out the default component name when
            more specific per-agent component keys are present.
        """
        keys = list(candidate.components.keys())
        if len(keys) > 1 and DEFAULT_COMPONENT_NAME in keys:
            # If multiple keys exist, assume default component might be an alias/proxy
            # or simply one of many.
            # For now, simplistic rule: if other keys exist, exclude default.
            return [k for k in keys if k != DEFAULT_COMPONENT_NAME]
        return keys

    def _count_batch(
        self, candidate: Candidate, batch: EvaluationBatch, phase: str
    ) -> None:
        """Count one adapter evaluation and log it under the candidate's id.

        Adds the batch's rows to the evaluation counter that stoppers read
        and its ``failed_indices`` to the failures pending for the next
        record (the baseline or the current iteration), folds the batch's
        ``TokenRollup`` into the pending and the run token rollups, then logs
        ``evaluation.completed`` with the candidate's id, the phase, the row
        count and that same failure count.

        Args:
            candidate: Candidate whose components were evaluated.
            batch: Evaluation batch the adapter just returned.
            phase: ``"reflection"`` for a trainset evaluation with traces,
                ``"scoring"`` for a valset evaluation.

        Notes:
            Called once per adapter ``evaluate()`` call, so a reused batch
            is neither counted nor logged, because no row was evaluated
            again. An adapter that leaves ``failed_indices`` as None reports
            zero failures.
        """
        failed = len(batch.failed_indices or [])
        self._total_evaluations += len(batch.scores)
        self._pending_failed_evaluations += failed
        usage = TokenRollup.from_batch(batch)
        self._pending_token_usage = self._pending_token_usage.combine(usage)
        self._run_token_usage = self._run_token_usage.combine(usage)
        logger.debug(
            "evaluation.completed",
            candidate_id=candidate.id,
            phase=phase,
            n=len(batch.scores),
            failed=failed,
            iteration=self._state.iteration if self._state is not None else 0,
        )

    async def _initialize_baseline(self) -> None:
        """Initialize baseline evaluation.

        Evaluates the initial candidate on trainset for reflection and
        on valset for scoring. Caches the reflection batch for use in
        the first mutation proposal. When the valset is the trainset, the
        reflection batch also serves scoring, so the baseline is evaluated
        once.

        Notes:
            Sets up both reflection and scoring baselines up front. With a
            candidate selector and objective scores, the baseline's Pareto
            entry gets per-objective means (``statistics.fmean``) for the
            OBJECTIVE, HYBRID and CARTESIAN frontiers, and per-example
            objective scores as well for CARTESIAN. The
            baseline evaluation is counted and logged via ``_count_batch``.
            Rows that failed during these evaluations are stored on the state
            as ``baseline_failed_evaluations``. The baseline's token usage
            stays in the run rollup and is cleared from the pending one, so
            the first iteration's record does not include it. The baseline's
            cached batch covers the full trainset, so its rows are None on
            the state and in the Pareto row map. Ends by writing
            a checkpoint through ``_write_checkpoint`` when
            ``config.checkpoint_path`` is set.
        """
        # Create pareto_state before evaluation if candidate_selector exists
        # so that _evaluate_scoring can use evaluation_policy
        if self._candidate_selector is not None:
            self._pareto_state = ParetoState(frontier_type=self.config.frontier_type)

        reflection_batch = await self.adapter.evaluate(
            self._trainset,
            self._initial_candidate.components,
            capture_traces=True,
        )
        self._count_batch(self._initial_candidate, reflection_batch, "reflection")
        # Use _evaluate_scoring for baseline to get eval_indices
        (
            baseline_score,
            scoring_batch,
            baseline_eval_indices,
        ) = await self._evaluate_scoring(
            self._initial_candidate, reflection_batch=reflection_batch
        )
        baseline_reflection_score = sum(reflection_batch.scores) / len(
            reflection_batch.scores
        )
        baseline_valset_mean = (
            sum(scoring_batch.scores) / len(scoring_batch.scores)
            if scoring_batch.scores
            else 0.0
        )
        self._state = _EngineState(
            best_candidate=self._initial_candidate,
            best_score=baseline_score,
            original_score=baseline_score,
            iteration=0,
            stagnation_counter=0,
            iteration_history=[],
            last_eval_batch=reflection_batch,
            best_reflection_score=baseline_reflection_score,
            best_valset_mean=baseline_valset_mean,
            best_objective_scores=scoring_batch.objective_scores,
            baseline_failed_evaluations=self._take_pending_failed_evaluations(),
        )
        self._take_pending_token_usage()
        if self._candidate_selector is not None:
            # Prepare objective scores for baseline if needed
            objective_scores: dict[str, float] | None = None
            per_example_objective_scores: dict[int, dict[str, float]] | None = None

            if scoring_batch.objective_scores is not None:
                if self.config.frontier_type in (
                    FrontierType.OBJECTIVE,
                    FrontierType.HYBRID,
                ):
                    objective_scores_by_name: dict[str, list[float]] = {}
                    for obj_scores in scoring_batch.objective_scores:
                        for obj_name, obj_score in obj_scores.items():
                            objective_scores_by_name.setdefault(obj_name, []).append(
                                obj_score
                            )
                    objective_scores = {
                        obj_name: fmean(scores)
                        for obj_name, scores in objective_scores_by_name.items()
                    }

                if self.config.frontier_type == FrontierType.CARTESIAN:
                    per_example_objective_scores = {
                        baseline_eval_indices[i]: scoring_batch.objective_scores[i]
                        for i in range(len(baseline_eval_indices))
                    }
                    objective_scores_by_name: dict[str, list[float]] = {}
                    for obj_scores in scoring_batch.objective_scores:
                        for obj_name, obj_score in obj_scores.items():
                            objective_scores_by_name.setdefault(obj_name, []).append(
                                obj_score
                            )
                    objective_scores = {
                        obj_name: fmean(scores)
                        for obj_name, scores in objective_scores_by_name.items()
                    }

            assert self._pareto_state is not None, "Pareto state not initialized"  # noqa: S101  # narrows state that run() sets before this call
            candidate_idx = self._pareto_state.add_candidate(
                self._initial_candidate,
                scoring_batch.scores,
                score_indices=baseline_eval_indices,
                objective_scores=objective_scores,
                per_example_objective_scores=per_example_objective_scores,
                logger=logger,
            )
            self._candidate_eval_batches[candidate_idx] = reflection_batch
            self._candidate_eval_rows[candidate_idx] = None
        self._write_checkpoint()

    async def _evaluate_reflection(
        self, candidate: Candidate
    ) -> tuple[float, EvaluationBatch]:
        """Evaluate a candidate on the trainset for reflection.

        Args:
            candidate: Candidate to evaluate.

        Returns:
            Tuple of (mean score across trainset examples, evaluation batch).

        Notes:
            Supplies trajectories for reflective dataset construction.
            The evaluation is counted and logged via ``_count_batch``.
        """
        eval_batch = await self.adapter.evaluate(
            self._trainset,
            candidate.components,
            capture_traces=True,
        )
        self._count_batch(candidate, eval_batch, "reflection")
        score = sum(eval_batch.scores) / len(eval_batch.scores)
        return score, eval_batch

    async def _evaluate_scoring(
        self,
        candidate: Candidate,
        reflection_batch: EvaluationBatch | None = None,
    ) -> tuple[float, EvaluationBatch, list[int]]:
        """Evaluate a candidate on the valset for scoring decisions.

        Args:
            candidate: Candidate to evaluate on the validation set.
            reflection_batch: The candidate's trainset reflection batch. When
                the valset is the trainset, scoring reuses this batch (or its
                entries at the selected indices) instead of calling the
                adapter again.

        Returns:
            Tuple of (aggregated acceptance score, evaluation batch, eval_indices).
            Score is aggregated using acceptance_metric (sum or mean).
            eval_indices are the valset indices that were actually evaluated.

        Notes:
            Supplies scores without traces for acceptance decisions.
            Aggregation method (sum/mean) is determined by config.acceptance_metric.
            Uses evaluation_policy to determine which examples to evaluate.
            Only the canonical ordered index list counts as a full evaluation;
            any other selection, including a permutation, is built row by row
            in the policy's order.
            A reused batch adds nothing to the evaluation or failure
            counters, because no example is evaluated again; its reuse log
            names the ``candidate_id``. The acceptance score is stored under
            the candidate's id so a later duplicate proposal is not
            evaluated again.
        """
        # Get indices to evaluate from evaluation policy
        valset_ids = list(range(len(self._valset)))
        if self._pareto_state is not None:
            eval_indices = self._evaluation_policy.get_eval_batch(
                valset_ids, self._pareto_state
            )
        else:
            # Fallback to all indices if no pareto state yet
            eval_indices = valset_ids

        # Filter valset to only include selected indices
        # Only the canonical ordered list counts as a full evaluation, so a
        # policy that permutes the indices gets a batch built in its order.
        is_full_eval = list(eval_indices) == valset_ids

        if reflection_batch is not None and self._valset_is_trainset:
            logger.debug(
                "evaluation.reuse_trainset_batch",
                candidate_id=candidate.id,
                components=sorted(candidate.components),
                reason="valset_is_trainset",
                full=is_full_eval,
                n=len(eval_indices),
            )
            eval_batch = (
                reflection_batch
                if is_full_eval
                else _select_batch_rows(reflection_batch, eval_indices)
            )
        else:
            eval_valset = (
                self._valset
                if is_full_eval
                else [self._valset[i] for i in eval_indices]
            )
            eval_batch = await self.adapter.evaluate(
                eval_valset,
                candidate.components,
                capture_traces=False,
            )
            self._count_batch(candidate, eval_batch, "scoring")
        score = self._aggregate_acceptance_score(eval_batch.scores)
        self._scored[candidate.id] = score
        return score, eval_batch, eval_indices

    async def _propose_mutation(
        self,
    ) -> tuple[Candidate, list[str], int | None]:
        """Propose a new candidate via reflective mutation.

        Uses the cached evaluation batch from the most recent best candidate
        evaluation to generate the reflective dataset, avoiding redundant
        adapter calls.

        Returns:
            Tuple of (new candidate with proposed component updates,
            list of component names that were updated, Pareto index of the
            parent the reflector rewrote). The index is the selector's draw;
            without a selector (or when the selector found no candidate) it
            is the index of the current best candidate in the Pareto state,
            searched from the end, and None when there is no Pareto state or
            the parent is absent from it.

        Raises:
            ConfigurationError: If the candidate selector returns an index
                with no cached reflection batch, such as a negative index.
                An empty frontier (``NoCandidateAvailableError``) is not an
                error: the most recent cached batch is used instead.

        Notes:
            Spawns a new candidate with updated components based on reflective
            dataset analysis and component selector strategy. The selected
            parent is logged with its ``candidate_id``. Every candidate's
            reflection batch is cached when it is added to the Pareto state,
            so the selected parent is never re-evaluated. The parent's
            trainset batch it reflected on, and the trainset rows that batch
            covers (None for the full trainset), are kept on the engine as
            ``_mutation_parent_batch`` and ``_mutation_parent_rows`` for the
            reflection minibatch gate. The reflective dataset is built from
            whatever batch the parent has, which is its sampled minibatch
            rows when it was accepted with a separate valset. The parent is drawn
            once per iteration: the proposal carries ``parent_id`` set to the
            parent's ``Candidate.id`` and ``generation`` set to the parent's
            plus one, and the returned index is the one the loop passes to
            ``ParetoState.add_candidate`` as ``parent_indices``.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        assert self._state.last_eval_batch is not None, "No eval batch cached"  # noqa: S101  # narrows state that run() sets before this call

        selected_candidate = self._state.best_candidate
        selected_idx: int | None = None
        eval_batch = self._state.last_eval_batch
        eval_rows = self._state.last_eval_rows

        if self._candidate_selector is not None and self._pareto_state is not None:
            try:
                selected_idx = await self._candidate_selector.select_candidate(
                    self._pareto_state
                )
                selected_candidate = self._pareto_state.candidates[selected_idx]
                eval_batch = self._candidate_eval_batches.get(selected_idx)
                eval_rows = self._candidate_eval_rows.get(selected_idx)
                logger.info(
                    "pareto_selection.mutation_parent_selected",
                    candidate_idx=selected_idx,
                    candidate_id=selected_candidate.id,
                    iteration=self._state.iteration,
                    selector_type=type(self._candidate_selector).__name__,
                )
            except NoCandidateAvailableError as exc:
                logger.info(
                    "pareto_selection.empty_frontier_fallback",
                    iteration=self._state.iteration,
                    selector_type=type(self._candidate_selector).__name__,
                    error=str(exc),
                )
                eval_batch = self._state.last_eval_batch
                eval_rows = self._state.last_eval_rows

        if eval_batch is None:
            # Every added candidate is cached at once; a miss is a bad index
            raise ConfigurationError(
                f"candidate selector returned index {selected_idx}, which has "
                "no cached reflection batch",
                field="candidate_selector",
                value=selected_idx,
                constraint="0 <= index < len(pareto_state.candidates)",
            )
        # The minibatch gate compares the proposal with this parent batch
        self._mutation_parent_batch = eval_batch
        self._mutation_parent_rows = eval_rows

        # Build component list
        available_components = self._build_component_list(selected_candidate)

        # Select components to update
        components_to_update = await self._component_selector.select_components(
            components=available_components,
            iteration=self._state.iteration,
            candidate_idx=selected_idx if selected_idx is not None else 0,
        )

        logger.info(
            "mutation.components_selected",
            iteration=self._state.iteration,
            components=components_to_update,
            selector=type(self._component_selector).__name__,
        )

        # Build reflective dataset
        reflective_dataset = await self.adapter.make_reflective_dataset(
            selected_candidate.components,
            eval_batch,
            components_to_update,
        )

        # Propose new texts
        proposed_components = await self.adapter.propose_new_texts(
            selected_candidate.components,
            reflective_dataset,
            components_to_update,
        )

        # The parent's Pareto index, for the genealogy edge
        parent_idx = selected_idx
        if parent_idx is None and self._pareto_state is not None:
            parent_idx = next(
                (
                    idx
                    for idx in range(len(self._pareto_state.candidates) - 1, -1, -1)
                    if self._pareto_state.candidates[idx].id == selected_candidate.id
                ),
                None,
            )

        # Create new candidate with proposed components and its lineage
        new_components = dict(selected_candidate.components)
        new_components.update(proposed_components)
        return (
            Candidate(
                components=new_components,
                generation=selected_candidate.generation + 1,
                parent_id=selected_candidate.id,
            ),
            components_to_update,
            parent_idx,
        )

    async def _record_iteration(  # noqa: PLR0913  # one argument per recorded field; private helper
        self,
        score: float,
        component_text: str,
        evolved_component: str,
        accepted: bool,
        objective_scores: list[dict[str, float]] | None = None,
        reflection_reasoning: str | None = None,
        skip_reason: str | None = None,
        candidate_id: str | None = None,
        parent_ids: list[str] | None = None,
        rejection_reason: str | None = None,
        *,
        rows_evaluated: int | None = None,
    ) -> None:
        """Record iteration outcome and notify the ``on_iteration`` callback.

        Args:
            score (float): Score achieved in this iteration.
            component_text (str): The text of the component that was evaluated.
            evolved_component (str): The name of the component that was evolved
                (e.g., "instruction", "output_schema").
            accepted (bool): Whether proposal was accepted.
            objective_scores (list[dict[str, float]] | None): Optional objective scores
                from this iteration's evaluation. None when adapter does not provide
                objective scores.
            reflection_reasoning (str | None): Optional natural language reasoning from
                the reflection agent explaining the mutation. None when
                reasoning is not available.
            skip_reason (str | None): Why the iteration produced no evaluated proposal
                (e.g., ``"empty_proposal"``). None for ordinary iterations.
            candidate_id (str | None): Id of the candidate the record concerns, stored on
                the record and passed to the callback. None when nothing was
                proposed.
            parent_ids (list[str] | None): Ids of the candidates the proposal was made from,
                stored on the record. None when nothing was proposed.
            rejection_reason (str | None): The reason ``config.proposal_validator``
                returned for a ``"proposal_rejected"`` skip, stored on the
                record. None for every other iteration.
            rows_evaluated (int | None): Number of rows ``score`` aggregates over, stored
                on the record: the scoring batch's row count on an evaluated
                record, the minibatch's on a ``"minibatch_rejected"`` skip,
                0 on a skip that evaluated nothing and None on a
                ``"duplicate"`` skip, whose score is not this iteration's.
                Defaults to None (not recorded); every engine path passes it.

        Notes:
            Appends an IterationRecord to ``state.iteration_history`` so the
            chronological evolution trace is preserved for analysis. The
            record's ``failed_evaluations`` takes the failures counted since
            the iteration began, including any merge evaluation, and its
            ``token_usage`` the token rollup of the same evaluations (zeros
            when the iteration evaluated nothing), split into ``evaluation``
            and ``reflection``; ``reflection`` is None unless the proposer
            reported the iteration's reflection usage. Every record
            path goes through here, so ``config.on_iteration``, when set, is
            called with the record and ``candidate_id`` right after the
            append; an awaitable return value is awaited. Exceptions from the
            callback are not caught. Ends by writing a checkpoint through
            ``_write_checkpoint`` when ``config.checkpoint_path`` is set.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        record = IterationRecord(
            iteration_number=self._state.iteration,
            score=score,
            component_text=component_text,
            evolved_component=evolved_component,
            accepted=accepted,
            objective_scores=objective_scores,
            reflection_reasoning=reflection_reasoning,
            skip_reason=skip_reason,
            failed_evaluations=self._take_pending_failed_evaluations(),
            token_usage=self._take_record_token_usage(),
            candidate_id=candidate_id,
            parent_ids=parent_ids,
            rejection_reason=rejection_reason,
            rows_evaluated=rows_evaluated,
        )
        self._state.iteration_history.append(record)
        callback = self.config.on_iteration
        if callback is not None:
            outcome = callback(record, candidate_id)
            if inspect.isawaitable(outcome):
                await outcome
        self._write_checkpoint()

    async def _record_empty_proposal(self, error: EmptyProposalError) -> None:
        """Record an iteration whose reflection returned an empty proposal.

        Args:
            error (EmptyProposalError): The error raised while proposing; its ``component`` names
                the component the reflection was working on.

        Notes:
            Logs ``evolution.proposal_skipped`` with
            ``reason="empty_proposal"``, counts the iteration toward
            stagnation and appends a not-accepted IterationRecord with
            ``score=0.0``, empty ``component_text`` and
            ``skip_reason="empty_proposal"``. Nothing is evaluated, so the
            record's ``rows_evaluated`` is 0. The
            ``on_iteration`` callback receives ``None`` as the candidate id.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        logger.debug(
            "evolution.proposal_skipped",
            iteration=self._state.iteration,
            reason="empty_proposal",
            component=error.component,
        )
        self._state.stagnation_counter += 1
        await self._record_iteration(
            score=0.0,
            component_text="",
            evolved_component=error.component,
            accepted=False,
            skip_reason="empty_proposal",
            rows_evaluated=0,
        )

    async def _record_reflection_timeout(self, error: ReflectionTimeoutError) -> None:
        """Record an iteration whose reflection agent timed out.

        Args:
            error (ReflectionTimeoutError): The error raised while proposing; its
                ``component`` names the component the reflection was working on and its
                ``timeout_seconds`` the timeout it ran under.

        Notes:
            Logs ``evolution.proposal_skipped`` with
            ``reason="reflection_timeout"`` and ``timeout_seconds``, counts
            the iteration toward stagnation and appends a not-accepted
            IterationRecord with ``score=0.0``, empty ``component_text`` and
            ``skip_reason="reflection_timeout"``. Nothing is evaluated
            (``rows_evaluated=0``), and accepted candidates and the Pareto state are untouched.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        logger.debug(
            "evolution.proposal_skipped",
            iteration=self._state.iteration,
            reason="reflection_timeout",
            component=error.component,
            timeout_seconds=error.timeout_seconds,
        )
        self._state.stagnation_counter += 1
        await self._record_iteration(
            score=0.0,
            component_text="",
            evolved_component=error.component,
            accepted=False,
            skip_reason="reflection_timeout",
            rows_evaluated=0,
        )

    async def _record_reflection_error(self, error: ReflectionError) -> None:
        """Record an iteration whose reflection function kept raising.

        Args:
            error (ReflectionError): The retryable error the proposer raised after its retry;
                its ``component`` names the component the reflection was
                working on, ``cause`` the provider exception and
                ``attempts`` the number of reflection calls made.

        Notes:
            Logs ``evolution.proposal_skipped`` at warning level with
            ``reason="reflection_error"``, the cause's ``error_type`` and
            text and ``attempts``, counts the iteration toward stagnation
            and appends a not-accepted IterationRecord with ``score=0.0``,
            empty ``component_text`` and ``skip_reason="reflection_error"``.
            Nothing is evaluated (``rows_evaluated=0``), and accepted candidates and the Pareto
            state are untouched.

            When the record just appended is the
            ``config.max_consecutive_reflection_errors``-th in a row whose
            cause has the same exception type name and text, raises a
            non-retryable ``ReflectionError``; ``run()`` attaches the
            partial result. Any other record in between, from a successful
            reflection or another skip, or a different cause, restarts the
            count.

        Raises:
            ReflectionError: With ``retryable=False``, the last cause and its
                attempts, once the identical errors reach the limit.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        error_type = type(error.cause).__name__
        logger.warning(
            "evolution.proposal_skipped",
            iteration=self._state.iteration,
            reason="reflection_error",
            component=error.component,
            error_type=error_type,
            error=str(error.cause),
            attempts=error.attempts,
        )
        self._state.stagnation_counter += 1
        signature = (error_type, str(error.cause))
        index = len(self._state.iteration_history)
        if (
            signature == self._reflection_error_signature
            and self._reflection_error_index == index - 1
        ):
            self._reflection_error_streak += 1
        else:
            self._reflection_error_signature = signature
            self._reflection_error_streak = 1
        self._reflection_error_index = index
        await self._record_iteration(
            score=0.0,
            component_text="",
            evolved_component=error.component,
            accepted=False,
            skip_reason="reflection_error",
            rows_evaluated=0,
        )
        limit = self.config.max_consecutive_reflection_errors
        if self._reflection_error_streak >= limit:
            logger.error(
                "evolution.reflection_errors_exhausted",
                component=error.component,
                error_type=error_type,
                error=str(error.cause),
                consecutive=self._reflection_error_streak,
            )
            raise ReflectionError(
                error.component,
                cause=error.cause,
                retryable=False,
                attempts=error.attempts,
            ) from error

    async def _record_incomplete_proposal(self, error: IncompleteProposalError) -> None:
        """Record an iteration whose reflection output was incomplete.

        Args:
            error (IncompleteProposalError): The error the reflection function raised; its
                ``component`` names the component the reflection was working
                on, ``finish_reason`` why the output is incomplete and
                ``raw_text`` the truncated text.

        Notes:
            Logs ``evolution.proposal_skipped`` at warning level with
            ``reason="incomplete_proposal"``, ``finish_reason`` and
            ``response_length``, counts the iteration toward stagnation and
            appends a not-accepted IterationRecord with ``score=0.0``, the
            truncated text as ``component_text`` and
            ``skip_reason="incomplete_proposal"``. Nothing is evaluated
            (``rows_evaluated=0``), and accepted candidates and the Pareto state are untouched.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        logger.warning(
            "evolution.proposal_skipped",
            iteration=self._state.iteration,
            reason="incomplete_proposal",
            component=error.component,
            finish_reason=error.finish_reason,
            response_length=error.output_length,
        )
        self._state.stagnation_counter += 1
        await self._record_iteration(
            score=0.0,
            component_text=error.raw_text,
            evolved_component=error.component,
            accepted=False,
            skip_reason="incomplete_proposal",
            rows_evaluated=0,
        )

    async def _record_schema_validation_skip(
        self, proposal: Candidate, evolved_components: list[str]
    ) -> None:
        """Record an iteration whose proposed output schema failed validation.

        Args:
            proposal (Candidate): The proposal whose ``output_schema`` text is invalid.
            evolved_components (list[str]): Names of the components the proposal evolved,
                logged with the skip.

        Notes:
            Logs ``evolution.proposal_skipped`` with
            ``reason="schema_validation_failed"``, counts the iteration toward
            stagnation and appends a not-accepted IterationRecord with
            ``score=0.0``, the invalid schema text as ``component_text``,
            ``evolved_component="output_schema"`` and
            ``skip_reason="schema_validation_failed"``. Nothing is evaluated
            (``rows_evaluated=0``).
            The ``on_iteration`` callback receives the proposal's id, and the
            record carries it as ``candidate_id`` with ``parent_ids`` naming
            the proposal's parent.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        logger.debug(
            "evolution.proposal_skipped",
            iteration=self._state.iteration,
            reason="schema_validation_failed",
            candidate_id=proposal.id,
            components=evolved_components,
        )
        self._state.stagnation_counter += 1
        await self._record_iteration(
            score=0.0,
            component_text=proposal.components["output_schema"],
            evolved_component="output_schema",
            accepted=False,
            skip_reason="schema_validation_failed",
            rows_evaluated=0,
            candidate_id=proposal.id,
            parent_ids=_parent_ids(proposal),
        )

    async def _record_if_rejected(
        self, proposal: Candidate, evolved_components: list[str]
    ) -> bool:
        """Record a skipped iteration when the proposal validator rejects it.

        Args:
            proposal (Candidate): The candidate proposed this iteration.
            evolved_components (list[str]): Names of the components evolved this
                iteration; each is passed to the validator in order. When
                empty, every component of the proposal is checked.

        Returns:
            True when ``config.proposal_validator`` returned a reason for a
            component and the iteration was recorded as rejected; False when
            no validator is set or it accepted every component.

        Notes:
            The validator is called with the component name and the proposed
            text; the first non-None return is the reason. A rejection logs
            ``evolution.proposal_skipped`` at warning level with
            ``reason="proposal_rejected"`` and the ``rejection_reason``,
            counts toward stagnation and appends a not-accepted
            IterationRecord with ``score=0.0``, the rejected text, the
            proposal's ``candidate_id`` and ``parent_ids`` and the reason.
            No adapter call is made, so the record's ``rows_evaluated`` is
            0. Exceptions from the validator are not caught and propagate
            out of ``run()``.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        validator = self.config.proposal_validator
        if validator is None:
            return False
        names = evolved_components or list(proposal.components)
        for name in names:
            text = proposal.components[name]
            reason = validator(name, text)
            if reason is None:
                continue
            logger.warning(
                "evolution.proposal_skipped",
                iteration=self._state.iteration,
                reason="proposal_rejected",
                rejection_reason=reason,
                component=name,
                candidate_id=proposal.id,
            )
            self._state.stagnation_counter += 1
            await self._record_iteration(
                score=0.0,
                component_text=text,
                evolved_component=name,
                accepted=False,
                skip_reason="proposal_rejected",
                rows_evaluated=0,
                candidate_id=proposal.id,
                parent_ids=_parent_ids(proposal),
                rejection_reason=reason,
            )
            return True
        return False

    async def _record_if_duplicate(
        self, proposal: Candidate, evolved_components: list[str]
    ) -> bool:
        """Record a skipped iteration when the proposal was already scored.

        Args:
            proposal (Candidate): The candidate proposed this iteration.
            evolved_components (list[str]): Names of the components evolved this
                iteration; the first one names the record's component.

        Returns:
            True when the proposal's id was already scored and the iteration
            was recorded as a duplicate; False when it must be evaluated.

        Notes:
            A duplicate logs ``proposal.duplicate`` with the stored
            acceptance score, counts toward stagnation and appends a
            not-accepted IterationRecord carrying that score, the proposal's
            text for the evolved component and ``skip_reason="duplicate"``.
            No adapter call is made. The record's ``rows_evaluated`` is None
            because its score comes from an earlier evaluation. The
            ``on_iteration`` callback receives the duplicate proposal's id,
            and the record carries it as
            ``candidate_id`` with ``parent_ids`` naming the proposal's parent.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        score = self._scored.get(proposal.id)
        if score is None:
            return False
        component = (
            evolved_components[0]
            if evolved_components
            else next(iter(proposal.components))
        )
        logger.info(
            "proposal.duplicate",
            iteration=self._state.iteration,
            candidate_id=proposal.id,
            score=score,
        )
        self._state.stagnation_counter += 1
        await self._record_iteration(
            score=score,
            component_text=proposal.components.get(component, ""),
            evolved_component=component,
            accepted=False,
            skip_reason="duplicate",
            candidate_id=proposal.id,
            parent_ids=_parent_ids(proposal),
            rows_evaluated=None,
        )
        return True

    def _effective_minibatch_size(self) -> int | None:
        """Return the reflection minibatch size in effect for this run.

        Returns:
            ``min(reflection_minibatch_size, len(trainset))`` when that is
            smaller than the trainset, otherwise None, meaning every proposal
            is evaluated on the full trainset.
        """
        size = self.config.reflection_minibatch_size
        if size is None:
            return None
        k = min(size, len(self._trainset))
        return k if k < len(self._trainset) else None

    async def _gate_on_minibatch(
        self, proposal: Candidate, evolved_components: list[str]
    ) -> bool:
        """Decide whether a proposal earns its full evaluation.

        Samples trainset rows from the rows the mutation parent has scores
        for, evaluates the proposal on them with traces, and compares the
        mean of its scores with the mean of the parent's cached scores on the
        same rows.

        Args:
            proposal (Candidate): The candidate proposed this iteration.
            evolved_components (list[str]): Names of the components evolved this
                iteration; the first one names the record's component.

        Returns:
            True when the minibatch is disabled or the proposal's mean is
            strictly greater than the parent's; False when the proposal was
            rejected and its iteration recorded.

        Notes:
            The parent's known rows are ``_mutation_parent_rows``, or every
            trainset row when that is None. When the parent has at most
            ``k`` known rows, all of them are used (sorted) and the seeded
            random source is not drawn from; otherwise ``k`` of them are
            drawn with it and sorted. Each sampled trainset index is mapped
            to its position in the parent's batch, so the comparison is
            always on rows both sides scored. The evaluated batch and its
            trainset indices are stored as ``_gate_batch`` and
            ``_gate_rows`` (both None when the minibatch is disabled), so the
            loop can reuse them as the proposal's reflection batch.
            The minibatch evaluation is counted through ``_count_batch`` as a
            ``"reflection"`` evaluation. A rejection logs
            ``proposal.minibatch_rejected``, counts toward stagnation and
            appends a not-accepted IterationRecord whose score is the
            acceptance aggregate over the minibatch rows, whose
            ``rows_evaluated`` is the number of minibatch rows and whose
            ``skip_reason`` is ``"minibatch_rejected"``, with the proposal's
            ``candidate_id`` and its parent in ``parent_ids``. A rejected proposal
            is not stored as scored, so an identical later proposal draws a
            fresh sample. A pass logs ``proposal.minibatch_passed``. Both
            log events carry ``parent_rows``, the number of rows the parent
            has scores for. A full parent batch is sampled from ``range(n)``
            and read by trainset row, so no per-iteration row list or
            position map is built for it; only a sampled parent batch maps
            row to position.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        self._gate_batch = None
        self._gate_rows = None
        k = self._effective_minibatch_size()
        if k is None:
            return True
        parent_batch = self._mutation_parent_batch
        assert parent_batch is not None, "No parent batch cached"  # noqa: S101  # set by the mutation step that precedes the gate
        # A full batch is indexed by trainset row, so no position map or
        # row list is built for it; a sampled batch maps row to position.
        known: list[int] | range = (
            self._mutation_parent_rows
            if self._mutation_parent_rows is not None
            else range(len(self._trainset))
        )
        position = (
            {row: pos for pos, row in enumerate(known)}
            if self._mutation_parent_rows is not None
            else None
        )
        indices = (
            sorted(known)
            if len(known) <= k
            else sorted(self._minibatch_rng.sample(known, k))
        )
        minibatch = await self.adapter.evaluate(
            [self._trainset[i] for i in indices],
            proposal.components,
            capture_traces=True,
        )
        self._count_batch(proposal, minibatch, "reflection")
        self._gate_batch = minibatch
        self._gate_rows = indices
        proposal_mean = sum(minibatch.scores) / len(minibatch.scores)
        parent_mean = sum(
            parent_batch.scores[i if position is None else position[i]] for i in indices
        ) / len(indices)
        fields = {
            "iteration": self._state.iteration,
            "candidate_id": proposal.id,
            "indices": indices,
            "parent_rows": len(known),
            "proposal_mean": proposal_mean,
            "parent_mean": parent_mean,
        }
        if proposal_mean > parent_mean:
            logger.info("proposal.minibatch_passed", **fields)
            return True
        logger.info("proposal.minibatch_rejected", **fields)
        component = (
            evolved_components[0]
            if evolved_components
            else next(iter(proposal.components))
        )
        self._state.stagnation_counter += 1
        await self._record_iteration(
            score=self._aggregate_acceptance_score(minibatch.scores),
            component_text=proposal.components.get(component, ""),
            evolved_component=component,
            accepted=False,
            skip_reason="minibatch_rejected",
            candidate_id=proposal.id,
            parent_ids=_parent_ids(proposal),
            rows_evaluated=len(minibatch.scores),
        )
        return False

    async def _evaluate_passed_proposal(
        self, proposal: Candidate
    ) -> tuple[float, EvaluationBatch, list[int] | None]:
        """Build the reflection batch of a proposal that passed the gate.

        Args:
            proposal: The candidate that passed the minibatch gate (or ran
                with the minibatch disabled).

        Returns:
            Tuple of (mean score over the batch, the batch, the trainset
            indices its rows cover or None for the full trainset in order).

        Notes:
            With a reflection minibatch in effect and a valset separate from
            the trainset, the gate's batch (``_gate_batch``, evaluated with
            traces) is the reflection batch and no full trainset pass runs;
            its rows are ``_gate_rows``. Otherwise the proposal is evaluated
            on the full trainset through ``_evaluate_reflection``, so a
            valset that is the trainset can reuse that batch for scoring.
        """
        if (
            self._effective_minibatch_size() is not None
            and not self._valset_is_trainset
            and self._gate_batch is not None
        ):
            batch = self._gate_batch
            return sum(batch.scores) / len(batch.scores), batch, self._gate_rows
        score, batch = await self._evaluate_reflection(proposal)
        return score, batch, None

    def _start_merge(self, merge_result: ProposalResult) -> bool:
        """Count a merge attempt and decide whether to evaluate it.

        Args:
            merge_result (ProposalResult): The merge proposer's result for this iteration.

        Returns:
            True when the merge candidate should be evaluated; False when it
            is skipped for an invalid schema or an already scored id.

        Notes:
            Decrements ``merges_due``, increments the invocation count and
            logs ``merge_scheduling.merge_attempted`` for every attempt,
            skipped or not.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        self._merges_due -= 1
        self._merge_invocations += 1
        logger.info(
            "merge_scheduling.merge_attempted",
            iteration=self._state.iteration,
            candidate_id=merge_result.candidate.id,
            parent_indices=merge_result.parent_indices,
            ancestor_idx=merge_result.metadata.get("ancestor_idx"),
            merges_due=self._merges_due,
            total_invocations=self._merge_invocations,
        )
        return not self._skip_merge_candidate(merge_result.candidate)

    def _skip_merge_candidate(self, candidate: Candidate) -> bool:
        """Decide whether a merge candidate must not be evaluated.

        Args:
            candidate (Candidate): The candidate produced by the merge proposer.

        Returns:
            True when the candidate has an invalid output schema or its id
            was already scored; False when it should be evaluated.

        Notes:
            Logs ``merge.proposal_skipped`` with
            ``reason="schema_validation_failed"`` or ``reason="duplicate"``.
            No iteration record is written for a skipped merge; the
            iteration's own proposal record is unaffected.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        if not self._validate_schema_component(candidate):
            reason = "schema_validation_failed"
        elif candidate.id in self._scored:
            reason = "duplicate"
        else:
            return False
        logger.debug(
            "merge.proposal_skipped",
            iteration=self._state.iteration,
            candidate_id=candidate.id,
            reason=reason,
        )
        return True

    def _should_stop(self) -> StopReason | None:
        """Check if evolution should terminate.

        Returns:
            The ``StopReason`` that triggered termination, or ``None`` if
            evolution should continue. Conditions are checked in priority
            order: max iterations, early stopping patience, custom stoppers.

        Notes:
            Only active stoppers (those that passed setup or have no setup
            method) are invoked. Patience-based early stopping returns
            ``PATIENCE`` and is off when ``config.patience`` is 0;
            ``MAX_ITERATIONS`` means only the iteration cap.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        # Condition 1: Max iterations reached (built-in, fast path)
        if self._state.iteration >= self.config.max_iterations:
            return StopReason.MAX_ITERATIONS

        # Condition 2: Early stopping (patience exhausted, built-in)
        if (
            self.config.patience > 0
            and self._state.stagnation_counter >= self.config.patience
        ):
            return StopReason.PATIENCE

        # Condition 3: Custom stoppers (T010-T013)
        # Use _active_stoppers which excludes stoppers that failed setup
        active_stoppers = getattr(self, "_active_stoppers", None)
        if active_stoppers:
            stopper_state = self._build_stopper_state()
            for stopper in active_stoppers:
                try:
                    if stopper(stopper_state):
                        # Log stopper trigger (T013)
                        logger.info(
                            "stopper.triggered",
                            stopper=type(stopper).__name__,
                            iteration=self._state.iteration,
                        )
                        return StopReason.STOPPER_TRIGGERED
                except Exception:
                    # T032: Handle stopper exception gracefully
                    logger.exception(
                        "stopper.error",
                        stopper=type(stopper).__name__,
                        iteration=self._state.iteration,
                    )
                    # Continue checking other stoppers

        return None

    def _should_accept(self, proposal_score: float, best_score: float) -> bool:
        """Check if proposal should be accepted.

        Args:
            proposal_score: Score of the proposed candidate.
            best_score: Current best score.

        Returns:
            True if proposal_score > best_score + min_improvement_threshold.

        Notes:
            Signals True when proposal exceeds best score by the configured
            improvement threshold, enabling configurable acceptance sensitivity.
        """
        threshold = self.config.min_improvement_threshold
        return proposal_score > best_score + threshold

    def _validate_schema_component(self, proposal: Candidate) -> bool:
        """Validate output_schema component if present.

        Validates that proposed schema text is syntactically correct and
        structurally valid (inherits from BaseModel, no imports/functions).
        Invalid schemas are rejected to prevent evolution from accepting
        non-functional schema proposals.

        Args:
            proposal (Candidate): Candidate containing components to validate.

        Returns:
            True if valid or no output_schema component present.
            False if output_schema validation fails.

        Notes:
            This validation runs before expensive evaluation to reject
            invalid schemas early. Security checks (no imports, no functions)
            are enforced to prevent code injection.
        """
        if "output_schema" not in proposal.components:
            return True

        schema_text = proposal.components["output_schema"]

        try:
            validate_schema_text(schema_text)
        except SchemaValidationError as e:
            logger.warning(
                "schema_validation.rejected",
                iteration=self._state.iteration if self._state else None,
                validation_stage=e.validation_stage,
                line_number=e.line_number,
                error=e.validation_error,
            )
            return False
        else:
            logger.debug(
                "schema_validation.passed",
                iteration=self._state.iteration if self._state else None,
            )
            return True

    def _accept_proposal(  # noqa: PLR0913  # optional fields are keyword-only; private helper
        self,
        proposal: Candidate,
        score: float,
        eval_batch: EvaluationBatch,
        *,
        candidate_idx: int | None = None,
        eval_rows: list[int] | None = None,
        reflection_score: float | None = None,
        valset_mean: float | None = None,
        objective_scores: list[dict[str, float]] | None = None,
    ) -> None:
        """Accept a proposal and update state.

        Args:
            proposal (Candidate): Proposed candidate to accept.
            score (float): Acceptance score of the proposed candidate (sum or mean).
            eval_batch (EvaluationBatch): Reflection batch from proposal evaluation (cached for
                next iteration's reflective dataset generation).
            candidate_idx (int | None): Optional ParetoState candidate index to update with
                lineage metadata.
            eval_rows (list[int] | None): Trainset indices the rows of ``eval_batch`` cover, in
                batch order; None means the full trainset in order.
            reflection_score (float | None): Optional trainset score to store with best
                candidate metadata: the mean over ``eval_batch``.
            valset_mean (float | None): Optional valset mean score to track separately from
                acceptance score.
            objective_scores (list[dict[str, float]] | None): Optional objective scores
                from scoring batch. None when adapter does not provide objective scores.

        Notes:
            Replaces the cached reflection batch and its trainset rows for
            the next proposal iteration and tracks acceptance score and valset mean separately.
            The proposal is stored as it is: a mutation already carries its
            parent's id and generation from ``_propose_mutation``, and a
            merge candidate keeps the lineage the merge proposer set.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        if candidate_idx is not None and self._pareto_state is not None:
            self._pareto_state.candidates[candidate_idx] = proposal
        self._state.best_candidate = proposal
        self._state.best_score = score
        self._state.stagnation_counter = 0
        self._state.last_eval_batch = eval_batch
        self._state.last_eval_rows = eval_rows
        if reflection_score is not None:
            self._state.best_reflection_score = reflection_score
        if valset_mean is not None:
            self._state.best_valset_mean = valset_mean
        self._state.best_objective_scores = objective_scores

    def _build_result(
        self, stop_reason: StopReason = StopReason.COMPLETED
    ) -> EvolutionResult:
        """Build final result from current state.

        Args:
            stop_reason (StopReason): Why the evolution run terminated.

        Returns:
            Frozen EvolutionResult with all metrics and original_components.

        Notes:
            Synthesizes a frozen EvolutionResult containing all evolution metrics,
            history, and original_components snapshot, suitable for immutable
            result reporting. The evolved_components dict contains all component
            values from the best candidate. ``total_failed_evaluations`` is
            the baseline count plus the sum over ``iteration_history``, and
            ``token_usage`` is the run token rollup, baseline included,
            with its ``evaluation`` split and a ``reflection`` split that is
            None when no reflection usage was observed.
        """
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call
        baseline_failed = self._state.baseline_failed_evaluations
        return EvolutionResult(
            stop_reason=stop_reason,
            original_score=self._state.original_score,
            final_score=self._state.best_score,
            evolved_components=dict(self._state.best_candidate.components),
            iteration_history=self._state.iteration_history,
            total_iterations=self._state.iteration,
            valset_score=self._state.best_valset_mean,
            trainset_score=self._state.best_reflection_score,
            objective_scores=self._state.best_objective_scores,
            original_components=dict(self._initial_candidate.components),
            baseline_failed_evaluations=baseline_failed,
            total_failed_evaluations=baseline_failed
            + sum(r.failed_evaluations for r in self._state.iteration_history),
            token_usage=self._run_token_rollup(),
        )

    def _write_checkpoint(self) -> None:
        """Write the engine's state to ``config.checkpoint_path``.

        Does nothing when no checkpoint path is configured. Otherwise
        builds the checkpoint dict (version, iteration, stagnation counter,
        best candidate and scores, history, scored map, evaluation counter,
        best reflection batch, random states and run identity) and writes it
        atomically through ``write_checkpoint``.

        Raises:
            TypeError: If a batch field holds a value JSON cannot encode.
            OSError: If the file cannot be written. Nothing is swallowed.

        Notes:
            ``rng_state`` is the engine ``rng``'s state when one was given,
            else null; ``minibatch_rng_state`` is always written.
            ``valset_size`` is null when the valset is the trainset.
            ``last_eval_rows`` holds the trainset indices the cached batch
            covers, null for the full trainset.
            ``run_token_usage`` is the run's token rollup so far, with its
            ``evaluation`` and ``reflection`` splits, so a resumed run
            reports the whole run's usage.
        """
        if self.config.checkpoint_path is None or self._state is None:
            return
        path = Path(self.config.checkpoint_path)
        state = self._state
        data = {
            "checkpoint_version": CHECKPOINT_VERSION,
            "iteration": state.iteration,
            "stagnation_counter": state.stagnation_counter,
            "best_candidate": state.best_candidate.to_dict(),
            "best_score": state.best_score,
            "original_score": state.original_score,
            "best_reflection_score": state.best_reflection_score,
            "best_valset_mean": state.best_valset_mean,
            "best_objective_scores": state.best_objective_scores,
            "baseline_failed_evaluations": state.baseline_failed_evaluations,
            "iteration_history": [r.to_dict() for r in state.iteration_history],
            "scored": dict(self._scored),
            "total_evaluations": self._total_evaluations,
            "last_eval_batch": batch_to_dict(state.last_eval_batch),
            "last_eval_rows": state.last_eval_rows,
            "rng_state": (
                None if self._rng is None else rng_state_to_json(self._rng.getstate())
            ),
            "minibatch_rng_state": rng_state_to_json(self._minibatch_rng.getstate()),
            "initial_candidate_id": self._initial_candidate.id,
            "trainset_size": len(self._trainset),
            "valset_size": None if self._valset_is_trainset else len(self._valset),
            "seed": self.config.seed,
            "run_token_usage": self._run_token_rollup().to_dict(),
            "written_at": datetime.now(UTC).isoformat(),
        }
        write_checkpoint(path, data)
        logger.debug("checkpoint.written", iteration=state.iteration, path=str(path))

    def _load_checkpoint(self) -> dict[str, Any]:
        """Read the checkpoint and refuse one from a different run.

        Returns:
            The checkpoint dict.

        Raises:
            ConfigurationError: If ``config.checkpoint_path`` is unset, the
                file is missing, its version is not ``CHECKPOINT_VERSION``,
                or its initial candidate, trainset size or valset size
                differs from this engine's.
        """
        path = self.config.checkpoint_path
        if path is None:
            raise ConfigurationError(
                "resume=True requires checkpoint_path",
                field="checkpoint_path",
                value=None,
                constraint="not None when resume=True",
            )
        data = read_checkpoint(Path(path))
        check_run_matches(
            data,
            initial_candidate_id=self._initial_candidate.id,
            trainset_size=len(self._trainset),
            valset_size=None if self._valset_is_trainset else len(self._valset),
        )
        return data

    def _resume_from_checkpoint(self) -> None:
        """Restore engine state from ``config.checkpoint_path``.

        Rebuilds ``_EngineState``, the scored map, the evaluation counter,
        the run token rollup, the mutation parent batch and the random
        states from the file, and marks the engine restored so the loop
        skips the baseline.

        Raises:
            ConfigurationError: If ``_load_checkpoint`` refuses the file.

        Notes:
            The engine ``rng`` is restored only when one was given and a
            state was stored; the minibatch random source always is. A
            checkpoint without ``run_token_usage`` restores an unknown
            rollup whose ``rows_unknown`` is the checkpointed evaluation
            count, so the pre-resume rows read as unknown, not free. A
            checkpoint without ``last_eval_rows`` restores the cached batch
            as covering the full trainset; ``_propose_mutation`` reads the
            parent's rows from the restored state. A
            stored rollup without splits restores as evaluation usage with
            reflection not observed; a split one restores its ``evaluation``
            split (zeros when absent) as evaluation and its ``reflection``
            split as reflection, so reflection usage never enters the
            evaluation total. The
            ``on_iteration`` callback is not called for restored history.
            Logs ``checkpoint.resumed`` with the iteration, the stagnation
            counter, the evaluation counter and the path.
        """
        data = self._load_checkpoint()
        last_eval_batch = batch_from_dict(data["last_eval_batch"])
        # Absent in older files: the cached batch is the full trainset
        last_eval_rows = data.get("last_eval_rows")
        self._state = _EngineState(
            best_candidate=Candidate.from_dict(data["best_candidate"]),
            best_score=data["best_score"],
            original_score=data["original_score"],
            iteration=data["iteration"],
            stagnation_counter=data["stagnation_counter"],
            iteration_history=[
                IterationRecord.from_dict(r) for r in data["iteration_history"]
            ],
            last_eval_batch=last_eval_batch,
            last_eval_rows=last_eval_rows,
            best_reflection_score=data["best_reflection_score"],
            best_valset_mean=data["best_valset_mean"],
            best_objective_scores=data["best_objective_scores"],
            baseline_failed_evaluations=data["baseline_failed_evaluations"],
        )
        self._scored = dict(data["scored"])
        self._total_evaluations = data["total_evaluations"]
        stored_usage = data.get("run_token_usage")
        loaded = (
            TokenRollup.from_dict(stored_usage)
            if stored_usage is not None
            else _unknown_tokens(data["total_evaluations"])
        )
        has_split = loaded.evaluation is not None or loaded.reflection is not None
        self._run_token_usage = (
            (loaded.evaluation or _ZERO_TOKENS) if has_split else loaded
        )
        self._run_reflection_usage = loaded.reflection
        self._mutation_parent_batch = last_eval_batch
        if self._rng is not None and data["rng_state"] is not None:
            self._rng.setstate(rng_state_from_json(data["rng_state"]))
        self._minibatch_rng.setstate(rng_state_from_json(data["minibatch_rng_state"]))
        self._restored = True
        logger.info(
            "checkpoint.resumed",
            iteration=self._state.iteration,
            stagnation_counter=self._state.stagnation_counter,
            total_evaluations=self._total_evaluations,
            path=str(self.config.checkpoint_path),
        )

    def _reset_run_state(self) -> None:
        """Clear per-run state so a repeated ``run()`` starts fresh.

        Resets the stopper timer, the evaluation and pending failure
        counters, the pending and run token and reflection rollups, the
        reflection error streak, the scored-candidate map, the engine state
        and the restored flag.
        """
        # Initialize stopper state tracking (T004)
        self._start_time = time.monotonic()
        self._total_evaluations = 0
        self._pending_failed_evaluations = 0
        self._pending_token_usage = self._run_token_usage = _ZERO_TOKENS
        self._pending_reflection_usage = self._run_reflection_usage = None
        self._reflection_error_signature = None
        self._reflection_error_streak = 0
        self._reflection_error_index = -1
        # A fresh run scores every candidate again; stale ids would read
        # first-iteration proposals as duplicates.
        self._scored.clear()
        # State from an earlier run must not leak into this run's partial
        # result; the baseline or the checkpoint rebuilds it.
        self._state = None
        self._restored = False

    async def run(self) -> EvolutionResult:
        """Execute the evolution loop.

        Runs the core evolution loop:
        1. Evaluate baseline candidate
        2. For each iteration until max_iterations or convergence:
           a. Generate reflective dataset from traces
           b. Propose new candidate text
           c. Evaluate proposal
           d. Accept if improves above threshold
           e. Record iteration
        3. Return frozen EvolutionResult
        4. On ``KeyboardInterrupt`` or ``asyncio.CancelledError``, return
           partial result with best-so-far components
        5. On an ``EvolutionError`` after the baseline, attach a partial
           result to the error and re-raise it

        Returns:
            EvolutionResult containing evolution metrics and components.
            On normal completion, ``stop_reason`` reflects the termination
            condition. On interrupt, a partial result is returned with
            ``StopReason.KEYBOARD_INTERRUPT`` or ``StopReason.CANCELLED``.

        Raises:
            KeyboardInterrupt: Re-raised if interrupt occurs before baseline
                evaluation completes (no meaningful partial result possible).
            asyncio.CancelledError: Re-raised if cancellation occurs before
                baseline evaluation completes.
            EvolutionError: Re-raised when it aborts the run, for example a
                non-retryable ``ReflectionError`` (also raised after
                ``config.max_consecutive_reflection_errors`` identical
                reflection errors in a row). When the baseline was
                scored, its ``partial_result`` holds an ``EvolutionResult``
                with ``StopReason.ERROR``, the recorded iterations and the
                best candidate so far; otherwise it stays ``None``.
            Exception: Adapter ``Exception`` subclasses propagate unchanged.

        Examples:
            Running evolution:

            ```python
            result = await engine.run()
            print(f"Improved: {result.improved}")
            print(f"Best score: {result.final_score}")
            ```

        Notes:
            Outputs a frozen EvolutionResult after completing the evolution
            loop. Engine instance should not be reused after run() completes.
            Method is idempotent if called multiple times (restarts fresh):
            the evaluation counters and the scored-candidate map used for
            duplicate detection are cleared before the baseline runs.
            Fail-fast behavior: adapter ``Exception`` subclasses propagate
            unchanged. ``KeyboardInterrupt`` and ``asyncio.CancelledError``
            (``BaseException`` subclasses) are caught and converted to partial
            results with appropriate ``StopReason``. An ``EvolutionError`` is
            logged as ``evolution.aborted`` at error level, without a
            traceback, and re-raised; after the
            baseline it carries ``partial_result`` whose
            ``total_iterations`` counts recorded iterations only, since the
            aborted iteration was never recorded. Logs seed value at start
            for reproducibility tracking, and logs
            ``reflection.minibatch.enabled`` when a reflection minibatch
            smaller than the trainset is in effect; with a separate valset a
            proposal that passes that gate is scored on the valset without a
            full trainset pass, and ``trainset_score`` is then the mean over
            the accepted candidate's sampled rows. ``_reset_run_state``
            resets the evaluation counter, the pending failure counter, the
            engine state, the pending and run token rollups and the pending
            and run reflection rollups at start, so a reused engine
            never attaches an earlier run's state to ``partial_result``.
            With ``config.resume`` it then restores
            the checkpoint through ``_resume_from_checkpoint``, which sets
            the counters and scored map from the file, and the loop skips
            the baseline. An exception raised by
            ``config.proposal_validator`` is not an ``EvolutionError``, so it
            propagates unchanged without a partial result; a rejection it
            returns is recorded with ``skip_reason="proposal_rejected"``.
        """
        logger.info("engine.start", seed=self.config.seed)
        if self._effective_minibatch_size() is not None:
            logger.info(
                "reflection.minibatch.enabled",
                size=self.config.reflection_minibatch_size,
                trainset_size=len(self._trainset),
            )

        self._reset_run_state()
        if self.config.resume:
            self._resume_from_checkpoint()

        # Setup stopper lifecycle (T023)
        setup_stoppers = self._setup_stoppers()

        try:
            return await self._run_evolution_loop()
        except EvolutionError as error:
            logger.error(  # noqa: TRY400  # re-raised below with partial_result attached
                "evolution.aborted",
                iteration=self._state.iteration if self._state else 0,
                error_type=type(error).__name__,
                error=str(error),
            )
            if self._state is not None:
                # The aborted iteration was never recorded
                self._state.iteration = len(self._state.iteration_history)
                error.partial_result = self._build_result(stop_reason=StopReason.ERROR)
            raise
        except KeyboardInterrupt:
            logger.info(
                "evolution.interrupted",
                iteration=self._state.iteration if self._state else 0,
            )
            if self._state is None:
                raise
            return self._build_result(stop_reason=StopReason.KEYBOARD_INTERRUPT)
        except asyncio.CancelledError:
            logger.info(
                "evolution.cancelled",
                iteration=self._state.iteration if self._state else 0,
            )
            if self._state is None:
                raise
            return self._build_result(stop_reason=StopReason.CANCELLED)
        finally:
            # Cleanup stopper lifecycle (T024)
            self._cleanup_stoppers(setup_stoppers)

    async def _run_evolution_loop(self) -> EvolutionResult:  # noqa: C901, PLR0912, PLR0915  # decomposition tracked in #507
        """Run propose-evaluate-accept iterations until a stop condition holds.

        This method contains the actual evolution loop logic, separated
        from lifecycle management for clean try/finally handling.

        Returns:
            EvolutionResult with evolution outcomes.

        Raises:
            ReflectionError: If proposing raises a non-retryable
                ``ReflectionError``, or after
                ``config.max_consecutive_reflection_errors`` identical
                retryable ones in a row; ``run()`` attaches the partial result.

        Notes:
            Only called from run(). Handles the evolution loop body
            while run() manages stopper lifecycle. The loop tracks
            ``StopReason`` to report why evolution terminated.
            The parent index ``_propose_mutation`` returns is the proposal's
            only genealogy edge in the Pareto state; the selector is not
            asked a second time.
            Each iteration records ``reflection_reasoning`` from the
            adapter's proposer via a ``getattr`` chain when available.
            Each proposal's reflection batch is handed to scoring so a
            valset that is the trainset is not evaluated twice.
            An ``EmptyProposalError`` from proposing is recorded as a
            skipped iteration (``skip_reason="empty_proposal"``) that counts
            toward stagnation; the loop then checks stop conditions and
            continues. A ``ReflectionTimeoutError`` is handled the same way
            with ``skip_reason="reflection_timeout"``, and a retryable
            ``ReflectionError`` with ``skip_reason="reflection_error"``; a
            non-retryable ``ReflectionError`` propagates to ``run()``,
            which attaches the partial result. An
            ``IncompleteProposalError`` is recorded with
            ``skip_reason="incomplete_proposal"``, counts toward stagnation
            and is followed by the stop check. A proposal that
            ``config.proposal_validator`` rejects is recorded, before schema
            validation, with ``skip_reason="proposal_rejected"`` and the
            validator's reason, counts toward stagnation and is followed by
            the stop check; an exception from the validator propagates. A
            proposal whose
            ``output_schema`` text fails validation is recorded the same way
            with ``skip_reason="schema_validation_failed"``, counts toward
            stagnation and is followed by the stop check. A proposal whose
            ``Candidate.id`` was already scored (baseline, proposal or merge)
            is recorded with
            ``skip_reason="duplicate"`` and its stored score instead of being
            evaluated. With a reflection minibatch in effect, a proposal
            that does not beat its parent on the iteration's sampled rows is
            recorded with ``skip_reason="minibatch_rejected"`` and not
            evaluated further. An evaluated proposal's record carries the
            scoring batch's row count as ``rows_evaluated``. A proposal that
            passes is evaluated on the
            full trainset when the valset is the trainset (that batch is
            reused for scoring); with a separate valset the gate's batch is
            its reflection batch and only the valset is evaluated (see
            ``_evaluate_passed_proposal``). The trainset rows of each cached
            batch are stored beside it for the next gate and reflection. A
            merge candidate is always evaluated on the full trainset, so
            its rows are None. A merge candidate that is a duplicate or has an
            invalid schema is not evaluated, and the iteration's own record
            and stop check still follow. Each iteration starts with no
            pending failures, a zero pending token rollup and no pending
            reflection usage, so an unrecorded iteration's failures and tokens
            do not carry into the next record. After the proposal returns,
            or raises one of the skip errors, the adapter proposer's
            ``last_token_usage`` is read through ``_take_proposer_usage`` so
            the iteration's record carries its reflection cost.
            Each accept decision is logged as ``proposal.accepted`` or
            ``proposal.rejected`` with the proposal's ``candidate_id``.
            Every recorded iteration, skipped or not, reaches
            ``config.on_iteration`` before the stop check.
            State restored from a checkpoint replaces the baseline
            evaluation.
        """
        # A restored checkpoint already holds the baseline
        if not self._restored:
            await self._initialize_baseline()
        assert self._state is not None, "Engine state not initialized"  # noqa: S101  # narrows state that run() sets before this call

        # Evolution loop
        stop_reason = self._should_stop()
        while stop_reason is None:
            self._state.iteration += 1
            # Failures from an unrecorded previous iteration do not carry over
            self._pending_failed_evaluations = 0
            self._pending_token_usage = _ZERO_TOKENS
            self._pending_reflection_usage = None

            # Propose mutation (returns candidate and list of components evolved)
            try:
                (
                    proposal,
                    evolved_components_list,
                    parent_idx,
                ) = await self._propose_mutation()
            except EmptyProposalError as error:
                # Empty reflection after retry: failed iteration, not fatal
                self._take_proposer_usage()
                await self._record_empty_proposal(error)
                stop_reason = self._should_stop()
                continue
            except ReflectionTimeoutError as error:
                # Reflection timed out: skipped iteration, not fatal
                self._take_proposer_usage()
                await self._record_reflection_timeout(error)
                stop_reason = self._should_stop()
                continue
            except ReflectionError as error:
                # A programming or client error will not clear on its own
                if not error.retryable:
                    raise
                # Provider error survived the proposer's retry: skip it
                self._take_proposer_usage()
                await self._record_reflection_error(error)
                stop_reason = self._should_stop()
                continue
            except IncompleteProposalError as error:
                # Truncated reflection output: skipped iteration, not fatal
                self._take_proposer_usage()
                await self._record_incomplete_proposal(error)
                stop_reason = self._should_stop()
                continue
            self._take_proposer_usage()

            # Caller's validator rejected the text: record it, no evaluation
            if await self._record_if_rejected(proposal, evolved_components_list):
                stop_reason = self._should_stop()
                continue

            # Validate schema component if present (reject invalid early)
            if not self._validate_schema_component(proposal):
                # Invalid schema: record the skip, no evaluation
                await self._record_schema_validation_skip(
                    proposal, evolved_components_list
                )
                stop_reason = self._should_stop()
                continue

            # Already scored this run: reuse its score, no evaluation
            if await self._record_if_duplicate(proposal, evolved_components_list):
                stop_reason = self._should_stop()
                continue

            # Beat the parent on a trainset minibatch before the full pass
            if not await self._gate_on_minibatch(proposal, evolved_components_list):
                stop_reason = self._should_stop()
                continue

            # Reflection batch: the gate's rows with a separate valset,
            # otherwise the full trainset pass
            (
                reflection_score,
                reflection_batch,
                reflection_rows,
            ) = await self._evaluate_passed_proposal(proposal)
            proposal_score, scoring_batch, eval_indices = await self._evaluate_scoring(
                proposal,
                reflection_batch=reflection_batch if reflection_rows is None else None,
            )

            candidate_idx = None
            if self._pareto_state is not None:
                # Use eval_indices returned from _evaluate_scoring (T066)
                # Prepare objective scores if available (T068)
                objective_scores: dict[str, float] | None = None
                per_example_objective_scores: dict[int, dict[str, float]] | None = None

                if scoring_batch.objective_scores is not None:
                    if self.config.frontier_type in (
                        FrontierType.OBJECTIVE,
                        FrontierType.HYBRID,
                    ):
                        # Aggregate objective scores across evaluated examples
                        objective_scores_accum: dict[str, list[float]] = {}
                        for obj_scores in scoring_batch.objective_scores:
                            for obj_name, obj_score in obj_scores.items():
                                objective_scores_accum.setdefault(obj_name, []).append(
                                    obj_score
                                )
                        # Take mean per objective
                        objective_scores = {
                            obj_name: fmean(scores)
                            for obj_name, scores in objective_scores_accum.items()
                        }

                    if self.config.frontier_type == FrontierType.CARTESIAN:
                        # For CARTESIAN, need per-example objective scores mapped to valset indices
                        per_example_objective_scores = {
                            eval_indices[i]: scoring_batch.objective_scores[i]
                            for i in range(len(eval_indices))
                        }
                        # Also need aggregated for validation
                        objective_scores_by_name: dict[str, list[float]] = {}
                        for obj_scores in scoring_batch.objective_scores:
                            for obj_name, obj_score in obj_scores.items():
                                objective_scores_by_name.setdefault(
                                    obj_name, []
                                ).append(obj_score)
                        objective_scores = {
                            obj_name: fmean(scores)
                            for obj_name, scores in objective_scores_by_name.items()
                        }

                # The genealogy edge is the parent the reflector rewrote
                parent_indices = [parent_idx] if parent_idx is not None else None

                # Pass scores with correct index mapping (T066)
                candidate_idx = self._pareto_state.add_candidate(
                    proposal,
                    scoring_batch.scores,
                    score_indices=eval_indices,
                    objective_scores=objective_scores,
                    per_example_objective_scores=per_example_objective_scores,
                    parent_indices=parent_indices,
                    logger=logger,
                )
                self._candidate_eval_batches[candidate_idx] = reflection_batch
                self._candidate_eval_rows[candidate_idx] = reflection_rows
                logger.info(
                    "pareto_frontier.candidate_added",
                    candidate_idx=candidate_idx,
                    candidate_id=proposal.id,
                    iteration=self._state.iteration,
                )

            # Calculate valset mean using only evaluated scores (T067)
            valset_mean = (
                sum(scoring_batch.scores) / len(scoring_batch.scores)
                if scoring_batch.scores
                else 0.0
            )

            # Accept if improves above threshold
            accepted = self._should_accept(proposal_score, self._state.best_score)
            if accepted:
                logger.info(
                    "proposal.accepted",
                    iteration=self._state.iteration,
                    candidate_id=proposal.id,
                    score=proposal_score,
                    best_score=self._state.best_score,
                )
                self._accept_proposal(
                    proposal,
                    proposal_score,
                    reflection_batch,
                    candidate_idx=candidate_idx,
                    eval_rows=reflection_rows,
                    reflection_score=reflection_score,
                    valset_mean=valset_mean,
                    objective_scores=scoring_batch.objective_scores,
                )
                # Schedule merge if enabled
                if (
                    self.config.use_merge
                    and self._merge_proposer is not None
                    and self._merge_invocations < self.config.max_merge_invocations
                ):
                    self._merges_due += 1
                    logger.debug(
                        "merge_scheduling.merge_scheduled",
                        iteration=self._state.iteration,
                        candidate_id=proposal.id,
                        merges_due=self._merges_due,
                    )
            else:
                logger.debug(
                    "proposal.rejected",
                    iteration=self._state.iteration,
                    candidate_id=proposal.id,
                    score=proposal_score,
                    best_score=self._state.best_score,
                )
                # Increment stagnation counter on rejection
                self._state.stagnation_counter += 1

            # Attempt merge if scheduled
            if (
                self._merges_due > 0
                and self._merge_proposer is not None
                and self._pareto_state is not None
                and self._merge_invocations < self.config.max_merge_invocations
            ):
                merge_result = await self._merge_proposer.propose(self._pareto_state)
                # A skipped merge (invalid schema or already scored) is not
                # evaluated; the iteration is still recorded below
                if merge_result is not None and self._start_merge(merge_result):
                    # Evaluate merge proposal
                    (
                        merge_reflection_score,
                        merge_reflection_batch,
                    ) = await self._evaluate_reflection(merge_result.candidate)
                    (
                        merge_proposal_score,
                        merge_scoring_batch,
                        merge_eval_indices,
                    ) = await self._evaluate_scoring(
                        merge_result.candidate,
                        reflection_batch=merge_reflection_batch,
                    )

                    # Add merge candidate to ParetoState
                    merge_candidate_idx = None
                    assert self._pareto_state is not None, (  # noqa: S101  # narrows state that run() sets before this call
                        "Pareto state not initialized"
                    )
                    merge_objective_scores: dict[str, float] | None = None
                    merge_per_example_objective_scores: (
                        dict[int, dict[str, float]] | None
                    ) = None

                    if merge_scoring_batch.objective_scores is not None:
                        if self.config.frontier_type in (
                            FrontierType.OBJECTIVE,
                            FrontierType.HYBRID,
                        ):
                            merge_objective_scores_accum: dict[str, list[float]] = {}
                            for obj_scores in merge_scoring_batch.objective_scores:
                                for obj_name, obj_score in obj_scores.items():
                                    merge_objective_scores_accum.setdefault(
                                        obj_name, []
                                    ).append(obj_score)
                            merge_objective_scores = {
                                obj_name: fmean(scores)
                                for obj_name, scores in merge_objective_scores_accum.items()
                            }

                        if self.config.frontier_type == FrontierType.CARTESIAN:
                            merge_per_example_objective_scores = {
                                merge_eval_indices[
                                    i
                                ]: merge_scoring_batch.objective_scores[i]
                                for i in range(len(merge_eval_indices))
                            }
                            merge_objective_scores_by_name: dict[str, list[float]] = {}
                            for obj_scores in merge_scoring_batch.objective_scores:
                                for obj_name, obj_score in obj_scores.items():
                                    merge_objective_scores_by_name.setdefault(
                                        obj_name, []
                                    ).append(obj_score)
                            merge_objective_scores = {
                                obj_name: fmean(scores)
                                for obj_name, scores in merge_objective_scores_by_name.items()
                            }

                    merge_candidate_idx = self._pareto_state.add_candidate(
                        merge_result.candidate,
                        merge_scoring_batch.scores,
                        score_indices=merge_eval_indices,
                        objective_scores=merge_objective_scores,
                        per_example_objective_scores=merge_per_example_objective_scores,
                        parent_indices=merge_result.parent_indices,
                        logger=logger,
                    )
                    self._candidate_eval_batches[merge_candidate_idx] = (
                        merge_reflection_batch
                    )
                    self._candidate_eval_rows[merge_candidate_idx] = None

                    merge_valset_mean = (
                        sum(merge_scoring_batch.scores)
                        / len(merge_scoring_batch.scores)
                        if merge_scoring_batch.scores
                        else 0.0
                    )

                    # Accept merge if improves
                    merge_accepted = self._should_accept(
                        merge_proposal_score, self._state.best_score
                    )
                    if merge_accepted:
                        self._accept_proposal(
                            merge_result.candidate,
                            merge_proposal_score,
                            merge_reflection_batch,
                            candidate_idx=merge_candidate_idx,
                            reflection_score=merge_reflection_score,
                            valset_mean=merge_valset_mean,
                            objective_scores=merge_scoring_batch.objective_scores,
                        )
                        logger.info(
                            "merge_scheduling.merge_accepted",
                            iteration=self._state.iteration,
                            candidate_id=merge_result.candidate.id,
                            merge_score=merge_proposal_score,
                        )
                    else:
                        logger.debug(
                            "merge_scheduling.merge_rejected",
                            iteration=self._state.iteration,
                            candidate_id=merge_result.candidate.id,
                            merge_score=merge_proposal_score,
                            best_score=self._state.best_score,
                        )
                elif merge_result is None:
                    # Merge not possible, decrement counter
                    if self._merges_due > 0:
                        self._merges_due -= 1

            # Record iteration with actual evolved component name (T033)
            # For single-component evolution, use the first (and only) component.
            # For multi-component round-robin, this tracks which component was
            # evolved in this iteration.
            if evolved_components_list:
                evolved_component_name = evolved_components_list[0]
            else:
                # Empty list indicates logic error - use first key from proposal
                logger.warning(
                    "engine.empty_evolved_components_list",
                    iteration=self._state.iteration,
                    proposal_keys=list(proposal.components.keys()),
                )
                evolved_component_name = next(iter(proposal.components.keys()))
            await self._record_iteration(
                score=proposal_score,
                component_text=proposal.components.get(evolved_component_name, ""),
                evolved_component=evolved_component_name,
                accepted=accepted,
                objective_scores=scoring_batch.objective_scores,
                # TODO: Surface last_reasoning through adapter protocol instead
                # of reaching into private _proposer attribute. Safe (defaults
                # to None) but couples engine to adapter internals.
                reflection_reasoning=getattr(
                    getattr(self.adapter, "_proposer", None),
                    "last_reasoning",
                    None,
                ),
                candidate_id=proposal.id,
                parent_ids=_parent_ids(proposal),
                rows_evaluated=len(scoring_batch.scores),
            )

            stop_reason = self._should_stop()

        # Build and return result
        return self._build_result(stop_reason=stop_reason)

pareto_state property

pareto_state: ParetoState | None

Return the current Pareto state, if initialized.

__init__

__init__(
    adapter: AsyncGEPAAdapter[
        DataInst, Trajectory, RolloutOutput
    ],
    config: EvolutionConfig,
    initial_candidate: Candidate,
    batch: list[DataInst],
    valset: list[DataInst] | None = None,
    candidate_selector: CandidateSelectorProtocol
    | None = None,
    component_selector: ComponentSelectorProtocol
    | None = None,
    evaluation_policy: EvaluationPolicyProtocol
    | None = None,
    merge_proposer: ProposerProtocol | None = None,
    rng: Random | None = None,
) -> None

Initialize the evolution engine.

PARAMETER DESCRIPTION
adapter

Implementation of AsyncGEPAAdapter protocol for evaluation and proposal generation.

TYPE: AsyncGEPAAdapter[DataInst, Trajectory, RolloutOutput]

config

Evolution parameters controlling iterations, thresholds, and early stopping.

TYPE: EvolutionConfig

initial_candidate

Starting candidate with at least one component.

TYPE: Candidate

batch

Trainset data instances for reflection and mutation.

TYPE: list[DataInst]

valset

Optional validation data for scoring candidates. Defaults to trainset when omitted. Must be non-empty if provided.

TYPE: list[DataInst] | None DEFAULT: None

candidate_selector

Optional selector strategy for Pareto-aware candidate sampling. When provided, initializes ParetoState for multi-objective tracking.

TYPE: CandidateSelectorProtocol | None DEFAULT: None

component_selector

Optional selector strategy for choosing which components to update. Defaults to RoundRobinComponentSelector.

TYPE: ComponentSelectorProtocol | None DEFAULT: None

evaluation_policy

Optional policy for selecting which validation examples to evaluate per iteration. Defaults to FullEvaluationPolicy. Requires candidate_selector, because the policy acts only through the Pareto state the selector creates.

TYPE: EvaluationPolicyProtocol | None DEFAULT: None

merge_proposer

Optional proposer for merge operations. If provided and config.use_merge is True, merge proposals will be attempted after successful mutations.

TYPE: ProposerProtocol | None DEFAULT: None

rng

Optional seeded random.Random instance for deterministic engine decisions. When provided, used for the auto-created merge proposer and for drawing reflection minibatch rows. None preserves current random behavior, and minibatch rows are then drawn from a random.Random(config.seed).

TYPE: Random | None DEFAULT: None

RAISES DESCRIPTION
ValueError

If batch is empty, valset is provided but empty, or initial_candidate has no components.

ConfigurationError

If evaluation_policy is provided without a candidate_selector, including an explicit FullEvaluationPolicy, or if config.checkpoint_path is set together with a candidate_selector (Pareto state is not checkpointed).

Examples:

Creating an engine:

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

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

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

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

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

    Examples:
        Creating an engine:

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

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

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

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

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

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

run async

run() -> EvolutionResult

Execute the evolution loop.

Runs the core evolution loop: 1. Evaluate baseline candidate 2. For each iteration until max_iterations or convergence: a. Generate reflective dataset from traces b. Propose new candidate text c. Evaluate proposal d. Accept if improves above threshold e. Record iteration 3. Return frozen EvolutionResult 4. On KeyboardInterrupt or asyncio.CancelledError, return partial result with best-so-far components 5. On an EvolutionError after the baseline, attach a partial result to the error and re-raise it

RETURNS DESCRIPTION
EvolutionResult

EvolutionResult containing evolution metrics and components.

EvolutionResult

On normal completion, stop_reason reflects the termination

EvolutionResult

condition. On interrupt, a partial result is returned with

EvolutionResult

StopReason.KEYBOARD_INTERRUPT or StopReason.CANCELLED.

RAISES DESCRIPTION
KeyboardInterrupt

Re-raised if interrupt occurs before baseline evaluation completes (no meaningful partial result possible).

CancelledError

Re-raised if cancellation occurs before baseline evaluation completes.

EvolutionError

Re-raised when it aborts the run, for example a non-retryable ReflectionError (also raised after config.max_consecutive_reflection_errors identical reflection errors in a row). When the baseline was scored, its partial_result holds an EvolutionResult with StopReason.ERROR, the recorded iterations and the best candidate so far; otherwise it stays None.

Exception

Adapter Exception subclasses propagate unchanged.

Examples:

Running evolution:

result = await engine.run()
print(f"Improved: {result.improved}")
print(f"Best score: {result.final_score}")
Notes

Outputs a frozen EvolutionResult after completing the evolution loop. Engine instance should not be reused after run() completes. Method is idempotent if called multiple times (restarts fresh): the evaluation counters and the scored-candidate map used for duplicate detection are cleared before the baseline runs. Fail-fast behavior: adapter Exception subclasses propagate unchanged. KeyboardInterrupt and asyncio.CancelledError (BaseException subclasses) are caught and converted to partial results with appropriate StopReason. An EvolutionError is logged as evolution.aborted at error level, without a traceback, and re-raised; after the baseline it carries partial_result whose total_iterations counts recorded iterations only, since the aborted iteration was never recorded. Logs seed value at start for reproducibility tracking, and logs reflection.minibatch.enabled when a reflection minibatch smaller than the trainset is in effect; with a separate valset a proposal that passes that gate is scored on the valset without a full trainset pass, and trainset_score is then the mean over the accepted candidate's sampled rows. _reset_run_state resets the evaluation counter, the pending failure counter, the engine state, the pending and run token rollups and the pending and run reflection rollups at start, so a reused engine never attaches an earlier run's state to partial_result. With config.resume it then restores the checkpoint through _resume_from_checkpoint, which sets the counters and scored map from the file, and the loop skips the baseline. An exception raised by config.proposal_validator is not an EvolutionError, so it propagates unchanged without a partial result; a rejection it returns is recorded with skip_reason="proposal_rejected".

Source code in src/gepa_adk/engine/async_engine.py
async def run(self) -> EvolutionResult:
    """Execute the evolution loop.

    Runs the core evolution loop:
    1. Evaluate baseline candidate
    2. For each iteration until max_iterations or convergence:
       a. Generate reflective dataset from traces
       b. Propose new candidate text
       c. Evaluate proposal
       d. Accept if improves above threshold
       e. Record iteration
    3. Return frozen EvolutionResult
    4. On ``KeyboardInterrupt`` or ``asyncio.CancelledError``, return
       partial result with best-so-far components
    5. On an ``EvolutionError`` after the baseline, attach a partial
       result to the error and re-raise it

    Returns:
        EvolutionResult containing evolution metrics and components.
        On normal completion, ``stop_reason`` reflects the termination
        condition. On interrupt, a partial result is returned with
        ``StopReason.KEYBOARD_INTERRUPT`` or ``StopReason.CANCELLED``.

    Raises:
        KeyboardInterrupt: Re-raised if interrupt occurs before baseline
            evaluation completes (no meaningful partial result possible).
        asyncio.CancelledError: Re-raised if cancellation occurs before
            baseline evaluation completes.
        EvolutionError: Re-raised when it aborts the run, for example a
            non-retryable ``ReflectionError`` (also raised after
            ``config.max_consecutive_reflection_errors`` identical
            reflection errors in a row). When the baseline was
            scored, its ``partial_result`` holds an ``EvolutionResult``
            with ``StopReason.ERROR``, the recorded iterations and the
            best candidate so far; otherwise it stays ``None``.
        Exception: Adapter ``Exception`` subclasses propagate unchanged.

    Examples:
        Running evolution:

        ```python
        result = await engine.run()
        print(f"Improved: {result.improved}")
        print(f"Best score: {result.final_score}")
        ```

    Notes:
        Outputs a frozen EvolutionResult after completing the evolution
        loop. Engine instance should not be reused after run() completes.
        Method is idempotent if called multiple times (restarts fresh):
        the evaluation counters and the scored-candidate map used for
        duplicate detection are cleared before the baseline runs.
        Fail-fast behavior: adapter ``Exception`` subclasses propagate
        unchanged. ``KeyboardInterrupt`` and ``asyncio.CancelledError``
        (``BaseException`` subclasses) are caught and converted to partial
        results with appropriate ``StopReason``. An ``EvolutionError`` is
        logged as ``evolution.aborted`` at error level, without a
        traceback, and re-raised; after the
        baseline it carries ``partial_result`` whose
        ``total_iterations`` counts recorded iterations only, since the
        aborted iteration was never recorded. Logs seed value at start
        for reproducibility tracking, and logs
        ``reflection.minibatch.enabled`` when a reflection minibatch
        smaller than the trainset is in effect; with a separate valset a
        proposal that passes that gate is scored on the valset without a
        full trainset pass, and ``trainset_score`` is then the mean over
        the accepted candidate's sampled rows. ``_reset_run_state``
        resets the evaluation counter, the pending failure counter, the
        engine state, the pending and run token rollups and the pending
        and run reflection rollups at start, so a reused engine
        never attaches an earlier run's state to ``partial_result``.
        With ``config.resume`` it then restores
        the checkpoint through ``_resume_from_checkpoint``, which sets
        the counters and scored map from the file, and the loop skips
        the baseline. An exception raised by
        ``config.proposal_validator`` is not an ``EvolutionError``, so it
        propagates unchanged without a partial result; a rejection it
        returns is recorded with ``skip_reason="proposal_rejected"``.
    """
    logger.info("engine.start", seed=self.config.seed)
    if self._effective_minibatch_size() is not None:
        logger.info(
            "reflection.minibatch.enabled",
            size=self.config.reflection_minibatch_size,
            trainset_size=len(self._trainset),
        )

    self._reset_run_state()
    if self.config.resume:
        self._resume_from_checkpoint()

    # Setup stopper lifecycle (T023)
    setup_stoppers = self._setup_stoppers()

    try:
        return await self._run_evolution_loop()
    except EvolutionError as error:
        logger.error(  # noqa: TRY400  # re-raised below with partial_result attached
            "evolution.aborted",
            iteration=self._state.iteration if self._state else 0,
            error_type=type(error).__name__,
            error=str(error),
        )
        if self._state is not None:
            # The aborted iteration was never recorded
            self._state.iteration = len(self._state.iteration_history)
            error.partial_result = self._build_result(stop_reason=StopReason.ERROR)
        raise
    except KeyboardInterrupt:
        logger.info(
            "evolution.interrupted",
            iteration=self._state.iteration if self._state else 0,
        )
        if self._state is None:
            raise
        return self._build_result(stop_reason=StopReason.KEYBOARD_INTERRUPT)
    except asyncio.CancelledError:
        logger.info(
            "evolution.cancelled",
            iteration=self._state.iteration if self._state else 0,
        )
        if self._state is None:
            raise
        return self._build_result(stop_reason=StopReason.CANCELLED)
    finally:
        # Cleanup stopper lifecycle (T024)
        self._cleanup_stoppers(setup_stoppers)

MergeProposer

Proposer that combines two Pareto-optimal candidates via genetic crossover.

Selects two candidates from the frontier that share a common ancestor, identifies which components each improved, and creates a merged candidate that combines improvements from both branches.

ATTRIBUTE DESCRIPTION
rng

Random number generator for candidate selection.

TYPE: Random

val_overlap_floor

Minimum overlapping validation coverage required.

TYPE: int

max_attempts

Maximum merge attempts before giving up.

TYPE: int

attempted_merges

Set of attempted merge triplets to prevent duplicates.

TYPE: set[AncestorLog]

Examples:

Creating a merge proposer:

proposer = MergeProposer(rng=random.Random(42))
result = await proposer.propose(state)
if result:
    print(f"Merged from parents {result.parent_indices}")

Notes: A proposer that combines two Pareto-optimal candidates via genetic crossover. Selects candidates from the frontier that share a common ancestor and merges their complementary component improvements.

Source code in src/gepa_adk/engine/merge_proposer.py
class MergeProposer:
    """Proposer that combines two Pareto-optimal candidates via genetic crossover.

    Selects two candidates from the frontier that share a common ancestor,
    identifies which components each improved, and creates a merged candidate
    that combines improvements from both branches.

    Attributes:
        rng (random.Random): Random number generator for candidate selection.
        val_overlap_floor (int): Minimum overlapping validation coverage required.
        max_attempts (int): Maximum merge attempts before giving up.
        attempted_merges (set[AncestorLog]): Set of attempted merge triplets to prevent duplicates.

    Examples:
        Creating a merge proposer:

        ```python
        proposer = MergeProposer(rng=random.Random(42))
        result = await proposer.propose(state)
        if result:
            print(f"Merged from parents {result.parent_indices}")
        ```
    Notes:
        A proposer that combines two Pareto-optimal candidates via genetic crossover.
        Selects candidates from the frontier that share a common ancestor and merges
        their complementary component improvements.
    """

    def __init__(
        self,
        rng: random.Random,
        val_overlap_floor: int = 5,
        max_attempts: int = 10,
    ) -> None:
        """Initialize MergeProposer.

        Args:
            rng: Random number generator for candidate selection.
            val_overlap_floor: Minimum overlapping validation examples required.
            max_attempts: Maximum merge attempts before giving up.

        Notes:
            The ``attempted_merges`` set starts empty so previously tried
            parent pairs can be skipped on subsequent calls.
        """
        self.rng = rng
        self.val_overlap_floor = val_overlap_floor
        self.max_attempts = max_attempts
        self.attempted_merges: set[AncestorLog] = set()

    async def propose(
        self,
        state: ParetoState,
        eval_batch: object | None = None,  # Ignored for merge proposals
    ) -> ProposalResult | None:
        """Attempt to merge two frontier candidates.

        Args:
            state (ParetoState): Current Pareto state with candidates, scores, and genealogy.
                Must contain at least 2 candidates on the Pareto frontier with a shared
                common ancestor for merge to succeed.
            eval_batch (object | None): Ignored for merge proposals. Merge operations
                do not require evaluation batch data as they combine existing candidates.

        Returns:
            ProposalResult | None: ProposalResult with merged candidate and both parent indices,
            or None if merge not possible (e.g., no common ancestor, insufficient frontier,
            or no complementary component changes).

        Examples:
            Proposing a merge from evolution state:

            ```python
            proposer = MergeProposer(rng=random.Random(42))
            result = await proposer.propose(state)
            if result:
                print(f"Merged from parents {result.parent_indices}")
                print(f"Ancestor: {result.metadata['ancestor_idx']}")
            ```

        Notes:
            Operations select candidates from Pareto frontier only. Requires common ancestor
            and complementary component changes for successful merge. Validates
            minimum validation overlap before merging. The common ancestor is
            found over ``state.parent_indices``, where each mutation's edge
            points at the candidate the reflector actually rewrote.
        """
        # Find suitable merge candidates
        merge_candidates = self._find_merge_candidates(state)
        if merge_candidates is None:
            logger.debug("merge_proposer.no_candidates", reason="no_suitable_pair")
            return None

        parent1_idx, parent2_idx, ancestor_idx = merge_candidates

        # Get candidate components
        ancestor = state.candidates[ancestor_idx]
        parent1 = state.candidates[parent1_idx]
        parent2 = state.candidates[parent2_idx]

        # Check if merge is desirable (complementary changes)
        if not has_desirable_predictors(
            ancestor.components, parent1.components, parent2.components
        ):
            logger.debug(
                "merge_proposer.no_desirable_predictors",
                parent1_idx=parent1_idx,
                parent2_idx=parent2_idx,
                ancestor_idx=ancestor_idx,
            )
            return None

        # Check validation overlap
        scores1 = state.candidate_scores.get(parent1_idx, {})
        scores2 = state.candidate_scores.get(parent2_idx, {})
        overlap = set(scores1.keys()) & set(scores2.keys())
        valid_overlap = {idx for idx in overlap if isinstance(idx, int) and idx >= 0}
        if len(valid_overlap) < self.val_overlap_floor:
            logger.debug(
                "merge_proposer.insufficient_overlap",
                parent1_idx=parent1_idx,
                parent2_idx=parent2_idx,
                overlap_count=len(valid_overlap),
                required=self.val_overlap_floor,
            )
            return None

        # Calculate average scores for component selection
        avg_score1 = fmean(scores1.values()) if scores1 else 0.0
        avg_score2 = fmean(scores2.values()) if scores2 else 0.0

        # Merge components
        merged_components = self._merge_components(
            ancestor.components,
            parent1.components,
            parent2.components,
            avg_score1,
            avg_score2,
        )

        # Create merged candidate
        merged_candidate = Candidate(
            components=merged_components,
            generation=max(parent1.generation, parent2.generation) + 1,
            parent_ids=[parent1_idx, parent2_idx],
        )

        # Log successful merge
        logger.info(
            "merge_proposer.merge_success",
            parent1_idx=parent1_idx,
            parent2_idx=parent2_idx,
            ancestor_idx=ancestor_idx,
            components_merged=list(merged_components.keys()),
        )

        return ProposalResult(
            candidate=merged_candidate,
            parent_indices=[parent1_idx, parent2_idx],
            tag="merge",
            metadata={"ancestor_idx": ancestor_idx},
        )

    def _find_merge_candidates(
        self,
        state: ParetoState,
    ) -> tuple[int, int, int] | None:
        """Find two candidates suitable for merging.

        Args:
            state (ParetoState): Current Pareto state.

        Returns:
            Tuple of (parent1_idx, parent2_idx, ancestor_idx), or None when
            the frontier holds fewer than two candidates or no suitable pair
            is found within ``max_attempts`` samples.

        Notes:
            Searches for suitable merge candidates from the Pareto frontier.
            Requires common ancestor and prevents duplicate merge attempts.
        """
        # Get frontier candidates (non-dominated)
        frontier_candidates = state.frontier.get_non_dominated()

        if len(frontier_candidates) < 2:  # noqa: PLR2004  # a merge needs two distinct parents
            logger.debug(
                "merge_proposer.insufficient_frontier",
                frontier_size=len(frontier_candidates),
            )
            return None

        # Try to find a suitable pair
        for _ in range(self.max_attempts):
            # Sample two different candidates from frontier
            candidate_list = list(frontier_candidates)
            if len(candidate_list) < 2:  # noqa: PLR2004  # a merge needs two distinct parents
                return None

            parent1_idx = self.rng.choice(candidate_list)
            parent2_idx = self.rng.choice(candidate_list)

            # Must be different candidates
            if parent1_idx == parent2_idx:
                continue

            # Ensure consistent ordering for deduplication
            if parent1_idx > parent2_idx:
                parent1_idx, parent2_idx = parent2_idx, parent1_idx

            # Find common ancestor
            ancestor_idx = find_common_ancestor(
                parent1_idx, parent2_idx, state.parent_indices
            )

            if ancestor_idx is None:
                continue

            # Check if already attempted
            merge_log: AncestorLog = (parent1_idx, parent2_idx, ancestor_idx)
            if merge_log in self.attempted_merges:
                continue

            # Check ancestor score constraint (ancestor should not be better than descendants)
            ancestor_scores = state.candidate_scores.get(ancestor_idx, {})
            parent1_scores = state.candidate_scores.get(parent1_idx, {})
            parent2_scores = state.candidate_scores.get(parent2_idx, {})

            if ancestor_scores and parent1_scores and parent2_scores:
                ancestor_avg = fmean(ancestor_scores.values())
                parent1_avg = fmean(parent1_scores.values())
                parent2_avg = fmean(parent2_scores.values())

                # Ancestor should not be better than both descendants
                if ancestor_avg > max(parent1_avg, parent2_avg):
                    logger.debug(
                        "merge_proposer.ancestor_too_good",
                        ancestor_idx=ancestor_idx,
                        ancestor_avg=ancestor_avg,
                        parent1_avg=parent1_avg,
                        parent2_avg=parent2_avg,
                    )
                    continue

            # Mark as attempted
            self.attempted_merges.add(merge_log)

            return (parent1_idx, parent2_idx, ancestor_idx)

        logger.debug("merge_proposer.max_attempts_exceeded", attempts=self.max_attempts)
        return None

    def _merge_components(
        self,
        ancestor: dict[str, str],
        parent1: dict[str, str],
        parent2: dict[str, str],
        score1: float,
        score2: float,
    ) -> dict[str, str]:
        """Merge components from two parents based on ancestor divergence.

        Args:
            ancestor (dict[str, str]): Component dictionary from common ancestor.
            parent1 (dict[str, str]): Component dictionary from first parent.
            parent2 (dict[str, str]): Component dictionary from second parent.
            score1 (float): Average score of first parent.
            score2 (float): Average score of second parent.

        Returns:
            Merged component dictionary.

        Notes:
            Strategy for merging components:
            - If both parents same → take either
            - If one unchanged from ancestor, other changed → take changed value
            - If both changed differently → take higher scorer's value
              (the first parent's on a tie)
            Components present only in parents (not ancestor) are ignored.
        """
        merged: dict[str, str] = {}

        for key, anc_val in ancestor.items():
            p1_val = parent1.get(key, anc_val)
            p2_val = parent2.get(key, anc_val)

            if p1_val == p2_val:
                # Both same - take either
                merged[key] = p1_val
            elif p1_val == anc_val and p2_val != anc_val:
                # P1 unchanged, P2 changed - take P2's innovation
                merged[key] = p2_val
            elif p2_val == anc_val and p1_val != anc_val:
                # P2 unchanged, P1 changed - take P1's innovation
                merged[key] = p1_val
            else:
                # Both changed differently - take higher scorer's value
                merged[key] = p1_val if score1 >= score2 else p2_val

        return merged

__init__

__init__(
    rng: Random,
    val_overlap_floor: int = 5,
    max_attempts: int = 10,
) -> None

Initialize MergeProposer.

PARAMETER DESCRIPTION
rng

Random number generator for candidate selection.

TYPE: Random

val_overlap_floor

Minimum overlapping validation examples required.

TYPE: int DEFAULT: 5

max_attempts

Maximum merge attempts before giving up.

TYPE: int DEFAULT: 10

Notes

The attempted_merges set starts empty so previously tried parent pairs can be skipped on subsequent calls.

Source code in src/gepa_adk/engine/merge_proposer.py
def __init__(
    self,
    rng: random.Random,
    val_overlap_floor: int = 5,
    max_attempts: int = 10,
) -> None:
    """Initialize MergeProposer.

    Args:
        rng: Random number generator for candidate selection.
        val_overlap_floor: Minimum overlapping validation examples required.
        max_attempts: Maximum merge attempts before giving up.

    Notes:
        The ``attempted_merges`` set starts empty so previously tried
        parent pairs can be skipped on subsequent calls.
    """
    self.rng = rng
    self.val_overlap_floor = val_overlap_floor
    self.max_attempts = max_attempts
    self.attempted_merges: set[AncestorLog] = set()

propose async

propose(
    state: ParetoState, eval_batch: object | None = None
) -> ProposalResult | None

Attempt to merge two frontier candidates.

PARAMETER DESCRIPTION
state

Current Pareto state with candidates, scores, and genealogy. Must contain at least 2 candidates on the Pareto frontier with a shared common ancestor for merge to succeed.

TYPE: ParetoState

eval_batch

Ignored for merge proposals. Merge operations do not require evaluation batch data as they combine existing candidates.

TYPE: object | None DEFAULT: None

RETURNS DESCRIPTION
ProposalResult | None

ProposalResult | None: ProposalResult with merged candidate and both parent indices,

ProposalResult | None

or None if merge not possible (e.g., no common ancestor, insufficient frontier,

ProposalResult | None

or no complementary component changes).

Examples:

Proposing a merge from evolution state:

proposer = MergeProposer(rng=random.Random(42))
result = await proposer.propose(state)
if result:
    print(f"Merged from parents {result.parent_indices}")
    print(f"Ancestor: {result.metadata['ancestor_idx']}")
Notes

Operations select candidates from Pareto frontier only. Requires common ancestor and complementary component changes for successful merge. Validates minimum validation overlap before merging. The common ancestor is found over state.parent_indices, where each mutation's edge points at the candidate the reflector actually rewrote.

Source code in src/gepa_adk/engine/merge_proposer.py
async def propose(
    self,
    state: ParetoState,
    eval_batch: object | None = None,  # Ignored for merge proposals
) -> ProposalResult | None:
    """Attempt to merge two frontier candidates.

    Args:
        state (ParetoState): Current Pareto state with candidates, scores, and genealogy.
            Must contain at least 2 candidates on the Pareto frontier with a shared
            common ancestor for merge to succeed.
        eval_batch (object | None): Ignored for merge proposals. Merge operations
            do not require evaluation batch data as they combine existing candidates.

    Returns:
        ProposalResult | None: ProposalResult with merged candidate and both parent indices,
        or None if merge not possible (e.g., no common ancestor, insufficient frontier,
        or no complementary component changes).

    Examples:
        Proposing a merge from evolution state:

        ```python
        proposer = MergeProposer(rng=random.Random(42))
        result = await proposer.propose(state)
        if result:
            print(f"Merged from parents {result.parent_indices}")
            print(f"Ancestor: {result.metadata['ancestor_idx']}")
        ```

    Notes:
        Operations select candidates from Pareto frontier only. Requires common ancestor
        and complementary component changes for successful merge. Validates
        minimum validation overlap before merging. The common ancestor is
        found over ``state.parent_indices``, where each mutation's edge
        points at the candidate the reflector actually rewrote.
    """
    # Find suitable merge candidates
    merge_candidates = self._find_merge_candidates(state)
    if merge_candidates is None:
        logger.debug("merge_proposer.no_candidates", reason="no_suitable_pair")
        return None

    parent1_idx, parent2_idx, ancestor_idx = merge_candidates

    # Get candidate components
    ancestor = state.candidates[ancestor_idx]
    parent1 = state.candidates[parent1_idx]
    parent2 = state.candidates[parent2_idx]

    # Check if merge is desirable (complementary changes)
    if not has_desirable_predictors(
        ancestor.components, parent1.components, parent2.components
    ):
        logger.debug(
            "merge_proposer.no_desirable_predictors",
            parent1_idx=parent1_idx,
            parent2_idx=parent2_idx,
            ancestor_idx=ancestor_idx,
        )
        return None

    # Check validation overlap
    scores1 = state.candidate_scores.get(parent1_idx, {})
    scores2 = state.candidate_scores.get(parent2_idx, {})
    overlap = set(scores1.keys()) & set(scores2.keys())
    valid_overlap = {idx for idx in overlap if isinstance(idx, int) and idx >= 0}
    if len(valid_overlap) < self.val_overlap_floor:
        logger.debug(
            "merge_proposer.insufficient_overlap",
            parent1_idx=parent1_idx,
            parent2_idx=parent2_idx,
            overlap_count=len(valid_overlap),
            required=self.val_overlap_floor,
        )
        return None

    # Calculate average scores for component selection
    avg_score1 = fmean(scores1.values()) if scores1 else 0.0
    avg_score2 = fmean(scores2.values()) if scores2 else 0.0

    # Merge components
    merged_components = self._merge_components(
        ancestor.components,
        parent1.components,
        parent2.components,
        avg_score1,
        avg_score2,
    )

    # Create merged candidate
    merged_candidate = Candidate(
        components=merged_components,
        generation=max(parent1.generation, parent2.generation) + 1,
        parent_ids=[parent1_idx, parent2_idx],
    )

    # Log successful merge
    logger.info(
        "merge_proposer.merge_success",
        parent1_idx=parent1_idx,
        parent2_idx=parent2_idx,
        ancestor_idx=ancestor_idx,
        components_merged=list(merged_components.keys()),
    )

    return ProposalResult(
        candidate=merged_candidate,
        parent_indices=[parent1_idx, parent2_idx],
        tag="merge",
        metadata={"ancestor_idx": ancestor_idx},
    )

AsyncReflectiveMutationProposer

Generates text mutations via LLM reflection.

This proposer takes a candidate's current component texts and feedback data, then uses an ADK reflection function to generate improved versions. It handles empty datasets gracefully by returning None without making LLM calls, and retries an empty reflection response once before raising EmptyProposalError.

Terminology
  • component: Evolvable unit with name + text (the "gear" being tuned)
  • component_text: The text content of a component
  • trial: One record {input, output, feedback, trajectory}
  • trials: Collection of trial records for reflection
  • proposed_component_text: The improved text for the same component
ATTRIBUTE DESCRIPTION
adk_reflection_fn

ADK reflection function for proposing mutations. Created via create_adk_reflection_fn().

TYPE: ReflectionFn

max_trials

Maximum trials per component handed to the reflection function, or None for no limit.

TYPE: int | None

max_trial_chars

Maximum length of any string value in a trial handed to the reflection function, or None for no limit.

TYPE: int | None

retry_backoff_seconds

Seconds to wait before retrying a retryable reflection error.

TYPE: float

last_reasoning

Most recent non-None reasoning from the last propose() call.

TYPE: str | None

last_token_usage

Token usage of the reflection calls made by the last propose() call, one row per call that returned; a call that reported no usage is an unknown row and a call that raised is no row.

TYPE: TokenRollup

Examples:

Standard usage with ADK reflection agent:

from gepa_adk.engine import create_adk_reflection_fn

reflection_fn = create_adk_reflection_fn(reflection_agent, executor)
proposer = AsyncReflectiveMutationProposer(adk_reflection_fn=reflection_fn)
result = await proposer.propose(
    candidate={"instruction": "Be helpful"},
    reflective_dataset={"instruction": [trials]},
    components_to_update=["instruction"],
)
Notes

ADK-based reflection via adk_reflection_fn is the only supported approach. Use create_adk_reflection_fn() from gepa_adk.engine.adk_reflection to create the reflection function.

Source code in src/gepa_adk/engine/proposer.py
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
class AsyncReflectiveMutationProposer:
    """Generates text mutations via LLM reflection.

    This proposer takes a candidate's current component texts and feedback
    data, then uses an ADK reflection function to generate improved versions.
    It handles empty datasets gracefully by returning None without making
    LLM calls, and retries an empty reflection response once before raising
    EmptyProposalError.

    Terminology:
        - component: Evolvable unit with name + text (the "gear" being tuned)
        - component_text: The text content of a component
        - trial: One record {input, output, feedback, trajectory}
        - trials: Collection of trial records for reflection
        - proposed_component_text: The improved text for the same component

    Attributes:
        adk_reflection_fn (ReflectionFn): ADK reflection function for proposing
            mutations. Created via `create_adk_reflection_fn()`.
        max_trials (int | None): Maximum trials per component handed to the
            reflection function, or None for no limit.
        max_trial_chars (int | None): Maximum length of any string value in
            a trial handed to the reflection function, or None for no limit.
        retry_backoff_seconds (float): Seconds to wait before retrying a
            retryable reflection error.
        last_reasoning (str | None): Most recent non-None reasoning from the
            last ``propose()`` call.
        last_token_usage (TokenRollup): Token usage of the reflection calls
            made by the last ``propose()`` call, one row per call that
            returned; a call that reported no usage is an unknown row and a
            call that raised is no row.

    Examples:
        Standard usage with ADK reflection agent:

        ```python
        from gepa_adk.engine import create_adk_reflection_fn

        reflection_fn = create_adk_reflection_fn(reflection_agent, executor)
        proposer = AsyncReflectiveMutationProposer(adk_reflection_fn=reflection_fn)
        result = await proposer.propose(
            candidate={"instruction": "Be helpful"},
            reflective_dataset={"instruction": [trials]},
            components_to_update=["instruction"],
        )
        ```

    Notes:
        ADK-based reflection via `adk_reflection_fn` is the only supported
        approach. Use `create_adk_reflection_fn()` from
        `gepa_adk.engine.adk_reflection` to create the reflection function.
    """

    def __init__(
        self,
        adk_reflection_fn: ReflectionFn,
        *,
        max_trials: int | None = None,
        max_trial_chars: int | None = None,
        retry_backoff_seconds: float = 2.0,
    ) -> None:
        """Initialize the mutation proposer.

        Args:
            adk_reflection_fn: Async callable for ADK-based reflection.
                Takes (component_text, trials, component_name) and returns
                (proposed_text, reasoning) tuple. Create with
                `create_adk_reflection_fn()` from
                `gepa_adk.engine.adk_reflection`.
            max_trials: Maximum trials per component handed to the
                reflection function. Failing trials take up to half the
                slots (rounded up), passing trials the rest. None sends
                every trial.
            max_trial_chars: Maximum length of any string value inside a
                trial handed to the reflection function. Longer strings are
                cut and marked. None leaves strings intact.
            retry_backoff_seconds: Seconds to wait before retrying a
                retryable reflection error. Zero retries immediately.

        Raises:
            ValueError: If adk_reflection_fn is None, or if
                retry_backoff_seconds is not a non-negative number.

        Examples:
            ```python
            from gepa_adk.engine import create_adk_reflection_fn

            reflection_fn = create_adk_reflection_fn(reflection_agent, executor)
            proposer = AsyncReflectiveMutationProposer(adk_reflection_fn=reflection_fn)
            ```

        Notes:
            Configuration validation happens immediately to fail fast rather
            than waiting until the first propose() call. After each
            ``propose()`` call, ``self.last_reasoning`` holds the most
            recent non-None reasoning string (or None) and
            ``self.last_token_usage`` the rollup of its reflection calls; it
            starts as a new zero rollup object. The backoff is validated
            here too.
        """
        if adk_reflection_fn is None:
            raise ValueError(
                "adk_reflection_fn is required. Use create_adk_reflection_fn() "
                "from gepa_adk.engine.adk_reflection to create one."
            )
        if (
            isinstance(retry_backoff_seconds, bool)
            or not isinstance(retry_backoff_seconds, (int, float))
            or not math.isfinite(retry_backoff_seconds)
            or retry_backoff_seconds < 0
        ):
            raise ValueError(
                "retry_backoff_seconds must be a non-negative number, got "
                f"{retry_backoff_seconds!r}."
            )

        self.adk_reflection_fn = adk_reflection_fn
        self.max_trials = max_trials
        self.max_trial_chars = max_trial_chars
        self.retry_backoff_seconds = retry_backoff_seconds
        self.last_reasoning: str | None = None
        self.last_token_usage: TokenRollup = _zero_usage()

        # Log proposer initialization
        logger.info("proposer_initialized", reflection_method="adk")

    async def propose(
        self,
        candidate: dict[str, str],
        reflective_dataset: ReflectiveDataset,
        components_to_update: list[str],
    ) -> ProposalResult:
        """Propose mutated component text via LLM reflection.

        Args:
            candidate (dict[str, str]): Current candidate component texts.
                Keys are component names, values are component text.
                Example: {"instruction": "Be helpful and concise"}
            reflective_dataset (ReflectiveDataset): Trials per component name.
                Each trial contains input, output, feedback, and optional
                trajectory.
                Example: {"instruction": [{
                    "input": "Hello",
                    "output": "Hi there!",
                    "feedback": {"score": 0.75, "feedback_text": "Could be more formal"},
                    "trajectory": {...}
                }]}
            components_to_update (list[str]): Component names to generate
                proposals for. Components missing from the candidate or the
                reflective dataset (or with empty trials) are silently
                skipped. Example: ["instruction"]

        Returns:
            ProposalResult: Dictionary mapping component names to proposed
                component text, or None if the reflective dataset is empty
                or has no entries for the requested components.

        Raises:
            EmptyProposalError: If ADK reflection returns an empty or
                whitespace-only response twice for the same component.
            ReflectionError: If the reflection function raises. A retryable
                error is raised after the retry fails too (``attempts=2``);
                a non-retryable one is raised at once (``attempts=1``).
            EvolutionError: If ADK reflection returns a non-string response.
                ``EvolutionError`` subclasses the reflection function
                raises, such as ``ReflectionTimeoutError`` and
                ``IncompleteProposalError``, propagate unwrapped and are not
                retried.

        Examples:
            ```python
            result = await proposer.propose(
                candidate={"instruction": "Be helpful"},
                reflective_dataset={
                    "instruction": [
                        {
                            "input": "I am the King",
                            "output": "Hey!",
                            "feedback": {"score": 0.3, "feedback_text": "Too casual"},
                            "trajectory": {...},
                        }
                    ]
                },
                components_to_update=["instruction"],
            )
            # result: {"instruction": "Greet users formally..."}
            ```

        Notes:
            Calls the reflection function directly with
            ``(component_text, trials, component_name)``. The function
            returns ``(proposed_text, reasoning)`` or
            ``(proposed_text, reasoning, token_usage)``; reasoning is stored
            in ``self.last_reasoning`` (last non-None value wins) and each
            call that returned adds one row to ``self.last_token_usage``,
            which is reset to a new zero rollup object at the start of the
            call. When
            ``max_trials`` or ``max_trial_chars`` is set, the trials are
            capped before the call and ``proposer.trials_capped`` is logged
            whenever a trial was dropped or a string truncated. An empty
            response is retried once (logged as ``proposer.empty_retry``);
            a second empty response raises EmptyProposalError, which the
            engine records as a skipped iteration. A retryable exception
            from the reflection function is retried once after
            ``retry_backoff_seconds`` (logged as ``proposer.error_retry``);
            the two retries share the same two attempts. Non-string
            responses raise EvolutionError.
        """
        # Reset reasoning and reflection usage at start of each propose() call
        self.last_reasoning = None
        self.last_token_usage = _zero_usage()

        # Early return for empty dataset (no LLM calls)
        if not reflective_dataset:
            return None

        proposals = {}

        for component in components_to_update:
            # Skip if component not in reflective_dataset or has empty feedback
            if component not in reflective_dataset:
                continue

            trials: list[dict[str, Any]] = [
                dict(t) for t in reflective_dataset[component]
            ]
            if not trials:
                continue

            # Skip component not in candidate
            if component not in candidate:
                continue

            component_text = candidate[component]
            trials = self._cap_trials(component, trials)

            logger.debug(
                "proposer.reflection_path",
                method="adk",
                component=component,
            )

            proposals[component] = await self._reflect_with_retry(
                component_text, trials, component
            )

        # Return None if no valid proposals generated
        if not proposals:
            return None

        return proposals

    def _cap_trials(
        self, component: str, trials: list[dict[str, Any]]
    ) -> list[dict[str, Any]]:
        """Apply the trial-count and string-length caps for one component.

        Args:
            component: Component name, used in the log event.
            trials: Trial records in batch order. They are not mutated.

        Returns:
            The trials to hand to the reflection function.
        """
        total = len(trials)
        if self.max_trials is not None:
            trials = _select_trials(trials, self.max_trials)
        truncated_fields = dropped_chars = 0
        if self.max_trial_chars is not None:
            trials, truncated_fields, dropped_chars = _truncate_trials(
                trials, self.max_trial_chars
            )
        dropped_trials = total - len(trials)
        if dropped_trials or truncated_fields:
            logger.info(
                "proposer.trials_capped",
                component=component,
                kept=len(trials),
                dropped_trials=dropped_trials,
                truncated_fields=truncated_fields,
                dropped_chars=dropped_chars,
            )
        return trials

    async def _reflect_with_retry(
        self,
        component_text: str,
        trials: list[dict[str, Any]],
        component: str,
    ) -> str:
        """Call reflection, retrying once on an empty response or a transient error.

        Args:
            component_text (str): Current text of the component.
            trials (list[dict[str, Any]]): Trial records for reflection.
            component (str): Name of the component being evolved.

        Returns:
            The stripped, non-empty proposed component text.

        Raises:
            EmptyProposalError: If the last of the two attempts returns
                empty or whitespace-only text.
            ReflectionError: If the reflection function raises a
                non-retryable error on any attempt, or a retryable error on
                the second attempt.
            EvolutionError: Any other error from ``_reflect_once``
                propagates without a retry, including
                ``ReflectionTimeoutError`` and ``IncompleteProposalError``.

        Notes:
            The empty retry and the error retry share one budget of two
            attempts. A retryable error on the first attempt is logged as
            ``proposer.error_retry`` and followed by
            ``asyncio.sleep(retry_backoff_seconds)``. The logged ``error``
            text is cut to ``_ERROR_TEXT_MAX_CHARS`` characters plus a
            ``…[truncated, N chars omitted]`` marker.
        """
        for attempt in (1, 2):
            try:
                proposed = await self._reflect_once(
                    component_text, trials, component, attempt=attempt
                )
            except ReflectionError as error:
                if attempt == 2 or not error.retryable:  # noqa: PLR2004  # second and last attempt of the two-attempt budget
                    raise
                logger.warning(
                    "proposer.error_retry",
                    component=component,
                    error_type=type(error.cause).__name__,
                    error=_truncate_value(
                        str(error.cause), _ERROR_TEXT_MAX_CHARS, [0, 0]
                    ),
                    attempt=attempt,
                    backoff_seconds=self.retry_backoff_seconds,
                )
                await asyncio.sleep(self.retry_backoff_seconds)
                continue
            if proposed:
                return proposed
            if attempt == 1:
                logger.warning(
                    "proposer.empty_retry",
                    component=component,
                    attempt=attempt,
                )
        raise EmptyProposalError(component)

    async def _reflect_once(
        self,
        component_text: str,
        trials: list[dict[str, Any]],
        component: str,
        *,
        attempt: int = 1,
    ) -> str:
        """Call the reflection function once and validate its output type.

        Args:
            component_text: Current text of the component.
            trials: Trial records for reflection.
            component: Name of the component being evolved.
            attempt: One-based number of this reflection call, recorded on
                a raised ``ReflectionError``.

        Returns:
            The stripped proposed text, which may be empty.

        Raises:
            ReflectionError: If the reflection function raises anything
                other than an ``EvolutionError``; ``retryable`` comes from
                ``is_retryable_reflection_error``.
            EvolutionError: If the response is not a string, or if the
                reflection function raises an ``EvolutionError`` (passed
                through unwrapped), such as ``IncompleteProposalError`` for a
                truncated proposal.

        Notes:
            A call that returned adds one row to ``self.last_token_usage``:
            counted when its third element is a ``TokenUsage``, unknown for
            a 2-tuple or a None usage. A call that raised adds nothing.
        """
        try:
            result = await self.adk_reflection_fn(component_text, trials, component)
        except EvolutionError:
            raise
        except Exception as e:
            raise ReflectionError(
                component,
                cause=e,
                retryable=is_retryable_reflection_error(e),
                attempts=attempt,
            ) from e

        proposed_component_text, reasoning, *rest = result
        usage = rest[0] if rest else None
        self.last_token_usage = self.last_token_usage.combine(_call_usage(usage))

        # Store the last non-None reasoning
        if reasoning is not None:
            self.last_reasoning = reasoning

        if not isinstance(proposed_component_text, str):
            raise EvolutionError(
                "Reflection agent must return a string, got "
                f"{type(proposed_component_text).__name__}."
            )
        return proposed_component_text.strip()

__init__

__init__(
    adk_reflection_fn: ReflectionFn,
    *,
    max_trials: int | None = None,
    max_trial_chars: int | None = None,
    retry_backoff_seconds: float = 2.0,
) -> None

Initialize the mutation proposer.

PARAMETER DESCRIPTION
adk_reflection_fn

Async callable for ADK-based reflection. Takes (component_text, trials, component_name) and returns (proposed_text, reasoning) tuple. Create with create_adk_reflection_fn() from gepa_adk.engine.adk_reflection.

TYPE: ReflectionFn

max_trials

Maximum trials per component handed to the reflection function. Failing trials take up to half the slots (rounded up), passing trials the rest. None sends every trial.

TYPE: int | None DEFAULT: None

max_trial_chars

Maximum length of any string value inside a trial handed to the reflection function. Longer strings are cut and marked. None leaves strings intact.

TYPE: int | None DEFAULT: None

retry_backoff_seconds

Seconds to wait before retrying a retryable reflection error. Zero retries immediately.

TYPE: float DEFAULT: 2.0

RAISES DESCRIPTION
ValueError

If adk_reflection_fn is None, or if retry_backoff_seconds is not a non-negative number.

Examples:

from gepa_adk.engine import create_adk_reflection_fn

reflection_fn = create_adk_reflection_fn(reflection_agent, executor)
proposer = AsyncReflectiveMutationProposer(adk_reflection_fn=reflection_fn)
Notes

Configuration validation happens immediately to fail fast rather than waiting until the first propose() call. After each propose() call, self.last_reasoning holds the most recent non-None reasoning string (or None) and self.last_token_usage the rollup of its reflection calls; it starts as a new zero rollup object. The backoff is validated here too.

Source code in src/gepa_adk/engine/proposer.py
def __init__(
    self,
    adk_reflection_fn: ReflectionFn,
    *,
    max_trials: int | None = None,
    max_trial_chars: int | None = None,
    retry_backoff_seconds: float = 2.0,
) -> None:
    """Initialize the mutation proposer.

    Args:
        adk_reflection_fn: Async callable for ADK-based reflection.
            Takes (component_text, trials, component_name) and returns
            (proposed_text, reasoning) tuple. Create with
            `create_adk_reflection_fn()` from
            `gepa_adk.engine.adk_reflection`.
        max_trials: Maximum trials per component handed to the
            reflection function. Failing trials take up to half the
            slots (rounded up), passing trials the rest. None sends
            every trial.
        max_trial_chars: Maximum length of any string value inside a
            trial handed to the reflection function. Longer strings are
            cut and marked. None leaves strings intact.
        retry_backoff_seconds: Seconds to wait before retrying a
            retryable reflection error. Zero retries immediately.

    Raises:
        ValueError: If adk_reflection_fn is None, or if
            retry_backoff_seconds is not a non-negative number.

    Examples:
        ```python
        from gepa_adk.engine import create_adk_reflection_fn

        reflection_fn = create_adk_reflection_fn(reflection_agent, executor)
        proposer = AsyncReflectiveMutationProposer(adk_reflection_fn=reflection_fn)
        ```

    Notes:
        Configuration validation happens immediately to fail fast rather
        than waiting until the first propose() call. After each
        ``propose()`` call, ``self.last_reasoning`` holds the most
        recent non-None reasoning string (or None) and
        ``self.last_token_usage`` the rollup of its reflection calls; it
        starts as a new zero rollup object. The backoff is validated
        here too.
    """
    if adk_reflection_fn is None:
        raise ValueError(
            "adk_reflection_fn is required. Use create_adk_reflection_fn() "
            "from gepa_adk.engine.adk_reflection to create one."
        )
    if (
        isinstance(retry_backoff_seconds, bool)
        or not isinstance(retry_backoff_seconds, (int, float))
        or not math.isfinite(retry_backoff_seconds)
        or retry_backoff_seconds < 0
    ):
        raise ValueError(
            "retry_backoff_seconds must be a non-negative number, got "
            f"{retry_backoff_seconds!r}."
        )

    self.adk_reflection_fn = adk_reflection_fn
    self.max_trials = max_trials
    self.max_trial_chars = max_trial_chars
    self.retry_backoff_seconds = retry_backoff_seconds
    self.last_reasoning: str | None = None
    self.last_token_usage: TokenRollup = _zero_usage()

    # Log proposer initialization
    logger.info("proposer_initialized", reflection_method="adk")

propose async

propose(
    candidate: dict[str, str],
    reflective_dataset: ReflectiveDataset,
    components_to_update: list[str],
) -> ProposalResult

Propose mutated component text via LLM reflection.

PARAMETER DESCRIPTION
candidate

Current candidate component texts. Keys are component names, values are component text. Example: {"instruction": "Be helpful and concise"}

TYPE: dict[str, str]

reflective_dataset

Trials per component name. Each trial contains input, output, feedback, and optional trajectory. Example: {"instruction": [{ "input": "Hello", "output": "Hi there!", "feedback": {"score": 0.75, "feedback_text": "Could be more formal"}, "trajectory": {...} }]}

TYPE: ReflectiveDataset

components_to_update

Component names to generate proposals for. Components missing from the candidate or the reflective dataset (or with empty trials) are silently skipped. Example: ["instruction"]

TYPE: list[str]

RETURNS DESCRIPTION
ProposalResult

Dictionary mapping component names to proposed component text, or None if the reflective dataset is empty or has no entries for the requested components.

TYPE: ProposalResult

RAISES DESCRIPTION
EmptyProposalError

If ADK reflection returns an empty or whitespace-only response twice for the same component.

ReflectionError

If the reflection function raises. A retryable error is raised after the retry fails too (attempts=2); a non-retryable one is raised at once (attempts=1).

EvolutionError

If ADK reflection returns a non-string response. EvolutionError subclasses the reflection function raises, such as ReflectionTimeoutError and IncompleteProposalError, propagate unwrapped and are not retried.

Examples:

result = await proposer.propose(
    candidate={"instruction": "Be helpful"},
    reflective_dataset={
        "instruction": [
            {
                "input": "I am the King",
                "output": "Hey!",
                "feedback": {"score": 0.3, "feedback_text": "Too casual"},
                "trajectory": {...},
            }
        ]
    },
    components_to_update=["instruction"],
)
# result: {"instruction": "Greet users formally..."}
Notes

Calls the reflection function directly with (component_text, trials, component_name). The function returns (proposed_text, reasoning) or (proposed_text, reasoning, token_usage); reasoning is stored in self.last_reasoning (last non-None value wins) and each call that returned adds one row to self.last_token_usage, which is reset to a new zero rollup object at the start of the call. When max_trials or max_trial_chars is set, the trials are capped before the call and proposer.trials_capped is logged whenever a trial was dropped or a string truncated. An empty response is retried once (logged as proposer.empty_retry); a second empty response raises EmptyProposalError, which the engine records as a skipped iteration. A retryable exception from the reflection function is retried once after retry_backoff_seconds (logged as proposer.error_retry); the two retries share the same two attempts. Non-string responses raise EvolutionError.

Source code in src/gepa_adk/engine/proposer.py
async def propose(
    self,
    candidate: dict[str, str],
    reflective_dataset: ReflectiveDataset,
    components_to_update: list[str],
) -> ProposalResult:
    """Propose mutated component text via LLM reflection.

    Args:
        candidate (dict[str, str]): Current candidate component texts.
            Keys are component names, values are component text.
            Example: {"instruction": "Be helpful and concise"}
        reflective_dataset (ReflectiveDataset): Trials per component name.
            Each trial contains input, output, feedback, and optional
            trajectory.
            Example: {"instruction": [{
                "input": "Hello",
                "output": "Hi there!",
                "feedback": {"score": 0.75, "feedback_text": "Could be more formal"},
                "trajectory": {...}
            }]}
        components_to_update (list[str]): Component names to generate
            proposals for. Components missing from the candidate or the
            reflective dataset (or with empty trials) are silently
            skipped. Example: ["instruction"]

    Returns:
        ProposalResult: Dictionary mapping component names to proposed
            component text, or None if the reflective dataset is empty
            or has no entries for the requested components.

    Raises:
        EmptyProposalError: If ADK reflection returns an empty or
            whitespace-only response twice for the same component.
        ReflectionError: If the reflection function raises. A retryable
            error is raised after the retry fails too (``attempts=2``);
            a non-retryable one is raised at once (``attempts=1``).
        EvolutionError: If ADK reflection returns a non-string response.
            ``EvolutionError`` subclasses the reflection function
            raises, such as ``ReflectionTimeoutError`` and
            ``IncompleteProposalError``, propagate unwrapped and are not
            retried.

    Examples:
        ```python
        result = await proposer.propose(
            candidate={"instruction": "Be helpful"},
            reflective_dataset={
                "instruction": [
                    {
                        "input": "I am the King",
                        "output": "Hey!",
                        "feedback": {"score": 0.3, "feedback_text": "Too casual"},
                        "trajectory": {...},
                    }
                ]
            },
            components_to_update=["instruction"],
        )
        # result: {"instruction": "Greet users formally..."}
        ```

    Notes:
        Calls the reflection function directly with
        ``(component_text, trials, component_name)``. The function
        returns ``(proposed_text, reasoning)`` or
        ``(proposed_text, reasoning, token_usage)``; reasoning is stored
        in ``self.last_reasoning`` (last non-None value wins) and each
        call that returned adds one row to ``self.last_token_usage``,
        which is reset to a new zero rollup object at the start of the
        call. When
        ``max_trials`` or ``max_trial_chars`` is set, the trials are
        capped before the call and ``proposer.trials_capped`` is logged
        whenever a trial was dropped or a string truncated. An empty
        response is retried once (logged as ``proposer.empty_retry``);
        a second empty response raises EmptyProposalError, which the
        engine records as a skipped iteration. A retryable exception
        from the reflection function is retried once after
        ``retry_backoff_seconds`` (logged as ``proposer.error_retry``);
        the two retries share the same two attempts. Non-string
        responses raise EvolutionError.
    """
    # Reset reasoning and reflection usage at start of each propose() call
    self.last_reasoning = None
    self.last_token_usage = _zero_usage()

    # Early return for empty dataset (no LLM calls)
    if not reflective_dataset:
        return None

    proposals = {}

    for component in components_to_update:
        # Skip if component not in reflective_dataset or has empty feedback
        if component not in reflective_dataset:
            continue

        trials: list[dict[str, Any]] = [
            dict(t) for t in reflective_dataset[component]
        ]
        if not trials:
            continue

        # Skip component not in candidate
        if component not in candidate:
            continue

        component_text = candidate[component]
        trials = self._cap_trials(component, trials)

        logger.debug(
            "proposer.reflection_path",
            method="adk",
            component=component,
        )

        proposals[component] = await self._reflect_with_retry(
            component_text, trials, component
        )

    # Return None if no valid proposals generated
    if not proposals:
        return None

    return proposals

create_adk_reflection_fn

create_adk_reflection_fn(
    reflection_agent: Any,
    executor: AgentExecutorProtocol,
    output_key: str = "proposed_component_text",
    timeout_seconds: int | None = None,
    reasoning_tags: Sequence[str] = _DEFAULT_REASONING_TAGS,
) -> ADKReflectionFn

Create a reflection function from an ADK LlmAgent.

This factory function creates an async callable that uses the Google ADK framework for reflection. The returned function can be passed to AsyncReflectiveMutationProposer as the adk_reflection_fn parameter.

The caller is responsible for selecting the appropriate reflection agent. See gepa_adk.api.evolve() for the standard wiring pattern.

PARAMETER DESCRIPTION
reflection_agent

ADK LlmAgent configured with instruction containing {component_text} and {trials} placeholders. The agent's instruction should include logic for improving text based on trial results.

TYPE: Any

executor

AgentExecutorProtocol implementation for unified agent execution. Handles session management and execution, enabling feature parity across all agent types.

TYPE: AgentExecutorProtocol

output_key

Key in session state where ADK stores the agent's output. Defaults to "proposed_component_text". When set, the agent's output_key is configured to this value, and output is retrieved from session state after execution. Falls back to event-based extraction if the output_key is not found in session state.

TYPE: str DEFAULT: 'proposed_component_text'

timeout_seconds

Seconds the reflection agent may run per call. Passed to executor.execute_agent as timeout_seconds only when not None; None (default) keeps the executor's own default.

TYPE: int | None DEFAULT: None

reasoning_tags

Reasoning tag names (without angle brackets) that mark an incomplete proposal when the stripped text opens with one of them and never closes it. Matched case-insensitively. Defaults to ("think", "thinking", "reasoning").

TYPE: Sequence[str] DEFAULT: _DEFAULT_REASONING_TAGS

RETURNS DESCRIPTION
ADKReflectionFn

Async callable matching ReflectionFn signature that generates proposed

ADKReflectionFn

component text via the ADK agent and returns

ADKReflectionFn

(proposed_text, reasoning, token_usage). Typed as

ADKReflectionFn

ADKReflectionFn so its component_name parameter may also be

ADKReflectionFn

passed by keyword.

RAISES DESCRIPTION
RuntimeError

If ADK agent execution fails (propagated from executor).

ReflectionTimeoutError

From the returned callable, when the executor reports ExecutionStatus.TIMEOUT.

IncompleteProposalError

From the returned callable, when the last captured event with a finish reason reports a length stop, or the proposal opens one of reasoning_tags without closing it.

Examples:

Basic usage with executor:

from google.adk.agents import LlmAgent
from google.adk.models.lite_llm import LiteLlm
from gepa_adk.adapters.execution.agent_executor import AgentExecutor
from gepa_adk.engine.adk_reflection import create_adk_reflection_fn

agent = LlmAgent(
    name="InstructionReflector",
    model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
    instruction="""Improve this component text:
    {component_text}

    Based on these trials:
    {trials}

    Return proposed component text only."""
)

executor = AgentExecutor()
reflection_fn = create_adk_reflection_fn(agent, executor=executor)
trials = [{"input": "Hi", "output": "Hey", "feedback": {"score": 0.5}}]
proposed, reasoning, usage = await reflection_fn(
    "Be helpful", trials, "instruction"
)
See Also
Notes

Opens a fresh ADK session for each invocation via AgentExecutor, ensuring complete isolation between reflection operations. State is initialized with component_text (str) and trials (JSON-serialized list of trial records). A timed-out execution logs reflection.timeout and raises ReflectionTimeoutError; reflection.empty_response is logged only for a completed run that produced no text. An incomplete proposal logs reflection.incomplete at warning and raises IncompleteProposalError; the length check runs before the empty-response path, so a length stop with no text is incomplete.

Source code in src/gepa_adk/engine/adk_reflection.py
def create_adk_reflection_fn(
    reflection_agent: Any,  # LlmAgent from google.adk.agents
    executor: AgentExecutorProtocol,
    output_key: str = "proposed_component_text",
    timeout_seconds: int | None = None,
    reasoning_tags: Sequence[str] = _DEFAULT_REASONING_TAGS,
) -> ADKReflectionFn:
    """Create a reflection function from an ADK LlmAgent.

    This factory function creates an async callable that uses the Google ADK
    framework for reflection. The returned function can be passed to
    AsyncReflectiveMutationProposer as the adk_reflection_fn parameter.

    The caller is responsible for selecting the appropriate reflection agent.
    See ``gepa_adk.api.evolve()`` for the standard wiring pattern.

    Args:
        reflection_agent: ADK LlmAgent configured with instruction containing
            `{component_text}` and `{trials}` placeholders. The agent's
            instruction should include logic for improving text based on
            trial results.
        executor: AgentExecutorProtocol implementation for unified agent
            execution. Handles session management and execution, enabling
            feature parity across all agent types.
        output_key: Key in session state where ADK stores the agent's output.
            Defaults to "proposed_component_text". When set, the agent's output_key
            is configured to this value, and output is retrieved from session
            state after execution. Falls back to event-based extraction if
            the output_key is not found in session state.
        timeout_seconds: Seconds the reflection agent may run per call. Passed
            to ``executor.execute_agent`` as ``timeout_seconds`` only when not
            ``None``; ``None`` (default) keeps the executor's own default.
        reasoning_tags: Reasoning tag names (without angle brackets) that
            mark an incomplete proposal when the stripped text opens with one
            of them and never closes it. Matched case-insensitively. Defaults
            to ``("think", "thinking", "reasoning")``.

    Returns:
        Async callable matching ReflectionFn signature that generates proposed
        component text via the ADK agent and returns
        ``(proposed_text, reasoning, token_usage)``. Typed as
        ``ADKReflectionFn`` so its ``component_name`` parameter may also be
        passed by keyword.

    Raises:
        RuntimeError: If ADK agent execution fails (propagated from executor).
        ReflectionTimeoutError: From the returned callable, when the executor
            reports ``ExecutionStatus.TIMEOUT``.
        IncompleteProposalError: From the returned callable, when the last
            captured event with a finish reason reports a length stop, or the
            proposal opens one of ``reasoning_tags`` without closing it.

    Examples:
        Basic usage with executor:

        ```python
        from google.adk.agents import LlmAgent
        from google.adk.models.lite_llm import LiteLlm
        from gepa_adk.adapters.execution.agent_executor import AgentExecutor
        from gepa_adk.engine.adk_reflection import create_adk_reflection_fn

        agent = LlmAgent(
            name="InstructionReflector",
            model=LiteLlm(model="ollama_chat/gpt-oss:20b"),
            instruction=\"\"\"Improve this component text:
            {component_text}

            Based on these trials:
            {trials}

            Return proposed component text only.\"\"\"
        )

        executor = AgentExecutor()
        reflection_fn = create_adk_reflection_fn(agent, executor=executor)
        trials = [{"input": "Hi", "output": "Hey", "feedback": {"score": 0.5}}]
        proposed, reasoning, usage = await reflection_fn(
            "Be helpful", trials, "instruction"
        )
        ```

    See Also:
        - [`gepa_adk.engine.proposer`][gepa_adk.engine.proposer]: Module containing
          ReflectionFn type alias and AsyncReflectiveMutationProposer class.
        - [`gepa_adk.api.evolve`][gepa_adk.api.evolve]: Standard wiring pattern
          for constructing the reflection chain.

    Notes:
        Opens a fresh ADK session for each invocation via AgentExecutor, ensuring
        complete isolation between reflection operations. State is initialized with
        component_text (str) and trials (JSON-serialized list of trial records).
        A timed-out execution logs ``reflection.timeout`` and raises
        ``ReflectionTimeoutError``; ``reflection.empty_response`` is logged only
        for a completed run that produced no text. An incomplete proposal logs
        ``reflection.incomplete`` at warning and raises
        ``IncompleteProposalError``; the length check runs before the
        empty-response path, so a length stop with no text is incomplete.
    """
    timeout_source = "config" if timeout_seconds is not None else "executor_default"
    timeout_kwargs: dict[str, Any] = (
        {"timeout_seconds": timeout_seconds} if timeout_seconds is not None else {}
    )

    # Configure output_key on agent if not already set
    # This enables ADK's automatic output storage to session.state
    if output_key and (
        not hasattr(reflection_agent, "output_key") or not reflection_agent.output_key
    ):
        reflection_agent.output_key = output_key
        logger.debug(
            "reflection.output_key.configured",
            output_key=output_key,
            agent_name=getattr(reflection_agent, "name", "unknown"),
        )

    async def reflect(
        component_text: str,
        trials: list[dict[str, Any]],
        component_name: str = "",
    ) -> tuple[str, str | None, TokenUsage | None]:
        """Reflect on component text using ADK agent to generate proposed version.

        Uses the configured ADK reflection agent to analyze the current component
        text and trials, then generates proposed component text based on the
        performance results.

        Args:
            component_text (str): The current component text to improve.
            trials (list[dict[str, Any]]): List of trial records from evaluation. Each
                trial contains input, output, feedback, and optional trajectory.
            component_name (str): Component name passed by the proposer. Logged
                for observability but not used for agent selection (caller
                pre-selects the agent). Defaults to empty string, which the
                logs and ``ReflectionTimeoutError`` report as ``"unknown"``.

        Returns:
            Tuple of (proposed_component_text, reasoning, token_usage). The
            proposed text is empty string if the agent produces no output.
            Reasoning is extracted from thought-tagged parts of captured
            events, or None if no thinking content is available. Token usage
            is read from the captured events, or None when the executor
            captured none or they carried no usage.

        Raises:
            RuntimeError: If the executor reports a FAILED execution. It and
                any other unexpected error are logged as ``reflection.error``
                and re-raised for upstream handling.
            ReflectionTimeoutError: If the executor reports a timeout. It is
                logged as ``reflection.timeout`` and not retried here.
            IncompleteProposalError: If the last model response reports a
                length stop or the text opens a reasoning tag it never
                closes. It is logged as ``reflection.incomplete``.

        Notes:
            Opens a unique session with fresh state for each invocation via
            AgentExecutor, ensuring isolation between reflection operations.
            The configured timeout, if any, is passed to the executor, and the
            start log records it with its source. Completeness is checked
            before the empty-response handling, and only for a string value;
            a non-string value is left to the proposer's type check.
        """
        # Generate unique session ID for this reflection
        session_id = f"reflect_{uuid4()}"

        # Log reflection start
        logger.info(
            "reflection.start",
            session_id=session_id,
            component_text_length=len(component_text),
            trial_count=len(trials),
            component_name=component_name or "unknown",
            timeout_seconds=timeout_seconds,
            timeout_source=timeout_source,
        )

        # Prepare session state for template substitution
        session_state: dict[str, Any] = {
            "component_text": component_text,
            "trials": json.dumps(trials),
        }

        # Simple trigger message - data is in session state via template placeholders
        user_message = "Please improve the component text based on the trial results."

        try:
            result = await executor.execute_agent(
                agent=reflection_agent,
                input_text=user_message,
                session_state=session_state,
                **timeout_kwargs,
            )

            if result.status == ExecutionStatus.TIMEOUT:
                component = component_name or "unknown"
                logger.warning(
                    "reflection.timeout",
                    session_id=result.session_id,
                    component=component,
                    timeout_seconds=timeout_seconds,
                    timeout_source=timeout_source,
                )
                raise ReflectionTimeoutError(component, timeout_seconds)  # noqa: TRY301  # outer handler logs and re-raises it unchanged

            if result.status == ExecutionStatus.FAILED:
                logger.error(
                    "reflection.error",
                    session_id=result.session_id,
                    error=result.error_message,
                )
                raise RuntimeError(result.error_message or "Executor returned FAILED")  # noqa: TRY301  # outer handler logs and re-raises it unchanged

            proposed_component_text = result.extracted_value or ""
            captured = getattr(result, "captured_events", None)

            # A non-string value is left for the proposer's type check
            incomplete = (
                _incomplete_reason(proposed_component_text, captured, reasoning_tags)
                if isinstance(proposed_component_text, str)
                else None
            )
            if incomplete is not None:
                component = component_name or "unknown"
                logger.warning(
                    "reflection.incomplete",
                    session_id=result.session_id,
                    component=component,
                    finish_reason=incomplete,
                    response_length=len(proposed_component_text),
                )
                raise IncompleteProposalError(  # noqa: TRY301  # outer handler logs and re-raises it unchanged
                    component,
                    finish_reason=incomplete,
                    raw_text=proposed_component_text,
                )

            # Extract reasoning from captured events
            reasoning = extract_reasoning_from_events(captured)
            if reasoning:
                logger.debug(
                    "reflection.reasoning_captured",
                    reasoning_length=len(reasoning),
                )

            usage = _extract_token_usage(captured) if captured else None

            # Log reflection complete
            logger.info(
                "reflection.complete",
                session_id=result.session_id,
                response_length=len(proposed_component_text),
            )

        except (ReflectionTimeoutError, IncompleteProposalError):
            raise
        except Exception as e:
            logger.error(  # noqa: TRY400  # re-raised below; the caller owns the traceback
                "reflection.error",
                session_id=session_id,
                error=str(e),
                error_type=type(e).__name__,
            )
            raise
        else:
            # Handle empty response
            if not proposed_component_text:
                logger.warning(
                    "reflection.empty_response",
                    session_id=result.session_id,
                )
                return ("", reasoning, usage)

            return (proposed_component_text, reasoning, usage)

    return reflect

is_length_stop

is_length_stop(finish_reason: Any) -> bool

Report whether a finish reason marks output cut off at the token limit.

PARAMETER DESCRIPTION
finish_reason

A google.genai.types.FinishReason member, its name as a string in any case, litellm's "length", or None.

TYPE: Any

RETURNS DESCRIPTION
bool

True when the reason is MAX_TOKENS or LENGTH (case-insensitive);

bool

False for None, an empty value or any other reason.

Examples:

from google.genai import types

is_length_stop(types.FinishReason.MAX_TOKENS)  # True
is_length_stop("length")  # True
is_length_stop("STOP")  # False
is_length_stop(None)  # False
Source code in src/gepa_adk/engine/adk_reflection.py
def is_length_stop(finish_reason: Any) -> bool:
    """Report whether a finish reason marks output cut off at the token limit.

    Args:
        finish_reason: A ``google.genai.types.FinishReason`` member, its name
            as a string in any case, litellm's ``"length"``, or ``None``.

    Returns:
        True when the reason is ``MAX_TOKENS`` or ``LENGTH`` (case-insensitive);
        False for ``None``, an empty value or any other reason.

    Examples:
        ```python
        from google.genai import types

        is_length_stop(types.FinishReason.MAX_TOKENS)  # True
        is_length_stop("length")  # True
        is_length_stop("STOP")  # False
        is_length_stop(None)  # False
        ```
    """
    if finish_reason is None or finish_reason == "":
        return False
    name = getattr(finish_reason, "name", None)
    text = name if isinstance(name, str) else str(finish_reason)
    return text.upper() in _LENGTH_STOP_NAMES

detect_component_divergence

detect_component_divergence(
    ancestor_components: dict[str, str],
    parent_components: dict[str, str],
) -> set[str]

Detect which components have diverged from ancestor to parent.

Identifies component keys where the parent's value differs from the ancestor's value, indicating where improvements or changes occurred.

PARAMETER DESCRIPTION
ancestor_components

Component dictionary from ancestor candidate.

TYPE: dict[str, str]

parent_components

Component dictionary from parent candidate.

TYPE: dict[str, str]

RETURNS DESCRIPTION
set[str]

Set of component keys that have diverged (changed values).

Examples:

Detecting divergence:

ancestor = {"instruction": "A", "output_schema": "B"}
parent = {"instruction": "A", "output_schema": "C"}
divergence = detect_component_divergence(ancestor, parent)
# Returns: {"output_schema"}
Notes

Only checks components present in the ancestor. Components added by the parent are ignored. Missing components are not considered diverged.

Source code in src/gepa_adk/engine/genealogy.py
def detect_component_divergence(
    ancestor_components: dict[str, str],
    parent_components: dict[str, str],
) -> set[str]:
    """Detect which components have diverged from ancestor to parent.

    Identifies component keys where the parent's value differs from the
    ancestor's value, indicating where improvements or changes occurred.

    Args:
        ancestor_components (dict[str, str]): Component dictionary from ancestor candidate.
        parent_components (dict[str, str]): Component dictionary from parent candidate.

    Returns:
        Set of component keys that have diverged (changed values).

    Examples:
        Detecting divergence:

        ```python
        ancestor = {"instruction": "A", "output_schema": "B"}
        parent = {"instruction": "A", "output_schema": "C"}
        divergence = detect_component_divergence(ancestor, parent)
        # Returns: {"output_schema"}
        ```

    Notes:
        Only checks components present in the ancestor. Components added by
        the parent are ignored. Missing components are not considered diverged.
    """
    diverged: set[str] = set()

    for key, ancestor_value in ancestor_components.items():
        if key in parent_components and ancestor_value != parent_components[key]:
            diverged.add(key)

    logger.debug(
        "genealogy.component_divergence",
        diverged_components=sorted(diverged),
        total_components=len(ancestor_components),
    )

    return diverged

filter_ancestors_by_score

filter_ancestors_by_score(
    ancestors: set[int],
    candidate_scores: dict[int, dict[int, float]],
    min_avg_score: float,
) -> set[int]

Filter ancestors by minimum average score constraint.

Removes ancestors that don't meet the minimum average score requirement, ensuring only viable ancestors are considered for merge operations.

PARAMETER DESCRIPTION
ancestors

Set of ancestor candidate indices to filter.

TYPE: set[int]

candidate_scores

Mapping of candidate index to per-example scores.

TYPE: dict[int, dict[int, float]]

min_avg_score

Minimum average score threshold.

TYPE: float

RETURNS DESCRIPTION
set[int]

Set of ancestor indices that meet the score constraint.

Examples:

Filtering ancestors by score:

ancestors = {0, 1, 2}
candidate_scores = {
    0: {0: 0.5, 1: 0.5},  # avg: 0.5
    1: {0: 0.7, 1: 0.7},  # avg: 0.7
    2: {0: 0.9, 1: 0.9},  # avg: 0.9
}
filtered = filter_ancestors_by_score(
    ancestors, candidate_scores, min_avg_score=0.6
)
# Returns: {1, 2} (0 filtered out)
Notes

Operations exclude ancestors without scores from the result.

Source code in src/gepa_adk/engine/genealogy.py
def filter_ancestors_by_score(
    ancestors: set[int],
    candidate_scores: dict[int, dict[int, float]],
    min_avg_score: float,
) -> set[int]:
    """Filter ancestors by minimum average score constraint.

    Removes ancestors that don't meet the minimum average score requirement,
    ensuring only viable ancestors are considered for merge operations.

    Args:
        ancestors: Set of ancestor candidate indices to filter.
        candidate_scores: Mapping of candidate index to per-example scores.
        min_avg_score: Minimum average score threshold.

    Returns:
        Set of ancestor indices that meet the score constraint.

    Examples:
        Filtering ancestors by score:

        ```python
        ancestors = {0, 1, 2}
        candidate_scores = {
            0: {0: 0.5, 1: 0.5},  # avg: 0.5
            1: {0: 0.7, 1: 0.7},  # avg: 0.7
            2: {0: 0.9, 1: 0.9},  # avg: 0.9
        }
        filtered = filter_ancestors_by_score(
            ancestors, candidate_scores, min_avg_score=0.6
        )
        # Returns: {1, 2} (0 filtered out)
        ```

    Notes:
        Operations exclude ancestors without scores from the result.
    """
    filtered: set[int] = set()

    for ancestor_idx in ancestors:
        scores = candidate_scores.get(ancestor_idx)
        if scores:
            avg_score = fmean(scores.values())
            if avg_score >= min_avg_score:
                filtered.add(ancestor_idx)

    logger.debug(
        "genealogy.ancestors_filtered",
        original_count=len(ancestors),
        filtered_count=len(filtered),
        min_avg_score=min_avg_score,
    )

    return filtered

find_common_ancestor

find_common_ancestor(
    idx1: int,
    idx2: int,
    parent_indices: dict[int, list[int | None]],
) -> int | None

Find the most recent common ancestor of two candidates.

Identifies the common ancestor with the highest index (most recent) between two candidates. Returns None if no common ancestor exists (separate lineages).

PARAMETER DESCRIPTION
idx1

First candidate index.

TYPE: int

idx2

Second candidate index.

TYPE: int

parent_indices

Mapping of candidate index to parent indices list.

TYPE: dict[int, list[int | None]]

RETURNS DESCRIPTION
int | None

Index of the most recent common ancestor, or None if no common ancestor exists.

Examples:

Candidates sharing a seed:

parent_indices = {0: [None], 1: [0], 2: [0]}
ancestor = find_common_ancestor(1, 2, parent_indices)
# Returns: 0

One candidate is ancestor of the other:

parent_indices = {0: [None], 1: [0], 2: [1]}
ancestor = find_common_ancestor(1, 2, parent_indices)
# Returns: 1 (1 is ancestor of 2)

No common ancestor:

parent_indices = {0: [None], 1: [0], 2: [None], 3: [2]}
ancestor = find_common_ancestor(1, 3, parent_indices)
# Returns: None (separate lineages)
Notes

Operations return the highest-indexed common ancestor to ensure we find the most recent shared ancestor, which is most useful for merge operations.

Source code in src/gepa_adk/engine/genealogy.py
def find_common_ancestor(
    idx1: int, idx2: int, parent_indices: dict[int, list[int | None]]
) -> int | None:
    """Find the most recent common ancestor of two candidates.

    Identifies the common ancestor with the highest index (most recent) between
    two candidates. Returns None if no common ancestor exists (separate lineages).

    Args:
        idx1: First candidate index.
        idx2: Second candidate index.
        parent_indices: Mapping of candidate index to parent indices list.

    Returns:
        Index of the most recent common ancestor, or None if no common ancestor exists.

    Examples:
        Candidates sharing a seed:

        ```python
        parent_indices = {0: [None], 1: [0], 2: [0]}
        ancestor = find_common_ancestor(1, 2, parent_indices)
        # Returns: 0
        ```

        One candidate is ancestor of the other:

        ```python
        parent_indices = {0: [None], 1: [0], 2: [1]}
        ancestor = find_common_ancestor(1, 2, parent_indices)
        # Returns: 1 (1 is ancestor of 2)
        ```

        No common ancestor:

        ```python
        parent_indices = {0: [None], 1: [0], 2: [None], 3: [2]}
        ancestor = find_common_ancestor(1, 3, parent_indices)
        # Returns: None (separate lineages)
        ```

    Notes:
        Operations return the highest-indexed common ancestor to ensure we find the most
        recent shared ancestor, which is most useful for merge operations.
    """
    # If same candidate, return itself
    if idx1 == idx2:
        logger.debug(
            "genealogy.ancestor_found",
            idx1=idx1,
            idx2=idx2,
            ancestor_idx=idx1,
            relationship="same_candidate",
        )
        return idx1

    ancestors1 = get_ancestors(idx1, parent_indices)
    ancestors2 = get_ancestors(idx2, parent_indices)

    # Include the candidates themselves in case one is an ancestor of the other
    if idx1 in ancestors2:
        # idx1 is an ancestor of idx2
        logger.debug(
            "genealogy.ancestor_found",
            idx1=idx1,
            idx2=idx2,
            ancestor_idx=idx1,
            relationship="idx1_is_ancestor",
        )
        return idx1

    if idx2 in ancestors1:
        # idx2 is an ancestor of idx1
        logger.debug(
            "genealogy.ancestor_found",
            idx1=idx1,
            idx2=idx2,
            ancestor_idx=idx2,
            relationship="idx2_is_ancestor",
        )
        return idx2

    # Find common ancestors
    common = ancestors1 & ancestors2

    if not common:
        logger.debug(
            "genealogy.no_common_ancestor",
            idx1=idx1,
            idx2=idx2,
        )
        return None

    # Return most recent (highest index) common ancestor
    ancestor_idx = max(common)
    logger.debug(
        "genealogy.common_ancestor_found",
        idx1=idx1,
        idx2=idx2,
        ancestor_idx=ancestor_idx,
        all_common=sorted(common),
    )

    return ancestor_idx

get_ancestors

get_ancestors(
    candidate_idx: int,
    parent_indices: dict[int, list[int | None]],
) -> set[int]

Return all ancestor indices for a candidate.

Traverses the genealogy tree using breadth-first search to find all ancestors transitively. Seed candidates (with [None] parents) have no ancestors.

PARAMETER DESCRIPTION
candidate_idx

Index of the candidate to trace.

TYPE: int

parent_indices

Mapping of candidate index to parent indices list.

TYPE: dict[int, list[int | None]]

RETURNS DESCRIPTION
set[int]

Set of all ancestor candidate indices (excluding the candidate itself).

Examples:

Simple linear genealogy:

parent_indices = {0: [None], 1: [0], 2: [1]}
ancestors = get_ancestors(2, parent_indices)
# Returns: {0, 1}

Merge candidate with two parents:

parent_indices = {0: [None], 1: [0], 2: [0], 3: [1, 2]}
ancestors = get_ancestors(3, parent_indices)
# Returns: {0, 1, 2}
Notes

Operations use BFS to avoid recursion depth issues with deep genealogies. Prevents cycles by tracking visited nodes.

Source code in src/gepa_adk/engine/genealogy.py
def get_ancestors(
    candidate_idx: int, parent_indices: dict[int, list[int | None]]
) -> set[int]:
    """Return all ancestor indices for a candidate.

    Traverses the genealogy tree using breadth-first search to find all
    ancestors transitively. Seed candidates (with [None] parents) have
    no ancestors.

    Args:
        candidate_idx: Index of the candidate to trace.
        parent_indices: Mapping of candidate index to parent indices list.

    Returns:
        Set of all ancestor candidate indices (excluding the candidate itself).

    Examples:
        Simple linear genealogy:

        ```python
        parent_indices = {0: [None], 1: [0], 2: [1]}
        ancestors = get_ancestors(2, parent_indices)
        # Returns: {0, 1}
        ```

        Merge candidate with two parents:

        ```python
        parent_indices = {0: [None], 1: [0], 2: [0], 3: [1, 2]}
        ancestors = get_ancestors(3, parent_indices)
        # Returns: {0, 1, 2}
        ```

    Notes:
        Operations use BFS to avoid recursion depth issues with deep genealogies.
        Prevents cycles by tracking visited nodes.
    """
    ancestors: set[int] = set()
    visited: set[int] = {candidate_idx}
    queue: deque[int] = deque([candidate_idx])

    while queue:
        current = queue.popleft()
        parents = parent_indices.get(current, [])

        for parent in parents:
            if parent is not None and parent not in visited:
                ancestors.add(parent)
                visited.add(parent)
                queue.append(parent)

    logger.debug(
        "genealogy.ancestors_found",
        candidate_idx=candidate_idx,
        ancestor_count=len(ancestors),
        ancestors=sorted(ancestors),
    )

    return ancestors

has_desirable_predictors

has_desirable_predictors(
    ancestor_components: dict[str, str],
    parent1_components: dict[str, str],
    parent2_components: dict[str, str],
) -> bool

Check if merge has desirable complementary component changes.

A merge is desirable when parents have changed different components from the ancestor, indicating complementary improvements that can be combined.

PARAMETER DESCRIPTION
ancestor_components

Component dictionary from common ancestor.

TYPE: dict[str, str]

parent1_components

Component dictionary from first parent.

TYPE: dict[str, str]

parent2_components

Component dictionary from second parent.

TYPE: dict[str, str]

RETURNS DESCRIPTION
bool

True if parents have complementary component changes, False otherwise.

Examples:

Complementary changes (desirable):

ancestor = {"instruction": "A", "output_schema": "B"}
parent1 = {"instruction": "A", "output_schema": "C"}  # output_schema changed
parent2 = {"instruction": "D", "output_schema": "B"}  # instruction changed
assert has_desirable_predictors(ancestor, parent1, parent2) is True

Overlapping changes (less desirable):

ancestor = {"instruction": "A", "output_schema": "B"}
parent1 = {"instruction": "C", "output_schema": "B"}
parent2 = {"instruction": "C", "output_schema": "B"}  # Same change
assert has_desirable_predictors(ancestor, parent1, parent2) is False
Notes

Operations return False if no components have changed, or if both parents changed the same components identically.

Source code in src/gepa_adk/engine/genealogy.py
def has_desirable_predictors(
    ancestor_components: dict[str, str],
    parent1_components: dict[str, str],
    parent2_components: dict[str, str],
) -> bool:
    """Check if merge has desirable complementary component changes.

    A merge is desirable when parents have changed different components
    from the ancestor, indicating complementary improvements that can
    be combined.

    Args:
        ancestor_components: Component dictionary from common ancestor.
        parent1_components: Component dictionary from first parent.
        parent2_components: Component dictionary from second parent.

    Returns:
        True if parents have complementary component changes, False otherwise.

    Examples:
        Complementary changes (desirable):

        ```python
        ancestor = {"instruction": "A", "output_schema": "B"}
        parent1 = {"instruction": "A", "output_schema": "C"}  # output_schema changed
        parent2 = {"instruction": "D", "output_schema": "B"}  # instruction changed
        assert has_desirable_predictors(ancestor, parent1, parent2) is True
        ```

        Overlapping changes (less desirable):

        ```python
        ancestor = {"instruction": "A", "output_schema": "B"}
        parent1 = {"instruction": "C", "output_schema": "B"}
        parent2 = {"instruction": "C", "output_schema": "B"}  # Same change
        assert has_desirable_predictors(ancestor, parent1, parent2) is False
        ```

    Notes:
        Operations return False if no components have changed, or if both parents
        changed the same components identically.
    """
    divergence1 = detect_component_divergence(ancestor_components, parent1_components)
    divergence2 = detect_component_divergence(ancestor_components, parent2_components)

    # No divergence means no desirable merge
    if not divergence1 or not divergence2:
        logger.debug(
            "genealogy.no_desirable_predictors",
            reason="no_divergence",
            divergence1=sorted(divergence1),
            divergence2=sorted(divergence2),
        )
        return False

    # Check if divergences are complementary (different components or different values)
    # If they overlap but have different values, that's still desirable
    if divergence1 != divergence2:
        # Different components changed - complementary
        logger.debug(
            "genealogy.desirable_predictors",
            divergence1=sorted(divergence1),
            divergence2=sorted(divergence2),
            reason="complementary_components",
        )
        return True

    # Same components changed - check if values differ
    for component in divergence1:
        if parent1_components.get(component) != parent2_components.get(component):
            # Same component, different values - still desirable
            logger.debug(
                "genealogy.desirable_predictors",
                divergence1=sorted(divergence1),
                divergence2=sorted(divergence2),
                reason="different_values",
            )
            return True

    # Same components, same values - not desirable
    logger.debug(
        "genealogy.no_desirable_predictors",
        reason="identical_changes",
        divergence1=sorted(divergence1),
        divergence2=sorted(divergence2),
    )
    return False