One step, five phases¶
The longitudinal simulation loop is one class: LongitudinalSimulator in
socioverse/engine/loop.py. Its run() method is the whole runtime —
everything else in socioverse/ exists to be called from it. This page walks
one step of that loop at the level of function calls, typed objects and the
rows they leave in the store.
What a step computes¶
A step advances the environment by one unit of the study's time_unit and
produces exactly one behaviour per agent. Written out:
e_t^i = ω(E_t, p_i) # observe: project the world onto agent i
b_{i,t} = f(p_i, e_t^i, m_{i,t}) # decide: one typed action per agent
B_t = { b_{i,t} }_{i=1..N} # the step's collective behaviour
E_{t+1} = g(E_t, B_t) # apply: fold the behaviours back in
ω is the observation operator, f the behaviour function, g the
environment transition, and m_{i,t} = μ(b_{i,t−W:t−1}) the agent's bounded
memory of its own last W actions. Each symbol is a method on one of the four
interfaces in socioverse/abc/:
| symbol | interface method | implemented by |
|---|---|---|
P |
PopulationProvider.build(seed) -> list[Persona] |
the study's population provider |
E_0 |
EnvironmentProvider.reset(seed) |
the study's environment provider |
| exogenous change | EnvironmentProvider.advance_to(t) -> list[ScheduledEvent] |
same |
ω |
EnvironmentProvider.observe_batch(agent_ids, t, round_idx) -> list[Observation] |
same |
f |
DecisionModel.decide_batch(obs, memories) -> list[Action] |
the study's decision model |
g |
EnvironmentProvider.apply(actions) -> None |
the study's environment provider |
| the panel row's state | EnvironmentProvider.agent_state(agent_id) -> dict |
same |
| the metrics row | MetricCollector.collect(env, actions, t) -> dict |
the study's collector |
| persistence | TrajectoryStore.record / record_metrics / record_events |
DuckDbTrajectoryStore |
The engine never reaches inside a provider. It only calls these methods, in a fixed order, and writes whatever they return.
Step 0 is not a step¶
def materialize_initial(self) -> tuple[list[Persona], list[TrajectoryRecord]]:
personas = self.population.build(self.seed)
self.env.reset(self.seed)
panel0 = self._panel_rows(personas, actions=None, t=0)
return personas, panel0
run() opens with materialize_initial(). It materializes the fixed
population P — persistent, deterministic agent_ids that are never reused —
builds E_0, and writes one panel row per agent at t = 0 with
action_kind = NULL. No decision model is called at step 0 and no LLM token
is spent. A step-0 metrics row is collected too, so every metric series
starts at its pre-run baseline.
Two consequences worth knowing:
- The method is a pure function of
seedfor deterministic providers, which is whysv-build-populationcan call the module-level twinsocioverse.engine.builder.materialize_initial(env_bundle, pop_bundle, seed)to writepopulation/roster.jsonlbefore any run exists. The roster you inspect at build time is byte-for-byte thet = 0the run will start from. - A trajectory of
n_steps: 12holds 13 panel rows per agent (steps 0…12).
run() allocates one AgentMemory per agent as soon as materialize_initial()
returns — before the store is even opened — and then enters the loop. A branch
enters it later: _replay_inherited first re-derives steps 1 … t*−1 from the
parent version's stored actions, and the first step decided for real is the fork
step t*. Every computed step runs these five phases, in this order:
flowchart LR
A["① exogenous update<br/>env.advance_to(t)"] --> B["② observe<br/>env.observe_batch(ids, t, r)"]
B --> C["③ decide<br/>decision.decide_batch(obs, memories)"]
C --> D["④ apply<br/>env.apply(actions)"]
D -->|"r < interaction_rounds-1"| B
D --> E["⑤ record<br/>memory · panel · metrics"]
① Exogenous update¶
fired = self.env.advance_to(t) # ① exogenous update
if fired:
self.store.record_events(t, [self._event_note(e) for e in fired])
Reads the environment bundle's scheduled_events and
information_program.broadcasts (environment/environment.json).
Writes the provider's own layer state, and one row per fired item into the
events table.
This is the only phase that changes E without an agent doing anything, and it
runs before anyone observes — so every agent in step t decides against the
same world state. An intervention is nothing more than an entry that fires
here: a ScheduledEvent (a typed numeric mutation — set, add, multiply,
add_pct, replace_source — on a selected part of a declared physical layer)
or a Broadcast (information content with a channel, a ttl and an
audience that is either "all" or a selector dict). See
Intervention and counterfactual branching.
The provider decides what the returned list contains. The engine logs whatever
comes back, turning each item into a note via
ev.note or f"{ev.op} {ev.property_name or ''}".strip(). Providers that want broadcasts in
the events table wrap them in a ScheduledEvent carrying the note, as the
Chicago adapter does:
newly = self.info_env.advance_to(t) # InformationEnvironment
for b in newly:
fired.append(ScheduledEvent(at_step=t, target_layer="information",
op="set", note=f"broadcast: {b.content}"))
The same notes are joined into the step's metrics row under the events
column (_collect), which is what puts the vertical markers on a metric
timeline.
② Observe¶
Reads the provider's layer state plus each agent's position, network and
attributes. Writes nothing — it returns list[Observation], one per agent,
and the engine passes it straight to the decision model.
An Observation (socioverse/schemas/runtime.py) is the four-quadrant view,
modality × scope:
| field | modality × scope | typical content |
|---|---|---|
macro_physical |
physical × macro | citywide vacancy rate, index level, weather |
local_physical |
physical × local | own tract's rent and composition, neighbours' opinions |
macro_information |
information × macro | a broadcast with audience="all" |
local_information |
information × local | an audience-scoped notice, neighbours' posts |
Plus agent_id, step, and an optional rendered string — a pre-rendered
prompt fragment, so an LLM decision model does not have to re-serialize the
same dicts for every agent. Observation.quadrants_nonempty() returns which
quadrants actually carry content, which is the quickest audit of whether a
study's information layers are reaching anyone.
Three reusable layer helpers live in socioverse/env_layers/ and are composed
by the provider — the engine never calls them:
InformationEnvironment(information.py) — activates and expires broadcasts byat_step/ttl, and renders them intomacro()/local_for(agent_ctx)/rendered_lines(agent_ctx). Local audiences are resolved by a matcher, defaultmatch_selector, which understands selector keys of the form<attr>,<attr>_in,<attr>_below,<attr>_above,geoid_listandindex_below. An empty selector matches nobody.NeighborFeed(neighbor_feed.py) — turnspost/replyactions into other agents'local_information, mediated by aMessageBusand aneighbors_fn, never O(N²) direct calls.news_broadcast.py—default_information_layers()plus themacro_news(...)andlocal_notice(...)constructors. A citywide announcement and a ward notice are the same mechanism differing only inaudience.
③ Decide¶
Reads the observations and memories (dict[agent_id, AgentMemory]).
Writes nothing — it returns list[Action], the step's B_t.
The interface is deliberately batch-first: decide_batch sees every agent at
once, so an implementation is free to group agents by cohort, issue one LLM
call per archetype, or run a thread pool over per-agent prompts. The engine
never calls the model agent by agent. The shipped from-scratch templates use the
thread pool — one LLM call per agent, sent concurrently — and never ask for one
JSON reply covering the whole population; a legacy adapter keeps its engine's
own pattern. (DecisionModel.decide() exists as a
single-agent convenience and just wraps decide_batch.)
Nothing forces an implementation to return an action for every agent. Agents
with no action that step simply get a panel row whose action_kind is NULL
— which is also how replay reconstructs an idle agent.
④ Apply¶
Reads the whole batch of actions. Writes the provider's layer state —
this call is g.
The engine hands apply the complete B_t and calls it once per round, so
the round settles synchronously: no agent sees another agent's action
inside its own decision, and there is no ordering advantage from being early
in the list. A provider is free to resolve contention here — the Chicago
adapter applies a per-race move budget and then annotates each action's
payload with whether the move actually executed:
a.payload["moved"] = bool(agent.moved_this_step)
if agent.moved_this_step:
a.payload["new_tract"] = agent.tract_id
That mutation happens before the panel row is built, so the panel records the realized outcome, not just the intent.
For from-scratch (Path B) studies apply is expected to be a pure function
of the actions passed to it. That single property is what makes replay — and
therefore branching — possible.
⑤ Record¶
for a in last_actions: # ⑤ record — memory window first
self.memories[a.agent_id].push(t, a)
panel_t = self._panel_rows(personas, last_actions, t)
self.store.record(panel_t)
self._emit_panel(panel_t, t)
m = self._collect(personas, last_actions, t, fired_notes=[...])
Reads env.agent_state(agent_id) for every persona plus the step's
actions. Writes, in this order: each agent's memory window, one panel row
per agent, and one metrics row.
build_panel_rows (a free function, so the pre-run materializer and the loop
produce identically shaped rows) builds each TrajectoryRecord as:
TrajectoryRecord(agent_id=p.agent_id, step=t,
state=env.agent_state(p.agent_id),
action_kind=a.kind if a else None,
action_payload=a.payload if a else {})
Note what is not in the panel: rationale and source stay on the
in-memory Action and are not persisted by the store. The panel keeps exactly
kind + payload — the minimum a replay needs to rebuild the action.
_collect calls collector.collect(env, actions, t), forces m["step"] = t,
appends the fired-event notes as m["events"], hands the row to
store.record_metrics, and finally fires the per-step heartbeat and the
optional on_step(t, metrics) callback.
Two plain files are written next to the DuckDB store as a side effect, both
best-effort and never able to break a run: progress.jsonl (one line per step,
truncated at t = 0) and panel_live.jsonl (the same rows as the panel
table). They exist because DuckDB is single-writer-locked while a run is open,
so the dashboard reads these instead of the database mid-run.
The Action contract¶
class Action(BaseModel):
agent_id: str
step: int
kind: str # move | stay | post | reply | adopt_opinion | ...
payload: dict[str, Any] = {}
rationale: list[str] = []
source: Literal["llm", "rule", "fallback", "replay"] = "llm"
kind— the action type, a study-defined string. The environment branches on it (if a.kind == "update_opinion": ...instudies/opinion_diffusion/model.py);NeighborFeed.ingestroutes only"post"and"reply"onto the bus.payload— the type-specific content: a destination tract, a number, a text body. Must be JSON-serializable; it is stored as a JSON column.rationale— a list of first-person reason strings. Useful for auditing an LLM run; not persisted to the panel.source— exactly four values:
source |
set when |
|---|---|
llm |
the model replied and the reply parsed |
rule |
a deterministic update rule produced the action |
fallback |
the LLM was called but its reply failed to parse, so the rule filled in |
replay |
the engine rebuilt the action from a parent version's panel rows |
Keeping fallback distinct from rule is what lets you measure how much of an
"LLM run" was actually the LLM.
Because all four behaviour functions return this same typed object, the
environment and the store are decoupled from the mechanism that produced the
decision. apply, agent_state, the panel schema and every metric work
unchanged whether f was a rule, a prompt, a trained policy, or a replay —
which is also what makes a rule-versus-LLM comparison a controlled one: both
act in the same discrete action space and are recorded in the same table.
The four behaviour functions f¶
f is the only place a study's mechanism lives, and inside the loop it is
always a DecisionModel. A from-scratch (Path B) study registers it under a
string key:
from socioverse.engine.registry import register
@register("decision", "opinion.decision")
class BoundedConfidenceDecision(DecisionModel):
def __init__(self, confidence: float = 0.3, llm_kind: str = "scripted", ...):
...
simulation/simulation.json names that key and its constructor arguments:
{
"decision_ref": "opinion.decision",
"decision_args": {"confidence": 0.3, "llm_kind": "openai", "model": "gpt-4o-mini", "max_workers": 8}
}
build_simulator (socioverse/engine/builder.py) resolves it with
resolve("decision", sim_config.decision_ref)(**sim_config.decision_args).
The registry holds six kinds — environment, population, decision,
collector, store, reporter — and resolution fails loudly if the study
module was never imported, because @register runs at import time.
A study that wraps a legacy simulator skips the registry for this one
component: its own build_*_simulator factory constructs the decision model
directly (decision = SchellingDecisionModel(engine) in
studies/chicago_schelling/adapter/build.py) so several components can share
one mutable legacy engine. The loop cannot tell the difference — it receives a
DecisionModel either way.
f |
what it does | how it is wired |
|---|---|---|
| rule-f | evaluates a classical update rule; the parity baseline | one registered DecisionModel whose decide_batch runs the arithmetic and tags source="rule" |
| LLM-f | composes persona + the four observation fields + the memory window into a prompt, parses the structured reply into an action | usually the same registered class, switched by a decision_args flag (llm_kind: "scripted" \| "openai"), so the rule stays available as the fallback path |
| RL-f | maps observations to actions through a trained policy, one per class of strategic actor | enters through a study adapter (studies/drug_procurement/adapter/, a study not included in the open-source release) that runs the external trainer and exports the same panel / metrics tables |
| hybrid-f | different implementations for different agent groups in one run — typically LLM-f for a small core, rule-f for the remainder | one registered class that branches inside decide_batch, e.g. studies/hisim_roe/model.py's HybridTwitterDecision: self._decide_core(ob, ...) if is_core(ob.agent_id) else self._decide_ordinary(ob) |
Pairing rule-f and LLM-f behind one decision_ref is the pattern to copy:
the LLM path falls back to the identical rule arithmetic whenever a reply fails
to parse, so the metric columns are always filled and the two runs are
comparable by construction.
The memory window¶
class AgentMemory:
def __init__(self, agent_id: str, window: int = 8):
self.history: deque[dict] = deque(maxlen=window)
def push(self, t, action=None, observation_summary=None):
self.history.append({"t": t, "action_kind": ..., "payload": ..., "summary": ...})
Each agent owns a bounded rolling window of its own actions — not other
agents', not the environment's. The default is 8 steps — the W of
m_{i,t} = μ(b_{i,t−W:t−1}) above — configurable per study via
engine_args.memory_window, which build_simulator reads as
sim_config.engine_args.get("memory_window", 8):
run() creates the windows before any step is computed and pushes into them in
phase ⑤.
AgentMemory also offers recent(n), last_action_kind() and
count_action(kind) so a decision model can condition on the agent's own past
("I already moved twice, I will stay") without re-reading the store.
The window is filled only from the actions the panel records, which makes
it exactly reconstructible from the panel table. That is the precondition for
a branch: _replay_inherited rebuilds the parent's actions from its panel
rows, applies them, and pushes them into the windows, so at the fork step each
agent resumes with precisely the memory it had in the parent run.
out.append(Action(agent_id=r["agent_id"], step=t, kind=kind,
payload=dict(r.get("action_payload") or {}), source="replay"))
interaction_rounds¶
for r in range(self.interaction_rounds):
obs = self.env.observe_batch(agent_ids, t, r)
actions = self.decision.decide_batch(obs, self.memories)
self.env.apply(actions)
last_actions = actions
interaction_rounds (default 1, clamped by max(1, …)) is the number of
observe → decide → apply cycles inside one step. Raising it lets agents
react to what others said within the step; advance_to still fires only once,
before round 0, and rounds are not time.
Only the final round is recorded — so a multi-round study cannot be replayed
Memory receives last_actions, and the panel row carries the last round's
kind + payload. Rounds 0 … K−2 leave no trace in the trajectory. That
is why _replay_inherited refuses to run when interaction_rounds != 1:
ValueError: branch replay supports interaction_rounds == 1 only (study has 3);
the parent panel keeps only the final round's actions, so multi-round steps
can't be replayed exactly — re-run from step 0.
(warm_start is the engine's field name for a branch's replay spec —
SimulationConfig.warm_start, a WarmStartSpec whose fork_step is
resume_from + 1.) A multi-round study therefore iterates as a new
version and runs cold from step 0, never as a branch. See
Multi-Round Interaction and
Intervention and counterfactual branching.
What lands on disk¶
DuckDbTrajectoryStore (socioverse/io/duckdb_store.py) writes one file per
run, studies/<id>/trajectory/study.duckdb. open() deletes an existing file
first — a run is always fresh; older results live in version snapshots.
| table | schema | written by | cardinality |
|---|---|---|---|
panel |
agent_id VARCHAR, step INTEGER, state JSON, action_kind VARCHAR, action_payload JSON |
record(), streaming per step |
N × (T + 1) rows |
metrics |
step first, then one column per metric key, plus an events column if any step fired something (NULL on the rest) |
buffered by record_metrics(), materialized as a wide table at finalize() |
one row per step |
events |
step INTEGER, note VARCHAR |
record_events(), streaming |
one row per fired event or broadcast |
messages |
author_id, step, round, channel, content, audience |
record_messages(), table created lazily on first call |
only for studies that persist a bus |
(agent_id, step) identifies a panel row — the longitudinal key, held by
construction rather than by a declared constraint. finalize() also exports
panel.parquet and metrics.parquet beside the database.
Query it with any DuckDB client, or from Python:
from socioverse.io import open_readonly
con = open_readonly("studies/opinion_diffusion/trajectory/study.duckdb")
One agent's complete trajectory — state, action and payload at every step:
SELECT step,
state->>'opinion' AS state_opinion,
action_kind,
action_payload->>'opinion' AS decided_opinion
FROM panel
WHERE agent_id = 'od-007'
ORDER BY step;
The metric series with the interventions that fired against it:
How many agents acted, by action kind, per step:
SELECT step, action_kind, count(*) AS n
FROM panel
WHERE action_kind IS NOT NULL
GROUP BY step, action_kind
ORDER BY step, n DESC;
The metrics columns are whatever the study's MetricCollector.collect()
returned — step, mean_opinion, opinion_std, frac_above_0_5, events for the
opinion-diffusion template — so SELECT * first. collector.columns() is
the declared contract when a study bothers to declare one.
See also¶
- Overview — B = f(P, E) — why the loop is shaped this way
- Intervention and counterfactual branching — what happens when phase ① carries an intervention, and how a branch replays
- Multi-Round Interaction —
interaction_rounds > 1in full socioverse.engineAPI reference — the loop, builder, registry and memory modules