Architecture¶
This document explains how AgentDescent's components fit together and how a diff travels from a worker to a committed change in the shared artifact library.
For the why behind each mechanism see concepts.md; for how to run the system see install and first run; for extending it, run everything, and extend it.
1. The one-paragraph model¶
AgentDescent runs N workers in parallel. Each worker takes a snapshot of a
shared, versioned artifact library (the Ledger), runs tasks against it,
and emits a diff + evidence card (a "gradient"). A single Aggregator
(the "optimizer") collects these diffs per-artifact, resolves conflicts, fuses
complementary ones, accepts them by a statistical test, and commits the winner
back to the Ledger — which is then broadcast to workers. Everything else
(schedulers, verifier, governance) exists to make that loop fast, safe, and
resistant to the three long tails.
2. Data flow¶
flowchart TD
TS["TaskScheduler (UCB)<br/>leases task clusters"] -->|lease| W1[Worker 1]
TS -->|lease| W2[Worker 2]
TS -->|lease| WN[Worker N]
W1 -->|diff + evidence + base_version| EB
W2 --> EB
WN --> EB
EB["EvidenceBuffer<br/>bucketed by artifact, thread-safe"] --> AG
subgraph AG["Aggregator — per bucket"]
direction TB
S1[1. staleness filter η vs α] --> S2[2. conflict resolution]
S2 --> S3[3. fusion tournament]
S3 --> S7["4. audit gate (force_oracle)"]
S7 --> S4["5. Beta acceptance P(Δ>0) > 1−δ"]
S4 --> S5[6. commit CAS]
S5 --> S6[7. dual-branch dev→stable]
end
AG -->|commit| LG["Ledger (git-backed)<br/>dev + stable branches"]
LG -->|broadcast changed artifact| W1
LG -->|broadcast| W2
LG -->|broadcast| WN
S7 -->|Ĝ priority| AUD["AuditScheduler → Oracle"]
AUD -->|veto: oracle-rejected| OUT[dropped]
The same flow, with the design-doc section numbers annotated:
┌──────────────────────────────────────────────┐
│ TaskScheduler (UCB) │ §5.2
│ leases task clusters to workers │
└───────────────┬──────────────────────────────┘
lease tasks │
┌─────────────┬──────────┴────────┬─────────────┐
▼ ▼ ▼ ▼
Worker 1 Worker 2 ... Worker N each holds a Ledger
rollout+ rollout+ rollout+ snapshot V_i (may lag
propose propose propose head → staleness η)
│ │ │ │
└──── Diff + EvidenceCard + base_version ─────────┘
│
▼
EvidenceBuffer (bucketed by artifact, thread-safe) §4.1
│
▼
┌─────────────────────────────────────────────────────┐
│ Aggregator (per bucket) │ §4
│ 1. staleness filter η vs α → ACCEPT/REBASE/DISCARD│ §4.2
│ 2. conflict resolve contradictions dropped │ §4.3
│ 3. fusion tournament complementary diffs merged │ §4.3
│ 4. audit gate oracle may VETO here │ §5.3
│ 5. Beta acceptance P(Δ>0) > 1−δ │ §4.4
│ 6. commit CAS (one artifact per merge) │ §4.1
│ 7. dual-branch dev → stable, K clean rounds │ §4.5
└───────────────────────────┬─────────────────────────┘
▼
Ledger (git-backed, version-vectored) §3.1
dev branch (fast) stable branch (EMA-confirmed)
│
broadcast changed artifact → Workers refresh
(step 4 above submits the candidate to the AuditScheduler,
which spends the oracle budget by Ĝ and may VETO it) §5.3
The three-layer verifier (rule / learned / oracle) is the evaluation backend the Aggregator calls at steps 1–3 and 5 (cheap) and at step 4 (oracle, budgeted).
The audit is a gate, not a spot-check
It runs before the acceptance test and can return oracle-rejected outright,
so it sits on the critical path of every merge that trips force_oracle — the
diagrams used to place it after the commit with a dotted "spot-check" arrow,
which reads as advisory when it holds a veto.
3. Component responsibilities¶
| Component | Module | Responsibility |
|---|---|---|
| Evolvable | evolvable.py |
The interface every unit of evolution implements (diff/apply/cheap_eval/full_eval). Also Diff, EvidenceCard, version-vector math. |
| Ledger | ledger.py |
Git-backed store. Per-artifact integer versions form the version vector. CAS commits and dev/stable branches; commit_atomic (2PC across artifacts) is provided and tested but not used by any engine path today. Runs git with an isolated config (no system/user gitconfig, no hooks, no signing) so a personal git preference cannot decide whether the ledger can write. |
| Aggregator | aggregator.py |
The optimizer. Buckets evidence by artifact and runs the 7-step merge pipeline. Owns the per-artifact Beta posteriors. |
| StalenessPolicy | staleness.py |
Full / Guarded / Reflective. Decides ACCEPT/REBASE/DISCARD for a stale diff. Swappable without touching the pipeline. |
| Verifier | verifier.py |
rule (cheap subset), learned (noisy + uncertainty), oracle (ground truth, budgeted). The cheap subset is fixed for the run, so candidates ranked against each other are always scored on the same tasks; evolve(cheap_eval_tasks=) sizes it. |
| Schedulers | scheduler.py |
TaskScheduler (UCB over task clusters — the design's × artifact axis is not implemented), AuditScheduler (oracle-budget allocation + trust; its priority queue has no consumer), ResumeQueue (straggler records; nothing resumes them — see §4). |
| Governance | governance.py |
classify is the single definition of the L1/L2 boundary (FAST_MAX = 0.30), used by the aggregator's staleness tolerance and the audit gate rather than re-derived. L0 is reached by name, not radius, and is read-only to the loop. L1SerialGate is a primitive for concurrent merging, not in the path — the shipped runtimes merge on one thread. |
| Worker | worker.py |
rollout + propose. Emits evidence cards; never mutates the Ledger directly. |
| Sync runtime | orchestrator.py |
AgentDescent: round-barrier DP loop + fork baseline. |
| Async runtime | async_runtime.py |
AsyncAgentDescent: barrier-free thread pipeline + async_ratio + backpressure. |
| Parallel paradigms | parallel.py |
DP / TP / PP partition & recombine primitives. |
| Reference domain | domains/router.py |
A deterministic keyword-router skill so the whole loop runs with no LLM. See Orchestrator. |
| Strategies | evolution.py |
What the artifact is and how a proposal becomes a diff: SingleSlot / AppendRules / KeyedRules. The key space is the op-space the aggregator merges over. See Strategies. |
| Directory evolution | filetree.py · treestrategy.py · runners.py |
A directory as artifact state: state keys are file paths, so file-level fuse/contradict semantics come from the existing aggregator unchanged. The runner materialises each candidate into a throwaway workspace for a real agent. See Directory evolution. |
| Agent layer | agents.py · backends.py |
The provider-agnostic prompt -> text contract, WorkspaceAgent for agents that act in a directory, and the document-task adapter. See Agents and Backends. |
| Task sampling | sampling.py |
Which task inside a worker's shard to roll out next — RoundRobin or UCB over learning signal. See Sampling. |
3.5 What the infrastructure owns, and what the algorithm owns¶
An evolution algorithm decides what to try and what to keep. Everything else --
where a rollout runs, how many run at once, what happens when one dies -- is
machinery. agentdescent/policies.py is where that line is written down, as
Protocol definitions with no implementations.
| owns | |
|---|---|
| the algorithm | task sampling · proposal generation · conflict resolution · fusion · staleness · acceptance · promotion |
| the infrastructure | sandbox provisioning, placement, reuse and reclamation · quotas and admission · processes and re-dispatch · secret injection · environment fingerprints · all measurement |
And one thing neither owns, stated as a rule because it is easy to violate by accident:
The algorithm may not see sandbox, process or host identity. A policy that decides differently because a candidate came from worker 3 makes the run irreproducible, and makes any comparison between parallel configurations meaningless.
The testable form of that rule is purity: the default policies are functions of
(artifact, cards, versions) and read no ambient process state -- not
os.environ, not the working directory, not the clock. Multiple agents running
the same algorithm in different sandboxes is only a coherent idea if this holds.
Two consequences worth knowing when you write a policy:
- Contracts are written from the call sites, not from these docs. This page
once described the verifier as three methods when the merge path calls four; a
verifier written from the page raised
AttributeErrorhalf an hour into a run.tests/test_policy_contract.pynow greps the call sites and fails if the contract drifts. - Everything replaceable arrives in one argument.
evolve(policies=...)takes aPoliciesbundle; the individual keyword arguments are shortcuts onto its fields and keep working. A field whose implementation has not landed raises rather than being accepted and ignored.
4. The two runtimes¶
There is one engine now, and two runtimes on top of it
There used to be two stacks. The data-flow diagram above described the
reference one --
AgentDescent
and AsyncAgentDescent
with their own loops, worker dispatch and merger thread — while
evolve() / async_evolve(), the entry point every algorithm
port uses, implemented the same shape again. This page called that "a known
wart rather than a design intent", and it kept costing: two measured fixes
hand-ported, two early-stop epsilons nobody chose, and three mechanisms the
general engine re-derived — and got wrong — because the reference stack
already had them.
Both reference classes are now adapters: they describe the reference
domain in the vocabulary evolve() and async_evolve() speak and run that.
Their public surface is unchanged, and the numbers on the
results page still come out of them.
What the general engine gained on the way, each of which was a real gap for ordinary callers and not just migration scaffolding:
evolve(refresh_interval=N) |
synchronous staleness. Without it eta was 0 by construction, so staleness_policy= could not change a single decision on that path |
RoundInfo.considered / discarded_stale / conflicts_dropped / fused |
what the merge did, per round — which RoundStat and AsyncStats had all along |
ClusterParallel + ParallelStrategy.observe |
UCB task-cluster leasing (§5.2 / L-task) moves to the general engine rather than being lost with the reference TaskScheduler |
Partial-rollout resume is still unimplemented — run(rendered, task) ->
output is opaque, so there is no continuation state to check point; both
paths detect and count stragglers. What does exist is recovery one level
coarser: a
task whose worker is lost is re-dispatched whole,
under the same lease id so a late answer from the original can be dropped.
Three things the domain translation does not preserve exactly, listed in
agentdescent/domains/router.py: before_after_delta and evidence_eval
are measured over the whole cluster rather than the failing subset, and noise
is per proposal rather than per worker — the general engine has one propose
for every worker and, by design, no worker identity to branch on.
AgentDescent separates what to merge (the Aggregator, identical in both) from when workers and the aggregator run relative to each other (the runtime).
4.1 Synchronous DP — AgentDescent (orchestrator.py)¶
A round barrier:
for round in range(R):
leases = scheduler.select_batch(n_workers) # UCB-ordered, cycling if
# there are fewer clusters
for worker, cluster in zip(workers, leases):
card = worker.run(snapshot, base_version, cluster.tasks)
aggregator.ingest(card)
aggregator.step() # <-- barrier: one sweep per round
Deterministic and easy to reason about. Used for the RQ1 (merge-vs-fork) and RQ2 (staleness sweep) experiments.
4.2 Asynchronous stage orchestration — AsyncAgentDescent (async_runtime.py)¶
No barrier. Threads run independently:
worker thread (× N) aggregator thread (× 1)
────────────────── ───────────────────────
loop: loop:
if drift > async_ratio: refresh reports = aggregator.step()
cluster = lease_round_robin() update published head on commit
card = worker.run(...) sample accuracy
aggregator.ingest(card) if stalled: bump refresh epoch
Connected only through the thread-safe EvidenceBuffer. The rollout/propose and
aggregate/commit stages overlap — a worker keeps proposing while the
aggregator is still merging the previous batch.
5. Concurrency & correctness¶
Because the reference runtime uses in-process threads, shared state is guarded explicitly:
- Ledger — an
RLockserializes all git operations; CAS is what makes the logical concurrency safe (a commit whose declared base version is stale is rejected, forcing a rebase). - EvidenceBuffer — an internal lock guards the per-artifact buckets so many
worker threads can
add()while the aggregator threaddrain()s. - TaskScheduler — a lock guards UCB state so concurrent
lease_*/recordcalls don't race. - Verifier / posteriors — touched only by the single aggregator thread, so they need no locking.
A ledger failure is its own category, distinct from a caller bug (ContractError,
propagated) and a backend blip (absorbed and retried): it is infrastructure, it
ends the run, and the drivers still return the artifact evolved so far rather than
raising. See the failure-category table in evolution.md.
The GIL means threads don't give true CPU parallelism, but the pipeline overlap and every concurrency-control mechanism (CAS, version vectors, per-diff staleness, backpressure) are real — the same code shape drives a genuinely parallel process or multi-host pool.
6. Version vectors & staleness in one picture¶
head (dev): {mol-router: 7}
worker A base: {mol-router: 7} → η = 0 → ACCEPT
worker B base: {mol-router: 5} → η = 2 → REBASE (if η ≤ α) or DISCARD
worker C base: {mol-router: 1} → η = 6 → DISCARD (Guarded) / REBASE (Reflective)
η(d) = max over touched artifacts (head_version − base_version). The active
StalenessPolicy maps (η, α, contract_breaking) to an action; async_ratio
(async runtime only) bounds how large η is allowed to grow before a worker is
forced to refresh. See concepts.md §3 for the full
treatment.