A technical deep-dive into building a 100,000-agent belief-contagion simulator in four days — where the LLM is a compiler, never a runtime.
1. The premise
A prion is a protein folded into the wrong shape. It has no genome, carries no instructions, and replicates by the only trick it knows: touching a normally-folded protein and templating its own wrong shape onto it. The copy then does the same. Copies drift. Different misfoldings become different strains with different incubation periods and different symptoms. Some strains jump a species barrier; most don't. Some cases arise spontaneously, from nothing.
Now read that paragraph again, replacing protein with belief.
That is a complete and accurate description of how a rumor moves through a society. So we built PRION: a live simulation of 100,000 people, each with a personality, a memory of
who told them what, and a real position in a small-world social graph. You type a rumor
into a box. It enters one person. Then you watch it spread, mutate as it passes from mouth
to mouth, split into rival strains, jump between communities, and either take over the
population or burn out — in real time, at ~20 ticks per second.
The hard part was never the metaphor. It was this constraint:
You cannot call an LLM once per agent per tick. 100,000 agents at 20 ticks/second is
two million calls per second. That is physics, not a budget problem.
Everything in this post falls out of how we answered that constraint.
2. Architecture: the LLM is a compiler, never a runtime
The design rule for the whole system:
The LLM compiles inputs and decorates outputs. It never executes inside the loop.
┌──────────────────────────────────────────────────┐
│ YOU (browser) │
│ deck.gl field · rail · strain lineage · ticker │
└───────────────▲──────────────────┬───────────────┘
binary WS deltas│ │ controls
~33 KB/tick @ 100k │ │ (inoculate/pause/inspect/reset)
┌───────────────┴──────────────────▼───────────────┐
│ FastAPI broker (port 8000) │
│ bounded per-client queues · slow-client safe │
└───────────────▲──────────────────┬───────────────┘
frames │ │ controls
┌───────────────┴──────────────────▼───────────────┐
│ SIM PROCESS (multiprocessing) │
│ ┌────────────────────────────────────────────┐ │
│ │ TIER 0 — numeric core (numpy, zero LLM) │ │
│ └──────────────┬─────────────────────────────┘ │
│ ┌──────────────▼─────────────────────────────┐ │
│ │ TIER 2 — misfold worker (async thread) │ │──┼──► LLM
│ └──────────────┬─────────────────────────────┘ │ │
│ ┌──────────────▼─────────────────────────────┐ │
│ │ TIER 1 — strain compiler (on inoculate) │ │──┼──► LLM
│ └──────────────┬─────────────────────────────┘ │
│ ┌──────────────▼─────────────────────────────┐ │
│ │ TIER 3 — narrator + inspect monologues │ │──┼──► LLM
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
Four tiers:
| Tier | Runs | Frequency | LLM? |
|---|---|---|---|
| 0 — Numeric core | templating, incubation, titer decay, strain competition | every agent, every tick | never |
| 1 — Strain compiler | text → simulation parameters | once per inoculation | 1 structured call |
| 2 — Misfold worker | text mutation as the belief travels | per flagged templating event | async, fire-and-forget |
| 3 — Narration | wire copy + click-to-inspect monologues | every ~15s / on click | async, fire-and-forget |
The contract that makes this safe: the tick loop never blocks on inference. Tier 0 runs in its own process with zero LLM calls. Tier 2 consumes a queue on a background thread. If the LLM endpoint dies mid-demo, Tier 0 doesn't notice; the simulation keeps running on deterministic fallbacks. This is also why the demo never hard-fails without an API key — every LLM tier has a deterministic fallback, and the fallbacks are good enough to demo with.
3. Tier 0: 100,000 agents with zero objects
The AgentTorch paper's core insight is that per-agent Python objects die at six-figure scale — the whole population must be arrays. PRION takes that literally. The population is a struct-of-arrays:
class Population:
def __init__(self, s: Settings):
self.titer = np.zeros((s.N, 1)) # (N, strains) — grows as strains branch
self.incubation = np.zeros(s.N)
self.traits = np.zeros((s.N, 6)) # openness, skepticism, conformity, ...
self.codon = np.zeros(s.N, np.int8) # resistance polymorphism (0/1/2)
self.held = np.zeros(s.N, bool)
...
The social graph is a Watts-Strogatz small-world ring (each node rewired with p=0.08) plus a sprinkle of long-range bridge edges and high-degree hubs — as a scipy CSR adjacency.
The 20× matmul lesson
The obvious way to compute local prevalence — how many of your neighbors hold strain c —
is one matmul over all strains at once:
local = adj @ held # held: (N, M) — 19.7 ms/tick at 100k
That measured 19.7 ms/tick. The same work as per-strain 1D passes measured 1.1 ms:
def _step_dense(self, c, col, rng):
held = self.titer[:, c] > 0.0
infectious = held & (self.state == STATE_INFECTIOUS)
if not infectious.any():
return
local = self.adj.dot(infectious.astype(np.float64)) # 1D CSR dot: 1.1 ms
...
An 18× difference for mathematically identical work. The 2D matmul materializes
intermediates and defeats cache locality; the 1D pass over a CSR matrix stays in cache.
Lesson: at 100k agents, loop over strains in Python and keep every numpy op 1D.
Then strains with few holders get a second path — O(nonzeros) instead of O(N):
def _step_sparse(self, c, nz, rng):
"""Strain with few holders: O(nonzeros) instead of O(N)."""
This is what keeps ~49 ticks/sec at 100k with misfolds flowing (67 tps dense). When a
misfold branches, it usually infects a handful of agents — paying O(N) for a strain held
by 3 people is 33,000× waste.
Non-consensus is structural, not tuned
The easiest way to ruin a contagion sim is a field that saturates at 100%. PRION stops saturation structurally:
- Codon hard block — every agent carries a resistance codon (0/1/2). A strain with a matching resistant codon infects with probability zero, regardless of titer.
- Refusal floor — below a titer floor, refusal is absolute.
- Contrarian refusal — agents high in contrarianism refuse a belief precisely when it dominates their neighborhood — the sociological "backfire effect" as a mechanic.
Result: the field plateaus around 40%, which is also what real rumor curves do.
4. Tier 1: text becomes parameters, once per inoculation
When you type a belief into the box, one structured LLM call compiles it into simulation parameters. This is the "LLM as compiler" tier — it runs once per inoculation, never per tick. We use instructor over litellm pointed at any OpenAI-compatible endpoint:
resp = client.chat.completions.create(
model=llm.litellm_model(), # "openai/glm-5.3-flash"
api_base=cfg.api_base, # https://api.particle.ai/v1
api_key=cfg.api_key,
response_model=Strain, # pydantic schema = the contract
max_tokens=1500, # reasoning model: room to think AND emit JSON
messages=[...],
)
The pydantic schema is the contract. Its validators clamp at the boundary:
transmissibility: float = Field(ge=0.0, le=1.0)
@field_validator("susceptibility")
def _clamp(cls, v):
return {k: max(-1.0, min(1.0, float(c))) for k, c in v.items() if k in TRAIT_NAMES}
A hallucinated transmissibility: 4.2 arrives as 1.0. A hallucinated trait name is dropped. The simulation can never receive a parameter that breaks it.
One practical note for anyone doing this with glm-5.3-flash: it is a reasoning model. It emits reasoning_content first and only then fills content. If your max_tokens budget is small, reasoning eats all of it and content comes back empty with finish_reason: length. Budget accordingly, and never trust a "successful" call that returns an empty string (more on this in §7).
5. Tier 2: the text mutates as it travels
This is the feature that makes PRION prion. Every templating event (A tells B) is flagged as a misfold with probability misfold_rate. Flagged events are queued to an async worker that makes one LLM call:
Given this person's personality and the version of the belief they received, what is the one short sentence they would repeat?
The rumor literally changes shape as it travels. "The tap water is being secretly poisoned" becomes, through one personality, "My coworker's cousin works down at the water plant, and she says…" — and through another, "Y'all, apparently…".
Mutations are deliberately less fit than their parents (transmissibility ×0.5, decay ×1.6, susceptibility ×0.7). Most variants get cleared quickly. This is the prion biology:
strains branch the lineage without becoming second epidemics, and the simulation stays one epidemic with a branching text history rather than N parallel epidemics.
Strains that die out (no infectious agent for a while) have their matrix column recycled through a freelist — bounded memory, unbounded history.
6. The wire: binary deltas at 33 KB/tick
The renderer never blocks on inference or on JSON. The wire protocol is binary:
| Frame | Layout |
|---|---|
INIT 0x00
|
u32 N · u32 n_comm · pos N*2 f4 · dom u8[N] · titerQ u8[N] · state u8[N] · u32 mlen · meta JSON (seed + full strain tree) |
DELTA 0x01
|
u32 tick · u32 n · n×(u32 idx,u8 dom,u8 titerQ,u8 state) · u32 slen · sidecar JSON · u32 n_arcs · arcs |
CTRL 0x02
|
u32 seq + JSON (inoculate / pause / resume / speed / inspect / reset) |
MONO 0x03
|
JSON monologue for a clicked agent |
Deltas are structural-only: only agents whose dominant strain or state changed are shipped, with titer attached. Continuous titer decay of the steady infected pool is not shipped per tick — the client fades brightness locally between structural events:
fade(dt) {
for (let i = 0; i < this.N; i++) {
if (st[i] === STATE_INFECTIOUS && b[i] > 0.05)
b[i] = Math.max(0.05, b[i] - dt * 0.08);
}
}
Naive per-tick full-state shipping measured 169 KB/tick; structural deltas measure ~33 KB/tick at 100k. The first version shipped per-agent Python objects over JSON and died; the second shipped full state; the third ships structure only. Each iteration was a 10× or better.
The population field itself is deck.gl — ScatterplotLayer with binary attribute buffers (positions uploaded once, colors re-uploaded per tick) plus an ArcLayer showing only the current tick's templating events, fading over 500 ms. We considered cosmos.gl for the GPU force layout, but its OES_texture_float requirement is exactly the WebGL extension iOS Safari is least reliable about — so deck.gl (WebGL1-safe) is the floor, not the ceiling.
7. War stories: what actually broke
Four days of building means four days of things breaking in interesting ways.
The 20× matmul. adj @ held over all strains: 19.7 ms/tick. Per-column 1D passes: 1.1 ms. Same math, 18× apart — the 2D matmul materializes intermediates and defeats cache locality. At 100k agents, loop over strains in Python and keep every numpy op 1D.
The 5× delta. Full-state JSON shipping: 169 KB/tick. Structural-only deltas: 33 KB.
The 7 tps collapse. Day 3 added misfolds and tick rate fell to 7 tps at 100k. Three compounding causes: unbounded strain growth (fixed with extinction + a column freelist), a worker flooding the queue (fixed with a live-strain cap + backoff), and dense O(N) work for strains held by 3 people (fixed with the O(nonzeros) sparse path). Back to 49 tps.
The empty-content plague. glm-5.3-flash is a reasoning model: it emits reasoning_content first and only then fills content. Small token budgets mean reasoning eats everything and content arrives empty with finish_reason: length. The insidious part: the call succeeds. Our misfold worker shipped empty strings as strain text for ~half of all misfolds — the fallback only fired on exceptions, and an empty-but-successful response raises nothing. The fix is a hard rule now: an empty LLM response must never ship — fall back to the deterministic path.
The Svelte 5 black page. The UI shipped as a black page in a real browser. Three stacked causes, each invisible until we drove it in headless Chrome:
-
new App({ target })— the Svelte 4 constructor — throwseffect_orphanunder Svelte 5. The app never mounted. Fix:mount(App, { target }). - The INIT decoder did
new Float32Array(buffer, 9, …)— offset 9 isn't 4-byte aligned, a hardRangeErrorfor typed-array views. Fixed by copying bytes into a fresh buffer. - The lineage panel computed its tree once at component-init from an empty
strainsarray and its recursiveNodechildren never re-rendered. Rewritten as a flat, depth-first render over one$derivedarray — no recursion, no derived-in-template reactivity traps.
Plus one reactivity trap worth memorializing:
// client.meta.strains is mutated in place with .push():
this.meta.strains.push(...sidecar.new_strains);
// then in the component:
strains = client.meta.strains; // same reference — Svelte sees NO change
strains = [...client.meta.strains]; // copy — new reference, re-render fires
Lesson: in Svelte 5, an in-place mutation plus a same-reference assignment is a silent no-op. If a framework ever feels non-reactive, check for reference identity before blaming the framework.
8. What the simulation actually shows
The behavior that falls out of the mechanics is the demo:
- The S-curve. Inoculate one agent, watch slow ignition, exponential spread, then a plateau around 40% — the structural non-consensus mechanics, not a tuned cap.
- Drift. "The tap water is being secretly poisoned" becomes "My coworker's cousin works down at the water plant, and she says…" becomes "Y'all, apparently…" — each version is a strain, and the text mutates as it travels.
- Extinction and replacement. Weak variants decay and die; their matrix columns are recycled for new variants. The lineage remembers; the matrix doesn't hoard.
-
Sporadic cases. Occasionally a belief ignites from nothing —
sporadic_probper tick — because that's what spontaneous case generation looks like in a society too. - Click-to-inspect. Click any agent: an LLM writes their internal monologue from their actual traits, their held strains, and their neighbors' strains.
9. What we'd build next
- Sparse CSR titer matrix past ~50 live strains — raises the live-strain ceiling an order of magnitude.
- Codon editor — paint resistance codons onto communities and watch a strain hit the species barrier in real time.
- Strain arena — inoculate two rival beliefs at opposite ends of the graph and watch them compete for the same population.
- Replay diffing — same seed, different misfold texts, diff the lineage shapes.
- Run export — one-click GIF/video of a run.
10. Closing thought
The LLM-in-the-loop question has a boring, correct answer: don't. Put the model at the boundaries — compiling intent into parameters, decorating events into prose — and keep the inner loop deterministic, vectorized, and testable. The simulation gets faster, the costs get bounded, the failures degrade gracefully, and the demo survives the WiFi going out mid-presentation.
Beliefs are misfolded proteins. Now they have a simulator.
Code & more: https://www.dailybuild.xyz/project/237-prion
Top comments (0)