car-topology
Amortized coordination-topology selection: pick which communication topology a multi-agent run should use, from a short list, in one feed-forward pass, scored on measured utility and measured tokens.
Implements the design of "Codebook Agent: Amortized Topology Design for LLM Multi-Agent Systems" (Yu, Li, Jiang et al., UCLA, 2026) as a deterministic, serde-only leaf crate.
Why CAR wants this
CAR already decides how to coordinate agents, twice over, and neither decision learns anything:
car_agents::coordinator::Coordinatorspends a full LLM round trip on a hand-written prompt to choose among four patterns. A pattern that solved the task at 3× the token cost is indistinguishable, to the next call, from one that solved it cheaply.car-multiexecutes whichever pattern the caller named.
Every coordination run already carries its own cost — TokenAccounting rides
on every AgentOutput. This crate is the missing half: fit a selector on what
runs actually cost and actually achieved, and replace the round trip with a fold
over data CAR can already produce.
The three measurements the design rests on
The paper's argument is empirical, not architectural. It does not claim a
codebook beats a decoder in general — it measures three properties of the data
and shows the incumbent design is misaligned with them. Those are properties of
a workload, so they can be false for yours. diagnostics re-measures each on
your own records, and you should run it first.
| Claim | What the paper measured | Your check |
|---|---|---|
| The useful design space is a short list | Reward-surviving topologies collapse to ~6 distinct graphs even as codebook capacity grows 8 → 64 | design_space_collapse |
| The structural cost surrogate is inverted | Edge count correlates with measured tokens at r ≈ -0.4 — so minimizing |E| raises the bill it was introduced to cut |
edge_count_token_correlation |
| Message-passing scorers are topology-blind on homogeneous teams | With identical agent profiles every aggregator pools identical features, so every candidate scores the same and the ranking does not exist | team_homogeneity / scorer_advice |
diagnose(&records, &[], &Default::default()) folds the two claims a record set
can answer into one DiagnosticsReport, with render() for a terminal and
headline() for a log line. The third claim needs the team's agent profiles,
which execution records do not carry, so it reads as NOT MEASURED unless you
call diagnose_with_profiles — a gap named rather than papered over. The report
also carries a Sufficiency verdict, because 60 records over 10 tasks is well
under the paper's own protocol (300 over 50) and should not read like a finding.
topology diagnostics
records: 60 over 10 tasks, team size 4, embedder all-MiniLM-L6-v2
sample: THIN — 60 records over 10 tasks, below the paper's protocol (300 over 50); ...
[1] does the useful design space stay a short list?
capacity 4 -> 4 codes used
capacity 8 -> 6 codes used
capacity 64 -> 6 codes used
6 distinct reward-surviving topologies; COLLAPSED — extra capacity went idle, so an index fits
[2] is edge count an inverted cost surrogate?
INVERTED (Pearson r = -1.000) — minimizing |E| raises measured tokens; ...
[3] can a profile-node message-passing scorer rank anything?
all 4 agent profiles are identical, so message passing over profile nodes pools the
same features for every candidate and cannot rank adjacencies; ...
The third claim matters most for CAR, because CAR's teams are usually
homogeneous — four instances of one AgentSpec, differing only in the task
text. car_multi::topology::scorer_advice answers it directly off the specs, no
encoder needed: if every agent shares a system prompt, a profile-node graph
network is not a weak ranker, it is a constant.
Pipeline
offline, once fitted, no model calls per query
┌────────────────┐ ┌──────────────────────┐ ┌──────────────┐
│ execute fixed │ │ Codebook (short │ │ predictor → │
│ topologies, │ ───► │ list) │ ───► │ top-M codes │
│ log (A,c,u,τ) │ │ CodePredictor (prior)│ │ → decode │
└────────────────┘ │ ExecutionProxy │ │ → 1 batched │
│ (û, ĉ) │ │ proxy call │
└──────────────────────┘ │ → argmax │
└──────────────┘
Topology::collection_protocol(n) is the offline step's protocol — complete,
chain, star, and three seeded Erdős–Rényi samples. It is the only step that
costs LLM calls; everything after it is a pure function.
use ;
let selector = fit?;
let selection = selector.select?;
// selection.topology — what to run
// selection.shape — the car-multi pattern, when it is one
// selection.considered — every candidate and why it lost
What this implementation is not
The paper's three stages are learned: a VQ autoencoder, an MLP prior, and an MLP proxy with an auxiliary structure-token head. CAR has no autograd framework in the workspace, and this crate is a deterministic leaf that must stay one, so each stage is fitted in closed form instead.
| Paper | Here | Consequence |
|---|---|---|
VQ-VAE over vec(A), EMA codebook, dead-code reset |
Deterministic k-medoids over Hamming distance | Codes decode to topologies that were really executed; the fit is bit-reproducible; it cannot emit a graph between two observed ones |
MLP p(k|c) under soft cross-entropy (Eq. 7) |
Cosine-similarity kernel regression on the same reward-weighted soft targets | Same inputs, targets, outputs; no generalization to query directions far from every training condition |
MLP [vec(A); c] → (û, ĉ) (Eqs. 8–9) |
Locally-weighted closed-form ridge on the same targets | Query conditioning moves into the record weighting; still linear in the edges, so an edge-pair interaction is out of reach, and the fit retains its training records |
| Structure-token auxiliary loss (Eq. 10) | Dropped | It regularizes a shared hidden representation, and ridge has none. The ridge penalty and kernel bandwidth are the weaker substitutes |
Eq. (2) (per-task token normalization), Eq. (6) (reward-weighted soft targets), and Eq. (11) (the one-pass selection rule) are reproduced exactly.
One substitution is load-bearing and worth naming. A global linear ridge
over [1; vec(A); c] — the literal linear reading of Eq. (8) — has no term
coupling the query to the adjacency, so it ranks topologies identically for
every query and can override a correct choice by the prior. That is why
ProxyConditioning::Local is the default: the records are weighted by cosine
similarity to the query and a small ridge over [1; vec(A)] is solved against
those weights. The solve depends only on the query, not the candidate, so
scoring the whole candidate set is still one batched pass plus one F × F
Cholesky (F = 1 + N(N-1); 13×13 for a four-agent team).
ProxyConditioning::Global is kept, and its blindness is asserted in a test
rather than left implicit.
The paper's numbers are the paper's
84.6 average accuracy, 2.4 ms generation, 21.9–33.2% fewer tokens — measured on
GSM8K / MATH / MultiArith / SVAMP / MBPP / HumanEval with gpt-4o-mini and
Qwen-3-8B, under the authors' own implementation. Nothing here has been
evaluated against those benchmarks, and the substitutions above mean this
implementation should not be assumed to reach them. What transfers without an
experiment is the reasoning — and diagnostics is how you check whether it
holds on your data.
Persistence, and the embedder guard
journal is an append-only JSONL log of execution records at
<state root>/topology/records.jsonl — journal_path takes the root rather
than resolving it, so the crate keeps no car-home dependency and a caller with
its own layout is not fighting a default (journal_path(car_home::root_or_relative())
is the daemon spelling). It follows the car-eventlog journal idiom the rest of
the workspace uses: one record per line, torn-line tolerant on
load, a missing file reads as empty, an exclusive advisory lock on <path>.lock
for one writer per path. (In car-sync that lock prevents a forked seq chain;
here records are an unordered set, so it guards only against two writers
interleaving bytes mid-record.)
Every line names the encoder that produced its query embedding, and that is
the point. The prior is a kernel over cosine similarity between query
directions, so the entire pipeline is defined relative to one embedding space.
Fold two encoders into one RecordSet and the similarities between them are
arbitrary; query a fitted selector with a third and it answers from arbitrary
similarities. In neither case does anything fail, slow down, or look wrong —
which is why the tag is enforced at the boundary instead of left to a
convention:
RecordJournal::load_recordsrefuses a mixed journal unless you name which embedder you want, and labels the set it returns.TopologySelector::fitcarries that label into the selector, andselect_with(query, embedder)refuses a query from a different one. An unlabeled selector passes the check, because "nobody recorded it" is not "any encoder fits".
Wire types validate their own invariants
Serde bypasses constructors, so every type that can be read from disk
deserializes through a try_from that re-checks what its constructors
establish. This is not defensive habit — one case was a live panic. A journal
line whose Topology.edges array is the wrong length parses as JSON, so the
torn-line tolerance does not catch it; before the check it loaded happily and
then panicked with an out-of-bounds index on the first edge read, deep inside
fitting rather than at load. The journal is a plain file a person can edit.
The rest are the same class without the panic, which is harder to diagnose, not
easier: ExecutionProxy's dot zips its slices, so a weight vector that lost
entries scores against a truncated feature vector and returns a plausible
number. A cost head that is quietly wrong defeats the whole point of regressing
measured tokens. Codebook, CodePredictor, ExecutionProxy and
TopologySelector each carry a validate() and refuse to load when it fails —
including the codebook/predictor agreement from_parts checks, which a
persisted selector would otherwise bypass.
Boundaries
No model calls, no network, no clock, and no randomness that is not seeded.
Fitting and selection are pure functions of their inputs — testable without a
daemon, a model, or a filesystem — and a fitted TopologySelector round-trips
through serde. journal is the one module that touches disk, kept separate for
that reason. Record collection is the caller's; car_multi::topology is the
bridge that folds finished coordination runs into records, and record_run is
the one-call form that writes straight to the journal.