跳转至

迭代:版本与分支

一个已经跑完的研究是一份结果。对它的迭代绝不能悄悄毁掉这份结果;而当这次改动是一个 反事实时,也不该为那些不可能改变的步数重新付钱。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():

  1. 用同样的 bundle、同样的 seed 物化 P 与 E_0。
  2. 对 1..t*-1 的每一步:激活父版本的排程(env.advance_to(t)),从父版本的 panel 表读出 该步存下来的行,重建成 source="replay" 的 Action 对象,用 env.apply(actions) 施加, 压入每个 agent 的记忆窗口,并把 panel / metrics / events 行写进新的存储。
  3. 从 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。见 运行面板。

接下来读什么

  • 报告——怎么把分支与它的父版本对照,怎么把一个版本与另一个版本对比。
  • 步内多轮交互——为什么 interaction_rounds > 1 无法重放。
  • LLM 接入与干跑——模式切换要保留的那些 scripted / 本地运行。
  • 真实世界锚定与溯源——grounding 会带进新版本,并随每个快照 各存一份。