跳转至

一个 Study 的解剖

study(研究)是 SocioVerse 的工作单元:一个研究问题、一个 studies/<study_id>/ 目录、一组固定的产物。工作流的每个阶段恰好写入 一份产物,所以这个目录就是研究的全部状态——可检查、可编辑、可 diff。

studies/<study_id>/
├── study.yaml                      # StudySpec —— 问题、步数、指标、发现字段
├── grounding/
│   └── grounding.json              # 事实 + 来源 + 声明的假设(溯源侧车)
├── model.py                        # (从零构建的研究)四个接口实现
├── environment/
│   └── environment.json            # E —— 层、定时干预、广播
├── population/
│   ├── population.json             # P —— personas、规模、交互结构、传播模式
│   └── roster.jsonl                # 实例化后的 agent 在 t=0 的状态
├── simulation/
│   └── simulation.json             # 运行配置 —— decision/collector/store 引用、轮数、分支重放规格
├── trajectory/
│   ├── study.duckdb                # 持久化的 panel/metrics/events 存储
│   └── metrics_history.json
├── reports/
│   └── report.md                   # 渲染出的轨迹 + 图表
├── resources.json                  # 本研究实际使用的外部能力(按名字记录)
├── versions.json                   # 版本清单:每个版本/分支一条记录 —— 由 /sv-iterate 写入
└── versions/                       # v1/、v2/…… 冻结的快照(本地历史,gitignore)

产物逐个说,按阶段

study.yaml —— StudySpec

由 /sv-init 写入,后续构建阶段继续补齐。它是唯一一份人会从头读到尾的产物: 问题、时钟、指标,以及让研究能被找到的元数据。Schema 在 socioverse/schemas/study.py。

身份与问题

字段 类型 默认 含义
study_id str 必填 工作区目录名;会嵌进每一份产物和每一行 DuckDB 记录
title / research_question / hypothesis str "" 研究问题,用用户自己的语言写
title_i18n / research_question_i18n / hypothesis_i18n dict[str, str] {} {zh, en} 变体;dashboard 按访问者语言取值,取不到就回落到无后缀字段
study_type "longitudinal" | "cross_sectional" longitudinal 纵向面板 vs 单次横截面

时钟

字段 类型 默认 含义
n_steps int 5 时间跨度
seed int 42 运行种子
time_unit str "abstract" 一步在现实中推进多久:day / week / month / quarter / year;实在没有日历映射才用 abstract。自由字符串,不做校验
step_meaning str "" 一句人话,把「一步」和研究问题绑起来,例如 "每步=1个月的消费决策周期"

指标

字段 类型 默认 含义
metrics list[str] [] 运行必须产出的指标名(collector 的 columns() 必须覆盖它们)
metric_descriptions dict[str, str] {} 每个指标一行说明——量的是什么、单位与方向;渲染在 dashboard 卡片上,也作为时间线图例的 tooltip
display_metrics list[str] [] dashboard 时间线真正会画的 3–5 个指标,免得记账类指标淹没主线;留空则回落到 metrics 的前五个
run_note dict[str, str] {zh, en} {} 当这个研究的运行发生在外部/重型流水线里时,显示在 run 卡片上的提示——「请 clone 到本地跑,别在托管服务里跑」

产物引用 —— 工作区内的相对路径;默认值就是约定布局,通常不必改。

字段 默认
environment_ref environment/environment.json
population_ref population/population.json
simulation_ref simulation/simulation.json
resources_ref resources.json

发现 / catalog —— sv-init 拿新问题去匹配的就是这几项。

字段 类型 默认 含义
domain str "" 主路由键,例如 urban-segregation
tags list[str] [] 次级路由键,例如 ["ABM", "Schelling"]
legacy_simulator str "" 被包装的遗留引擎路径/名称;Core 原生研究写 "from_scratch"
provider_refs list[str] [] 本研究注册的 registry key
adjustable_params list[str] [] fork 出去只改产物、不动代码就能调整的量。新问题匹配的就是这个面
status str "draft" 约定取值:draft / demo-only / parity-tested
maintainer str "" 谁在维护这个研究

参考 / 教学

字段 类型 默认 含义
demonstrates list[str] [] 本研究示范了哪些可复用模式——受控 kebab-case 词表,按字符串精确匹配(见下)
teaches str "" 一句话:照抄这个研究是为了学什么
reference bool False 只读基线:可以被 fork、可以当模板读,永不原地修改
created_by str "sv-init" fork 出来的研究写成 "sv-init (fork of <src>)"

demonstrates 词表,扩展要审慎:

legacy-seam · from-scratch-core · external-events · real-user-pool
cross-sectional-survey · branch-replay · multi-round-messaging
geo-spatial · policy-intervention · info-broadcast

正因为是精确字符串匹配,构建 skill 是按能力查 catalog (reference: true 且 含 from-scratch-core),而不是把模板名写死在 skill 文本里——随仓库提供的示例集换一批,skill 一个字都不用改。

发现字段与参考字段合起来,就是 sv-init 路由新问题所用的 catalog: 已有研究覆盖得了的问题,fork 并调整,而不是重建。

