Skip to content

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 == step and m.round < round_idx — posted earlier this step;
  • carry_previous_step and m.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