car-topology 0.55.0

Amortized coordination-topology selection core for Common Agent Runtime
Documentation

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::Coordinator spends 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-multi executes 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 car_topology::{ExecutionRecord, RecordSet, SelectorConfig, Topology, TopologySelector};

let selector = TopologySelector::fit(&RecordSet::new(records)?, &SelectorConfig::default())?;
let selection = selector.select(&query_embedding)?;
// 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.jsonljournal_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_records refuses a mixed journal unless you name which embedder you want, and labels the set it returns.
  • TopologySelector::fit carries that label into the selector, and select_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.