版本与可控研究循环¶
一个研究里嵌套着两个循环。
纵向模拟循环是内层。它把研究本身固定住,只推进时间:B_t = f(P, E_t),
再 E_{t+1} = g(E_t, B_t),每个 agent 每一步落一行面板数据。实现在
socioverse/engine/loop.py。
可控研究循环是外层。它什么都不固定:被编辑的对象就是研究本身,每一次 修改产出它的一个新版本:
这个循环里没有任何隐式的东西。研究状态 σ 是磁盘上的一个目录,一次修改
a_k 是一次文件改动,而 h 就是 skills/sv_workspace.py 往 versions.json
里写下的一条新记录。本页讲的就是这套机制:σ 的哪个分量落在哪些文件上、
哪些修改成为分支、哪些成为版本、清单记了什么、dashboard 又拿它做什么。
单次运行内部的干预机制(一条 ScheduledEvent 或 Broadcast,以及分支
如何重放父版本的历史),见干预与分支。
整个目录布局见一个 Study 的解剖。
磁盘上的研究状态¶
σ 不是内存里的对象。它的四个分量各自对应 studies/<study_id>/ 下的一份或
几份产物,而 live 目录永远是当前版本的工作副本。
| 分量 | 含义 | 产物 | 由谁写 |
|---|---|---|---|
| P | 固定人群 | population/population.json(personas、interaction、propagation、materialized_count)+ population/roster.jsonl(每个被实例化的 agent 在 t=0 的那一行) |
/sv-build-population |
| E0:T | 整条环境轨迹,不只是它的初始状态 | environment/environment.json —— provider_ref / provider_args、layers[],以及各自带 at_step 的 scheduled_events[] 与 information_program.broadcasts[] |
/sv-build-environment |
| f | 行为函数 | model.py(从零构建的研究)或 adapter/*.py(包装遗留模型),运行时经 decision_ref / decision_args 绑定 |
/sv-build-model,或适配器作者 |
| Θ | 指标、资源清单、种子 | simulation/simulation.json(seed、n_steps、interaction_rounds、collector_ref / collector_args、store_ref / store_args、engine_args)、study.yaml(metrics、display_metrics)、resources.json(资源清单) |
/sv-init,以及 /sv-run 的预检 |
有两条推论值得挑明。
E 是 σ 的分量,不是某次运行的分量。 让环境在 0..T 上动起来的那张
日程表,是在跑之前一次性写好的,只住在一个 JSON 文件里。正因为如此,
"只改环境"的修改才便宜到足以自成一类迭代。
live 目录就是当前版本,归档版本并不是可以并行跑的目录。 Python 的包导入
把可执行代码钉死在 studies/<study_id>/(import studies.<id>,或适配器的
seam 模块),所以 versions/v3/model.py 是一份 dashboard 能渲染、人能读的
归档,永远不是引擎会 import 的东西。因此迭代的做法是冻结旧状态、就地改动,
而不是把目录分叉。
一次修改,三种出口¶
一次修改是一个二元组 a_k = (c, u):c 指明它触及了 σ 的哪些分量
(P、E、f、Θ 的一个子集),u 是施加在这些分量上的更新。h 恰好只有三种情形:
| σ_k c = {} 没有改动
σ_{k+1} = h(a_k, σ_k) | σ_k[ E_{t*:T} <- u ] c = {E} 在分叉步 t* 上开分支
| σ_k[ c <- u ] 其它 新版本
落到工作流上:
| 这次修改动了什么 | 出口 | 调用 | 实际跑什么 |
|---|---|---|---|
| 什么都没动(修改被否掉,或本来就已满足) | 没有改动 | 无 | 什么都不跑 |
只动 environment.json,且只动 at_step >= t* 的条目;可以同时把 n_steps 拉长 |
分支 | create_branch(study_dir, note, fork_step=t*) |
从父版本的存储重放 0..t*-1,只为 t*..n_steps 做真实决策 |
只把 n_steps 拉长、别的都没动 |
分支(退化情形,t* = 父版本 n_steps + 1) |
create_branch(..., fork_step=parent_n_steps + 1) |
父版本跑过的全部重放,只算新增的这段尾巴 |
population/*、model.py / adapter/*.py、seed,或任何 at_step < t* 的环境条目 |
版本 | create_version(study_dir, note, resume_from_stage=...) |
从第 0 步冷跑 |
| 回到某个更早的设计重新出发 | 版本,带 base="vX" |
create_version(..., base="vX", resume_from_stage="sv-init") |
从第 0 步冷跑 |
分支是一个断言,而这个断言会被核验
开一个分支,等于断言:凡是 fork_step 之后与父版本不同的东西,都只由
声明出来的那次干预造成。这个断言成立的前提,是 P、f、运行设置与分叉步之前
的日程表都与父版本逐字节相同。check_branch_invariant() 对着父版本的冻结
快照核验的正是这些,而 resolve_warm_start() 会在通往运行的每条路径上调用它。
一旦违约就降级、而不是抛错:kind 改成 "version",warm_start 与
fork_step 被清空,这次运行从第 0 步冷跑,原因作为 downgraded_from_branch
记在这条版本记录上。
skills/sv_workspace.py 里的 check_branch_invariant() 具体比对什么:
| 比对对象 | 规则 | 在源码里 |
|---|---|---|
population/population.json |
丢掉易变键(created_at、generated_at、updated_at、materialized_at、written_at)之后必须相等 |
BRANCH_INVARIANT_FILES |
population/roster.jsonl |
逐字节相同(sha256) | BRANCH_INVARIANT_FILES |
*.py + adapter/*.py |
逐字节相同 —— 行为函数 f 就是代码 | _code_digest() |
simulation.json 的字段 |
seed、decision_ref、decision_args、collector_ref、collector_args、interaction_rounds、store_ref、store_args、engine_args 均不变。n_steps 是故意不在其中的,所以分支可以跑更长的时间跨度 |
BRANCH_INVARIANT_SIM_FIELDS |
| 环境历史 | provider_ref、provider_args,以及每条 at_step < fork_step 的 scheduled_events / broadcasts 都不变 |
_env_history() |
| 环境层 | 分支可以新增一层(比如加一个 information 层去承载新广播),但不能改写或删除父版本已有的层 |
check_branch_invariant() |
还有两个资格条件来自引擎而不是清单,写在
socioverse/schemas/simulation.py 的 WarmStartSpec 上:重放要求
interaction_rounds == 1,且 env.apply(actions) 必须是传入动作的纯函数。
像 chicago_schelling 这样包装遗留模型的研究无法重放(它们的移动意图挂在
decide_batch 期间被设置的隐藏遗留 agent 上),多轮研究同样不行。
对这两类研究,每一次迭代都是一个版本。
versions.json —— 版本清单¶
每个研究一份,放在 study 根目录,以原子方式写入(tmp + rename),
所以 dashboard 的轮询永远读不到半截文件。顶层两个键:current(live 目录
当前所承载的版本 id)与 versions[]。下面是两条记录的例子——一个主线起点,
外加从它开出的一个分支:
{
"current": "v2",
"versions": [
{
"id": "v1",
"parent": null,
"note": "initial build",
"kind": "version",
"fork_step": null,
"warm_start": null,
"created_at": 1783433449.879952,
"snapshot": "versions/v1"
},
{
"id": "v2",
"parent": "v1",
"note": "第3月起发¥100/月餐补,跑4步",
"kind": "branch",
"fork_step": 3,
"warm_start": {"source": "v1", "resume_from": 2},
"resume_from_stage": "sv-build-environment",
"created_at": 1783439750.058673,
"snapshot": null
}
]
}
| 字段 | 类型 | 含义 |
|---|---|---|
id |
"v<N>" |
create_version() 取 max(已有数字 id) + 1,所以即便新版本是从一个很老的父版本出发,id 仍然单调 |
kind |
"branch" | "version" |
由 warm_start 决定:有重放规格就是 "branch",否则 "version" |
parent |
str \| null |
这个版本是从哪个版本创建出来的。只有 v1 是 null |
fork_step |
int \| null |
分支自己开始计算的第一步,= warm_start.resume_from + 1。版本为 null |
warm_start |
{source, resume_from} \| null |
分支的重放出处:继承 source 的 0..resume_from 步,其余自己算。版本为 null |
resume_from_stage |
str \| null |
这次迭代重新进入流水线的最早那个 sv-* 阶段。待复核门靠它驱动(见下)。由 create_version() 写入;init_manifest() 写的 v1 条目不含这个键 |
note |
str |
一行人话描述,显示在 dashboard 的版本框里 |
created_at |
float |
unix 时间戳。同时也是区分"沿用产物"与"本版本自己写的产物"的分界线 |
snapshot |
str \| null |
版本被冻结之后写成 "versions/<id>"。它还是 live 的当前版本时为 null |
downgraded_from_branch |
object |
只有在分支未通过不变量核验时才出现:{source, fork_step, violations[], at}。审计留痕,永不删除 |
v1 是一个 version,老清单也不需要迁移
没有 "root" 这种 kind。init_manifest() 把 live 目录登记为 v1、
kind: "version"、parent: null,这就是主线的起点,仅此而已。
在 kind 与 fork_step 出现之前写下的清单(campus_dining_choice
这个研究就还带着一份),通过 version_kind(entry) 与
version_fork_step(entry) 读取:它们回退到 warm_start 出处去推断——
带 warm_start.source 的条目就是分支,它的分叉步是 resume_from + 1。
dashboard 服务端在 dashboard/server/app.py 里有一份等价的函数对。
磁盘上什么都不用改写。
在 Python 里读清单,请用这几个 helper,不要自己手动解析:
from skills.sv_workspace import (
current_version, version_entry, version_kind, version_fork_step,
)
S = "studies/campus_dining_choice"
current_version(S) # "v5"
version_kind(version_entry(S)) # "version" —— v5 是冷跑的
version_fork_step(version_entry(S)) # None
version_kind(version_entry(S, "v2")) # "branch"
version_fork_step(version_entry(S, "v2")) # 3
version_entry(study_dir) 不给第二个参数时,返回的是当前版本的条目。
快照语义¶
create_version() 靠执行顺序做到崩溃安全。每一步要么幂等,要么在最后
一步落地之前对外不可见。
graph LR
A["1 · 冻结 live<br/>versions/v<current>/"] --> B["2 · 可选恢复<br/>base 快照 → live"]
B --> C["3 · 清空输出<br/>trajectory/ reports/"]
C --> D["4 · 追加记录<br/>移动 current(原子)"]
1. 冻结。 _snapshot_live() 把整个 live 目录拷进
versions/<current_id>/,跳过 SNAPSHOT_EXCLUDE:
| 被排除 | 为什么 |
|---|---|
versions/ |
快照树本身,否则每冻结一次就把上一次套进去一层 |
versions.json |
study 级、只存在于 live。一个研究一份清单,不是一个版本一份 |
runs/ |
demo 的临时输出 |
__pycache__、.DS_Store |
缓存与噪声 |
progress.jsonl、panel_live.jsonl |
两个运行期的临时文件。归档副本若带着它们,在 dashboard 看来就像正跑到一半 |
其余一切都被冻结,包括 trajectory/study.duckdb。这是刻意的:
version_trajectory_db(study_dir, vid) 解析到的就是
versions/<vid>/trajectory/study.duckdb,而分支重放读的正是这份归档存储。
grounding/、resources.json 与 narrative/<stage>.json 也一并冻结,
所以归档版本带着的是它自己的证据与自己的阶段说理,而不是隔壁版本的。
如果快照目录已经存在(说明上一次 create_version 在冻结之后崩了),
它按原样保留:那是那个版本权威的冻结,而 live 目录可能已经往前走了。
2. 恢复,仅当 base 指名了一个更早的版本。 live 目录里除
RESTORE_KEEP(versions/、versions.json、runs/、__pycache__、
.DS_Store)之外的一切都被删除,然后把 base 快照拷回来,但不包括
OUTPUT_SUBDIRS——输出留在归档里,因为新版本本来就要重跑。拷贝走
shutil.copy2,它保留 mtime,而这一点对下面的待复核门是关键。
3. 清空输出。 trajectory/ 与 reports/ 从 live 目录移除并重建成空布局,
让新版本老老实实地从"已构建、尚未运行"起步。父版本的结果安全地待在它的快照里。
4. 登记。 追加新记录、移动 current,在同一次原子清单写入中完成。
在这次写入落地之前,拷到一半的状态对 dashboard 完全不可见。
待复核门¶
新版本会原封不动地继承父版本的大部分产物。这正是它的意义所在,也正是它的风险: 一条流水线可以看上去已经完工,而其中六个阶段里有三个其实还是父版本的活、 根本没人看过。
dashboard/server/app.py 里的 compute_stages() 用两遍扫描堵上这个洞。
沿用检测。 对一个带 parent 的版本,凡是产物为 done、但 mtime 早于该
版本 created_at 的阶段,都是沿用过来的、不是在这里写的,会被打上
carried_from: "<父版本 id>"。
这道门。 从 resume_from_stage 开始,每一个沿用阶段只要没有在本版本里
写过 narrative,就显示为 review(待复核),流水线指针停在其中第一个上。
判定依据是 narrative/<stage>.json 的 ts 与该版本 created_at 的先后。
resume_from_stage 由 /sv-iterate 在创建时选定:
| 版本出口 | resume_from_stage |
理由 |
|---|---|---|
| 分支 | "sv-build-environment"(create_branch 的默认值) |
环境包是分支唯一会重写的产物 |
| 从当前版本出发的新版本 | 这次修改影响到的最早那个阶段 | 在它之前的一切确实没有变 |
| 以某个历史版本为起点的新版本 | "sv-init" |
回到旧设计是一次很大的上下文切换,每个阶段都重新复核 |
| 缺失或无法识别 | 第一个沿用阶段 | 失败时取安全侧,绝不落在运行指针上 |
清掉一个 review 标记,恰好只有两条路:
- 重做这个阶段。 跑一遍该阶段的 skill,产物被重写,mtime 越过
created_at,它就不再是沿用的了。 - 核验并记录。 对着新意图检查这份沿用产物,然后为本版本补上该阶段的
narrative(
sv_emit.py narrative --study <id> --stage <name> --text "...")。 把阶段标记为已复核的,正是这条 narrative。
不要为了保险去重写一份没改的产物
一次内容完全相同的重写,照样会顶掉文件的 mtime。这会毁掉沿用检测
(该阶段不再显示为继承而来),毁掉过期检测(STAGE_DEPS 靠比对 mtime
判断一次运行是否还反映它的输入),还会往活动日志里塞进一条虚假的
"有人写过"事件——build_events() 完全是从 mtime 重建这份日志的。
请改用"核验并补 narrative"。
有两处作用域值得记住。这道门只作用于 live 的当前版本;归档快照是已经尘埃
落定的历史,它的沿用阶段永不被标记。另外,过期与这道门是两回事:一个 done
阶段如果早于它 STAGE_DEPS 里的某个依赖(run 依赖 model + environment +
population,report 依赖 run)构建,就会被标为 stale,与版本无关。
四类受控对照¶
因为 P、E、f 是三份各自可替换的产物,而每一次运行都是一个记录了血缘的版本,
一个研究的成长方式就是不断加版本:每次只变一个轴、其余固定。下面四类全都是
版本而不是分支,理由始终是同一条:分支不变量只允许新增 at_step >= fork_step
的环境条目。
| 对照 | 改什么 | 产物 | 为什么它不能是分支 |
|---|---|---|---|
| 换人群 —— 同样的 E 与 f,不同的人口构成(普查合成的成年人 vs 开源池里的 personas) | 重建 P | population/population.json + population/roster.jsonl |
这两个文件都在 BRANCH_INVARIANT_FILES 里。roster 换了就是换了一批个体,父版本已实现的历史并不是这批人的历史,没有东西可以重放 |
| 换环境 —— 同样的 P 与 f,一个结构上不同的环境(换一座城市、换一种交互拓扑) | 重建 E | environment/environment.json(provider_ref / provider_args、layers[]) |
_env_history() 从第 0 步就钉住 provider_ref 与 provider_args,且已有的层不许被改写。这一类对照,恰恰是没法表达成一次干预的 |
| 行为函数消融 —— 在同一对 (P, E) 上比 rule-f / LLM-f / hybrid-f | 换决策模型 | model.py / adapter/*.py,以及 simulation.json 里的 decision_ref / decision_args |
代码摘要和 simulation.json 字段列表都会逮住它。父版本的动作是另一个 f 产出的,重放它们等于把旧的 f 偷渡进新的运行 |
| 换数据源 —— 换一个口径/年份的指标,或换一个用户池,模型本身不动 | 重新解析资源清单 | resources.json,以及被它喂养、随之重新生成的 P 或 E 包 |
这次替换落在第 0 步之前的 P 或 E 上,整条轨迹都会变 |
这几类可以叠加。一个版本可以同时变好几个轴,清单里仍然只记一个父版本, 所以血缘始终是一棵树。
可复现与可审计¶
三条性质互相支撑,而且每一条都是磁盘上的一个文件,不是一句约定。
每个版本快照的是整个工作区。 不只是结果:代码、各个包、grounding、
轨迹存储、报告、阶段说理全都在内。任何一个更早的 σ_k 都可以被查看,
也可以被恢复到 live 目录、当作新版本的起点。这正是修改方程第一种情形的意义
所在——否掉一次修改,或者撤销一次已经施加的修改,随时都办得到。
grounding 随版本一起冻结。 grounding/grounding.json 在快照内,所以一个
版本的事实、来源与声明出来的假设,就是它跑的时候为真的那一套。后来的版本修订
了某个事实,并不会追溯性地改变更早版本当初的主张。见
真实世界锚定与溯源。
资源清单钉住数据源。 resources.json 按名字记下这个研究实际用到的外部
能力(mcp_servers[]、datasets[]、tools[]),并与其它一切一起被快照。
因此换一个数据口径是一次对已版本化产物的可见修改,而不是上游服务里一次
看不见的漂移。
降级会留下痕迹。 一个未通过不变量核验的分支,会在它的清单记录上保留
downgraded_from_branch,连同违约项与时间戳。versions_view() 把它连同
kind、fork_step、branch_of 一起以 downgraded: true 暴露给 UI,
所以读的人能看出:这个版本本来打算做成一个反事实,以及它为什么没能成为反事实。
跨版本对照¶
每个版本的存储都由 socioverse/io/duckdb_store.py 按同一套契约写出,
所以跨版本对照就是在两个文件上跑一条查询:
| 表 | 列 |
|---|---|
panel |
agent_id、step、state(JSON)、action_kind、action_payload(JSON) |
metrics |
step 在最前,其后是 collector 自己的 columns() |
events |
step、note |
messages |
author_id、step、round、channel、content、audience。只有研究记录消息时才创建 |
ATTACH 'studies/<id>/versions/v1/trajectory/study.duckdb' AS control (READ_ONLY);
ATTACH 'studies/<id>/versions/v2/trajectory/study.duckdb' AS treated (READ_ONLY);
SELECT 'v1' AS version, * FROM control.metrics
UNION ALL BY NAME
SELECT 'v2' AS version, * FROM treated.metrics
ORDER BY version, step;
对分支来说,这一对就是同一批个体、同一个种子下的对照组与处理组,
fork_step 之前的每一步逐位相同——这是构造出来的,不是碰巧。对两个版本来说,
它是两套研究设计之间的比较;只要两个版本用的是同一个 collector,逐步的行仍然
能对齐。
在 Python 里,socioverse.io.duckdb_store.open_readonly(path) 返回一个指向
已完成存储的只读连接。如果你更想用 pandas 或 polars 读面板数据,finalize()
在 export_parquet 打开时会把 panel.parquet 与 metrics.parquet 导出到
数据库旁边。
延伸阅读¶
- 干预与分支 ——
ScheduledEvent/Broadcast的 schema、一步之内的五个阶段,以及重放如何复现父版本的历史。 - 一个 Study 的解剖 —— 每一份产物及其 schema,
包括布局导览里那份更简短的
versions.json条目说明。 - 迭代:版本与分支 ——
/sv-iterate的完整走法,从版本门到重新进入流水线。 - 真实世界锚定与溯源 —— 事实、来源与假设是怎么被记录和冻结的。