Versions and the controllable research loop¶
A study has two nested loops.
The longitudinal simulation loop is the inner one. It holds the study
fixed and advances time: B_t = f(P, E_t), then E_{t+1} = g(E_t, B_t), one
panel row per agent per step. It is implemented by
socioverse/engine/loop.py.
The controllable research loop is the outer one. It holds nothing fixed. The study itself is the thing being edited, and each edit produces a new version of it:
Nothing about this loop is implicit. The study state σ is a
directory on disk, an edit a_k is a file change, and h is
skills/sv_workspace.py writing a new entry into versions.json. This page
is about that machinery: which files carry which component of σ,
which edits become a branch and which become a version, what the
manifest records, and what the dashboard does with it.
For the mechanics of an intervention inside one run (a ScheduledEvent
or a Broadcast, and how a branch replays its parent), see
Interventions and branching. For the
directory layout as a whole, see
Anatomy of a Study.
The study state on disk¶
σ is not an in-memory object. Each of its four components is one or
more artifacts under studies/<study_id>/, and the live directory is
always the current version's working copy.
| component | meaning | artifacts | written by |
|---|---|---|---|
| P | the fixed population | population/population.json (personas, interaction, propagation, materialized_count) + population/roster.jsonl (every instantiated agent's t=0 row) |
/sv-build-population |
| E0:T | the whole environment trajectory, not just its initial state | environment/environment.json — provider_ref / provider_args, layers[], plus scheduled_events[] and information_program.broadcasts[], each carrying an at_step |
/sv-build-environment |
| f | the behavior function | model.py (from-scratch studies) or adapter/*.py (legacy wrap), bound at run time through decision_ref / decision_args |
/sv-build-model, or the adapter author |
| Θ | metrics, resource manifest, seed | simulation/simulation.json (seed, n_steps, interaction_rounds, collector_ref / collector_args, store_ref / store_args, engine_args), study.yaml (metrics, display_metrics), resources.json (the resource manifest) |
/sv-init, /sv-run at preflight |
Two consequences worth stating plainly.
E is a component of σ, not a component of a run. The schedule that
makes the environment move over 0..T is authored once, before the run, and
lives in one JSON file. That is what makes an environment-only edit cheap
enough to be its own kind of iteration.
The live directory is the current version, and archived versions are
not parallel runnable directories. Python package imports pin the
executable code at studies/<study_id>/ (import studies.<id>, or the
adapter's seam module), so versions/v3/model.py is an archive that the
dashboard can render and a human can read, never something the engine
imports. Iteration therefore freezes the old state and mutates in place,
rather than branching directories.
One edit, three outcomes¶
An edit is a pair a_k = (c, u), where c names the components of σ it
touches (a subset of P, E, f, Θ) and u is the update applied to them.
Three cases exhaust h:
| σ_k c = {} unchanged
σ_{k+1} = h(a_k, σ_k) | σ_k[ E_{t*:T} <- u ] c = {E} branch at fork step t*
| σ_k[ c <- u ] otherwise version
In workflow terms:
| what the edit touches | outcome | call | what runs |
|---|---|---|---|
| nothing (the edit is declined, or already satisfied) | unchanged | none | nothing |
environment.json only, and only entries at at_step >= t*; may also extend n_steps |
branch (分支) | create_branch(study_dir, note, fork_step=t*) |
replay steps 0..t*-1 from the parent's store, real decisions for t*..n_steps |
n_steps alone, longer horizon, nothing else |
branch (the degenerate case, t* = parent n_steps + 1) |
create_branch(..., fork_step=parent_n_steps + 1) |
replay everything the parent ran, compute only the new tail |
population/*, model.py / adapter/*.py, seed, or any environment entry at at_step < t* |
version (版本) | create_version(study_dir, note, resume_from_stage=...) |
cold run from step 0 |
| starting again from an older design | version, with base="vX" |
create_version(..., base="vX", resume_from_stage="sv-init") |
cold run from step 0 |
A branch is a claim, and the claim is checked
A branch asserts that everything differing from its parent after
fork_step is caused by the declared intervention alone. That holds only
while P, f, the run settings and the pre-fork schedule are byte-identical
to the parent's. check_branch_invariant() verifies exactly that against
the parent's frozen snapshot, and resolve_warm_start() calls it on
every path to a run. A violation downgrades the version instead of
raising: kind becomes "version", warm_start and fork_step are
cleared, the run goes cold from step 0, and the reason is recorded on the
entry as downgraded_from_branch.
What the invariant compares, from check_branch_invariant() in
skills/sv_workspace.py:
| checked | rule | in the code |
|---|---|---|
population/population.json |
equal after dropping volatile keys (created_at, generated_at, updated_at, materialized_at, written_at) |
BRANCH_INVARIANT_FILES |
population/roster.jsonl |
byte-identical (sha256) | BRANCH_INVARIANT_FILES |
*.py + adapter/*.py |
byte-identical — the behavior function f is code | _code_digest() |
simulation.json fields |
seed, decision_ref, decision_args, collector_ref, collector_args, interaction_rounds, store_ref, store_args, engine_args unchanged. n_steps is deliberately absent, so a branch may run a longer horizon |
BRANCH_INVARIANT_SIM_FIELDS |
| environment history | provider_ref, provider_args, and every scheduled_events / broadcasts entry with at_step < fork_step unchanged |
_env_history() |
| environment layers | a branch may add a layer (for example an information layer to carry a new broadcast) but may not rewrite or delete one the parent already had |
check_branch_invariant() |
Two further eligibility conditions come from the engine rather than from the
manifest, and they are documented on WarmStartSpec in
socioverse/schemas/simulation.py: replay requires interaction_rounds == 1
and an env.apply(actions) that is a pure function of the actions passed to
it. Legacy-wrap studies such as chicago_schelling cannot be replayed
(their move intent lives on hidden legacy agents set during decide_batch),
and neither can multi-round studies. For those, every iteration is a version.
versions.json — the manifest¶
One file per study, at the study root, written atomically (tmp +
rename) so the dashboard poller never reads a torn file. Top level:
current (the version id the live directory carries) and versions[]. A
two-entry example, a mainline root plus one branch off it:
{
"current": "v2",
"versions": [
{
"id": "v1",
"parent": null,
"note": "initial build",
"kind": "version",
"fork_step": null,
"warm_start": null,
"created_at": 1783433449.879952,
"snapshot": "versions/v1"
},
{
"id": "v2",
"parent": "v1",
"note": "第3月起发¥100/月餐补,跑4步",
"kind": "branch",
"fork_step": 3,
"warm_start": {"source": "v1", "resume_from": 2},
"resume_from_stage": "sv-build-environment",
"created_at": 1783439750.058673,
"snapshot": null
}
]
}
| field | type | meaning |
|---|---|---|
id |
"v<N>" |
create_version() assigns max(existing numeric ids) + 1, so ids stay monotonic even when a new version starts from an old parent |
kind |
"branch" | "version" |
set from warm_start: "branch" when a replay spec is present, "version" otherwise |
parent |
str \| null |
the version this one was created from. null only on v1 |
fork_step |
int \| null |
the first step a branch computes for itself, = warm_start.resume_from + 1. null on a version |
warm_start |
{source, resume_from} \| null |
the branch's replay provenance: inherit source's steps 0..resume_from, compute the rest. null on a version |
resume_from_stage |
str \| null |
the earliest sv-* stage this iteration re-enters. Drives the review gate (below). Written by create_version(); the v1 entry init_manifest() writes omits the key |
note |
str |
the one-line human description shown in the dashboard's version box |
created_at |
float |
unix timestamp. Also the cutoff that separates carried artifacts from ones authored in this version |
snapshot |
str \| null |
"versions/<id>" once the version has been frozen. null while it is the live current version |
downgraded_from_branch |
object |
present only when a branch failed its invariant: {source, fork_step, violations[], at}. Audit trail, never removed |
v1 is a version, and old manifests need no migration
There is no "root" kind. init_manifest() registers the live directory
as v1 with kind: "version" and parent: null, which is simply the
start of the mainline.
Manifests written before kind and fork_step existed (the
campus_dining_choice study still has one) are read through
version_kind(entry) and version_fork_step(entry), which fall back to
the warm_start provenance: an entry with warm_start.source set is a
branch, and its fork step is resume_from + 1. The dashboard server
carries an equivalent pair of functions in
dashboard/server/app.py. Nothing needs rewriting on disk.
Read the manifest from Python with the helpers rather than by hand:
from skills.sv_workspace import (
current_version, version_entry, version_kind, version_fork_step,
)
S = "studies/campus_dining_choice"
current_version(S) # "v5"
version_kind(version_entry(S)) # "version" — v5 ran cold
version_fork_step(version_entry(S)) # None
version_kind(version_entry(S, "v2")) # "branch"
version_fork_step(version_entry(S, "v2")) # 3
version_entry(study_dir) without a second argument returns the current
version's entry.
Snapshot semantics¶
create_version() is crash-safe by ordering. Every step is idempotent or
invisible until the last one lands.
graph LR
A["1 · freeze live<br/>versions/v<current>/"] --> B["2 · optional restore<br/>base snapshot → live"]
B --> C["3 · clear outputs<br/>trajectory/ reports/"]
C --> D["4 · append entry<br/>move current (atomic)"]
1. Freeze. _snapshot_live() copies the whole live directory into
versions/<current_id>/, skipping SNAPSHOT_EXCLUDE:
| excluded | why |
|---|---|
versions/ |
the snapshot tree itself, otherwise each freeze nests the last |
versions.json |
study-level, live only. One manifest per study, not one per version |
runs/ |
demo scratch output |
__pycache__, .DS_Store |
caches and noise |
progress.jsonl, panel_live.jsonl |
the two transient run files. An archived copy carrying them would look mid-run to the dashboard |
Everything else is frozen, including trajectory/study.duckdb. That is
deliberate: version_trajectory_db(study_dir, vid) resolves to
versions/<vid>/trajectory/study.duckdb, and that archived store is exactly
what a branch replays from. grounding/, resources.json and
narrative/<stage>.json are frozen too, so an archived version carries its
own evidence and its own stage reasoning rather than a sibling's.
If the snapshot directory already exists (a previous create_version crashed
after freezing), it is kept as-is. It is the authoritative freeze of that
version and the live directory may already have moved on.
2. Restore, only when base names an older version. Everything in the
live directory except RESTORE_KEEP (versions/, versions.json, runs/,
__pycache__, .DS_Store) is deleted, then the base snapshot is copied
back, minus OUTPUT_SUBDIRS. The outputs stay archived because the new
version has to re-run. Files are copied with shutil.copy2, which preserves
mtimes, and that matters for the review gate below.
3. Clear outputs. trajectory/ and reports/ are removed from the live
directory and the empty layout is recreated, so the new version starts
honestly at "built, not yet run". The parent's results are safe in its
snapshot.
4. Register. The new entry is appended and current moves, in one atomic
manifest write. Until that write, a half-finished copy is invisible to the
dashboard.
The review gate¶
A new version inherits most of its parent's artifacts untouched. That is the point, and it is also the risk: a pipeline can look finished while three of its six stages are still the parent's work, unexamined.
compute_stages() in dashboard/server/app.py closes that hole in two
passes.
Carried detection. For a version with a parent, any stage whose
artifact is done but whose mtime is older than the version's created_at
was carried, not authored here. It gets carried_from: "<parent id>".
The gate. Starting at resume_from_stage, every carried stage that does
not have a narrative written in this version is shown as review
(待复核), and the pipeline pointer parks on the first of them. The comparison
is narrative/<stage>.json's ts against the version's created_at.
resume_from_stage is chosen at creation time by /sv-iterate:
| version choice | resume_from_stage |
rationale |
|---|---|---|
| branch | "sv-build-environment" (the create_branch default) |
the environment bundle is the only artifact a branch re-authors |
| new version from the current one | the earliest stage the edit affects | everything before it is genuinely unchanged |
| new version from a historical base | "sv-init" |
starting from an old design is a large context switch, so every stage is re-reviewed |
| missing or unrecognised | the first carried stage | fail safe, never the run pointer |
There are exactly two ways to clear a review flag:
- Re-author the stage. Running the stage skill rewrites the artifact,
its mtime moves past
created_at, and it is no longer carried. - Verify and record. Check the carried artifact against the new intent,
then post that stage's narrative for this version (
sv_emit.py narrative --study <id> --stage <name> --text "..."). The narrative is what marks the stage reviewed.
Do not rewrite an unchanged artifact just to be safe
An identical rewrite still bumps the file's mtime. That destroys carried
detection (the stage stops looking inherited), it destroys staleness
detection (STAGE_DEPS compares mtimes to decide whether a run still
reflects its inputs), and it puts a false authorship event into the
activity log, which build_events() reconstructs from mtimes alone.
Verify and post the narrative instead.
Two scopes are worth remembering. The gate applies only to the live
current version. An archived snapshot is settled history and its carried
stages are never flagged. And staleness is separate from the gate: a done
stage built before one of its STAGE_DEPS (run depends on model +
environment + population, report depends on run) is marked stale
regardless of versioning.
Four controlled comparisons¶
Because P, E and f are separate replaceable artifacts and every run is a
version with a recorded lineage, a study grows by adding versions that vary
one axis and hold the rest fixed. All four of these are versions, not
branches, and the reason is always the same: the branch invariant permits
only added environment entries at at_step >= fork_step.
| control | edit | artifact | why it cannot be a branch |
|---|---|---|---|
| Population replacement — same E and f, different demographic composition (census-synthesized adults versus personas from an open pool) | rebuild P | population/population.json + population/roster.jsonl |
both files are in BRANCH_INVARIANT_FILES. A different roster means different individuals, so the parent's realized history is not this population's history and there is nothing to replay |
| Environment variation — same P and f, a structurally different environment (another city, another interaction topology) | rebuild E | environment/environment.json (provider_ref / provider_args, layers[]) |
_env_history() pins provider_ref and provider_args from step 0, and existing layers may not be rewritten. This is the comparison that cannot be expressed as an intervention |
| Behavior-function ablation — rule-f versus LLM-f versus hybrid-f on the same (P, E) | swap the decision model | model.py / adapter/*.py, and decision_ref / decision_args in simulation.json |
the code digest and the simulation.json field list both catch it. The parent's actions were produced by a different f, so replaying them would smuggle the old f into the new run |
| Data-source substitution — a different vintage of an indicator, or a different user pool, with no model change | re-resolve the manifest | resources.json, plus the regenerated P or E bundle it feeds |
the substitution lands in P or E before step 0, so the whole trajectory changes |
These combine. One version may vary several axes at once, and the manifest still records a single parent, so the lineage stays a tree.
Reproducibility and audit¶
Three properties hold together, and each is a file on disk rather than a convention.
Every version snapshots the whole workspace. Not just the results: code,
bundles, grounding, trajectory store, reports and stage narratives. Any
earlier σ_k can be inspected, and restored into the live directory
as the base of a new version. This is what makes the first case of the edit
equation meaningful — declining an edit, or undoing one already applied,
is always available.
Grounding freezes with the version. grounding/grounding.json is inside
the snapshot, so a version's facts, sources and declared assumptions are the
ones that were true when it ran. A later version that revises a fact does not
retroactively change what an earlier version claimed. See
Grounding & Provenance.
The resource manifest pins the data sources. resources.json records the
external capabilities a study actually used, by name (mcp_servers[],
datasets[], tools[]), and it is snapshotted with everything else.
Swapping a data vintage is therefore a visible edit to a versioned artifact,
not an invisible drift in an upstream service.
Downgrades leave a trail. A branch that failed its invariant keeps
downgraded_from_branch on its manifest entry, with the violations and the
timestamp. versions_view() surfaces it to the UI as downgraded: true
alongside kind, fork_step and branch_of, so a reader can see that a
version was intended as a counterfactual and why it is not one.
Comparing versions¶
Every version's store is written under the same contract by
socioverse/io/duckdb_store.py, so a cross-version comparison is one query
over two files:
| table | columns |
|---|---|
panel |
agent_id, step, state (JSON), action_kind, action_payload (JSON) |
metrics |
step first, then the collector's own columns() |
events |
step, note |
messages |
author_id, step, round, channel, content, audience. Created only when a study records messages |
ATTACH 'studies/<id>/versions/v1/trajectory/study.duckdb' AS control (READ_ONLY);
ATTACH 'studies/<id>/versions/v2/trajectory/study.duckdb' AS treated (READ_ONLY);
SELECT 'v1' AS version, * FROM control.metrics
UNION ALL BY NAME
SELECT 'v2' AS version, * FROM treated.metrics
ORDER BY version, step;
For a branch, the pair is a control and a treatment over the same
individuals with the same seed, identical for every step before fork_step
by construction rather than by luck. For two versions, it is a comparison of
two study designs, and the per-step rows still line up as long as the two
versions kept the same collector.
From Python, socioverse.io.duckdb_store.open_readonly(path) returns a
read-only connection to a finalized store. panel.parquet and
metrics.parquet are exported next to the database at finalize() when
export_parquet is on, if you would rather read the panel with pandas or
polars.
See also¶
- Interventions and branching — the
ScheduledEvent/Broadcastschema, the five stages of a step, and how replay reproduces a parent's history. - Anatomy of a Study — every artifact and its schema,
including the shorter
versions.jsonentry in the layout tour. - Iterate: versions and branches —
the
/sv-iteratewalkthrough, from the version gate to re-entering the pipeline. - Grounding & Provenance — how facts, sources and assumptions are recorded and frozen.