Intervention and counterfactual branching¶
A run tells you what happened. A study has to answer what would have happened instead. SocioVerse makes that second question computable with two mechanisms that live entirely inside artifacts you already author:
- an intervention is a typed entry in the environment bundle — a
ScheduledEventor aBroadcast— that the engine activates in the exogenous-update phase of one step. - a branch is a new version of the study that inherits its parent's realized
history up to a fork step
t*by replaying the parent's recorded actions, and computes onlyt* .. Tfor itself.
Two branches of the same parent share one population P, one behaviour function
f, one seed, and — provably, not approximately — one realized history up to
t*. Everything that differs after t* is attributable to the intervention
declared for the steps at or after t*.
Branch or version
A branch changes only E, only from its fork step on, and inherits the
parent's history. Any other edit — population, behaviour function, seed, run
settings, or environment before the fork step — is a version, and a
version always runs cold from step 0. The classification, the version gate
that asks for it, and the snapshot tree it produces are described in
The controllable research loop and
Iterate: versions and branches.
1. What an intervention is¶
Both kinds are Pydantic models in
socioverse/schemas/environment.py, stored in the study's
environment/environment.json.
ScheduledEvent — a numeric change to a physical layer¶
| field | type | default | meaning |
|---|---|---|---|
at_step |
int |
required | the step whose exogenous phase fires it |
target_layer |
str |
required | name of a declared EnvironmentLayer (validated) |
op |
"set" | "add" | "multiply" | "add_pct" | "replace_source" |
required | the operation, from a closed set |
selector |
dict |
{} |
which units it hits — provider-interpreted (tracts, cohorts, …); {} conventionally means "everywhere" |
property_name |
str | None |
None |
the property of the layer being changed (price, level, amount, …) |
value |
Any |
None |
the operand |
note |
str |
"" |
the one line that ends up in the events table and on the metric timeline |
Broadcast — a piece of information delivered to an audience¶
| field | type | default | meaning |
|---|---|---|---|
message_id |
str |
required | stable id for the message |
content |
str |
required | the text agents actually read |
channel |
str |
"news" |
which information channel carries it |
at_step |
int |
0 |
the step it becomes visible |
ttl |
int |
1 |
how many steps it stays visible — active while at_step <= t < at_step + ttl |
audience |
str | dict |
"all" |
"all" → everyone's macro_information; a selector dict → only matching agents' local_information |
note |
str |
"" |
free annotation |
Broadcast.is_macro is the read-only property audience == "all". Broadcasts
are collected under information_program.broadcasts in the bundle; scheduled
events sit in scheduled_events.
Audience selectors are matched by match_selector() in
socioverse/env_layers/information.py, which supports exact equality on any
attribute plus the suffixed forms <attr>_in, <attr>_below, <attr>_above,
and the special keys geoid_list and index_below. An empty selector matches
nobody — a local broadcast must scope its audience explicitly.
What gets validated¶
EnvironmentBundle carries a model_validator (_events_reference_real_layers)
that refuses a bundle when:
- any
ScheduledEvent.target_layeris not thenameof a declared layer — the error lists the declared layer names. information_program.broadcastsis non-empty but no layer hasmodality="information".
The check runs wherever the bundle is loaded through
validate_handoff(path, EnvironmentBundle)
(socioverse/validation.py), so a malformed intervention stops at the stage
boundary rather than half-way through a paid run.
The engine itself does not interpret op or selector. The study's
EnvironmentProvider.advance_to(t) does — studies/opinion_diffusion/model.py
handles set/add on its media_pressure scalar, and
studies/campus_dining_choice/model.py multiplies prices in
_apply_price_event. The vocabulary is closed at the schema so that bundles
stay comparable and auditable across studies; the arithmetic belongs to the
provider that owns the layer.
A real bundle¶
From studies/opinion_diffusion/model.py (make_opinion_bundles) — one
macro-physical layer moved by an event, one macro-information layer carrying the
message:
EnvironmentBundle(
study_id=study_id, provider_ref="opinion.env",
provider_args={"n_agents": n_agents},
layers=[
EnvironmentLayer(name="peer_opinions", modality="physical", scope="local",
dynamics="endogenous", description="ring neighbours' current opinions"),
EnvironmentLayer(name="media_pressure", modality="physical", scope="macro",
dynamics="scheduled", description="city-wide media pressure scalar"),
EnvironmentLayer(name="campaign", modality="information", scope="macro",
dynamics="scheduled", description="broadcast campaign message"),
],
scheduled_events=[ScheduledEvent(at_step=campaign_step, target_layer="media_pressure",
op="set", property_name="level", value=1.0,
note="media pressure ramps up at the campaign step")],
information_program=InformationProgram(broadcasts=[
Broadcast(message_id="C1", channel="campaign", at_step=campaign_step, ttl=2,
audience="all", content="Adopt the new policy — it benefits everyone."),
]),
)
The dynamics tag (static / endogenous / scheduled, from
socioverse/schemas/common.py) is documentation for the reader and the
dashboard: scheduled marks the layers an intervention is allowed to move,
endogenous the ones the agents move through apply.
When it takes effect¶
An intervention fires in phase 1 of the step, exogenous update — before any agent observes:
① exogenous update env.advance_to(t) ← events fire, broadcasts activate/expire
② observe env.observe_batch(...)
③ decide decision.decide_batch(...) B_t = f(P, E_t)
④ apply env.apply(actions) E_{t+1} = g(E_t, B_t)
⑤ record memory window + panel row + metrics row
The ordering is what makes the comparison clean: every agent in step t* faces
exactly one state of the world, the post-intervention one. Nobody decides
against a stale environment, and nobody's decision is contaminated by another
agent's reaction within the same step. See
The simulation loop for the full five phases.
What it leaves on disk¶
In socioverse/engine/loop.py:
fired = self.env.advance_to(t) # ① exogenous update
if fired:
self.store.record_events(t, [self._event_note(e) for e in fired])
...
m = self._collect(personas, last_actions, t, fired_notes=[self._event_note(e) for e in fired])
| destination | what lands there |
|---|---|
events(step, note) in trajectory/study.duckdb |
one row per object returned by advance_to(t) |
the events column of that step's metrics row |
the same notes, "; ".join(...) — this is what draws the intervention marker on the metric timeline |
each agent's Observation |
a broadcast surfaces in macro_information / local_information for its ttl window; an event's effect shows up wherever the provider put it |
_event_note(ev) is ev.note or f"{ev.op} {ev.property_name or ''}", so an
intervention with an empty note still logs, just less legibly — write the note.
The from-scratch reference providers (studies/opinion_diffusion,
studies/campus_dining_choice) return only their fired ScheduledEvents from
advance_to, so a broadcast shows up in the agents' observations but not in the
events table. To mark a broadcast on the timeline too, return something for it:
the Chicago adapter wraps each newly activated broadcast in a ScheduledEvent
whose note carries the content, and note is the field the logger reads.
2. The formalization¶
Report §3.3. An intervention is a pair δ = (t*, op): a fork step and an
operation on the environment.
E'_t = E_t (t < t*) inherited, unchanged
E'_t = op(E_t) (t = t*) the operation applies in the exogenous phase
E'_{t+1} = g(E'_t, B'_t) (t > t*) the loop carries it forward
Behaviour in the branch is replayed before the fork and computed after it:
b'_{i,t} = b_{i,t} t < t* (replayed from the parent's panel)
b'_{i,t} = f(p_i, ω(E'_t, p_i), m'_{i,t}) t >= t* (real decision calls)
where ω is the observation projection and m' the branch's own memory window.
The quantity a counterfactual design reports is the paired contrast
for any Y computed from the environment or the panel — a metric column, an
agent-level share, a survival curve. The control run E_t is the parent, or a
sibling branch forked at the same t* with no operation declared.
3. How a branch is implemented¶
The call chain¶
/sv-iterate → create_branch(study_dir, note, fork_step=J)
writes versions.json: kind="branch", fork_step=J,
warm_start={"source": <parent>, "resume_from": J-1}
/sv-run → resolve_warm_start(study_dir)
→ check_branch_invariant(...) (§5)
→ {"source_version", "source_trajectory", "resume_from"}
WarmStartSpec(**ws) → SimulationConfig.warm_start
LongitudinalSimulator.run() → _replay_inherited(...)
WarmStartSpec (socioverse/schemas/simulation.py) is the on-disk name of a
branch's replay spec — the class name predates the branch/version vocabulary and
is kept so older simulation.json files still load. Its three fields are
source_version (provenance shown in the dashboard), source_trajectory (the
path versions/<source>/trajectory/study.duckdb, resolved by
version_trajectory_db()), and resume_from = the last inherited step. The
read-only property .fork_step returns resume_from + 1, the first step the
branch computes for itself.
_replay_inherited¶
run() materializes P and E_0 exactly as a cold run does
(materialize_initial() — population.build(seed) then env.reset(seed)),
records the t=0 panel and metrics, and then, if warm_start is set, hands
steps 1..K to _replay_inherited (K = min(resume_from, n_steps)) before
entering the real loop at K+1:
parent_by_step = self._load_parent_panel(ws.source_trajectory, ws.resume_from)
k = min(int(ws.resume_from), self.n_steps)
for t in range(1, k + 1):
fired = self.env.advance_to(t) # ① same schedule as the parent
if fired:
self.store.record_events(t, [self._event_note(e) for e in fired])
actions = self._reconstruct_actions(t, parent_by_step.get(t, []))
self.env.apply(actions) # ④ pure replay -> E_t == parent's E_t
for a in actions:
self.memories[a.agent_id].push(t, a) # ⑤ memory window
panel_t = self._panel_rows(personas, actions, t)
self.store.record(panel_t)
self._emit_panel(panel_t, t)
m = self._collect(personas, actions, t, fired_notes=[...])
rows.append(m)
return k
Phases ①, ④ and ⑤ run normally. Phases ② and ③ — observe and decide — are skipped: the decision model is never called on an inherited step, so the inherited horizon costs no tokens and no wall-clock time beyond the environment arithmetic.
_reconstruct_actions(t, rows) rebuilds Action objects from the parent's
stored action_kind and action_payload, tagging each with source="replay"
(the fourth value of Action.source, next to llm / rule / fallback). Rows
whose action_kind is None are skipped, which reproduces the parent's idle
agents rather than inventing actions for them.
| phase | cold step | replayed step |
|---|---|---|
| ① exogenous update | env.advance_to(t) |
env.advance_to(t) — identical, the pre-fork schedule is invariant |
| ② observe | env.observe_batch(...) |
skipped |
| ③ decide | decision.decide_batch(...) |
skipped — actions come from the parent's panel |
| ④ apply | env.apply(actions) |
env.apply(actions) with the parent's actions |
| ⑤ record | memory + panel + metrics + events | identical, into the branch's own store |
Why this reproduces the parent exactly¶
Three properties, each of them guaranteed by the code:
env.apply(actions)is a pure function of the actions it is passed for a from-scratch (Path-B) provider. Feeding back the parent's actions therefore walks the environment through the parent's states and leaves it at exactlyE_{t*-1}when the real loop takes over.E_0andPare reproduced, not copied. Both come frommaterialize_initial()under the same seed, and the invariant check (§5) guarantees the population bundle and roster are byte-identical to the parent's.- The memory window carries no provenance.
AgentMemory.pushstores only{t, action_kind, payload, summary}(socioverse/engine/memory.py), so an agent entering stept*in the branch holds exactly the window it held in the parent — thesource="replay"tag never reaches the prompt.
Consequently a branch and its parent (or two branches of one parent) have
identical rows before the fork, and the only thing that can explain a post-fork
difference is the schedule declared for the steps at or after t*.
Measured, not asserted¶
The technical report's fork demo (its script,
tech-report/scripts/fork_demo/run_fork.py, is not part of the repository) ran
this end to end on studies/opinion_diffusion with a real LLM: 24 agents, 8 steps, branching after
step 4 (resume_from = 4, so each branch computes from step 5 on), a media
campaign declared at step 5 in the treatment branch only. From the recorded
result.json:
| parent | control branch | treatment branch | |
|---|---|---|---|
| steps that called the decision model | 1–8 | 5–8 | 5–8 |
| wall-clock seconds | 47.3 | 25.3 | 23.0 |
| panel rows for steps 0–4 identical to the parent | — | yes | yes |
events rows |
none | none | step 5, "media pressure ramps up at the campaign step" |
The two branches' mean_opinion series coincide through step 4 and separate
from step 5 onward.
_load_parent_panel¶
con.execute(
"SELECT agent_id, step, action_kind, action_payload FROM panel "
"WHERE step >= 1 AND step <= ? ORDER BY step, agent_id", [int(upto)])
The parent's study.duckdb is read read-only and grouped by step. The same
function also accepts a path ending in .jsonl, which is how an interrupted run
is resumed (§7).
4. Eligibility and limits¶
| study shape | branch? | enforced by |
|---|---|---|
from-scratch / Path-B, interaction_rounds == 1 |
yes | — |
interaction_rounds > 1 |
no — cold run | the engine, with a ValueError |
legacy-wrap (a study whose model.py/adapter/ drives an external simulator, e.g. the Chicago segregation study) |
no — cold run | the workflow: /sv-iterate's version gate does not offer the branch option |
Multi-round studies. _replay_inherited raises immediately when
interaction_rounds != 1. The reason is the panel, not the replay code: the
loop runs for r in range(self.interaction_rounds) and records only
last_actions, the final round's actions, as that step's panel row. Rounds
0..K-2 of an inherited step are simply not on disk, so replaying the recorded
actions would reproduce the step's outcome without reproducing the exchange that
produced it. Rather than silently produce a trajectory that only looks like the
parent's, the engine refuses and the study runs cold. (See
Multi-round interaction for what the
messages table does store.)
Legacy-wrap studies. Here env.apply(actions) is not a pure function of the
actions it receives: the move intent lives on hidden legacy agent objects that
decide_batch sets, so skipping the decision phase leaves those objects unset
and apply no longer reproduces the parent. The engine cannot detect this, so
the rule is enforced one layer up — the version gate offers only the cold-run
options and says why.
The horizon. K = min(resume_from, n_steps): a branch whose n_steps is
shorter than what it would inherit simply inherits fewer steps. A branch may run
longer than its parent — see §7.
5. The E-only invariant¶
A branch makes a causal claim, and the claim only holds while everything except
the post-fork environment is the parent's. check_branch_invariant()
(skills/sv_workspace.py) re-verifies that against the parent's frozen snapshot
in versions/<source>/ before every run. Six things are compared:
population/population.json— parsed and compared as JSON, ignoring the volatile keyscreated_at,generated_at,updated_at,materialized_at,written_at(_VOLATILE_KEYS), which every author pass rewrites and which carry no behaviour.population/roster.jsonl— byte-for-byte (sha256). Together with (1) this isP.- The study's own Python —
*.pyplusadapter/*.py, byte-for-byte (_code_digest). The behaviour functionfis code, so a branch that edits it is not a branch. - Run settings — the
simulation.jsonfieldsseed,decision_ref,decision_args,collector_ref,collector_args,interaction_rounds,store_ref,store_args,engine_args(BRANCH_INVARIANT_SIM_FIELDS).n_stepsis deliberately absent — extending the horizon is a legitimate branch. So iswarm_start, which is the branch's own provenance. - The pre-fork environment —
provider_ref,provider_args, and everyscheduled_events/broadcastsentry withat_step < fork_step, compared as a sorted canonical set (_env_history). Entries at or after the fork step are exactly what the branch is allowed to change. - The parent's environment layers — a branch may add a layer (typically
an
informationlayer to carry a new broadcast), but a layer the parent already declared may be neither rewritten nor deleted.
A violation downgrades, it does not fail¶
entry["kind"] = "version"
entry["warm_start"] = None
entry["fork_step"] = None
entry["downgraded_from_branch"] = {"source": ..., "fork_step": ...,
"violations": [...], "at": time.time()}
resolve_warm_start() calls check_branch_invariant() first, so every path to
a run passes through the check. On a violation the version is rewritten in
versions.json as a plain version, the replay spec is dropped, resolve_warm_start
returns None, and the run goes cold from step 0. The results stay correct —
they just cost more. The downgraded_from_branch record, with one violation line
per offending artifact, stays on the manifest entry as the audit trail, and the
dashboard exposes it as downgraded: true.
A downgrade must be reported to the researcher
The downgrade is silent to the engine, never to the user. Whoever drives the
run has to say which artifact broke the branch, that steps 0..t*-1 will now
be recomputed by the decision model instead of replayed (under a real LLM they
will not reproduce the parent exactly, same seed included), and roughly what
that adds to the bill. The downgrade is not undone in place — the entry is a
version now. To keep the branch, restore the offending artifact from the
parent's snapshot and open a fresh one with create_branch.
Pre-check at any time, without touching the manifest:
from skills.sv_workspace import check_branch_invariant
check_branch_invariant("studies/<id>", downgrade=False)
# {"kind": "branch", "ok": False, "violations": [...], "downgraded": False,
# "source": "v1", "fork_step": 3, "error": None}
Verification never breaks a run: any exception inside the check is caught and
returned in the error field instead of propagating.
6. Reading the results¶
The lineage on disk¶
studies/<id>/versions.json records every node. A real lineage, from
studies/campus_dining_choice:
| id | parent | kind | fork step | note |
|---|---|---|---|---|
v1 |
null |
version | — | initial build |
v2 |
v1 |
branch | 3 | a ¥100/month meal subsidy from month 3 |
v3 |
v2 |
branch | 5 | extend the horizon to 7 months, first 4 unchanged |
v4 |
v1 |
version | — | double the canteen price increase (pre-fork E changed) |
v5 |
v1 |
version | — | 3 discussion rounds per dorm group (interaction_rounds changed) |
v4 and v5 show the boundary from the other side: changing the environment
before the fork step, or changing interaction_rounds, makes a version, not a
branch. Entries written before kind / fork_step existed still classify
correctly — version_kind(entry) and version_fork_step(entry) fall back to the
warm_start provenance, so no migration is needed.
The paired contrast, in SQL¶
Each version keeps its own store. Attach both and join on step:
ATTACH 'studies/campus_dining_choice/versions/v1/trajectory/study.duckdb'
AS control (READ_ONLY);
ATTACH 'studies/campus_dining_choice/versions/v2/trajectory/study.duckdb'
AS branch (READ_ONLY);
SELECT c.step,
round(b.share_canteen - c.share_canteen, 4) AS d_share_canteen,
round(b.mean_satisfaction - c.mean_satisfaction, 4) AS d_satisfaction
FROM control.metrics c
JOIN branch.metrics b USING (step)
ORDER BY c.step;
step d_share_canteen d_satisfaction
0 0.0000 0.0000
1 0.0000 0.0000
2 0.0000 0.0000
3 0.0133 0.0102
4 0.0600 0.0204
Exact zeros through step 2 and divergence from step 3 are the signature of a
correct branch: v2's fork step is 3. The same holds agent by agent —
SELECT count(*) AS mismatched_rows
FROM control.panel c
JOIN branch.panel b USING (agent_id, step)
WHERE c.step < 3
AND (c.action_kind IS DISTINCT FROM b.action_kind
OR CAST(c.action_payload AS VARCHAR) IS DISTINCT FROM CAST(b.action_payload AS VARCHAR));
returns 0. Use socioverse.io.duckdb_store.open_readonly(path) from Python for
the same thing. To see which steps carried an intervention, read the branch's
events table or the events column of its metrics table.
Pairing is only valid against the parent
Δ_t is a paired comparison between the same agents under two schedules.
It is meaningful between a branch and the version named in its
warm_start.source (the dashboard exposes this as branch_of), or between
two branches of one parent forked at the same step. It is not meaningful
between two arbitrary versions: they were run cold, they may differ in P,
f or seed, and even with an identical configuration a real LLM does not
reproduce a run step for step. Compare versions as two study designs —
trend, level, ordering — not as a matched difference.
7. Replay's other two uses¶
The replay machinery is not only for counterfactuals. The same spec covers two
ordinary needs, which is why n_steps is exempt from the invariant.
Extending a finished run. Declare the longer horizon, branch at
fork_step = parent_n_steps + 1, and the existing steps are inherited by replay
while only the new ones spend budget. v3 in the table above is exactly this: it
inherits v2's first four months and computes months 5–7. Nothing in the
environment changes, and the contrast against v2 is empty by construction —
the branch is being used as a continuation, not as a treatment.
Resuming an interrupted run. _load_parent_panel accepts a path ending in
.jsonl as well as a DuckDB file. That is the per-step stream the loop writes
alongside the store (trajectory/panel_live.jsonl, emitted by _emit_panel),
which survives a run that was killed by a budget ceiling or a crash — DuckDB is
single-writer-locked while a run is open, so the live stream is the only readable
copy. Point a WarmStartSpec at it with resume_from set to the last completed
step and the run continues from there without re-paying for the finished steps.
The sibling trajectory/progress.jsonl (from _emit_progress) tells you which
step that was.
Related¶
- The simulation loop — the five phases an intervention enters, and what each step records.
- The controllable research loop — branches and versions as
edits to the study state
σ = (P, E, f, Θ). - Iterate: versions and branches — the version gate, the three exits, and the workflow around a branch.
- Anatomy of a study — where
environment.json,versions.jsonand the trajectory store live.