迭代:版本与分支¶
一个已经跑完的研究是一份结果。对它的迭代绝不能悄悄毁掉这份结果;而当这次改动是一个 反事实时,也不该为那些不可能改变的步数重新付钱。SocioVerse 用同一套机制同时满足两者: 对研究的每一次修改都成为一个版本,其中"只改分叉步之后的环境"这一子集成为分支—— 它通过重放继承父版本已跑出来的历史。
一次修改就是一次状态转移¶
研究状态就是流水线写到磁盘上的那个元组;一次修改是一个二元组 a_k = (c, u)——它触及的
分量 c ⊆ {P, E, f, Θ},以及施加在这些分量上的更新 u。施加一次修改,就得到研究的下一个版本:
σ = (P, E_{0:T}, f, Θ) 研究状态
σ_{k+1} = h(σ_k, (c, u)) =
σ_k c = {} 没有改动
σ_k[ E_{t*:T} ← u ] c = {E} 在分叉步 t* 上开分支
σ_k[ c ← u ] 其它 新版本
| 研究状态 | 在工作区里的落点 |
|---|---|
| P | population/population.json + population/roster.jsonl |
| E_0:T | environment/environment.json(层、scheduled_events、information_program.broadcasts) |
| f | 研究自己的 model.py / adapter/*.py,经 simulation/simulation.json 的 decision_ref 绑定 |
| Θ | simulation/simulation.json(n_steps、seed、interaction_rounds、引擎与存储参数) |
h 的实现在 skills/sv_workspace.py:第三种情形是 create_version(),
第二种是 create_branch()。两者都会往 versions.json 追加一条记录,并把上一份状态冻结到
versions/ 下。
一个概念一个词
分支(branch)是只改环境、并靠重放继承历史的版本。版本(version)是其它任何改动, 从第 0 步冷跑。派生(fork)在 SocioVerse 里只有一个含义——从 reference 研究派生出一个 新 study(见下一节)。版本不叫 fork,分支也不叫 fork。
规则:所有修改都走 /sv-iterate¶
对既有研究的任何修改——加一条政策广播、加几步、换人群规模、调一个阈值——都经过
/sv-iterate。
不要手改产物再重跑
原地编辑产物再重跑,会把上一条轨迹无记录地覆盖掉。 「再加一个政策跑一轮」是一次迭代,不是一次编辑。
版本门之前:reference 守卫¶
study.yaml 带 reference: true 的研究——内置的示例模板与适配基线——是只能派生(fork)的。
这道守卫跑在版本门之前,并且直接绕过版本门:新版本、分支、原地三个出口都会改动同一个
study_id,而这恰恰是共享基线最不能承受的。
所以对 reference 研究说「改一下这个」,结果是派生出一个新 study:
from skills.sv_workspace import fork_study, init_manifest, study_paths
fork = fork_study("studies", "<reference_id>", "<new_id>") # 在每一份拷贝过去的产物里
# 重写 study_id,并置 reference=false
init_manifest(fork) # 派生出来的 study 自己从 v1 起线
print("iterate on:", study_paths(fork)["study"])
fork_study() 拷贝 study.yaml、resources / environment / population / simulation 四份 JSON
与 grounding/grounding.json,丢掉源研究的 trajectory/ 与 reports/ 输出,并拒绝覆盖已存在的
study_id。它不拷贝任何代码——派生出来的研究仍然指向源研究已注册的 provider_ref。
- 刚派生出来的 study 没有历史可版本化,因此跳过版本门——它从
v1开始自己的版本线, 直接进入流水线。 - 原地编辑基线模板是一个罕见而慎重的选择,必须先给出明确、说全的警告:此后所有由该模板 派生的研究都会继承这次改动。
非 reference 的工作研究不经这道守卫,直接进版本门。
版本门:三个出口¶
/sv-iterate 总是开口问(从不擅自决定)这次修改如何版本化。出口恰好有三个:
| 出口 | 什么时候适用 | 这一跑要花多少 |
|---|---|---|
| (a) 新版本(推荐默认) | 任何对 P、f、Θ 的改动,或改了在分叉步之前生效的环境条目 |
从第 0 步冷跑——整个时间跨度重新付费 |
| (b) 分支(反事实) | 这次修改只往环境包里新增 at_step >= t* 的干预,或只是把 n_steps 拉长 |
0..t*-1 由父版本重放、不发生任何决策调用,只有 t*..n_steps 花预算 |
| (c) 原地修改 | 用户明确接受丢失的一次性修补 | 当前输出在重跑时被覆盖且不可恢复 |
出口 (a) 带一个子问题:从哪个版本出发。只有当这次请求提到了某个更早的版本(或用户
自己提出来)时才会追问,并非每次开新版本都问。「当前版本」以现行产物为起点;
「历史版本 v<X>」先把那个归档版本的产物恢复到 live 目录,parent 记录它从哪来。
这是一个起点选择,不是分支——运行照样是冷跑,父版本的轨迹一步也不继承。
模式与 provider 的切换只能走新版本
决策 / 运行模式或 provider 的切换——scripted ↔ LLM、本地 ↔ 真 LLM、确定性 ↔ 随机——
根本不提供出口 (c)。之前那次运行就是科学对照基线:做 scripted 干跑或本地模型跑一遍,
意义正在于它要摆在真实运行旁边比较。这类切换改的是 f,所以出口 (b) 也不适用。
只提供出口 (a),连同它的起点子问题。见 LLM 接入与干跑。
怎么创建:create_version 与 create_branch¶
from skills.sv_workspace import create_version, create_branch, current_version
current_version("studies/<id>") # 父版本 id
# (b) 分支——只新增了 at_step >= J 的干预;0..J-1 来自父版本
new_id = create_branch("studies/<id>", note="<一行说明>", fork_step=J)
# (a) 新版本,从当前版本出发——在最早受影响的阶段重入
new_id = create_version("studies/<id>", note="<...>",
resume_from_stage="sv-build-environment")
# (a) 新版本,以某个历史版本的产物为起点
new_id = create_version("studies/<id>", note="<...>", base="v1",
resume_from_stage="sv-init")
实际的签名:
create_version(study_dir, note, base=None, warm_start=None, resume_from_stage=None) -> str
create_branch(study_dir, note, *, fork_step, source=None,
resume_from_stage="sv-build-environment") -> str
fork_step是仅限关键字(keyword-only)的参数,且必须>= 1——第 0 步是物化出来的 初始状态,没有什么可继承。否则create_branch抛ValueError。create_branch记录warm_start = {"source": src, "resume_from": fork_step - 1},再转交create_version;后者只要看到warm_start就把kind写成"branch"。(warm_start是 重放规格在磁盘上的字段名,早于现在这套词表。)source(分支)与base(版本)默认是当前版本。指定一个更早的版本,会先把那个版本的 快照恢复到 live 目录。- 分支的
resume_from_stage默认是"sv-build-environment",因为环境包是分支唯一要重写的 产物;而create_version在base不是当前版本时默认写"sv-init",好让旧设计逐阶段 重新复核。 - 两个调用都按这个崩溃安全的顺序执行:把 live 目录冻结到
versions/v<current>/→ 如果点名了 base 就恢复它 → 清掉 live 的运行输出 → 最后追加条目并移动current(原子写,放在最后)。
不要为了开分支去手改 simulation.json。/sv-run 通过 resolve_warm_start() 读清单,
versions.json 始终是唯一真源。
分支重放¶
分支不会重新模拟继承过来的那段前缀。socioverse/engine/loop.py 里的
LongitudinalSimulator.run() 一旦看到 WarmStartSpec,就在进入真正的循环之前调用
_replay_inherited():
- 用同样的 bundle、同样的 seed 物化
P与E_0。 - 对
1..t*-1的每一步:激活父版本的排程(env.advance_to(t)),从父版本的panel表读出 该步存下来的行,重建成source="replay"的Action对象,用env.apply(actions)施加, 压入每个 agent 的记忆窗口,并把 panel / metrics / events 行写进新的存储。 - 从
t*起,decide_batch()照常运行,分支只为自己的步数付费。
因为从零构建研究的 env.apply(actions) 是所传入行为的纯函数,重放出来的行与父版本逐行一致,
环境到达同一个 E_(t*-1),每个 agent 也带着它在父版本里的记忆窗口继续往下走。于是同一个父版本
分出来的两个分支共享完全相同的已实现历史——这正是 Δ_t = Y(E′) − Y(E) 能够只归因于所声明
干预的原因。
适用条件
分支要求研究是从零构建(Path B)且 interaction_rounds == 1。多轮交互研究一律从第 0
步重跑:_replay_inherited() 宁可抛 ValueError 也不会错误地重放。多轮之所以被排除,
不是回放代码做不到,而是存下来的东西不够:父版本的面板里只持久化了最后一轮的行为,
中间那些促成它的轮次无法重建。见步内多轮交互。
包装遗留引擎的研究(芝加哥适配器)被排除的理由不同:它的 env.apply() 不是所传入行为的
纯函数——迁居意图写在 decide_batch() 期间设置的遗留 agent 上,不在面板行里——所以重放
存下来的行为会悄悄跑偏。引擎并不会拦住这一点,因此 /sv-iterate 干脆不对这类研究提供
出口 (b)。
_load_parent_panel() 通常读 versions/<source>/trajectory/study.duckdb,但它也接受
panel_live.jsonl 路径——那是一次被中断的运行留下的逐步流。把重放指向这个文件,就是从
上一个跑完的步数续跑,而不必为已完成的步数再付一次钱。
一个实例。舆论扩散参考研究先跑了 8 步,然后在 fork_step=5 上分出一个对照分支
(不再加干预)和一个处理分支(第 5 步投放一条媒体宣传广播)。两个分支都逐行复现了父版本
第 0–4 步的记录,只在第 5–8 步调用决策模型:期末平均意见对照分支 0.539、处理分支 0.729,
在完全相同的历史之上 Δ_8 = +0.190。
E-only 不变量与自动降级¶
分支宣称:它与父版本在分叉步之后的一切差异,都由所声明的干预造成。这个宣称只有在人群、研究
代码、运行设置与分叉前排程都还是父版本那一份时才成立,所以 check_branch_invariant() 会在任何
一次运行之前,拿 live 工作区与父版本的冻结快照逐项比对。
| 比对什么 | 规则 |
|---|---|
population/population.json |
一致;忽略 created_at / generated_at / updated_at / materialized_at / written_at 这些易变键 |
population/roster.jsonl |
逐字节一致(SHA-256) |
研究自己的 *.py 与 adapter/*.py |
逐字节一致——行为函数 f 就是代码,改了它就不是分支 |
simulation.json 的 seed、decision_ref、decision_args、collector_ref、collector_args、interaction_rounds、store_ref、store_args、engine_args |
一致。n_steps 被有意排除在外——分支可以跑比父版本更长的时间跨度 |
分叉前的环境:provider_ref、provider_args,以及所有 at_step < fork_step 的 scheduled_events / broadcasts |
一致——干预只能加在 at_step >= fork_step 上 |
| 父版本已有的环境层 | 分支可以新增层(比如为广播加一个 information 层),但绝不能改写或删除父版本已有的层 |
违约会降级,不会报错。kind 改成 "version",重放规格被丢弃,fork_step 清空,这一跑改为
从第 0 步冷跑——结果依然正确,只是更贵——并把原因写进清单条目的 downgraded_from_branch
(source、fork_step、每个违规产物一行的 violations、at)。resolve_warm_start() 在通往运行的
每一条路径上都会先做这项检查,所以被降级的分支根本没有重放可解析。skill 有义务如实告诉你:
是哪份产物破坏了分支、重算前缀大概会多花多少钱。
随时可以预检,且不改动清单:
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': 5, 'error': None}
降级不会就地撤销——那条记录现在就是一个版本。若你更想保住分支,把违规产物从父版本的
快照恢复回来,再用 create_branch 开一个新的。
复核门(待复核)¶
创建版本或分支时必须说明流水线从哪里恢复。resume_from_stage 决定了 dashboard 把指针停在哪
一阶段,并从那里往后逐阶段复核:
| 改动类型 | resume_from_stage |
|---|---|
| 分支 | sv-build-environment——create_branch 已经写好,不要再传一次 |
| 从当前版本开新版本 | 受影响的最早阶段——新广播 → sv-build-environment;人群变化 → sv-build-population;决策逻辑变化 → sv-build-model |
| 从历史版本开新版本 | sv-init——从旧设计出发是一次大的语境切换,一切都要重新复核 |
从该阶段往后,每一个沿用(carried)的阶段在 dashboard 上都显示为待复核,直到你处理它。 处理方式只有两种:
- 受影响的阶段 → 照常用它的
sv-*skill 重新撰写。 - 未受影响的阶段 → 用
validate_handoff(path, Model)校验沿用下来的产物,确认它对新问题 依然成立,然后贴出它的叙述。正是这段叙述清掉「待复核」标记。
验证并叙述——不要重写未受影响的产物
「保险起见」重写一份沿用产物,会搅动它的 mtime,破坏 dashboard 对沿用 / 陈旧的判定。 校验它、说清它为什么依然成立,字节原样别动。
既没重写、也没验证的沿用阶段会一直保持待复核并扣住指针——所以流水线永远不可能在还
带着未复核阶段的情况下看起来已经完成。/sv-run 照旧在花钱前确认。
落盘成什么¶
versions.json(在 study 根目录)是清单——{"current": "<id>", "versions": [...]}——用
tmp + rename 原子写入,dashboard 的轮询永远读不到半截文件。每个版本一条记录:
{
"id": "v2",
"parent": "v1",
"note": "第 5 步加媒体宣传", // ≤ 50 字左右,显示在 dashboard 版本框里
"kind": "branch", // "branch" | "version"
"fork_step": 5, // 分支自己计算的第一步;version 为 null
"warm_start": {"source": "v1", "resume_from": 4}, // 重放规格;version 为 null
"resume_from_stage": "sv-build-environment",
"created_at": 1758000000.0,
"snapshot": "versions/v1", // 冻结时写在**父版本**上;还在 live 的版本为 null
"downgraded_from_branch": { // 只有在不变量违约后才出现
"source": "v1", "fork_step": 5, "violations": ["..."], "at": 1758000000.0
}
}
v1 就是一条普通的 kind: "version",parent: null 即主线起点——没有单独的 root 类型。
在 kind / fork_step 出现之前写下的清单也能正确归类:version_kind() 与
version_fork_step() 会退回到 warm_start 的出处去推断,因此不需要任何迁移。
versions/<id>/ 是那个版本被冻结的工作区:live 目录的完整拷贝,含代码、bundle、
trajectory/study.duckdb、reports/ 与 narrative/。不进快照的是:versions/ 自身、
versions.json、runs/、__pycache__、.DS_Store,以及两个临时运行文件 progress.jsonl 与
panel_live.jsonl——归档绝不能在 dashboard 眼里看起来"正在跑"。归档里的 study.duckdb 正是
子分支重放的对象。versions/ 和运行输出一样被 gitignore:旧版本永不自动删除。
live 目录在每次版本跃迁时都会丢掉 trajectory/ 与 reports/(它们在快照里保住了),
这样新版本诚实地从"已构建、尚未运行"开始。
dashboard 通过 dashboard/server/app.py 的 versions_view() 读这一切,它为每个版本额外
返回 kind、fork_step、branch_of(即 warm_start.source)与 downgraded。见
运行面板。