Skip to content

Reports

A run's result is the trajectory store, not the prose. /sv-report renders that store into something a human reads — figures, tables, and an argument — and the store stays queryable forever, so any question the report did not answer is one SQL statement away.

What the stage reads and writes

/sv-report reads trajectory/study.duckdb plus study.yaml, and writes reports/report.md and reports/figures/*.png. Nothing else. It never touches the trajectory, so re-rendering is free and repeatable.

trajectory/study.duckdb ──► reporting.generate_report(...) ──► reports/report.md
                                                          └──► reports/figures/*.png
grounding/grounding.json ─► the report's last section

The store is written by socioverse/io/duckdb_store.py (DuckDbTrajectoryStore), one DuckDB file per study:

table columns when it is written
panel agent_id, step, state (JSON), action_kind, action_payload (JSON) streamed per step — one row per agent per step, including step = 0
metrics step plus one column per metric the collector returns, and events on the steps where an intervention fired materialized at finalize() from the accumulated rows
events step, note streamed per step, one row per intervention that fired
messages author_id, step, round, channel, content, audience created lazily, only in studies that use the message bus

finalize() also exports panel.parquet and metrics.parquet next to the database for pandas and R workflows.

Each study owns its reporting.py. The Path-B template's entry point is:

generate_report(duckdb_path, out_dir, *, study_dir, study_title="",
                event_steps=None, run_mode="", version="")

event_steps marks the intervention steps on the timeline figures. In the from-scratch templates, omitting it makes the renderer fall back to the steps found in the events table, so the markers come from what actually fired rather than from what someone remembered to pass.

Regenerate the whole figure set after a version bump

Creating a version or a branch clears the live reports/ directory — the previous figures survive only inside versions/vN/. Re-render every figure this version's story needs, not just the new one. /sv-paper cites from reports/figures/, so a shrunken set becomes dangling citations downstream.

The mandated structure

report.md is analytical, not a data dump, and its structure is fixed — a 总–分–总 arc, in this order:

# section what it must do
① 总起 · 结论速览 (Overview) the 2–4 headline conclusions of the whole run, one line each, stated as claims — "X happened because Y", never "we simulated X". This is the thesis the body then proves.
② trajectory tables & figures the descriptive backbone everything else reads off
③ 分 · 发现与解读 (Findings) roughly one finding per key result. Each claim is immediately paired with the specific number or figure it reads off, and with the mechanism — the why, read off the panel and cohorts.
④ 深层洞察 (Deeper insights) non-obvious cross-metric / cross-cohort conclusions the raw tables don't state — who is insulated vs exposed, a tipping threshold, a divergence between two metrics — each still tied to the data that reveals it
⑤ 对照与对比 (Comparison) only when a sibling run exists, and the two kinds are never conflated — see below
⑥ 总结 · 回扣与深挖 (Conclusion) restate the headlines, now earned by the body, and go one level deeper: what they mean for the research question, the unifying mechanism, what remains uncertain. No new unsupported claims.
⑦ 改进建议与下一步 actionable suggestions, and what the next /sv-iterate round should test — a policy to try, a parameter to sweep, a cohort to probe
⑧ 数据基础与参考来源 last, rendered from the grounding sidecar

The binding rule of section ③

No claim without its number, no number without the claim it supports. A finding reads like "满意度从 0.74 跌到 0.61(step 2 政策后,见 figures/satisfaction.png)" followed by the mechanism — not a paragraph of interpretation next to a table the reader has to reconcile on their own.

Figures are referenced with plain ![alt](figures/x.png) markdown, which the dashboard renders as clickable thumbnails.

Comparison: two kinds, never conflated

Section ⑤ is where a report is most easily wrong, because two runs of the same study can be related in two very different ways.

Branch comparison — paired, same individuals

A branch and its parent (or two branches off the same parent) share the same population, the same behaviour function, the same seed, and the same realized history up to the fork step. They differ only in the environment afterwards. The per-step difference is therefore a paired effect of the intervention on the same individuals:

Δ_t = Y(E′) − Y(E)        Y = any metric or panel quantity at step t

Name the two arms 对照分支 / 处理分支 (control branch / treatment branch), mark the fork step on the timeline, and state Δ_t rather than two separate levels. Before the fork step Δ_t is exactly zero — that is the replay working, and it is worth showing.

Both stores are plain DuckDB files, so the comparison is one query. ATTACH the parent's archived store next to the branch's live one:

ATTACH 'studies/<id>/versions/v1/trajectory/study.duckdb' AS ctl (READ_ONLY);
ATTACH 'studies/<id>/trajectory/study.duckdb'            AS trt (READ_ONLY);

-- aggregate effect per step
SELECT t.step, t.mean_satisfaction - c.mean_satisfaction AS delta
FROM trt.metrics t JOIN ctl.metrics c USING (step)
ORDER BY t.step;

-- the paired per-agent version: join on agent_id AND step
SELECT t.step,
       avg(CAST(t.state->>'satisfaction' AS DOUBLE)
         - CAST(c.state->>'satisfaction' AS DOUBLE)) AS delta
FROM trt.panel t JOIN ctl.panel c USING (agent_id, step)
GROUP BY t.step ORDER BY t.step;

On a real branched study — a campus dining study whose branch adds a ¥100/month meal subsidy at step 3 — the second query returns 0.0, 0.0, 0.0, +0.0101, +0.0204 for steps 0 to 4: identical through the replayed prefix, then the subsidy's effect, agent by agent.

Only a branch is paired

The pairing is what the E-only invariant buys. If the branch was downgraded to a version (kind: "version" plus a downgraded_from_branch entry in versions.json), the prefix was recomputed rather than replayed and the runs are no longer paired. Read them as a version comparison instead.

Version comparison — different research designs

Two versions that differ in the population, the behaviour function, the run settings, or in the environment before the fork step are different research designs. Nothing pairs them: agent x-042 in one is not the counterfactual twin of agent x-042 in the other, and under a real LLM even an identical seed will not reproduce the earlier trajectory step for step.

Report the gap as a design difference — "the LLM-f version converges faster than the rule-f version" — never as the effect of an intervention. Useful designs of this kind are population replacement, environment variation, behaviour-function ablation (rule vs LLM vs hybrid), and data-source substitution through the resource manifest.

Every version stores its records under the same contract, so a cross-version query has the same shape as the one above. You just stop calling the difference an effect.

The last section: grounding

The report's final section is generated from grounding/grounding.json (loaded with skills.sv_grounding.load(), which returns None for studies that have none — the section is then skipped silently). facts renders as a pipe table:

事实 值 依据 来源
… … 实证 / 代理 / 假设 title (url)

The 依据 column is the fact's basis, one of sourced / proxy / assumed. implementation_refs follows as 建模参考 and assumptions as 声明的假设. Two special cases:

  • a study whose method_notes is "stylized" gets the single honest line "风格化模型,无真实世界锚点。";
  • a study whose study.yaml carries forked_from opens the section by naming the study it inherited its anchors from, so an inherited anchor is never mistaken for evidence gathered for this variant.

Sources render as plain title (url) text, because report.md is read through a minimal markdown renderer. Clickable sources live on the dashboard's 参考来源 card. See Grounding and provenance.

Ask your own questions with SQL

The report is a rendering. The store is the result, and it stays queryable:

-- the longitudinal view of one agent
SELECT step, state, action_kind
FROM panel WHERE agent_id = '…' ORDER BY step;

-- the aggregate trajectory
SELECT * FROM metrics ORDER BY step;

-- what fired, and when
SELECT step, note FROM events ORDER BY step;

-- pull a field out of the JSON state column
SELECT step, CAST(state->>'satisfaction' AS DOUBLE) AS sat
FROM panel WHERE agent_id = '…' ORDER BY step;

-- how many agents took each action kind, per step
SELECT step, action_kind, count(*) FROM panel
GROUP BY 1, 2 ORDER BY 1, 2;

-- if the study used the message bus: who said what
SELECT step, author_id, content FROM messages ORDER BY step;

Open a finished store read-only from Python with socioverse.io.duckdb_store.open_readonly(path), or read panel.parquet / metrics.parquet straight into pandas or R. More query recipes live in the FAQ.

Re-render, don't re-run

Different event_steps, a different metric subset, a different framing — all of those are a re-render of the same store. A change to the experiment (a new intervention, more steps, a different population) is an iteration instead, and goes through the version gate.

Where to go next