先把「一步」锚到真实时间

time_unit + step_meaning 是两个很小的字符串,却决定了一条轨迹读起来是 时间还是一串抽象 tick。读者看到 step 7,必须知道那是「第七周」还是 「第七年」——而模型里每一步的量级也必须和这个答案自洽。

先选真实行为发生的尺度,再让所有速率跟它对齐:

行为 合理的 time_unit 随之必须对齐的东西
食堂 / 外卖 / 做饭的就餐选择 month 月度预算、月度价格漂移
选举中的意见转变 week 周度媒体周期、周度衰减
住房搬迁 year 年度租金增长、年度搬迁概率

abstract 是正当选项——但仅当日历映射确实无意义时(这一步就是一个纯粹的 决策回合),并且必须在 step_meaning 里把这个判断写出来。

不要默默地默认成 abstract

time_unit 默认就是 "abstract" 且不做校验,所以「从没填过」和 「刻意选择」在文件里长得一模一样。/sv-build-model 现在会在写下任何 每步量级之前先定这个单位。这两个字段是新增的,随仓库提供的若干 研究早于它们——老研究里 step_meaning 为空应理解为「还没说明」, 而不是「刻意抽象」。

grounding/grounding.json —— 溯源侧车

由 /sv-init 引导创建(真实世界检索必定执行一次),随后被每个构建 阶段查阅与扩充。每个条目要么是带来源的事实,要么是建模参考,要么是 显式声明的假设(basis:sourced / proxy / assumed)。规则是 先锚定、再发明:承重的 E、P 数值必须引用一个事实 id 或假设 id。

它刻意不是 Pydantic 交接产物,而是一份扁平、易合并的 JSON:每个阶段 往里追加,dashboard 只读渲染。文档形状是约定,由 skills/sv_grounding.py 保证,而不是由 schema 强制:

字段 形状
study_id、query、updated_at 研究、原始问题、最后一次合并时间
method_notes 自由文本;哨兵值 "stylized" 有特殊含义(见下)
implementation_refs[] {title, url, takeaway, accessed} —— 别人怎么给同类现象建模
facts[] {id, claim, value, unit, as_of, basis, source, applies_to, note}
assumptions[] {id, claim, rationale} —— 没有 basis:假设本身就是它的依据

一条 fact 内部:

字段 取值
basis sourced | proxy | assumed
source {title, url, via, accessed, local_path?} —— local_path 指向下载到 grounding/data/ 的参考表
source.via event_service | web_search | provider | user
applies_to 这条事实支撑的产物路径,例如 ["environment.provider_args.base"]

facts 与 assumptions 按 id upsert(缺 id 的自动补 f<N> / a<N>), 所以后一个阶段可以修订某条而不产生重复,模型里的常量也就能在行尾注释里 引用一个稳定 id。

stylized 是一个决定,不是缺失

method_notes: "stylized" 且 facts 为空,是在明确记录这个研究 没有真实世界锚点——抽象/玩具模型,其常量不需要事实 id。这和「压根没做 grounding」是两种状态,工具的措辞也不同(「stylized model, no real-world anchors」 vs 「no grounding recorded」)。

environment/environment.json —— E

由 /sv-build-environment 写入。声明环境的层(两轴状态)、 定时干预(指定步数的外生变化)、以及带受众的信息广播 ("all" → 所有人的宏观信息象限;选择器 → 命中 agent 的局部信息象限)。

population/ —— P

由 /sv-build-population 写入。population.json 描述 personas、池子规模、 交互结构(谁能看见谁)与传播模式。人群在构建时就实例化—— roster.jsonl 存有每个 agent 在 t=0 的完整状态,让你在花掉任何一步模拟 预算之前就能审阅确切的人群。

字段 取值 含义
personas[] {agent_id, attributes, group_key, weight, init_state} agent_id 是纵向主键且必须唯一——schema 会拒绝重复 id
materialized_count int \| None 真实 agent 数;当 personas 为大规模人群留空时,它才是权威值;None = 尚未实例化
interaction.kind spatial_adjacency | explicit_network | none 谁能看见谁——explicit_network 带 edges,spatial_adjacency 带 provider 计算的 adjacency_ref
propagation independent | contagion | broadcast_then_local 影响怎么流:只看 E、受邻居状态影响、或宏观冲击后再局部扩散

大规模人群

人群很大时,population.json 里的 personas 列表保持为空, roster.jsonl 才是已实例化 agent 的事实来源——此时 materialized_count 是唯一可靠的计数。

propagation 是声明性标签

引擎不读它。它为研究自己的环境实现记录意图,并给 dashboard 的人群 卡片打标签;真正的影响路径取决于 observe_batch 往 agent 的信息象限里 塞了什么。见多轮交互。

simulation/simulation.json —— 运行配置

