Skip to content

Workflow helpers — skills.*

These two modules are the helper layer the sv-* workflow uses to scaffold, version, branch and ground a study. They are not part of the simulation engine — they operate on the study directory — but they are worth knowing if you drive the pipeline yourself instead of through the skills, or if you script batch experiments over many studies.

Both are stdlib-only and safe to import without a configured LLM.

skills.sv_workspace — study directories, versions and branches

Scaffolding (scaffold, study_paths), the catalog that sv-init routes against (discover_studies, catalog_view), study-level forking (fork_study — copies an existing study under a new study_id, rewrites that id throughout the copied artifacts, forces reference: false, and deliberately carries no code and no run outputs), and the capability registry view (load_capabilities, capability_view).

The version machinery behind /sv-iterate, in the order a run touches it:

function what it does
init_manifest(study_dir, note) registers the live directory as v1 with kind: "version" and parent: null. No-op once a manifest exists
create_version(study_dir, note, base=None, warm_start=None, resume_from_stage=None) freezes the current version into versions/<id>/, optionally restores an older base into the live dir, drops the live run outputs, then appends the new entry and moves current. Records kind — "branch" when a warm_start is passed, "version" otherwise — and fork_step
create_branch(study_dir, note, *, fork_step, source=None, resume_from_stage="sv-build-environment") the proper entry point for a branch. source defaults to the current version; naming an older one restores its artifacts first. fork_step < 1 raises ValueError (step 0 is the materialized initial state — there is nothing to inherit). Internally records warm_start={"source": src, "resume_from": fork_step - 1}
check_branch_invariant(study_dir, *, version_id=None, downgrade=True) verifies the E-only claim against the parent's snapshot and returns {kind, ok, violations, downgraded, source, fork_step, error}. On a violation it downgrades the entry (kind → "version", warm_start → None, fork_step → None, reason into downgraded_from_branch) instead of raising, so the run continues — cold, and still correct. downgrade=False checks without writing
resolve_warm_start(study_dir) turns the manifest's replay provenance into runnable engine params {source_version, source_trajectory, resume_from}. It calls check_branch_invariant first, so a downgraded branch resolves to None and sv-run starts cold from step 0
version_entry(study_dir, version_id=None), version_kind(entry), version_fork_step(entry) read one manifest entry and classify it. The latter two fall back to warm_start provenance, so manifests written before kind and fork_step existed classify correctly without a migration

skills.sv_workspace

Shared helper for the SocioVerse2 workflow skills.

Scaffolds a standardized study workspace and provides the strict-contract load/save helpers (thin wrappers over socioverse.validation) the skills must use at every boundary.

scaffold

scaffold(studies_root: str | Path, study_id: str) -> Path

Create the standardized study workspace and return its path.

Source code in skills/sv_workspace.py
def scaffold(studies_root: str | Path, study_id: str) -> Path:
    """Create the standardized study workspace and return its path."""
    root = Path(studies_root) / study_id
    for sub in STUDY_SUBDIRS:
        (root / sub).mkdir(parents=True, exist_ok=True)
    return root

write_roster

write_roster(path: str | Path, rows) -> Path

Write the materialized initial roster (population/roster.jsonl): one JSON line per agent, the t=0 panel row (agent_id, step, state, action_kind, action_payload). This is the durable 'instantiated agents' artifact sv-build-population produces so the dashboard's agent inspector can show every initialized agent BEFORE any run — the same shape the live panel_live.jsonl uses, so the dashboard reads it with no special casing. rows = TrajectoryRecords (or dicts with those keys). Lives under population/, so version snapshots keep it.

Source code in skills/sv_workspace.py
def write_roster(path: str | Path, rows) -> Path:
    """Write the materialized initial roster (population/roster.jsonl): one JSON line per agent,
    the t=0 panel row (agent_id, step, state, action_kind, action_payload). This is the durable
    'instantiated agents' artifact sv-build-population produces so the dashboard's agent inspector
    can show every initialized agent BEFORE any run — the same shape the live panel_live.jsonl
    uses, so the dashboard reads it with no special casing. ``rows`` = TrajectoryRecords (or dicts
    with those keys). Lives under population/, so version snapshots keep it."""
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)

    def _get(r, k, default=None):
        return getattr(r, k, default) if not isinstance(r, dict) else r.get(k, default)

    with p.open("w", encoding="utf-8") as f:
        for r in rows:
            f.write(json.dumps({
                "agent_id": _get(r, "agent_id"),
                "step": _get(r, "step", 0),
                "state": _get(r, "state", {}),
                "action_kind": _get(r, "action_kind"),
                "action_payload": _get(r, "action_payload", {}),
            }, ensure_ascii=False) + "\n")
    return p

save_study_yaml

save_study_yaml(path: str | Path, study_spec) -> Path

StudySpec is written as JSON-in-.yaml (valid YAML, simple + re-loadable).

Source code in skills/sv_workspace.py
def save_study_yaml(path: str | Path, study_spec) -> Path:
    """StudySpec is written as JSON-in-.yaml (valid YAML, simple + re-loadable)."""
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(study_spec.model_dump_json(indent=2), encoding="utf-8")
    return p

fork_study

fork_study(
    studies_root: str | Path,
    source_study_id: str,
    new_study_id: str,
    *,
    status: str = "draft",
) -> Path

Path-A reuse: copy an existing study into a NEW study_id so the source stays intact.

