Skip to content

Workflow helpers — skills.*

These two modules are the helper layer the sv-* workflow uses to scaffold, fork, version 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, forks & versions

Scaffolding (scaffold, study_paths), the catalog that sv-init routes against (discover_studies, catalog_view), forking (fork_study — which rewrites study_id throughout the copied artifacts, clears reference, and deliberately carries no code and no run outputs), the capability registry view (load_capabilities, capability_view), and the version machinery behind /sv-iterate (init_manifest, create_version, resolve_warm_start).

skills.sv_workspace

Shared helper for the SocioVerse-beta 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.

Beta'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.

    Beta'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,
                      "created_at": time.time(), "snapshot": None, "warm_start": 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).

note is the one-line human description shown in the dashboard's version box. base defaults to the current version; passing an older id branches from it (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).

    ``note`` is the one-line human description shown in the dashboard's version box.
    ``base`` defaults to the current version; passing an older id branches from it
    (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)}")
    # Branching from an older version 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 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,
                                 "created_at": time.time(), "snapshot": None,
                                 "warm_start": warm_start,   # {source, resume_from} | None
                                 "resume_from_stage": resume_from_stage})   # earliest re-entered stage | None
    manifest["current"] = new_id
    _write_manifest(d, manifest)
    return new_id

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)."""
    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

catalog_view

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

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

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."""
    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} 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 SocioVerse-beta 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)