由 /sv-run 在预检阶段写入。按 registry key 绑定各部件 (decision_ref、collector_ref、store_ref 及各自 *_args),重复 n_steps / seed,另外承载两件值得知道的事:

  • interaction_rounds(默认 1)—— 写出面板行之前,循环在一步之内 跑几轮交互。移动类研究用 1;讨论与传染类用 K > 1。见 多轮交互。
  • warm_start —— 分支的重放规格(WarmStartSpec,在 socioverse/schemas/simulation.py;类名早于 branch/version 这套词表, 为了让旧的 simulation.json 仍能加载而保留)。它携带 source_version、 source_trajectory(父版本归档的 versions/<v>/trajectory/study.duckdb)与 resume_from:引擎用父版本已存的 action 重放第 1..resume_from 步(不调用 LLM、不计费),从 resume_from + 1 起才跑真实决策 —— WarmStartSpec.fork_step 就是这个数。为 None 表示从第 0 步 冷跑。要求 interaction_rounds == 1,且研究的 env.apply(actions) 是传入 action 的纯函数,因此包装遗留模拟器的研究不具备资格。见 干预与分支与 迭代:版本与分支。

trajectory/ —— 运行输出

由 /sv-run 写入。study.duckdb 是可查询的存储,包含面板行、聚合指标 与已触发事件:

SELECT step, state FROM panel WHERE agent_id = '…' ORDER BY step;
SELECT * FROM metrics ORDER BY step;
SELECT step, note FROM events ORDER BY step;

reports/ —— 渲染结果

由 /sv-report 写入:分步可视化、带干预标记的指标时间线、以及 report.md——末尾附有从 grounding 侧车渲染出的「数据基础与参考来源」。

versions.json + versions/ —— 研究自己的历史

由 /sv-iterate(修改既有研究的唯一入口)写入。versions/vN/ 是某个版本的 冻结快照 —— 产物、代码、结果全在里面,只排除 versions/ 自身、versions.json 与 runs/。versions.json 是它旁边的清单:顶层一个 current(现行目录当前 承载的版本 id),加上每个版本一条记录。一条记录长这样:

{
  "id": "v2",
  "parent": "v1",
  "note": "第 3 步起发 ¥100/月餐补",
  "kind": "branch",                                   // "branch" | "version"
  "fork_step": 3,                                     // version 为 null
  "warm_start": {"source": "v1", "resume_from": 2},   // version 为 null
  "resume_from_stage": "sv-build-environment",
  "created_at": 1783439750.058673,
  "snapshot": null                                    // 冻结后变成 "versions/v2"
}
字段 类型 含义
id "v<N>" 取现有数字 id 的 max + 1,即使新版本是从旧版本出发,编号也保持单调
parent str \| null 这个版本从哪个版本创建而来。只有 v1 是 null
kind "branch" | "version" 分支只改分叉步及其之后的 E,并通过重放继承父版本的历史;版本可以改任何工件,从第 0 步冷跑
fork_step int \| null 分支自己计算的第一步(= warm_start.resume_from + 1)。version 为 null
warm_start {source, resume_from} \| null 分支的重放出处,跑批时由 resolve_warm_start() 解析成 WarmStartSpec
resume_from_stage str \| null 本次迭代重新进入的最早 sv-* 阶段 —— 分支是 sv-build-environment,版本是受影响的最早阶段
note str 一行人话描述,显示在 dashboard 的版本框里
created_at float unix 时间戳;同时也是区分「从父版本带过来的工件」与「本版本自己写的工件」的分界线
snapshot str \| null 冻结后是 "versions/<id>";当它还是现行版本时为 null
downgraded_from_branch object 仅当一个分支违反了不变式时出现:{source, fork_step, violations[], at}。审计留痕,不会被删除

两件事值得记住:

  • v1 就是 version,不是什么「root」。 init_manifest() 把现行目录注册为 v1,kind: "version"、parent: null、fork_step: null —— 它只是主线的起点, 不存在第三种 kind。
  • 老 manifest 不需要迁移。 在 kind / fork_step 出现之前写下的记录,通过 version_kind(entry) 与 version_fork_step(entry)(skills/sv_workspace.py) 读取:它们回退到重放出处 —— 带 warm_start.source 的就是分支,分叉步是 resume_from + 1。dashboard 服务端在 dashboard/server/app.py 里有一份等价实现, 所以磁盘上的旧文件一个字都不用改。

kind 决定下一次跑批做什么。分支开跑前,check_branch_invariant() 会把现行工作区 与父版本的快照逐项比对:人群包与 roster 逐字节、研究自己的 *.py 与 adapter/*.py、 除 n_steps 外的运行设置,以及分叉步之前排定的每一条事件与广播。一旦违约, 它降级而不是让跑批失败 —— warm_start 被清空、改为从第 0 步冷跑, 降级原因写进 downgraded_from_branch。

→ 版本与可控研究循环 —— 把这份清单看作研究状态 σ, 以及快照顺序与复核门。 → 迭代:版本与分支 —— 你修改一个研究时 真正要回答的那道门。

目录的规矩

两条不变式

  1. 永不原地修改参考研究(reference: true)或任何既有研究—— 复用靠 fork,修改走 /sv-iterate。
  2. 运行输出是本地的:versions/、runs/、*.duckdb 都是 gitignore 的历史,不进仓库。