The aggregator (the optimizer)¶
Plugs into
evolveviaagg_config=(tune the reference aggregator) oraggregator_factory=(replace it entirely).
The aggregator is the framework's optimizer — the discrete-space analogue of an optimizer step. It's the one place the training analogy breaks: gradients add, diffs do not, so aggregation is not averaging but conflict resolution + statistical acceptance + transactional commit. Every accepted change to the shared ledger goes through it.
Module: agentdescent.aggregator
· API: Aggregator, AggregatorConfig, MergeOutcome, …
· Neighbours: verifier scores candidates · ledger commits them ·
staleness decides what to do with an out-of-date diff ·
governance decides how hard to gate.
Replacing a decision¶
The seven decisions a merge makes are objects, and each can be swapped without
touching Aggregator:
from agentdescent import Policies, evolve
from agentdescent.policies import AcceptDecision
class AcceptEverything:
def accept(self, ctx):
return AcceptDecision(True, "committed")
evolve(tasks, reward, agent=agent, policies=Policies(acceptance=AcceptEverything()))
| decision | default | what it is given |
|---|---|---|
| task sampling | RoundRobin |
the shard's task ids and the round index |
| proposal generation | your propose= callable |
a ProposalContext |
| conflict | DefaultConflict |
the artifact and the surviving cards |
| fusion | DefaultFusion |
the artifact and the kept diffs |
| staleness | GuardedStaleness |
eta, alpha, whether the diff breaks a contract |
| acceptance | DefaultAcceptance |
a MergeContext |
| promotion | DefaultPromotion |
this round's MergeReports |
Two things the defaults know that a replacement should be told:
- Acceptance reads the full held-out set, never the cheap layer.
MergeContextcarries both (base_countsvsbase_cheap) because the regression guard once read the cheap one, whichcheap_eval_taskssub-samples -- so a four-task sample could veto a commit the full-set test had just approved. - Promotion counts rounds survived, not commits. Counting commits inverts the incentive: an artifact that converges stops committing and so can never be promoted, while one that thrashes promotes every K commits.
Not replaceable, deliberately: the audit gate. It asks whether the cheap layer is still trustworthy -- a question about the measuring instrument, which belongs to the infrastructure rather than the algorithm.
What it does (per artifact bucket)¶
Evidence cards are bucketed by artifact; a bucket fires on batch size B or a
T_max timeout. Then, in order:
- Staleness filter — per-diff
ηvsα; the staleness policy decidesACCEPT / REBASE / DISCARD. - Conflict resolution — contradictory diffs (same key, different value) are projected out PCGrad-style; keep the better of the pair, iterating until no surviving pair contradicts. Key overlap alone is not a conflict — identical proposals are duplicates and dedupe.
- Fusion tournament — complementary diffs are fused (model-soup style) and run against the singles on held-out; the best wins.
- Audit gate — the candidate is submitted to the
AuditScheduler; a high-blast-radius / low-trust merge is forced through the oracle, which can veto it outright (oracle-rejected) before the acceptance test runs. The optimizer audits itself. This is a blocking gate on the accept path, not a post-commit spot-check. - Statistical acceptance — commit only if
P(Δ > 0) > 1 − δunder a Beta posterior comparison (not a point threshold);δanneals with version. - Commit — compare-and-swap on
dev, one artifact per merge. TheLedgeralso offerscommit_atomic(2PC across several artifacts, for a contract-breaking diff that must land with its adapters), but the reference aggregator buckets by artifact and never needs it — no engine path calls it today. - Dual-branch promotion —
dev → stableafter K regression-free rounds on dev. One round is onestep(). A commit restarts the clock (the new version has survived nothing yet) and so does an oracle rejection. So a converged artifact — one that stopped committing because nothing beats it — is the one most likely to be promoted, which is the point. A run that ends cleanly also publishes its head viafinalize(), so stopping ontarget_rewarddoes not leavestablea confirmation short.
Deep dive on the why: concepts §4.
Tuning — agg_config= (AggregatorConfig)¶
Keep the reference pipeline, change its knobs:
from agentdescent import AggregatorConfig
evolve(tasks, reward, agent=agent, agg_config=AggregatorConfig(
batch_trigger=2, # fire a merge once this many proposals collect for an artifact
max_wait_rounds=1, # ...or after this many rounds (so cold artifacts don't starve)
base_delta=0.5, # acceptance risk: commit iff P(Δ>0) > 1-δ, annealed by version
alpha_head=5, # staleness tolerance for hot artifacts
alpha_tail=1, # ...and for cold ones
trust_region_ops=6, # max edits per diff
promote_after_k=3, # dev -> stable after K regression-free rounds (EMA)
))
| Field | Controls |
|---|---|
batch_trigger / max_wait_rounds |
when a bucket fires (size vs timeout) |
base_delta |
acceptance strictness (1 − δ threshold), annealed by version |
alpha_head / alpha_tail |
staleness tolerance α (hot vs cold artifacts) |
trust_region_ops |
diff-size cap (the trust region) |
promote_after_k |
dev→stable after this many regression-free rounds (EMA) |
anneal_half_life |
how fast the acceptance threshold tightens with version |
accept_samples |
Monte-Carlo draws behind each acceptance decision |
anneal_half_life sets the shape of a long run
base_delta was exposed; the half-life that turns it into the actual
threshold was a default argument inside stats.annealed_delta, reachable from
nothing a caller touches. It decides how quickly committing gets harder:
| artifact version | P(Δ>0) must exceed |
|---|---|
| 1 | 0.505 |
| 64 | 0.750 |
| 128 | 0.875 |
| 256 | 0.969 |
| 400+ | 0.990 (floor) |
Version counts commits, so a run with many small accepted diffs reaches the floor much sooner than one with a few large ones.
Making the cheap layer actually cheap — evolve(cheap_eval_tasks=)
The aggregator scores candidates twice for two different reasons, and only one of them needs to be exact:
| what it decides | cost | |
|---|---|---|
| cheap layer | which candidate to put forward — conflict resolution, the fusion tournament | once per candidate |
acceptance test (eval_counts) |
whether to commit it | once per merge |
evolve() used to pin the cheap layer to the whole held-out set, so rule /
learned / oracle were one full sweep wearing three names — and on an LLM
workload eval_fn runs the agent, so a round paid a full sweep for every
candidate it merely wanted to rank. oracle_budget capped nothing either: its
documented fallback (rule_eval) returned the very value it was trying to
avoid buying.
cheap_eval_tasks=N scores N held-out tasks for ranking. The acceptance test
still uses the full set — as does the regression guard beside it — so this
trades ranking precision, never commit safety.
The sample is fixed for the run — it used to be redrawn on every call,
which is harmless only while the "sample" is the whole set, and silently scores
candidate A on {1,3,5} against candidate B on {2,4,6} the moment it is not.
Default is None (exact), so nothing changes unless you opt in.
Replacing — aggregator_factory= (AggregatorProtocol)¶
To change the logic, not just the knobs, plug in your own aggregator. The contract is two methods:
from typing import Protocol, List
from agentdescent import EvidenceCard
from agentdescent import MergeReport
class AggregatorProtocol(Protocol):
def ingest(self, card: EvidenceCard) -> None: ... # a worker's diff + evidence
def step(self) -> List[MergeReport]: ... # decide what to merge now
Your aggregator is checked, and its mistakes are not hidden
The factory's result must have callable ingest and step — missing either
raises before the first rollout, naming what is absent. step() must return a
list of MergeReport; returning None or a list of something else raises
AggregatorContractError naming your class, instead of surfacing as
'NoneType' object is not iterable from inside the driver.
That error, RewardContractError and ProposalContractError all derive from
ContractError, and both engines let it propagate. A backend failure (a
rate limit, a dead endpoint) is absorbed and reported through result.error so
a long run keeps its partial results — a broken contract in your own code is
not, because the run is meaningless either way and hiding it wastes the budget.
evolve builds the aggregator through a factory that receives the runtime
deps it owns — (ledger, verifier, audit, config, staleness_policy) — and
returns any AggregatorProtocol:
from agentdescent import Aggregator
class StrictAggregator(Aggregator):
def _tournament(self, artifact, diffs):
# e.g. never fuse -- evaluate only single diffs
return super()._tournament(artifact, [diffs[0]] if diffs else diffs)
def factory(ledger, verifier, audit, config, staleness_policy):
return StrictAggregator(ledger, verifier, audit, config,
staleness_policy=staleness_policy)
evolve(tasks, reward, agent=agent, aggregator_factory=factory)
Override points on the reference Aggregator¶
The easiest customization is subclassing and overriding one decision; each stage above is a method:
| Method | Stage you're changing |
|---|---|
_staleness_filter(artifact, head, cards) |
which stale diffs survive / rebase |
_resolve_conflicts(artifact, cards) |
how contradictions are dropped |
_tournament(artifact, diffs) |
fusion + candidate selection |
_process(artifact_id) |
the acceptance test / commit block |
ingest / step |
buffering + when merges fire |
From scratch¶
You don't have to subclass — anything with ingest + step works. A trivial
"accept-everything, no merge" aggregator (for a baseline):
class NaiveAggregator:
def __init__(self, ledger, verifier, audit, config, staleness_policy):
self.ledger, self._pending = ledger, []
def ingest(self, card): self._pending.append(card)
def step(self):
# ... apply each pending diff to the ledger head, no conflict/acceptance ...
self._pending.clear()
return []
evolve(tasks, reward, agent=agent, aggregator_factory=NaiveAggregator)
Use this to A/B your own merge/acceptance policy against the reference optimizer while keeping the rest of the loop (agents, strategy, parallelism, governance) unchanged.
The async optimizer variant — SGD-style descent¶
On the barrier-free async path the expensive step is usually the held-out eval (an agent rollout per validation item). Validating every candidate — the reference greedy hill-climb and most frontier optimizers — makes held-out the wall-clock bottleneck when workers propose faster than one full eval completes.
An aggregator can amortise it, exactly like mini-batch SGD amortises the validation pass over many gradient steps:
- Apply each incoming diff as a cheap update step (
ingestaccumulates,stepcommits the moved head so workers immediately build on it) — no eval. - Validate every
Nsteps. Score the accumulated head on held-out once per N applied updates, not once per update. - Keep or roll back. If the mini-batch improved held-out, checkpoint it; otherwise roll back the head to the last validated checkpoint.
This costs ~N× fewer held-out evals. It is a different acceptance rule from
the per-candidate frontier — a deliberate async acceleration — so a faithful port
keeps the strict per-candidate optimizer on the sync path and switches to the
SGD variant only when asynchronous=True. EvoSkill's
SgdSkillAggregator is the worked example (val_every=N, checkpoint + rollback);
its sync path keeps the strict TopKFrontierAggregator.
class SgdMerger: # apply-then-periodically-validate, roll back on no gain
def __init__(self, ledger, verifier, ctx, artifact_id):
self.ledger, self.verifier, self.ctx, self.aid = ledger, verifier, ctx, artifact_id
self.cards, self.checkpoint, self.ckpt_score, self.steps = [], {}, 0.0, 0
def ingest(self, card): self.cards.append(card)
def step(self):
head = self.ledger.snapshot(Ledger.DEV).get(self.aid)
cards, self.cards = self.cards, []
for c in cards: # 1. apply updates, no eval
head = head.apply(c.diff); self.steps += 1
self._commit(head.state) # move the head; workers build on it
if self.steps >= self.ctx.val_every: # 2. validate every N steps
score = self._eval(head)
if score > self.ckpt_score: # 3. keep ...
self.checkpoint, self.ckpt_score = dict(head.state), score
else: # ... or roll back to checkpoint
self._commit(self.checkpoint)
self.steps = 0
return [...]
Because apply() only merges ops, a rollback that must drop skills added
since the checkpoint commits a full replacement artifact (exact state), not a
diff. The pending-intake lag budget
keeps the mini-batch bounded so one step() never faces an unbounded pile.
Example optimizers (from the algorithm ports)¶
The self-evolution examples are, at heart, custom
aggregator_factory= optimizers — each swaps the reference greedy hill-climb for
a paper's own selection/acceptance rule. They are all AggregatorProtocol
implementations you can read and reuse:
| Aggregator | Example | Selection / acceptance rule |
|---|---|---|
ParetoAggregator |
GEPA | per-instance Pareto frontier sampling (Algorithm 2); commits the sampled Pareto parent as the dev head |
TopKFrontierAggregator |
EvoSkill | bounded top-K aggregate frontier (sync path); commits the best member as the head |
SgdSkillAggregator |
EvoSkill | async SGD-style descent: apply skill updates, validate every val_every steps, roll back on no held-out gain |
StrictGateAggregator |
SkillOpt | strict held-out-EM gate + rejected-edit buffer + integer LR budget |
MetaSearchAggregator |
ADAS | keep-all archive with bootstrap-CI fitness (L1 harness) |
DGMArchiveAggregator |
DGM | keep-all archive + staged eval + sigmoid(perf)×1/(1+children) parent selection (L1) |
They share one trick: the archive/frontier/gate sets the dev head to the
selected parent each step(), so evolve()'s next round mutates it — that is
how non-greedy selection rides the greedy loop.