Skip to content

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 ScheduledEvent or a Broadcast — 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 only t* .. T for 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:

  1. any ScheduledEvent.target_layer is not the name of a declared layer — the error lists the declared layer names.
  2. information_program.broadcasts is non-empty but no layer has modality="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

Δ_t(δ) = Y(E'_t) − Y(E_t),      t >= t*

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:

  1. 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 exactly E_{t*-1} when the real loop takes over.
  2. E_0 and P are reproduced, not copied. Both come from materialize_initial() under the same seed, and the invariant check (§5) guarantees the population bundle and roster are byte-identical to the parent's.
  3. The memory window carries no provenance. AgentMemory.push stores only {t, action_kind, payload, summary} (socioverse/engine/memory.py), so an agent entering step t* in the branch holds exactly the window it held in the parent — the source="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:

  1. population/population.json — parsed and compared as JSON, ignoring the volatile keys created_at, generated_at, updated_at, materialized_at, written_at (_VOLATILE_KEYS), which every author pass rewrites and which carry no behaviour.
  2. population/roster.jsonl — byte-for-byte (sha256). Together with (1) this is P.
  3. The study's own Python — *.py plus adapter/*.py, byte-for-byte (_code_digest). The behaviour function f is code, so a branch that edits it is not a branch.
  4. Run settings — the simulation.json fields seed, decision_ref, decision_args, collector_ref, collector_args, interaction_rounds, store_ref, store_args, engine_args (BRANCH_INVARIANT_SIM_FIELDS). n_steps is deliberately absent — extending the horizon is a legitimate branch. So is warm_start, which is the branch's own provenance.
  5. The pre-fork environment — provider_ref, provider_args, and every scheduled_events / broadcasts entry with at_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.
  6. The parent's environment layers — a branch may add a layer (typically an information layer 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.