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  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:
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_notesis"stylized"gets the single honest line "风格化模型,无真实世界锚点。"; - a study whose
study.yamlcarriesforked_fromopens 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¶
- Iterate: versions and branches — how a sibling run comes into existence in the first place.
- The run dashboard — the same tables, rendered live while the run is still going.
- Grounding and provenance — what feeds the last section.