干预与反事实分支¶
一次运行告诉你发生了什么。一个研究还必须回答:换一种做法会怎样。 SocioVerse 用两个机制把第二个问题变成可计算的,而且它们都住在你本来就要写的产物里:
- 干预(intervention)是环境包里的一条带类型的条目——
ScheduledEvent或Broadcast——由引擎在某一步的外生更新阶段激活。 - 分支(branch)是研究的一个新版本:它通过重放父版本记录下来的动作,
继承父版本在分叉步
t*之前已经跑出来的历史,只为自己计算t* .. T。
同一个父版本的两个分支共享一份人群 P、一个行为函数 f、一个 seed,以及——
是可证明的,不是近似的——t* 之前同一段既成历史。t* 之后的一切差异,都归因于
为第 t* 步及其之后声明的那次干预。
分支还是版本
分支只改 E,只从它的分叉步开始改,并且继承父版本的历史。其它任何修改——
人群、行为函数、seed、运行设置,或分叉步之前的环境——都是一个版本,
而版本一律从第 0 步冷跑。这个分类、开口询问它的版本门,以及它长出来的快照树,
见可控研究循环与
迭代:版本与分支。
1. 干预是什么¶
两种干预都是 socioverse/schemas/environment.py 里的 Pydantic 模型,
落盘在研究的 environment/environment.json 中。
ScheduledEvent —— 对某个物理层的数值变更¶
| 字段 | 类型 | 默认 | 含义 |
|---|---|---|---|
at_step |
int |
必填 | 在哪一步的外生更新阶段触发 |
target_layer |
str |
必填 | 已声明的某个 EnvironmentLayer 的名字(会校验) |
op |
"set" | "add" | "multiply" | "add_pct" | "replace_source" |
必填 | 操作,取自一个封闭集合 |
selector |
dict |
{} |
打到哪些单元上——由 provider 解释(tract、cohort……);{} 按约定表示"全体" |
property_name |
str | None |
None |
被改动的层属性(price、level、amount……) |
value |
Any |
None |
操作数 |
note |
str |
"" |
会进 events 表、并画到指标时间线上的那一行字 |
Broadcast —— 投递给特定受众的一条信息¶
| 字段 | 类型 | 默认 | 含义 |
|---|---|---|---|
message_id |
str |
必填 | 这条消息的稳定 id |
content |
str |
必填 | agent 真正读到的文本 |
channel |
str |
"news" |
由哪个信息渠道承载 |
at_step |
int |
0 |
从哪一步开始可见 |
ttl |
int |
1 |
可见多少步——生效窗口是 at_step <= t < at_step + ttl |
audience |
str | dict |
"all" |
"all" → 进所有人的 macro_information;selector 字典 → 只进命中 agent 的 local_information |
note |
str |
"" |
自由注记 |
Broadcast.is_macro 是只读属性 audience == "all"。广播集中在环境包的
information_program.broadcasts 下,定时事件则在 scheduled_events 里。
受众选择器由 socioverse/env_layers/information.py 的 match_selector() 匹配:
支持任意属性的精确相等,加上带后缀的 <attr>_in、<attr>_below、<attr>_above,
以及两个特殊键 geoid_list 与 index_below。空 selector 谁都不匹配——
局部广播必须把受众范围显式写出来。
校验了什么¶
EnvironmentBundle 带一个 model_validator(_events_reference_real_layers),
在下面两种情况下拒收整个环境包:
- 任何
ScheduledEvent.target_layer不是某个已声明层的name——报错会把已声明的层名列出来。 information_program.broadcasts非空,却没有任何一层的modality="information"。
只要环境包是经 validate_handoff(path, EnvironmentBundle)
(socioverse/validation.py)加载的,这道检查就会跑,所以一条写坏的干预会停在阶段
交接处,而不是在一次要花钱的运行跑到一半时才炸。
引擎本身不解释 op 和 selector,解释它们的是研究自己的
EnvironmentProvider.advance_to(t):studies/opinion_diffusion/model.py 处理落在
media_pressure 标量上的 set / add,studies/campus_dining_choice/model.py 在
_apply_price_event 里乘价格。词表在 schema 层收口,是为了让不同研究之间的环境包仍然
可比、可审计;算术属于那个持有该层的 provider。
一个真实的环境包¶
取自 studies/opinion_diffusion/model.py(make_opinion_bundles)——一个宏观物理层被
事件推动,一个宏观信息层承载消息:
EnvironmentBundle(
study_id=study_id, provider_ref="opinion.env",
provider_args={"n_agents": n_agents},
layers=[
EnvironmentLayer(name="peer_opinions", modality="physical", scope="local",
dynamics="endogenous", description="ring neighbours' current opinions"),
EnvironmentLayer(name="media_pressure", modality="physical", scope="macro",
dynamics="scheduled", description="city-wide media pressure scalar"),
EnvironmentLayer(name="campaign", modality="information", scope="macro",
dynamics="scheduled", description="broadcast campaign message"),
],
scheduled_events=[ScheduledEvent(at_step=campaign_step, target_layer="media_pressure",
op="set", property_name="level", value=1.0,
note="media pressure ramps up at the campaign step")],
information_program=InformationProgram(broadcasts=[
Broadcast(message_id="C1", channel="campaign", at_step=campaign_step, ttl=2,
audience="all", content="Adopt the new policy — it benefits everyone."),
]),
)
dynamics 标签(static / endogenous / scheduled,定义在
socioverse/schemas/common.py)是写给读者和 dashboard 看的文档:scheduled 标出
允许被干预推动的层,endogenous 标出由 agent 经 apply 推动的层。
什么时候生效¶
一次干预在这一步的第 ① 个阶段,外生更新里触发——早于任何 agent 的观察:
① 外生更新 env.advance_to(t) ← 事件触发,广播生效/过期
② 观察 env.observe_batch(...)
③ 决策 decision.decide_batch(...) B_t = f(P, E_t)
④ 应用 env.apply(actions) E_{t+1} = g(E_t, B_t)
⑤ 记录 记忆窗 + 面板行 + 指标行
正是这个顺序让对比是干净的:第 t* 步里每个 agent 面对的都是唯一一个世界状态,
即干预之后的那个。没有人对着过期的环境做决策,也没有人的决策被同一步里另一个
agent 的反应污染。五个阶段的完整说明见一步,五个阶段。
落到磁盘上的是什么¶
在 socioverse/engine/loop.py 里:
fired = self.env.advance_to(t) # ① exogenous update
if fired:
self.store.record_events(t, [self._event_note(e) for e in fired])
...
m = self._collect(personas, last_actions, t, fired_notes=[self._event_note(e) for e in fired])
| 落点 | 落进去的是什么 |
|---|---|
trajectory/study.duckdb 里的 events(step, note) |
advance_to(t) 返回的每个对象一行 |
这一步 metrics 行的 events 列 |
同一批 note,"; ".join(...)——指标时间线上的干预竖线就是它画的 |
每个 agent 的 Observation |
一条广播在它的 ttl 窗口内出现在 macro_information / local_information;一个事件的效果则出现在 provider 把它放进去的地方 |
_event_note(ev) 就是 ev.note or f"{ev.op} {ev.property_name or ''}",所以
note 留空的干预照样会被记下来,只是读起来没那么清楚——把 note 写上。
从零构建的参考 provider(studies/opinion_diffusion、studies/campus_dining_choice)
只从 advance_to 返回自己触发的 ScheduledEvent,因此广播会出现在 agent 的观察里,
但不会进 events 表。想让广播也在时间线上留下标记,就为它一并返回点东西:
Chicago adapter 的做法是把每条刚激活的广播包进一个 ScheduledEvent,
把内容写在它的 note 里——记录器读的就是 note 这个字段。
2. 形式化¶
对应报告 §3.3。一次干预是一个二元组 δ = (t*, op):一个分叉步,加一个作用在环境上的操作。
E'_t = E_t (t < t*) 继承,原样不变
E'_t = op(E_t) (t = t*) 操作在外生更新阶段施加
E'_{t+1} = g(E'_t, B'_t) (t > t*) 循环把它往后带
分支里的行为,在分叉之前是重放的,在分叉之后才是算出来的:
其中 ω 是观察投影,m' 是分支自己的记忆窗。一个反事实设计要报告的量,是配对对比
Y 可以是任何从环境或面板算出来的量——某个指标列、某个 agent 层面的占比、一条生存曲线。
作为对照的 E_t,要么是父版本,要么是在同一个 t* 分叉、但没有声明任何操作的
同胞分支。
3. 分支是怎么实现的¶
调用链¶
/sv-iterate → create_branch(study_dir, note, fork_step=J)
写 versions.json: kind="branch", fork_step=J,
warm_start={"source": <父版本>, "resume_from": J-1}
/sv-run → resolve_warm_start(study_dir)
→ check_branch_invariant(...) (§5)
→ {"source_version", "source_trajectory", "resume_from"}
WarmStartSpec(**ws) → SimulationConfig.warm_start
LongitudinalSimulator.run() → _replay_inherited(...)
WarmStartSpec(socioverse/schemas/simulation.py)是一个分支的重放规格在磁盘上的
名字——这个类名早于"分支 / 版本"这套词表,保留它是为了让老的 simulation.json 还能加载。
它有三个字段:source_version(来源,dashboard 上会显示)、source_trajectory
(路径 versions/<source>/trajectory/study.duckdb,由 version_trajectory_db() 解析)、
resume_from(最后一个被继承的步)。只读属性 .fork_step 返回 resume_from + 1,
也就是分支自己计算的第一步。
_replay_inherited¶
run() 和冷跑一样先物化 P 与 E_0(materialize_initial()——先
population.build(seed) 再 env.reset(seed)),记下 t=0 的面板与指标;
如果设置了 warm_start,就把第 1..K 步交给 _replay_inherited
(K = min(resume_from, n_steps)),然后从 K+1 进入真正的循环:
parent_by_step = self._load_parent_panel(ws.source_trajectory, ws.resume_from)
k = min(int(ws.resume_from), self.n_steps)
for t in range(1, k + 1):
fired = self.env.advance_to(t) # ① 与父版本同一份日程
if fired:
self.store.record_events(t, [self._event_note(e) for e in fired])
actions = self._reconstruct_actions(t, parent_by_step.get(t, []))
self.env.apply(actions) # ④ 纯重放 -> E_t == 父版本的 E_t
for a in actions:
self.memories[a.agent_id].push(t, a) # ⑤ 记忆窗
panel_t = self._panel_rows(personas, actions, t)
self.store.record(panel_t)
self._emit_panel(panel_t, t)
m = self._collect(personas, actions, t, fired_notes=[...])
rows.append(m)
return k
阶段 ①、④、⑤ 照常跑。阶段 ② 与 ③——观察与决策——被跳过: 在一个被继承的步上,决策模型从来不会被调用,所以被继承的那段时间跨度不花 token, 除了环境算术之外也几乎不花墙钟时间。
_reconstruct_actions(t, rows) 用父版本存下的 action_kind 与 action_payload
重建 Action 对象,并给每一个打上 source="replay"(Action.source 的第四个取值,
另外三个是 llm / rule / fallback)。action_kind 为 None 的行被跳过——
这样重放的是父版本里那些没动作的 agent,而不是替它们编一个动作出来。
| 阶段 | 冷跑的一步 | 重放的一步 |
|---|---|---|
| ① 外生更新 | env.advance_to(t) |
env.advance_to(t)——完全相同,分叉之前的日程是不变量 |
| ② 观察 | env.observe_batch(...) |
跳过 |
| ③ 决策 | decision.decide_batch(...) |
跳过——动作来自父版本的面板 |
| ④ 应用 | env.apply(actions) |
env.apply(actions),喂进去的是父版本的动作 |
| ⑤ 记录 | 记忆 + 面板 + 指标 + 事件 | 一模一样,只是写进分支自己的存储 |
为什么这能精确复现父版本¶
三条性质,每一条都由代码保证:
- 对从零构建(Path-B)的 provider 而言,
env.apply(actions)是它所收动作的纯函数。 把父版本的动作重新喂回去,环境就会走过父版本走过的那串状态,并在真正的循环接手时 恰好停在E_{t*-1}。 E_0与P是被重新生成的,不是拷贝来的。 两者都出自同一个 seed 下的materialize_initial(),而不变量检查(§5)保证人群包与 roster 与父版本逐字节一致。- 记忆窗不带来源信息。
AgentMemory.push只存{t, action_kind, payload, summary}(socioverse/engine/memory.py), 所以一个 agent 在分支里进入第t*步时,手里的窗口和它在父版本里的一模一样——source="replay"这个标记永远到不了 prompt。
于是,一个分支和它的父版本(或同一父版本的两个分支)在分叉之前逐行相同,
分叉之后的差异只可能由为第 t* 步及其之后声明的日程解释。
实测,而非声称¶
技术报告的分叉演示(脚本 tech-report/scripts/fork_demo/run_fork.py 不在仓库里)
在 studies/opinion_diffusion 上用真实 LLM 端到端跑过这件事:24 个 agent,8 步,在第 4 步之后分叉(resume_from=4,
两个分支都从第 5 步起自己算),只有处理分支在第 5 步声明了一次媒体宣传。
下面的数取自记录下来的 result.json:
| 父版本 | 对照分支 | 处理分支 | |
|---|---|---|---|
| 调用了决策模型的步 | 1–8 | 5–8 | 5–8 |
| 墙钟秒数 | 47.3 | 25.3 | 23.0 |
| 第 0–4 步面板行与父版本一致 | — | 是 | 是 |
events 行 |
无 | 无 | 第 5 步,"media pressure ramps up at the campaign step" |
两个分支的 mean_opinion 序列在第 4 步之前重合,从第 5 步起分开。
_load_parent_panel¶
con.execute(
"SELECT agent_id, step, action_kind, action_payload FROM panel "
"WHERE step >= 1 AND step <= ? ORDER BY step, agent_id", [int(upto)])
父版本的 study.duckdb 以只读方式打开,结果按步分组。同一个函数也接受以 .jsonl
结尾的路径——被中断的运行就是这样续跑的(§7)。
4. 资格与限制¶
| 研究形态 | 能开分支吗 | 由谁挡住 |
|---|---|---|
从零构建 / Path-B,且 interaction_rounds == 1 |
能 | — |
interaction_rounds > 1 |
不能——冷跑 | 引擎,抛 ValueError |
legacy-wrap(model.py / adapter/ 驱动一个外部模拟器的研究,例如 Chicago 隔离研究) |
不能——冷跑 | 工作流层:/sv-iterate 的版本门根本不提供分支这个出口 |
多轮研究。 interaction_rounds != 1 时 _replay_inherited 立刻抛错。原因在
面板,不在重放代码:循环跑的是 for r in range(self.interaction_rounds),
而写进这一步面板行的只有 last_actions,即最后一轮的动作。被继承那一步的第
0..K-2 轮压根不在磁盘上,所以重放已记录的动作只能复现这一步的结果,
复现不了产生这个结果的那场交互。与其悄悄产出一条"看起来像父版本"的轨迹,
引擎选择拒绝,研究改为冷跑。(messages 表存了些什么,见
多轮交互。)
legacy-wrap 研究。 在这里 env.apply(actions) 不是所收动作的纯函数:
移动意图挂在 decide_batch 设置的那些隐藏的遗留 agent 对象上,跳过决策阶段就会让这些
对象保持未设置状态,apply 也就不再能复现父版本。引擎察觉不到这一点,所以这条规则被
提到上一层强制执行——版本门只给冷跑的出口,并说明原因。
时间跨度。 K = min(resume_from, n_steps):n_steps 比可继承步数还短的分支,
就少继承几步。分支可以比父版本跑得更长——见 §7。
5. E-only 不变量¶
分支是在做一个因果声称,而这个声称只有在"除了分叉步之后的环境,其它全都是父版本的"
时才成立。check_branch_invariant()(skills/sv_workspace.py)在每次运行之前,
拿 live 工作区去对 versions/<source>/ 里父版本那份冻结快照重新核验。要比对六样东西:
population/population.json—— 解析成 JSON 再比,忽略created_at、generated_at、updated_at、materialized_at、written_at这些易变键 (_VOLATILE_KEYS):它们每次作者过一遍都会被重写,且不携带任何行为。population/roster.jsonl—— 逐字节(sha256)。它和 (1) 合起来就是P。- 研究自己的 Python ——
*.py加adapter/*.py,逐字节(_code_digest)。 行为函数f就是代码,所以改了代码的东西不是分支。 - 运行设置 ——
simulation.json的seed、decision_ref、decision_args、collector_ref、collector_args、interaction_rounds、store_ref、store_args、engine_args(BRANCH_INVARIANT_SIM_FIELDS)。n_steps被刻意排除在外—— 延长时间跨度是正当的分支行为。warm_start同样排除在外,那是分支自己的来源记录。 - 分叉之前的环境 ——
provider_ref、provider_args,以及每一条at_step < fork_step的scheduled_events/broadcasts,按排序后的规范集合比对(_env_history)。 落在分叉步及其之后的条目,恰恰是分支被允许改动的部分。 - 父版本已有的环境层 —— 分支可以新增一层(典型场景是为新广播加一个
information层),但父版本已经声明过的层既不能改写也不能删除。
违约是降级,不是失败¶
entry["kind"] = "version"
entry["warm_start"] = None
entry["fork_step"] = None
entry["downgraded_from_branch"] = {"source": ..., "fork_step": ...,
"violations": [...], "at": time.time()}
resolve_warm_start() 会先调 check_branch_invariant(),所以通往一次运行的每一条路径
都会经过这道检查。一旦违约,这个版本在 versions.json 里被改写成一个普通版本,
重放规格被丢弃,resolve_warm_start 返回 None,运行从第 0 步冷跑。结果依然正确——
只是更贵。downgraded_from_branch 这条记录(每个出问题的产物一行违约说明)
留在 manifest 条目上作为审计痕迹,dashboard 把它暴露成 downgraded: true。
降级必须告知研究者
降级对引擎是静默的,对用户绝不能。驱动这次运行的人必须说清楚:是哪一份产物破坏了分支、
第 0..t*-1 步现在会由决策模型重新计算而不是重放(在真实 LLM 下,即便 seed 相同
也不会精确复现父版本)、以及这大概会多花多少钱。降级不会被就地撤销——这个条目现在
就是一个版本了。想保住分支,就把出问题的那份产物从父版本快照里恢复回来,
再用 create_branch 新开一个。
任何时候都可以只预检、不动 manifest:
from skills.sv_workspace import check_branch_invariant
check_branch_invariant("studies/<id>", downgrade=False)
# {"kind": "branch", "ok": False, "violations": [...], "downgraded": False,
# "source": "v1", "fork_step": 3, "error": None}
核验永远不会弄坏一次运行:检查内部抛出的任何异常都会被捕获,放进 error 字段返回,
而不是往外传。
6. 读结果¶
磁盘上的谱系¶
studies/<id>/versions.json 记录每一个节点。一份真实的谱系,来自
studies/campus_dining_choice:
| id | parent | kind | 分叉步 | 注记 |
|---|---|---|---|---|
v1 |
null |
version | — | 初次构建 |
v2 |
v1 |
branch | 3 | 第 3 月起发 ¥100/月餐补 |
v3 |
v2 |
branch | 5 | 把时间跨度延到 7 个月,前 4 月不变 |
v4 |
v1 |
version | — | 食堂涨幅翻倍(改了分叉步之前的 E) |
v5 |
v1 |
version | — | 每个宿舍组 3 轮讨论(改了 interaction_rounds) |
v4 与 v5 从反面标出了边界:改动分叉步之前的环境,或者改 interaction_rounds,
得到的是版本,不是分支。在 kind / fork_step 这两个字段存在之前写下的条目照样能被
正确归类——version_kind(entry) 与 version_fork_step(entry) 会回落到 warm_start
这份来源信息,所以不需要做任何迁移。
配对对比,用 SQL¶
每个版本各持有自己的存储。把两个都 attach 上,按 step join:
ATTACH 'studies/campus_dining_choice/versions/v1/trajectory/study.duckdb'
AS control (READ_ONLY);
ATTACH 'studies/campus_dining_choice/versions/v2/trajectory/study.duckdb'
AS branch (READ_ONLY);
SELECT c.step,
round(b.share_canteen - c.share_canteen, 4) AS d_share_canteen,
round(b.mean_satisfaction - c.mean_satisfaction, 4) AS d_satisfaction
FROM control.metrics c
JOIN branch.metrics b USING (step)
ORDER BY c.step;
step d_share_canteen d_satisfaction
0 0.0000 0.0000
1 0.0000 0.0000
2 0.0000 0.0000
3 0.0133 0.0102
4 0.0600 0.0204
到第 2 步为止严格为 0、从第 3 步起分离,正是一个正确分支的签名:v2 的分叉步就是 3。
逐个 agent 看也一样——
SELECT count(*) AS mismatched_rows
FROM control.panel c
JOIN branch.panel b USING (agent_id, step)
WHERE c.step < 3
AND (c.action_kind IS DISTINCT FROM b.action_kind
OR CAST(c.action_payload AS VARCHAR) IS DISTINCT FROM CAST(b.action_payload AS VARCHAR));
返回 0。在 Python 里做同样的事,用
socioverse.io.duckdb_store.open_readonly(path)。想看哪些步带了干预,
读分支的 events 表,或它 metrics 表的 events 列。
配对只在对着父版本时才成立
Δ_t 是同一批 agent 在两套日程下的配对比较。它在一个分支与它
warm_start.source 所指的那个版本之间有意义(dashboard 把它暴露成 branch_of),
在同一父版本、同一分叉步的两个分支之间也有意义。它在任意两个版本之间没有意义:
它们是各自冷跑出来的,P、f 或 seed 都可能不同,而且即便配置完全相同,
真实 LLM 也不会逐步复现一次运行。版本之间要当成两套研究设计来比——
趋势、水平、次序——而不是当成一个配对差值。
7. 重放的另外两个用途¶
重放这套机制不只服务于反事实。同一份规格还覆盖两件日常需求,
这也正是 n_steps 被豁免于不变量之外的原因。
把跑完的运行接着往下跑。 声明更长的时间跨度,在
fork_step = 父版本的 n_steps + 1 处开分支,已有的步由重放继承,只有新增的步花预算。
上表里的 v3 就是这个用法:它继承 v2 的前 4 个月,自己算第 5–7 月。环境一个字没改,
与 v2 的对比按构造就是空的——这里的分支被当作续跑用,不是当作处理组用。
续跑一次被中断的运行。 _load_parent_panel 除 DuckDB 文件外,也接受以 .jsonl
结尾的路径。那是循环在存储旁边写的逐步流(trajectory/panel_live.jsonl,由
_emit_panel 写出),它能在一次被预算上限掐断或崩掉的运行之后幸存下来——
DuckDB 在运行期间是单写者加锁的,所以这份实时流是唯一可读的副本。把一个
WarmStartSpec 指向它、resume_from 设成最后一个跑完的步,运行就从那里继续,
已经跑完的步不必再付一次钱。旁边的 trajectory/progress.jsonl(由 _emit_progress
写出)会告诉你那一步是第几步。
延伸阅读¶
- 一步,五个阶段——干预进入的那五个阶段,以及每一步记录了什么。
- 可控研究循环——把分支与版本看作对研究状态
σ = (P, E, f, Θ)的修改。 - 迭代:版本与分支——版本门、三个出口, 以及围绕一个分支的完整工作流。
- 一个 Study 的解剖——
environment.json、versions.json与轨迹存储各自落在哪里。