Skip to content

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:

σ_{k+1} = h(a_k, σ_k)          the research loop, version k
σ       = (P, E_{0:T}, f, Θ)   the study state

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&lt;current&gt;/"] --> 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:

  1. Re-author the stage. Running the stage skill rewrites the artifact, its mtime moves past created_at, and it is no longer carried.
  2. 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