报告¶
一次运行的结果是轨迹存储,不是那段文字。/sv-report 把存储渲染成人能读的东西——图、表,
以及一条论证——而存储本身永远可查:报告没回答的问题,一条 SQL 就够了。
这一阶段读什么、写什么¶
/sv-report 读 trajectory/study.duckdb 与 study.yaml,写 reports/report.md 与
reports/figures/*.png。仅此而已。它从不改动轨迹,所以重渲染是免费且可重复的。
trajectory/study.duckdb ──► reporting.generate_report(...) ──► reports/report.md
└──► reports/figures/*.png
grounding/grounding.json ─► 报告的最后一节
存储由 socioverse/io/duckdb_store.py(DuckDbTrajectoryStore)写出,每个研究一个 DuckDB 文件:
| 表 | 列 | 何时写入 |
|---|---|---|
panel |
agent_id、step、state(JSON)、action_kind、action_payload(JSON) |
逐步流式写入——每个 agent 每步一行,含 step = 0 |
metrics |
step 加上采集器返回的每个指标一列;有干预触发的那些步还带 events 列 |
在 finalize() 时由累积的行物化成表 |
events |
step、note |
逐步流式写入,每条触发的干预一行 |
messages |
author_id、step、round、channel、content、audience |
惰性创建,只有用到消息总线的研究才有 |
finalize() 还会在数据库旁边导出 panel.parquet 与 metrics.parquet,方便 pandas / R 工作流。
每个研究自带 reporting.py。从零构建模板的入口是:
generate_report(duckdb_path, out_dir, *, study_dir, study_title="",
event_steps=None, run_mode="", version="")
event_steps 决定在时间线图上标注哪些干预步。在从零构建的模板里,不传它就会退回到 events
表里出现过的步数——标记来自真正触发过的东西,而不是某个人记得去传的东西。
版本跃迁之后要重出整套图
创建版本或分支会清空 live 的 reports/ 目录——旧图只活在 versions/vN/ 里。请把这个版本
的叙事需要的每一张图都重新渲染,而不只是新增的那一张。/sv-paper 从
reports/figures/ 引用,图集缩水会在下游变成悬空引用。
被规定死的结构¶
report.md 是分析性的,不是数据堆砌,而且结构是固定的——一条 总–分–总 的弧线,顺序如下:
| # | 章节 | 必须做到什么 |
|---|---|---|
| ① | 总起 · 结论速览 | 全程 2–4 条头条结论,每条一行,写成断言——「X 发生了,因为 Y」,而不是「我们模拟了 X」。这是正文接下来要证明的论点。 |
| ② | 轨迹表格与图 | 后面一切据以解读的描述性骨架 |
| ③ | 分 · 发现与解读 | 大致一个关键结果一条发现。每条断言紧挨着它所依据的具体数字或图,并且给出机制——那个「为什么」,从面板与分群里读出来 |
| ④ | 深层洞察 | 原始表格没有直说的、跨指标 / 跨分群的非显然结论——谁被隔离、谁被暴露,某个临界翻转,两个指标的背离——每条仍然扣在揭示它的数据上 |
| ⑤ | 对照与对比 | 只有当存在同源的另一次运行时才写,而且两种对比绝不混为一谈——见下一节 |
| ⑥ | 总结 · 回扣与深挖 | 回扣那几条(现在已被正文挣到的)头条结论,并再深一层:它们对研究问题意味着什么、统摄它们的机制是什么、什么仍不确定。不引入无支撑的新论断。 |
| ⑦ | 改进建议与下一步 | 可操作的建议,以及下一轮 /sv-iterate 该测什么——试哪条政策、扫哪个参数、探哪个分群 |
| ⑧ | 数据基础与参考来源 | 放最后,由 grounding 侧车渲染 |
③ 的硬规则
没有数字的断言不写,没有断言撑着的数字也不写。
一条发现读起来是「满意度从 0.74 跌到 0.61(step 2 政策后,见
figures/satisfaction.png)」再接机制——而不是一段解读旁边扔一张表,让读者自己去对。
图用普通的  语法引用,dashboard 会把它渲染成可点开的缩略图。
对照与对比:两种,绝不混为一谈¶
第 ⑤ 节是报告最容易写错的地方,因为同一个研究的两次运行之间,可能是两种截然不同的关系。
分支对照——配对,同一批个体¶
一个分支与它的父版本(或者同一父版本分出来的两个分支)共享同一人群、同一行为函数、同一 seed,以及直到分叉步为止完全相同的已实现历史;差别只在其后的环境。所以逐步的差值是干预施加 在同一批个体上的配对效应:
两臂请称作对照分支 / 处理分支,在时间线上标出分叉步,并直接给出 Δ_t,而不是并排放两条
绝对水平线。分叉步之前 Δ_t 恰好为零——那正是重放在起作用,值得展示出来。
两边的存储都是普通 DuckDB 文件,所以对照就是一条查询。把父版本的归档存储 ATTACH 到分支的
live 存储旁边:
ATTACH 'studies/<id>/versions/v1/trajectory/study.duckdb' AS ctl (READ_ONLY);
ATTACH 'studies/<id>/trajectory/study.duckdb' AS trt (READ_ONLY);
-- 逐步的总体效应
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;
-- 配对到个体:按 agent_id 与 step 同时 join
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;
在一个真实的分支研究上——某校园就餐研究的分支在第 3 步发放 ¥100/月餐补——第二条查询对第 0–4
步返回 0.0, 0.0, 0.0, +0.0101, +0.0204:重放的那段前缀逐行一致,之后才是餐补的效应,而且是
逐个个体算出来的。
只有分支才是配对的
这种配对性是 E-only 不变量换来的。如果这个
分支被降级成了版本(versions.json 里 kind: "version" 且多出一条
downgraded_from_branch),那段前缀是被重算而不是重放的,两次运行不再配对,只能按版本
对比来读。
版本对比——不同的研究设计¶
两个版本如果在人群、行为函数、运行设置,或者分叉步之前的环境上有差别,那它们是不同的
研究设计。它们之间没有任何配对关系:一个版本里的 agent x-042 不是另一个版本里 x-042 的
反事实孪生;而且在真 LLM 下,即使 seed 相同也不会逐步复现出先前的轨迹。
把差距写成设计差异——「LLM-f 版本比 rule-f 版本收敛更快」——绝不要写成某个干预的效应。
这类设计常见的有:换人群、换环境、行为函数消融(规则 vs LLM vs 混合),以及只经由资源清单
替换数据来源。
所有版本都按同一套契约存记录,所以跨版本查询和上面那条形状一样,只是你不再把差值叫作效应。
最后一节:grounding¶
报告的最后一节由 grounding/grounding.json 渲染(用 skills.sv_grounding.load() 读取;
没有锚点的研究会返回 None,这一节就静默跳过)。facts 渲染成一张管道表:
| 事实 | 值 | 依据 | 来源 |
|---|---|---|---|
| … | … | 实证 / 代理 / 假设 | title (url) |
「依据」一列就是该条事实的 basis,取值为 sourced / proxy / assumed 之一。
随后是 implementation_refs(建模参考)与 assumptions(声明的假设)。两个特例:
method_notes为"stylized"的研究只得到一行诚实的「风格化模型,无真实世界锚点。」;study.yaml带forked_from的研究会在这一节开头点明锚点继承自哪个研究,免得继承来的锚点 被误当成专为这个变体收集的证据。
来源渲染成纯文本 title (url),因为 report.md 是用一个极简的 markdown 渲染器读的;
可点击的来源在 dashboard 的「参考来源」卡片上。见真实世界锚定与溯源。
用 SQL 问你自己的问题¶
报告是一种渲染;存储才是结果本身,而且它一直可查:
-- 单个 agent 的纵向视图
SELECT step, state, action_kind
FROM panel WHERE agent_id = '…' ORDER BY step;
-- 聚合轨迹
SELECT * FROM metrics ORDER BY step;
-- 什么在何时触发
SELECT step, note FROM events ORDER BY step;
-- 从 JSON 的 state 列里取一个字段
SELECT step, CAST(state->>'satisfaction' AS DOUBLE) AS sat
FROM panel WHERE agent_id = '…' ORDER BY step;
-- 每步各种行为类型各有多少人
SELECT step, action_kind, count(*) FROM panel
GROUP BY 1, 2 ORDER BY 1, 2;
-- 如果研究用了消息总线:谁说了什么
SELECT step, author_id, content FROM messages ORDER BY step;
在 Python 里用 socioverse.io.duckdb_store.open_readonly(path) 以只读方式打开跑完的存储,
或者直接把 panel.parquet / metrics.parquet 读进 pandas / R。更多查询示例见
常见问题。
重渲染,而不是重跑
换 event_steps、换一组指标、换一种叙述角度——这些都只是对同一份存储重新渲染。而对
实验本身的改动(新干预、更多步数、换人群)是一次迭代,要走
版本门。