跳转至

干预与反事实分支

一次运行告诉你发生了什么。一个研究还必须回答:换一种做法会怎样。 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), 在下面两种情况下拒收整个环境包:

  1. 任何 ScheduledEvent.target_layer 不是某个已声明层的 name——报错会把已声明的层名列出来。
  2. 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*)      循环把它往后带

分支里的行为,在分叉之前是重放的,在分叉之后才是算出来的:

b'_{i,t} = b_{i,t}                              t < t*   (从父版本的面板重放)
b'_{i,t} = f(p_i, ω(E'_t, p_i), m'_{i,t})       t >= t*  (真实的决策调用)

其中 ω 是观察投影,m' 是分支自己的记忆窗。一个反事实设计要报告的量,是配对对比

Δ_t(δ) = Y(E'_t) − Y(E_t),      t >= t*

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),喂进去的是父版本的动作
⑤ 记录 记忆 + 面板 + 指标 + 事件 一模一样,只是写进分支自己的存储

为什么这能精确复现父版本

三条性质,每一条都由代码保证:

  1. 对从零构建(Path-B)的 provider 而言,env.apply(actions) 是它所收动作的纯函数。 把父版本的动作重新喂回去,环境就会走过父版本走过的那串状态,并在真正的循环接手时 恰好停在 E_{t*-1}。
  2. E_0 与 P 是被重新生成的,不是拷贝来的。 两者都出自同一个 seed 下的 materialize_initial(),而不变量检查(§5)保证人群包与 roster 与父版本逐字节一致。
  3. 记忆窗不带来源信息。 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>/ 里父版本那份冻结快照重新核验。要比对六样东西:

  1. population/population.json —— 解析成 JSON 再比,忽略 created_at、 generated_at、updated_at、materialized_at、written_at 这些易变键 (_VOLATILE_KEYS):它们每次作者过一遍都会被重写,且不携带任何行为。
  2. population/roster.jsonl —— 逐字节(sha256)。它和 (1) 合起来就是 P。
  3. 研究自己的 Python —— *.py 加 adapter/*.py,逐字节(_code_digest)。 行为函数 f 就是代码,所以改了代码的东西不是分支。
  4. 运行设置 —— 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 同样排除在外,那是分支自己的来源记录。
  5. 分叉之前的环境 —— provider_ref、provider_args,以及每一条 at_step < fork_step 的 scheduled_events / broadcasts,按排序后的规范集合比对(_env_history)。 落在分叉步及其之后的条目,恰恰是分支被允许改动的部分。
  6. 父版本已有的环境层 —— 分支可以新增一层(典型场景是为新广播加一个 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 写出)会告诉你那一步是第几步。


延伸阅读