SocioVerse2's reuse rule is never edit a matched or reference study in place — fork it. Any study whose study.yaml carries reference: true (the bundled templates), and any already-adapted study, is a read-only baseline; a new query becomes its own study. This copies the source's authored artifacts (study.yaml + the resources/environment/population/simulation JSON that exist, plus grounding/grounding.json — the fork inherits the source's real-world anchors and refreshes only the entries it changes), rewrites the fork's identity (study_id across study.yaml AND every copied artifact that embeds it — resources/environment/population/simulation — plus status / created_by), forces reference: false (a working fork is never a read-only template), and DROPS stale run outputs (trajectory/*, reports/*) so the fork re-runs clean. No code is copied or written: the fork's bundles keep pointing at the source's registered provider_refs (that is what makes this the "no new code" path). Returns the new study root. Refuses to overwrite an existing study_id.

Source code in skills/sv_workspace.py
def fork_study(
    studies_root: str | Path,
    source_study_id: str,
    new_study_id: str,
    *,
    status: str = "draft",
) -> Path:
    """Path-A reuse: copy an existing study into a NEW ``study_id`` so the source stays intact.

    SocioVerse2's reuse rule is **never edit a matched or reference study in place — fork it.** Any
    study whose ``study.yaml`` carries ``reference: true`` (the bundled templates), and any
    already-adapted study, is a read-only baseline; a new query becomes its own study. This copies the source's
    authored artifacts (``study.yaml`` + the resources/environment/population/simulation JSON
    that exist, plus ``grounding/grounding.json`` — the fork inherits the source's real-world
    anchors and refreshes only the entries it changes), rewrites the fork's identity
    (``study_id`` across study.yaml AND every copied artifact that embeds it —
    resources/environment/population/simulation — plus ``status`` / ``created_by``), forces
    ``reference: false`` (a working fork is never a read-only template), and
    DROPS stale run outputs (``trajectory/*``, ``reports/*``) so the fork re-runs clean. No code
    is copied or written: the fork's bundles keep pointing at the source's registered
    ``provider_refs`` (that is what makes this the "no new code" path). Returns the new study
    root. Refuses to overwrite an existing ``study_id``.
    """
    src = Path(studies_root) / source_study_id
    if not (src / "study.yaml").exists():
        raise FileNotFoundError(f"no study to fork at {src}")
    dst = Path(studies_root) / new_study_id
    if dst.exists():
        raise FileExistsError(f"refusing to overwrite existing study {dst}")

    scaffold(studies_root, new_study_id)
    src_paths, dst_paths = study_paths(src), study_paths(dst)
    for key in ("study", "resources", "environment", "population", "simulation", "grounding"):
        s = src_paths[key]
        if s.exists():
            dst_paths[key].parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(s, dst_paths[key])

    spec = load_study_yaml(dst_paths["study"])
    spec.study_id = new_study_id
    spec.status = status
    spec.created_by = f"sv-init (fork of {source_study_id})"
    spec.reference = False   # a working fork is NEVER a read-only template — else it masquerades as
                             # one (blocks in-place edits, shows a REF badge, can be picked as a build template)
    save_study_yaml(dst_paths["study"], spec)

    # Rewrite the copied artifacts' embedded study_id too. resources/environment/population/simulation
    # each carry `study_id`; a stale SOURCE id mis-tags the fork's trajectory store (DuckDB `study_id`),
    # binding the fork's results onto the source study in the dashboard. fork_study historically only
    # rewrote study.yaml, so every caller had to hand-patch these (the recurring fork id-drift bug).
    for key in ("resources", "environment", "population", "simulation"):
        p = dst_paths[key]
        if not p.exists():
            continue
        try:
            obj = json.loads(p.read_text())
        except (json.JSONDecodeError, OSError):
            continue
        if isinstance(obj, dict) and obj.get("study_id") not in (None, new_study_id):
            obj["study_id"] = new_study_id
            p.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")
    return dst

current_version

current_version(study_dir: str | Path) -> str

The version id the live dir currently carries (implicit v1 pre-manifest).

Source code in skills/sv_workspace.py
def current_version(study_dir: str | Path) -> str:
    """The version id the live dir currently carries (implicit ``v1`` pre-manifest)."""
    man = load_manifest(study_dir)
    return (man or {}).get("current") or "v1"

init_manifest

init_manifest(
    study_dir: str | Path, note: str = "initial build"
) -> dict

Register the live dir as v1. sv-init calls this at scaffold/fork time so the dashboard shows a default version from day one; pre-versioning studies get it lazily on their first /sv-iterate. No-op when a manifest already exists.

Source code in skills/sv_workspace.py
def init_manifest(study_dir: str | Path, note: str = "initial build") -> dict:
    """Register the live dir as ``v1``. sv-init calls this at scaffold/fork time so the
    dashboard shows a default version from day one; pre-versioning studies get it
    lazily on their first /sv-iterate. No-op when a manifest already exists."""
    existing = load_manifest(study_dir)
    if existing:
        return existing
    manifest = {
        "current": "v1",
        "versions": [{"id": "v1", "parent": None, "note": note, "kind": "version",
                      "created_at": time.time(), "snapshot": None, "warm_start": None,
                      "fork_step": None}],
    }
    _write_manifest(study_dir, manifest)
    return manifest

version_trajectory_db

version_trajectory_db(
    study_dir: str | Path, version_id: str
) -> Path

Path to an archived version's trajectory store — what warm-start replays from. create_version snapshots the live dir into versions/<id>/ and study.duckdb is NOT in SNAPSHOT_EXCLUDE, so a finished version's panel is preserved here for the next version to inherit. (Only defined for versions that have a snapshot; the live/current version's store is at trajectory/study.duckdb.)

Source code in skills/sv_workspace.py
def version_trajectory_db(study_dir: str | Path, version_id: str) -> Path:
    """Path to an archived version's trajectory store — what warm-start replays from.
    ``create_version`` snapshots the live dir into ``versions/<id>/`` and ``study.duckdb`` is NOT
    in SNAPSHOT_EXCLUDE, so a finished version's panel is preserved here for the next version to
    inherit. (Only defined for versions that have a snapshot; the live/current version's store is
    at ``trajectory/study.duckdb``.)"""
    return Path(study_dir) / VERSIONS_DIR / version_id / "trajectory" / "study.duckdb"

create_version

create_version(
    study_dir: str | Path,
    note: str,
    base: str | None = None,
    warm_start: dict | None = None,
    resume_from_stage: str | None = None,
) -> str

Iterate the study to a NEW version, preserving the current one as a snapshot.

This is the version-iterate path of /sv-iterate (the in-place path simply skips this call). Order is crash-safe: (1) freeze live → versions/v<current>/; (2) if base names an older version, restore its snapshot into the live dir (copy2 keeps mtimes, so untouched artifacts read as carried-from-parent); (3) drop the live run outputs (kept in the snapshot) so the new version starts at "built, not yet run"; (4) append the new entry + move current (atomic, last — a half-done copy is invisible to the dashboard until the manifest says otherwise).

Every entry records a kind: a branch (warm_start given — inherits the parent's realized history by replay and changes only the environment from its fork step on) or a version (anything else — any artifact may change, the run starts cold). See create_branch / check_branch_invariant for the branch contract.

note is the one-line human description shown in the dashboard's version box. base defaults to the current version; passing an older id starts the new version from that archived one instead (cross-version iteration). warm_start (optional) records the inherit-steps provenance on the NEW version entry — {"source": <parent v>, "resume_from": K} — set by /sv-iterate when the user opts to inherit the parent's already-run steps (the parent's snapshot is what sv-run then replays). resume_from_stage (optional) records the earliest sv-* stage this iteration re-enters (e.g. "sv-build-environment"); the dashboard reads it so the new version's progress pointer parks on that stage — rather than reading the parent's carried artifacts as this version's finished work — until Claude actually re-authors it. Returns the new version id. The caller (skill contract) must not invoke this while a run is writing the trajectory store.

Source code in skills/sv_workspace.py
def create_version(study_dir: str | Path, note: str, base: str | None = None,
                   warm_start: dict | None = None, resume_from_stage: str | None = None) -> str:
    """Iterate the study to a NEW version, preserving the current one as a snapshot.

    This is the **version-iterate** path of /sv-iterate (the in-place path simply skips
    this call). Order is crash-safe: (1) freeze live → ``versions/v<current>/``;
    (2) if ``base`` names an older version, restore its snapshot into the live dir
    (``copy2`` keeps mtimes, so untouched artifacts read as carried-from-parent);
    (3) drop the live run outputs (kept in the snapshot) so the new version starts at
    "built, not yet run"; (4) append the new entry + move ``current`` (atomic, last —
    a half-done copy is invisible to the dashboard until the manifest says otherwise).

    Every entry records a ``kind``: a **branch** (``warm_start`` given — inherits the parent's
    realized history by replay and changes only the environment from its fork step on) or a
    **version** (anything else — any artifact may change, the run starts cold). See
    ``create_branch`` / ``check_branch_invariant`` for the branch contract.

    ``note`` is the one-line human description shown in the dashboard's version box.
    ``base`` defaults to the current version; passing an older id starts the new version from
    that archived one instead (cross-version iteration). ``warm_start`` (optional) records the inherit-steps provenance
    on the NEW version entry — ``{"source": <parent v>, "resume_from": K}`` — set by /sv-iterate
    when the user opts to inherit the parent's already-run steps (the parent's snapshot is what
    `sv-run` then replays). ``resume_from_stage`` (optional) records the earliest sv-* stage this
    iteration re-enters (e.g. ``"sv-build-environment"``); the dashboard reads it so the new
    version's progress pointer parks on that stage — rather than reading the parent's carried
    artifacts as this version's finished work — until Claude actually re-authors it. Returns the
    new version id. The caller (skill contract) must not invoke this while a run is writing the
    trajectory store.
    """
    d = Path(study_dir)
    if not (d / "study.yaml").exists():
        raise FileNotFoundError(f"no study at {d}")
    manifest = load_manifest(d) or init_manifest(d)
    cur = manifest["current"]
    entries = {v["id"]: v for v in manifest["versions"]}
    base = base or cur
    if base not in entries:
        raise ValueError(f"unknown base version {base!r}; have {sorted(entries)}")
    # Starting a new VERSION from an older one (base != cur) is a big context switch → default the
    # re-entry to sv-init so the dashboard's review gate (see compute_stages) makes Claude re-review
    # EVERY stage from the start. (A BRANCH is different: create_branch passes sv-build-environment,
    # because E is the only artifact it re-authors.) A same-line new version leaves resume_from_stage
    # to the caller (the earliest affected stage); if that too is omitted, compute_stages fails safe
    # to the first carried stage, never run.
    if resume_from_stage is None and base != cur:
        resume_from_stage = "sv-init"

    # 1) freeze the live dir as the current version's archive
    entries[cur]["snapshot"] = _snapshot_live(d, cur)

    # 2) branching from an older version: make live == that snapshot (minus outputs)
    if base != cur:
        base_snap = d / (entries[base].get("snapshot") or "")
        if not base_snap.is_dir():
            raise FileNotFoundError(f"base version {base!r} has no snapshot at {base_snap}")
        for child in d.iterdir():
            if child.name in RESTORE_KEEP:
                continue
            shutil.rmtree(child) if child.is_dir() else child.unlink()
        for child in base_snap.iterdir():
            if child.name in OUTPUT_SUBDIRS:
                continue  # outputs stay archived; the new version re-runs
            dst = d / child.name
            if child.is_dir():
                shutil.copytree(child, dst)
            else:
                shutil.copy2(child, dst)

    # 3) clear live run outputs (preserved in the snapshot) + restore the empty layout
    for sub in OUTPUT_SUBDIRS:
        if (d / sub).exists():
            shutil.rmtree(d / sub)
    for sub in STUDY_SUBDIRS:
        (d / sub).mkdir(parents=True, exist_ok=True)

    # 4) register the new version and point `current` at it (atomic, last)
    nums = [int(v["id"][1:]) for v in manifest["versions"]
            if v["id"].startswith("v") and v["id"][1:].isdigit()]
    new_id = f"v{(max(nums) if nums else len(manifest['versions'])) + 1}"
    manifest["versions"].append({"id": new_id, "parent": base, "note": note,
                                 # branch = inherits the parent's realized history by replay and
                                 # changes only E from the fork step on; version = anything else.
                                 "kind": "branch" if warm_start else "version",
                                 "created_at": time.time(), "snapshot": None,
                                 "warm_start": warm_start,   # {source, resume_from} | None
                                 "fork_step": _fork_step_of(warm_start),   # first self-computed step | None
                                 "resume_from_stage": resume_from_stage})   # earliest re-entered stage | None
    manifest["current"] = new_id
    _write_manifest(d, manifest)
    return new_id

version_entry

version_entry(
    study_dir: str | Path, version_id: str | None = None
) -> dict | None

The manifest entry for version_id (default: the current version).

Source code in skills/sv_workspace.py
def version_entry(study_dir: str | Path, version_id: str | None = None) -> dict | None:
    """The manifest entry for ``version_id`` (default: the current version)."""
    man = load_manifest(study_dir)
    if not man:
        return None
    vid = version_id or man.get("current") or "v1"
    return next((v for v in man.get("versions", []) if v.get("id") == vid), None)

version_kind

version_kind(entry: dict | None) -> str

"branch" or "version". Manifests written before kind existed are read by their warm-start provenance, so old studies classify correctly without a migration.

Source code in skills/sv_workspace.py
def version_kind(entry: dict | None) -> str:
    """``"branch"`` or ``"version"``. Manifests written before ``kind`` existed are read by their
    warm-start provenance, so old studies classify correctly without a migration."""
    if not entry:
        return "version"
    kind = entry.get("kind")
    if kind in ("branch", "version"):
        return kind
    return "branch" if (entry.get("warm_start") or {}).get("source") else "version"

version_fork_step

version_fork_step(entry: dict | None) -> int | None

A branch's fork step, derived from warm-start provenance on pre-fork_step manifests.

Source code in skills/sv_workspace.py
def version_fork_step(entry: dict | None) -> int | None:
    """A branch's fork step, derived from warm-start provenance on pre-``fork_step`` manifests."""
    if not entry:
        return None
    fs = entry.get("fork_step")
    if isinstance(fs, int):
        return fs
    return _fork_step_of(entry.get("warm_start"))

create_branch

create_branch(
    study_dir: str | Path,
    note: str,
    *,
    fork_step: int,
    source: str | None = None,
    resume_from_stage: str = "sv-build-environment",
) -> str

Branch the study at fork_step — the counterfactual path of /sv-iterate.

The new version inherits source's realized history for steps 0..fork_step-1 (sv-run replays that version's stored actions, no LLM, so the inherited steps reproduce the parent exactly) and computes fork_step onward for itself. Only the environment may differ, and only from fork_step on: the intervention is a ScheduledEvent or Broadcast authored at a step >= fork_step. check_branch_invariant enforces that before the run.

source defaults to the current version. Naming an older one restores that version's artifacts into the live dir first (as create_version(base=...) does), so the branch really departs from that version's design. Re-entry defaults to sv-build-environment because the environment bundle is the only artifact a branch re-authors. Returns the new version id.

Source code in skills/sv_workspace.py
def create_branch(study_dir: str | Path, note: str, *, fork_step: int,
                  source: str | None = None,
                  resume_from_stage: str = "sv-build-environment") -> str:
    """Branch the study at ``fork_step`` — the counterfactual path of /sv-iterate.

    The new version inherits ``source``'s realized history for steps ``0..fork_step-1`` (sv-run
    replays that version's stored actions, no LLM, so the inherited steps reproduce the parent
    exactly) and computes ``fork_step`` onward for itself. Only the environment may differ, and
    only from ``fork_step`` on: the intervention is a ScheduledEvent or Broadcast authored at a
    step >= ``fork_step``. ``check_branch_invariant`` enforces that before the run.

    ``source`` defaults to the current version. Naming an older one restores that version's
    artifacts into the live dir first (as ``create_version(base=...)`` does), so the branch really
    departs from that version's design. Re-entry defaults to ``sv-build-environment`` because the
    environment bundle is the only artifact a branch re-authors. Returns the new version id.
    """
    d = Path(study_dir)
    fork_step = int(fork_step)
    if fork_step < 1:
        raise ValueError(f"fork_step must be >= 1 (step 0 is the materialized initial state, "
                         f"nothing to inherit); got {fork_step}")
    src = source or current_version(d)
    return create_version(d, note, base=None if src == current_version(d) else src,
                          warm_start={"source": src, "resume_from": fork_step - 1},
                          resume_from_stage=resume_from_stage)

check_branch_invariant

check_branch_invariant(
    study_dir: str | Path,
    *,
    version_id: str | None = None,
    downgrade: bool = True,
) -> dict

Verify that the current version really is a branch, and downgrade it to a version if not.

A branch claims that everything differing from its parent after the fork step is caused by the declared intervention. That claim only holds while the population, the study's code, the run settings and the pre-fork schedule are the parent's. This compares the live workspace against the parent's frozen snapshot and reports every artifact that breaks the claim.

On a violation the version is downgraded, not rejected: kind becomes "version", the warm-start provenance is dropped (so sv-run does a cold run from step 0 and the results stay correct, just more expensive) and the reason is recorded on the entry as downgraded_from_branch for the audit trail. The caller is expected to tell the user.

Returns {kind, ok, violations, downgraded, source, fork_step, error}. A version (or a study with no manifest) is trivially ok — there is nothing to inherit.

Source code in skills/sv_workspace.py
def check_branch_invariant(study_dir: str | Path, *, version_id: str | None = None,
                           downgrade: bool = True) -> dict:
    """Verify that the current version really is a branch, and downgrade it to a version if not.

    A branch claims that everything differing from its parent after the fork step is caused by the
    declared intervention. That claim only holds while the population, the study's code, the run
    settings and the pre-fork schedule are the parent's. This compares the live workspace against
    the parent's frozen snapshot and reports every artifact that breaks the claim.

    On a violation the version is **downgraded, not rejected**: ``kind`` becomes ``"version"``,
    the warm-start provenance is dropped (so sv-run does a cold run from step 0 and the results
    stay correct, just more expensive) and the reason is recorded on the entry as
    ``downgraded_from_branch`` for the audit trail. The caller is expected to tell the user.

    Returns ``{kind, ok, violations, downgraded, source, fork_step, error}``. A version (or a
    study with no manifest) is trivially ``ok`` — there is nothing to inherit.
    """
    d = Path(study_dir)
    report = {"kind": "version", "ok": True, "violations": [], "downgraded": False,
              "source": None, "fork_step": None, "error": None}
    man = load_manifest(d)
    if not man:
        return report
    vid = version_id or man.get("current") or "v1"
    entry = next((v for v in man.get("versions", []) if v.get("id") == vid), None)
    if version_kind(entry) != "branch":
        return report

    ws = (entry or {}).get("warm_start") or {}
    source, fork_step = ws.get("source"), version_fork_step(entry)
    report.update(kind="branch", source=source, fork_step=fork_step)
    violations: list[str] = []
    try:
        snap = d / VERSIONS_DIR / str(source)
        if not snap.is_dir():
            violations.append(f"the snapshot of parent version {source} does not exist, so its history cannot be inherited")
        else:
            for rel, label in BRANCH_INVARIANT_FILES:
                live_p, snap_p = d / rel, snap / rel
                if live_p.suffix == ".json":
                    live_v = _strip_volatile(_load_json(live_p)) if live_p.is_file() else None
                    snap_v = _strip_volatile(_load_json(snap_p)) if snap_p.is_file() else None
                    same = _stable(live_v) == _stable(snap_v)
                else:
                    same = _digest(live_p) == _digest(snap_p)
                if not same:
                    violations.append(f"{label} ({rel}) differs from the parent version — a branch must not change the population P")
            if _code_digest(d) != _code_digest(snap):
                violations.append("model code (model.py / adapter/*.py) differs from the parent version "
                                  "— a branch must not change the behavior function f")
            live_sim, snap_sim = _load_json(d / "simulation" / "simulation.json"), \
                _load_json(snap / "simulation" / "simulation.json")
            if live_sim is not None and snap_sim is not None:
                changed = [f for f in BRANCH_INVARIANT_SIM_FIELDS
                           if _stable(live_sim.get(f)) != _stable(snap_sim.get(f))]
                if changed:
                    violations.append("run settings " + ", ".join(changed) +
                                      " differ from the parent version — a branch may only lengthen n_steps")
            live_env, snap_env = _load_json(d / "environment" / "environment.json"), \
                _load_json(snap / "environment" / "environment.json")
            if fork_step is not None and live_env is not None and snap_env is not None:
                if _stable(_env_history(live_env, fork_step)) != \
                        _stable(_env_history(snap_env, fork_step)):
                    violations.append(f"the environment before step {fork_step} (provider / scheduled events / broadcasts) "
                                      "differs from the parent version — a branch may only add interventions at or after the fork step")
                live_layers = {l.get("name"): l for l in (live_env.get("layers") or [])}
                for layer in snap_env.get("layers") or []:
                    name = layer.get("name")
                    if name not in live_layers:
                        violations.append(f"the parent version's environment layer {name!r} was deleted in the branch")
                    elif _stable(live_layers[name]) != _stable(layer):
                        violations.append(f"environment layer {name!r} was rewritten — a branch may only add layers, never change existing ones")
    except Exception as exc:                     # verification must never break a run
        report["error"] = f"{type(exc).__name__}: {exc}"
        return report

    if not violations:
        return report
    report.update(ok=False, violations=violations)
    if downgrade and entry is not None:
        entry["kind"] = "version"
        entry["warm_start"] = None
        entry["fork_step"] = None
        entry["downgraded_from_branch"] = {"source": source, "fork_step": fork_step,
                                           "violations": violations, "at": time.time()}
        _write_manifest(d, man)
        report["downgraded"] = True
        report["kind"] = "version"
    return report

resolve_warm_start

resolve_warm_start(study_dir: str | Path) -> dict | None

Turn the current version's manifest warm_start provenance into runnable engine params.

/sv-iterate records {"source": <parent v>, "resume_from": K} on the new version entry; sv-run calls this to build a WarmStartSpec — it resolves source to the parent's archived store (versions/<source>/trajectory/study.duckdb) and returns {source_version, source_trajectory, resume_from}, or None when there is no warm-start or the parent snapshot is missing (→ fall back to a cold run from step 0). Keeps the manifest the single source of truth (sv-run doesn't re-derive the resume point).

Source code in skills/sv_workspace.py
def resolve_warm_start(study_dir: str | Path) -> dict | None:
    """Turn the current version's manifest ``warm_start`` provenance into runnable engine params.

    /sv-iterate records ``{"source": <parent v>, "resume_from": K}`` on the new version entry;
    sv-run calls this to build a ``WarmStartSpec`` — it resolves ``source`` to the parent's
    archived store (``versions/<source>/trajectory/study.duckdb``) and returns
    ``{source_version, source_trajectory, resume_from}``, or ``None`` when there is no warm-start
    or the parent snapshot is missing (→ fall back to a cold run from step 0). Keeps the manifest
    the single source of truth (sv-run doesn't re-derive the resume point)."""
    # The branch contract is verified here, not only in the skill: every path to a run goes
    # through this call. A branch whose workspace no longer satisfies it was downgraded to a
    # plain version by check_branch_invariant, so there is no warm start left to resolve.
    try:
        if not check_branch_invariant(study_dir).get("ok", True):
            return None
    except Exception:
        pass
    man = load_manifest(study_dir)
    if not man:
        return None
    cur = man.get("current")
    entry = next((v for v in man.get("versions", []) if v.get("id") == cur), None)
    ws = (entry or {}).get("warm_start")
    if not ws or not ws.get("source"):
        return None
    db = version_trajectory_db(study_dir, ws["source"])
    if not db.exists():
        return None
    return {"source_version": ws["source"], "source_trajectory": str(db),
            "resume_from": int(ws.get("resume_from", 0))}

discover_studies

discover_studies(
    studies_root: str | Path = "studies",
) -> list

The runtime 'catalog': glob studies/*/study.yaml -> list[StudySpec].

sv-init Step 0 calls this to see what has already been adapted, then matches a new query against the discovery fields (domain / tags / legacy_simulator / metrics / adjustable_params) to choose Path A (reuse) vs Path B (build new). Decentralised by design — each study carries its own card in its own study.yaml, so collaborators add studies without editing a shared index (no merge conflicts). Malformed/partial study.yaml files are skipped rather than raising.

Source code in skills/sv_workspace.py
def discover_studies(studies_root: str | Path = "studies") -> list:
    """The runtime 'catalog': glob ``studies/*/study.yaml`` -> list[StudySpec].

    sv-init Step 0 calls this to see what has already been adapted, then matches a new
    query against the discovery fields (domain / tags / legacy_simulator / metrics /
    adjustable_params) to choose Path A (reuse) vs Path B (build new). Decentralised by
    design — each study carries its own card in its own study.yaml, so collaborators add
    studies without editing a shared index (no merge conflicts). Malformed/partial
    study.yaml files are skipped rather than raising.
    """
    out = []
    for p in sorted(Path(studies_root).glob("*/study.yaml")):
        try:
            out.append(load_study_yaml(p))
        except Exception:
            continue
    return out

sibling_root

sibling_root(name: str) -> Path

Where the companion repo name is looked for: $ (process env, else .env), else ../.

Source code in skills/sv_workspace.py
def sibling_root(name: str) -> Path:
    """Where the companion repo `name` is looked for: $<its env var> (process env, else
    `.env`), else ../<name>."""
    from socioverse.external_events import env_setting

    info = SIBLING_REPOS.get(name) or {}
    env = env_setting(info["env"]) if info.get("env") else None
    return Path(env) if env else _REPO_ROOT.parent / name

requirement_status

requirement_status(req: str) -> dict

Resolve one requires entry -> {requirement, ok, where, setup}.

Supported kinds

sibling:<Name> a companion repo on disk (see SIBLING_REPOS); extra:<name> the Python packages a seam imports, installed by pip install -e ".[<name>]" (abm, chicago, workbench); legacy:chicago the legacy Chicago model's data, located exactly as the chicago seam does ($SV_CHICAGO_LEGACY, $SV_ABM_ROOT, ../SocioVerse-ABM).

Unknown kinds are reported as not satisfied so a typo never silently marks a study runnable.

Source code in skills/sv_workspace.py
def requirement_status(req: str) -> dict:
    """Resolve one `requires` entry -> {requirement, ok, where, setup}.

    Supported kinds:
      ``sibling:<Name>``   a companion repo on disk (see SIBLING_REPOS);
      ``extra:<name>``     the Python packages a seam imports, installed by
                           ``pip install -e ".[<name>]"`` (``abm``, ``chicago``,
                           ``workbench``);
      ``legacy:chicago``   the legacy Chicago model's data, located exactly as the chicago
                           seam does ($SV_CHICAGO_LEGACY, $SV_ABM_ROOT, ../SocioVerse-ABM).
    Unknown kinds are reported as not satisfied so a typo never silently marks a study runnable."""
    kind, _, name = req.partition(":")
    if kind == "sibling" and name:
        root = sibling_root(name)
        markers = (SIBLING_REPOS.get(name) or {}).get("markers") or []
        ok = root.is_dir() and all((root / m).exists() for m in markers)
        url = (SIBLING_REPOS.get(name) or {}).get("url")
        setup = (f"git clone {url} ../{name}" if url else f"clone {name} next to this repo")
        env = (SIBLING_REPOS.get(name) or {}).get("env")
        if env:
            setup += f"  (or set {env})"
        return {"requirement": req, "ok": ok, "where": str(root), "setup": setup}
    if kind == "extra" and name:
        try:
            missing = _extra_missing(name)
        except Exception as exc:                      # a broken seam import counts as unmet
            missing = [f"seam import failed: {exc}"]
        if missing is not None:
            return {"requirement": req, "ok": not missing, "where": "",
                    "setup": f'pip install -e ".[{name}]"  (missing: {", ".join(missing)})'}
    if kind == "legacy" and name == "chicago":
        url = SIBLING_REPOS["SocioVerse-ABM"]["url"]
        setup = (f"git clone {url} ../SocioVerse-ABM  (or set SV_CHICAGO_LEGACY to the legacy "
                 "dir, or SV_ABM_ROOT to a SocioVerse-ABM checkout)")
        try:
            seam = _seam_module("chicago")
            root = seam.resolve_chicago_legacy()
            ok, where = seam.chicago_data_present(root), str(root)
        except Exception as exc:                      # a broken seam import counts as unmet
            ok, where = False, f"seam import failed: {exc}"
        return {"requirement": req, "ok": ok, "where": where, "setup": setup}
    return {"requirement": req, "ok": False, "where": "",
            "setup": f"unknown requirement {req!r}"}

study_availability

study_availability(spec) -> dict

{requires, available, missing, setup, rerunnable} for one StudySpec.

Source code in skills/sv_workspace.py
def study_availability(spec) -> dict:
    """{requires, available, missing, setup, rerunnable} for one StudySpec."""
    reqs = list(spec.requires or [])
    status = [requirement_status(r) for r in reqs]
    missing = [s["requirement"] for s in status if not s["ok"]]
    return {
        "requires": reqs,
        "available": not missing,
        "missing": missing,
        "setup": [s["setup"] for s in status if not s["ok"]],
        "rerunnable": bool(spec.rerunnable),
    }

catalog_view

catalog_view(
    studies_root: str | Path = "studies",
) -> list[dict]

Compact, matching-relevant projection of discover_studies() for display/routing.

Besides the discovery fields, every row carries availability: requires (the study.yaml list), available (every requirement is present on disk), missing (the unmet ones), setup (how to satisfy each missing one) and rerunnable (False for imported references). sv-init offers a Path-A fork or a run only when available and rerunnable; otherwise it shows the study as "needs " with setup.

Source code in skills/sv_workspace.py
def catalog_view(studies_root: str | Path = "studies") -> list[dict]:
    """Compact, matching-relevant projection of discover_studies() for display/routing.

    Besides the discovery fields, every row carries availability: ``requires`` (the
    study.yaml list), ``available`` (every requirement is present on disk), ``missing``
    (the unmet ones), ``setup`` (how to satisfy each missing one) and ``rerunnable``
    (False for imported references). sv-init offers a Path-A fork or a run only when
    ``available and rerunnable``; otherwise it shows the study as "needs <X>" with ``setup``.
    """
    fields = ("study_id", "title", "domain", "tags", "study_type", "metrics",
              "legacy_simulator", "provider_refs", "adjustable_params", "status",
              "demonstrates", "teaches", "reference")
    return [{**{f: getattr(s, f) for f in fields}, **study_availability(s)}
            for s in discover_studies(studies_root)]

load_capabilities

load_capabilities(
    path: str | Path | None = None,
) -> list[dict]

Raw registry entries (list of dicts); [] when the registry is absent or empty.

Lazy-imports yaml so environments without PyYAML can still use every other helper.

Source code in skills/sv_workspace.py
def load_capabilities(path: str | Path | None = None) -> list[dict]:
    """Raw registry entries (list of dicts); [] when the registry is absent or empty.

    Lazy-imports yaml so environments without PyYAML can still use every other helper."""
    import yaml

    p = Path(path) if path is not None else CAPABILITIES_PATH
    if not p.exists():
        return []
    data = yaml.safe_load(p.read_text(encoding="utf-8")) or []
    return [e for e in data if isinstance(e, dict) and e.get("name")]

capability_view

capability_view(
    stage: str | None = None,
    path: str | Path | None = None,
    mcp_json: str | Path | None = None,
) -> list[dict]

catalog_view's sibling for external SERVICES — what each build skill's generic "capability check" hook lists.

stage filters to the entries anchored at that skill (their stages field); None returns all. Each entry is returned verbatim plus available / availability_note resolved from local config. Adding, removing, or changing a service is an edit to the registry only — the skills' hook text never changes.

Source code in skills/sv_workspace.py
def capability_view(stage: str | None = None, path: str | Path | None = None,
                    mcp_json: str | Path | None = None) -> list[dict]:
    """``catalog_view``'s sibling for external SERVICES — what each build skill's generic
    "capability check" hook lists.

    ``stage`` filters to the entries anchored at that skill (their ``stages`` field);
    ``None`` returns all. Each entry is returned verbatim plus ``available`` /
    ``availability_note`` resolved from local config. Adding, removing, or changing a
    service is an edit to the registry only — the skills' hook text never changes.
    """
    mj = Path(mcp_json) if mcp_json is not None else MCP_JSON_PATH
    out = []
    for e in load_capabilities(path):
        if stage and stage not in (e.get("stages") or []):
            continue
        avail, note = _capability_available(e, mj)
        out.append({**e, "available": avail, "availability_note": note})
    return out

skills.sv_grounding — the provenance sidecar

Read/merge access to studies/<id>/grounding/grounding.json. merge upserts facts and assumptions by id, so every build stage can extend the ledger without clobbering an earlier stage's entries, and writes atomically.

Deliberately not a Pydantic handoff: the ledger is append-friendly documentation for review, not a runtime-validated contract. See Grounding & Provenance.

skills.sv_grounding

Grounding sidecar for SocioVerse2 studies (stdlib only — no socioverse import).

studies/<id>/grounding/grounding.json records the study's real-world anchors: facts with provenance, implementation_refs (how similar phenomena are modeled), and declared assumptions. Deliberately NOT a pydantic handoff artifact — a flat, merge-friendly JSON the sv-* stages append to ("ground before you invent") and the dashboard renders read-only. Downloaded reference tables (small, MB-scale) live next to it under grounding/data/ and are pointed at by source.local_path.

Document shape (convention, not schema):

{"study_id": "...", "query": "...", "updated_at": "...", "method_notes": "...",
 "implementation_refs": [{"title", "url", "takeaway", "accessed"}],
 "facts": [{"id", "claim", "value", "unit", "as_of",
            "basis": "sourced" | "proxy" | "assumed",
            "source": {"title", "url",
                       "via": "event_service" | "web_search" | "provider" | "user",
                       "accessed", "local_path"?},
            "applies_to": ["environment.provider_args.base", ...], "note"}],
 "assumptions": [{"id", "claim", "rationale"}]}

method_notes="stylized" + empty facts records the decision that a study has no real-world anchors (abstract/toy models) — distinct from grounding merely missing.

load

load(study_dir: str | Path) -> dict | None

Parsed grounding.json, or None when missing/corrupt (callers treat as empty).

Source code in skills/sv_grounding.py
def load(study_dir: str | Path) -> dict | None:
    """Parsed grounding.json, or None when missing/corrupt (callers treat as empty)."""
    p = grounding_path(study_dir)
    if not p.exists():
        return None
    try:
        return json.loads(p.read_text(encoding="utf-8"))
    except Exception:
        return None

merge

merge(
    study_dir: str | Path,
    *,
    facts=(),
    implementation_refs=(),
    assumptions=(),
    method_notes: str | None = None,
    query: str | None = None,
) -> Path

Merge entries into grounding.json (creating it, and grounding/, if absent).

Facts/assumptions upsert by id (order preserved; missing ids assigned f<N>/ a<N> past collisions); refs dedupe by url; method_notes/query overwrite only when not None. updated_at bumps on every merge. Write is atomic (tmp + rename, like sv_workspace._write_manifest) — the dashboard poller must never read a torn file. Returns the file path.

Source code in skills/sv_grounding.py
def merge(study_dir: str | Path, *, facts=(), implementation_refs=(), assumptions=(),
          method_notes: str | None = None, query: str | None = None) -> Path:
    """Merge entries into grounding.json (creating it, and ``grounding/``, if absent).

    Facts/assumptions upsert by ``id`` (order preserved; missing ids assigned ``f<N>``/
    ``a<N>`` past collisions); refs dedupe by url; ``method_notes``/``query`` overwrite
    only when not None. ``updated_at`` bumps on every merge. Write is atomic (tmp +
    rename, like sv_workspace._write_manifest) — the dashboard poller must never read
    a torn file. Returns the file path.
    """
    doc = load(study_dir) or _skeleton(study_dir)
    doc["facts"] = _merge_by_id(doc.get("facts") or [], facts, "f")
    doc["assumptions"] = _merge_by_id(doc.get("assumptions") or [], assumptions, "a")
    doc["implementation_refs"] = _merge_refs(doc.get("implementation_refs") or [],
                                             implementation_refs)
    if method_notes is not None:
        doc["method_notes"] = method_notes
    if query is not None:
        doc["query"] = query
    doc["updated_at"] = _now_iso()

    p = grounding_path(study_dir)
    p.parent.mkdir(parents=True, exist_ok=True)  # tolerate pre-grounding scaffolds
    tmp = p.with_name(p.name + ".tmp")
    tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8")
    tmp.replace(p)
    return p

summary

summary(study_dir: str | Path) -> str

One-liner for sv_emit narratives / stage reports.

'12 facts: 8 sourced / 2 proxy / 2 assumed · 3 refs · 2 assumptions' 'stylized model, no real-world anchors' (method_notes=="stylized", no facts) 'no grounding recorded' (file missing/corrupt)

Source code in skills/sv_grounding.py
def summary(study_dir: str | Path) -> str:
    """One-liner for sv_emit narratives / stage reports.

    '12 facts: 8 sourced / 2 proxy / 2 assumed · 3 refs · 2 assumptions'
    'stylized model, no real-world anchors'  (method_notes=="stylized", no facts)
    'no grounding recorded'                  (file missing/corrupt)
    """
    doc = load(study_dir)
    if doc is None:
        return "no grounding recorded"
    facts = doc.get("facts") or []
    if not facts and doc.get("method_notes") == "stylized":
        return "stylized model, no real-world anchors"
    counts = {b: 0 for b in BASES}
    for f in facts:
        if f.get("basis") in counts:
            counts[f["basis"]] += 1
    parts = [f"{len(facts)} facts: " + " / ".join(f"{counts[b]} {b}" for b in BASES)]
    if doc.get("implementation_refs"):
        parts.append(f"{len(doc['implementation_refs'])} refs")
    if doc.get("assumptions"):
        parts.append(f"{len(doc['assumptions'])} assumptions")
    return " · ".join(parts)