Multi-Round Interaction¶
Most studies decide once per step. Some need the agents to talk to each
other before the step closes — a rumour spreading through a dorm, a
committee converging, a market reacting to what everyone just said. That is
what interaction_rounds is for.
It is a single integer in simulation/simulation.json, default 1, clamped
to max(1, …) by the engine. Setting it above 1 changes the shape of a step
in a way that is easy to get wrong, so read the whole page before you raise it.
What one step actually does¶
flowchart TD
A["advance_to(t) — exogenous events + broadcasts, ONCE per step"] --> B{round r = 0 … K-1}
B --> C["observe_batch(ids, t, r) — 4-quadrant Observation"]
C --> D["decide_batch(obs, memories)"]
D --> E["env.apply(actions) — fold behaviour into E"]
E -->|"r < K-1: next round"| B
E -->|"r = K-1: final round"| F["memory.push(last_actions)"]
F --> G["panel row per agent · collector.collect() · store"]
Two structural facts follow from that diagram, and they are the whole point of this page.
Only the final round is recorded
advance_to(t) fires the step's scheduled events and broadcasts
once, before round 0 — intermediate rounds see no new exogenous
input. And only the last round's actions are pushed into agent memory
and written to the panel. Rounds 0…K−2 leave no trace in the trajectory:
they exist purely so the environment's information layers can accumulate
within the step. If you need round-by-round evidence, persist it
yourself from the bus (see drain() below).
Rounds are not steps¶
They are orthogonal axes, and conflating them is the most common modelling error here.
n_steps |
interaction_rounds |
|
|---|---|---|
| axis | the longitudinal axis — real time | intra-step interaction depth |
| unit | whatever time_unit says (day/week/month/…) |
none; a round is not time |
| panel rows | one row per agent per step | none — rounds never produce rows |
| exogenous events | fire per step | never fire mid-step |
| memory | one entry per agent per step | only the final round enters memory |
A study with n_steps: 12, interaction_rounds: 3 produces twelve panel rows
per agent, not thirty-six. The three rounds are how each of those twelve rows
was arrived at.
When one round is right¶
Use interaction_rounds: 1 — the default — when an agent's decision resolves
against the world, not against each other:
- Schelling / Chicago segregation — an agent inspects its neighbourhood and moves or stays. Whether the neighbours also moved is next step's problem; that's exactly what makes the model tractable.
- Sugarscape-style resource models — harvest, consume, relocate.
- Any survey-shaped or choice-shaped study where the agent answers from its own state plus the broadcast information environment.
The diagnostic question: within a single step, does agent A need to see what agent B just did? If no, one round.
When K > 1 is right¶
Raise it when the step models an exchange — the agents must react to what others said inside the same step:
- Discussion and deliberation — a dorm, a household, a committee. Round 0 everyone states a lean; round 1 they read their peers' leans and reasons and may be persuaded; round 2 the group has settled. Only the settled position is the month's decision — which is exactly what the panel records.
- Contagion / opinion diffusion within a period — where one week of spreading involves several hops of transmission.
- Markets reacting to public quotes before the period clears.
Three rounds is usually enough to see convergence; more than that mostly buys you tokens.
Wiring responsibility¶
The core loop never touches the MessageBus. It only passes round_idx
into observe_batch. Everything about round mechanics is the study
environment's business:
| responsibility | who | where |
|---|---|---|
pass round_idx down |
engine | observe_batch(ids, t, r) |
| own the bus | your EnvironmentProvider |
usually created in reset(seed) |
| turn actions into messages | your env, via NeighborFeed.ingest(actions, step, round_idx) |
inside apply(actions) |
| deliver messages to agents | your env, via NeighborFeed.local_for(agent_id, t, round_idx) |
inside observe_batch(), dropped into the agent's local-information quadrant |
| persist messages | your env, via bus.drain() |
drain() returns and clears the round buffer, ready for the DuckDB messages table |
NeighborFeed.ingest only routes actions whose kind is "post" or
"reply" — a move or choose_meal action passes through it untouched. If
your agents' opinions should be visible to their peers, they must be emitted
as a post-shaped action (or posted to the bus directly).
Delivery is mediated, not O(N²): posts land on the bus, and each observer
reads only its own neighbours' messages, resolved through the
neighbors_fn you gave the feed.
The visibility rule¶
InMemoryMessageBus.visible_to(recipient, neighbors, step, round_idx) returns
a message m iff its author is one of the recipient's neighbors and
either of:
m.step == stepandm.round < round_idx— posted earlier this step;carry_previous_stepandm.step == step - 1— last step's messages.
Worked example, interaction_rounds: 3, carry_previous_step=False:
| round | what an agent sees in its feed |
|---|---|
r=0 |
nothing from this step — no one has posted yet |
r=1 |
every neighbour's round-0 post |
r=2 |
neighbours' round-0 and round-1 posts |
So round r sees rounds 0 … r−1. The consequence for a single-round study
is absolute: with interaction_rounds: 1 an agent never sees anything
posted in the same step. Its only inter-agent input is the previous step's
messages, and only if the bus was built with carry_previous_step=True. A
study that posts messages but leaves rounds at 1 has built a one-step-delayed
channel, not a conversation — sometimes that is what you want, but say so
deliberately.
Write the round-0 prompt accordingly: it has no peer content to react to, so it should ask for an opening position ("say what you're leaning towards and why"), while rounds ≥ 1 show the peers and invite revision.
Costs¶
K rounds ≈ K× the LLM calls per step
decide_batch runs once per round, so a 12-step, 200-agent study at
interaction_rounds: 3 makes ~7200 decisions instead of ~2400 — for the
same twelve panel rows per agent. Rounds buy you within-step realism,
paid for linearly. Dry-run the whole loop deterministically first (see
Build a Study from Scratch).
You lose warm starts
Warm start requires interaction_rounds == 1 and the engine raises
rather than replaying incorrectly. The reason is exactly the invariant at
the top of this page: the parent's panel kept only the final round's
actions, so a multi-round step cannot be reproduced from stored actions.
A multi-round study always re-runs from step 0. See
Iterate, Versions & Reports.
propagation does not do this¶
population.json carries propagation
(independent / contagion / broadcast_then_local), and it is tempting to
read it as the switch that turns interaction on.
It is a declarative tag — the engine never reads it
Nothing in the loop branches on propagation. It documents the intended
influence structure for the study's own environment implementation, and
it labels the population card on the dashboard. Setting it to contagion
does not make anything propagate; the feed you wire in observe_batch
does. Set it to describe what you built, and treat a mismatch between the
tag and the wiring as a documentation bug.
The same goes for interaction.kind and edges: they describe the network,
but it is your neighbors_fn that actually resolves who hears whom.
See also¶
- Anatomy of a Study — where
interaction_rounds,propagationandinteraction.kindlive - Build a Study from Scratch — the four interfaces the round mechanics hang off
- Iterate, Versions & Reports — warm starts and version snapshots