时间线分支(世界线)
WORLDLINE BRANCHES
🚫 本文的四阶段实施方案已作废(2026-09-13)
看 统一记忆底座设计,别照这份做。
为什么作废:本文 §7 提的四阶段(消息 uid → 分支表 → 摘要血缘 → 跨线搬运) 是照 ZOOT 的消息级 git 式分叉设计的。而 2026-09-13 定下的模型是 「互斥 + 线级快照」——同一时刻只有一条线活跃,建线就是拍一张状态快照。 那就不需要祖先图:没有
fork_message_uid、没有实体版本快照、 没有可见性链、没有跨线搬运。✅ 仍然有效的部分(本文的价值在这里): · §1 的考古结论:ZOOT 的"时间线"不是剧情时间线(它的正典数据文件压根没发布) · §3 的埋点现状:
worldline_id已埋好、只有写入端 · §4「报告说我们缺 5 样,核过只有 1 样真缺」——那套核对仍然成立 · §6 的映射表(ZOOT 各机制 ↔ 我们的对应物)⚠️ 结论本身没错,错的是从结论推出的实现路线:我默认了"分叉", 而用户要的是"切换"。"切换"比"分叉"弱得多,能省掉整套血缘机制。
状态:🟡 只有设计,未实现。本文是给博士审的方案,不是已完成的功能。 起因:2026-09-12 用户提出「想聊时间线的功能」,并已抄了 ZOOT 的时间线分支。 参考:
I:\ZOOT!-pc-0.5.1.0621的考古结论(见 §4,含一段重要的"别被它骗了")。
0. 一句话结论
我们缺的不是"剧情时间线数据" —— 那个我们有 480 个分区,ZOOT 一个都没有。 我们缺的是"会话层可以分叉" —— 同一个博士,同一段对话, 想试试"如果当初那么回答会怎样"。
ZOOT 把这件事做成了 timeline.db(分支 + 提交 + 实体快照 + 跨线搬运)。
它领先我们的只有这一件事。
1. ⚠️ 先纠正一个前提:ZOOT 的时间线不是剧情时间线
我做了一轮彻底考古(全部结论有路径 + 行号),结论与直觉相反:
| ZOOT 里叫"时间线"的东西 | 实际是什么 |
|---|---|
data/content/timeline.db(5 张 timeline_* 表) |
用户会话状态的 git 式分叉(分支/提交/实体快照/跨线搬运)—— 不是剧情 |
全屏「剧情时间线」页 + /timeline/events API |
⚠️ 数据文件根本没随包发布(data/timeline_events.json 不存在)→ 永远"暂无时间线数据" |
| 罗德岛纪事 / 传闻 | 从用户自己的聊天记录 LLM 生成的,本机 0 行;且它永远不可能是正典 |
82 MB static_knowledge.json(34,297 条) |
按干员的原子知识碎片,无时序、无日期、无排序字段 |
⚠️ 在它的 82 MB 知识库里搜 怒号光明 / 覆雪之下 / 孤星 / 生于黑夜 / 长夜临光 /
遗尘漫步 —— 全部 0 命中。它压根没做明日方舟正典编年史。
它的"默认年份"更能说明问题:config.global_year 是一个手填的整数(默认 1099),
而且三处默认值不一致(后端 '1099' / 事件装配器 1099 / 前端兜底 1102)。
它靠"用户自己聊天生成的纪事"来冒充时间线内容。
所以:我们可以抄它的机制,但抄不到任何数据模型。 数据模型我们得自己定 —— 而我们的数据比它厚一个数量级。
2. 我们已有的:静态正典时间线(✅ 已上线)
2009 个剧本 JSON
→ 480 个事件分区(mainline 18 · activity 54 · mini 21 · 密录/番外 387)
→ data/story_timeline.json
→ recall.timeline_of() / build_timeline_block()
特性:
· 只列她真的有经历的事件(480 个里她可能只在 5~40 个里出场)
· 三段输出,段间不声称先后(主线内部顺序可靠;活动之间在数据里没有权威来源)
· 每段取一条她自己的第一人称经历当 sample(只给章节名她不知道怎么接话)
· 时间线块预算 800 字符 / 14 项
⚠️ 这一块是静态的 —— 它描述"剧情里发生了什么",不是"这次对话发生了什么"。 这个区分是全文的关键:正典时间线 ≠ 会话世界线。
3. 我们已有的:世界线埋点(✅ 埋好了,⚠️ 没人消费)
app/systems/memory/l1_short.py 里已经埋好了(2026-09-11,A8 埋点):
WORLDLINE_MAIN = "main" # 第 40 行
...
msg = { ..., "worldline_id": WORLDLINE_MAIN } # 第 431 行,无条件写入
源码注释(第 31–39、417–431 行)写明了三件事:
- 现在是纯元数据,到不了模型面前(
get_context_messages()是显式白名单,不会泄漏进 prompt) - 没有任何读写路径消费它,这是刻意的埋点,不是死代码
- 理由是成本不对称:现在写这一行的成本 ≈ 0; 将来补数据迁移的成本很高(存量记录没有这个字段,而新增字段无法回溯历史)
tests/test_l1_worldline_marker.py(199 行)锁着这个埋点,包括:
· 老数据(无 worldline_id)读取时不能被凭空加字段
· 新消息必须带 worldline_id
· trim 之后不能丢 worldline_id
· 源码里那行必须是无条件写入(if worldline 会被测试判红)
✅ 所以"要不要在消息上加线标识"这个问题,2026-09-11 已经回答过了, 而且答案已经落地、有测试守着。 这一步不用重做。
4. ⚠️ ZOOT 说我们缺 5 样,我核过:只有 1 样是真缺
考古报告列了"我们缺的"5 项。我逐条对着我们的仓库核了一遍, 其中 4 项我们已经有了 —— 这条必须说清,否则会去重造已有的轮子:
| 报告说我们缺 | 实际 | 证据 |
|---|---|---|
| ① 世界线/分支语义 | 🔴 真缺 | worldline_id 只有一个值、无人消费(§3) |
| ② 权威分层 + "传闻→事实"洗白校验 | ✅ 已有 | app/systems/relation/authority.py:AUTH_CANON/AUTH_USER/AUTH_CONVERSATION/AUTH_INFERRED + rank() + NON_CANON_DISCOUNT=0.85 |
| ③ 到最小单位的证据指针 | ✅ 已有,且更强 | 事件带 evidence[{line_id, line_no, note}];经历带 evidence_lines;关系带 evidence_line_ids(扁平形式,注释写明"方便直接接进 L3 的 evidence_ids");且 scripts/audit_relation_evidence.py 已经拿 line_no 回原文全量核对 |
| ④ base 只读 + 用户 overlay 的稳定 ID | ✅ 大部分已有 | data/users/{u}/ 按用户隔离;l1_short.py 注释:"将来做世界线分支时不必对存量数据做迁移"。⚠️ 缺的是"刷新不冲掉用户编辑"的那半 |
| ⑤ 生成物先进待审草稿 | ✅ 已有(evolution.py 的审批流) |
深聊的字段变更走 evidence + 轮次,不是直接进正典 |
⚠️ 另外报告漏了我们的机械化校验:关系抽取里每个候选边都带
basis_claimed 模型自己声称的依据(direct/reported/inferred)
basis 从证据**反推**出来的依据 ← 关键:不是模型写的
basis_overclaimed 声称 > 反推(模型吹了)
presence_ok 对话窗口内双方是否真的在场
presence_downgraded 不在场 → 自动降级
这就是"机械化幻觉校验",而且比 ZOOT 的阈值式检查更硬 —— 它是把"模型声称"和"证据反推"对撞,不是查关键词。
⚠️ 教训:一份外部报告的"你缺什么"是假设,不是结论。 5 条里 4 条我们已经有了 —— 直接照着建会造出一套和
authority.py打架的第二实现, 而"同一个概念两份实现"是这个仓库最稳定的失败形状。
5. ZOOT 的分支设计:值得抄的四件机制
抛开数据模型,它的机制是干净的,四件:
① 分支 timeline_branches(id, scope_type, parent_branch_id,
fork_commit_id, fork_message_uid, revision, status)
② 实体快照 timeline_entity_versions PRIMARY KEY(branch_id, entity_type, entity_id)
+ base_revision + deleted_at ← MVCC 味
③ 可见性链 visibility_chain() → 「从当前分支到祖先的可见上限」
④ 跨线搬运 timeline_promotions —— **唯一的**跨线事实通道,
注释明确「只允许带入记忆或剧情事实」
外加一条最聪明的:摘要血缘
memory_timeline_lineage(source_start_message_uid, source_end_message_uid,
derivation_kind, provenance_state)
→ 跨分叉点的摘要在新线上**隐藏**,并用新路径**重新总结**
⚠️ 最后这条是关键:它保证派生数据(摘要)不会跨越分叉点继续冒充有效。 没有它,老摘要会把旧线的记忆带进新线 —— 而那是最隐蔽的污染: 不报错、不空、读起来完全正常。
6. 映射到我们的架构
| ZOOT | 我们对应 | 现状 |
|---|---|---|
timeline_branches |
新表/新文件 | 🔴 没有 |
timeline_commits |
L1 消息(data/users/{u}/{op}_l1.json) |
🟡 有消息,已有 worldline_id 字段,但没有分支表 |
fork_message_uid |
消息需要一个 uid | 🔴 我们的消息只有 ts + chunk_id,没有稳定 uid |
timeline_entity_versions |
.char 的 ## 我/## 物理标签 + ## 我们 |
🟡 有状态,但没有按分支快照 |
timeline_promotions |
新机制 | 🔴 没有 |
memory_timeline_lineage |
L2 摘要 = chunk 的 summary 字段(实测结构 {id, topic, summary, created_ts, active}) |
🟡 消息靠 chunk_id 归属 chunk,但 chunk 不记首尾消息(且没有 uid 可记) |
visibility_chain |
分支继承(先复制,后上链) | 🔴 没有 |
⚠️ 最硬的一条:ZOOT 能分叉,是因为它的消息有稳定 uid
(fork_message_uid 指向"从这条消息开始分叉")。
我们的 L1 消息没有 uid —— 只有 ts 和 chunk_id
(chunk_id 是 c{毫秒时间戳},一串消息共用一个,不是逐条标识)。
所以第一步必须先是消息 uid,而不是分支表。
⚠️ 而且它带出一个连带项:L2 摘要的血缘也要 uid。 chunk 现在不记自己覆盖了哪些消息,所以摘要"跨没跨过分叉点"现在判不出来 —— 这正是阶段 3 的前提。
7. 建议的分阶段实现(每阶段独立可验证)
按"每项做完都有检测、出错可溯源"来切,每阶段都能单独停:
阶段 1:消息 uid(🔴 必须先做,成本最低)
· 每条 L1 消息加 `uid`(uuid4 或 `{op}-{seq}`)
· 老消息没有 → 读侧按 `worldline_id` 缺省的**同一口径**处理(`None`,不凭空补)
· ⚠️ 与 `worldline_id` 同样:**纯元数据,不进 prompt**
验收:一个测试锁住"新消息必带 uid / 老消息不被加字段 / trim 不丢 uid"
(照 test_l1_worldline_marker.py 的形状写)。
阶段 2:分支表 + 分叉操作
· `data/users/{u}/{op}_branches.json`(或 SQLite,谁轻用谁)
字段:branch_id · parent_branch_id · fork_message_uid · name · created_at · revision
· 分叉 = 复制当前 L1 到新 branch_id + 记下 `fork_message_uid`
· ⚠️ **先只分叉 L1(对话),不碰 `.char` / 关系 / L3** —— 见 §8 的理由
验收:能在两条线上各说一句不同的话,/api/chat/history 按 branch_id 过滤后互不可见。
阶段 3:摘要血缘(⚠️ 不做这步,阶段 2 会静默污染)
· chunk(= L2 摘要)加 `source_start_uid` / `source_end_uid`
⚠️ 实测 chunk 现在只有 `{id, topic, summary, created_ts, active}`,
**不记自己覆盖了哪些消息**,所以"跨没跨过分叉点"现在判不出来
· 读取时:摘要**跨过** `fork_message_uid` → **判为失效**,不注入
· 失效的摘要按新线的消息**重新生成**
验收:造一个"分叉后老摘要仍在"的场景,断言它不被注入
(这是一个负例测试,形状同回归集里那条 今天天气不错)。
阶段 4(可选):跨线搬运
· `promote(branch_a, branch_b, fact)` —— **唯一**通道
· 只允许搬运 `authority.rank()` **不低于** `AUTH_CONVERSATION` 的事实
· ⚠️ 直接复用 `authority.py`,**不要新建第二套权威判断**(§4 的教训)
验收:低权威事实被拒;高权威可搬运且带来源线标识。
8. ⚠️ 一个必须现在定的设计决策:分叉哪些状态
ZOOT 走的是全局分叉(消息/记忆/信赖/剧情/事务/财务/动态全部世界状态)。 我们不该照抄,理由:我们的数据分层和它不同 ——
不可变基座(正典) 2009 剧本 / 18,663 事件 / 30,180 关系 / 480 分区
→ ❌ **不该被分叉**。它不随对话变化,分叉它是纯浪费。
干员设定 `.char` 的 `## 锚`(不可变) → ❌ 不分叉
可变态 `.char` 的 `## 我`/`## 物理标签`/`## 我们` → ?
会话态 L1 消息 / L2 摘要 / L3 记忆 → ✅ 必须分叉
建议:阶段 2 只分叉"会话态",可变态先不动。 这样"如果当初"是对话层面的重放,不是"换一个世界"。
好处:改动面小、语义清楚、不出"两个博士"这种鬼
代价:不能做"另一个世界线的罗德岛"那种大分叉
⚠️ 这个决策要博士定,因为它改的是产品语义,不是我该替你选的。
(如果要做大分叉,## 我 那层就要进阶段 2,成本约翻倍。)
9. 与已有方向的关系
剧情记忆 她**记得什么**(正典,静态) ← 已上线
剧情时间线 剧情**按什么顺序**(正典,静态) ← 已上线
世界线分支 这次**对话**可以怎么走(会话,可变) ← 本文,未实现
L3 记忆 她和博士**经历过什么**(按用户隔离) ← 已上线
⚠️ 世界线分支必须建在 L3 之上而不是之前 —— 因为它的价值是
"两条线上她和博士的关系不一样",而那正是 L3 在存的东西。
L3 现在按用户隔离、没有线标识,所以L3 也要加 worldline_id
(和 §3 的消息同一口径)。这一条别漏。
10. 待博士决定
| # | 问题 | 我的建议 |
|---|---|---|
| 1 | 分叉哪些状态(§8) | 先只分叉会话态 |
| 2 | 分叉粒度:按 (用户, 干员) 还是全局 |
按 (用户, 干员) —— 和 L1 的落点一致 |
| 3 | 存储:JSON 文件还是 SQLite | 跟随 L1 用 JSON({op}_branches.json),量小 |
| 4 | 要不要 UI(分支切换/树视图) | 先不做;API 能切就够,UI 是另一个方向 |
| 5 | 阶段 1(消息 uid)现在做吗 | ✅ 建议做 —— 成本最低,且是阶段 2 的硬前提 |
11. 诚实的风险
· ⚠️ 分叉会让"她记得什么"变得依赖"你在哪条线上" —— 如果 UI 不显示当前线,用户会以为她「记错了」。这是产品风险不是技术风险。 · ⚠️ 摘要血缘(阶段 3)不做就上线,会静默污染 —— 老摘要带旧线记忆进新线。 这是全文最危险的一处,且有前例(本仓库的失败几乎都是"静默"型)。 · ⚠️ ZOOT 的分支机制它自己也没用起来 —— 实测它的库(只读连接数了一遍):
timeline.db timeline_branches 1 行(就是默认的 main)
commits 0 · entity_versions 0 · local_heads 0 · promotions 0
stories.db 15 张表(templates/sessions/checkpoints/anchors/effects/
lore_decks/lore_vectors/rolls/reply_candidates…)
⚠️ **除 story_templates 1 行之外,全部 0 行**
rhodes_archive.db chronicle 0 · rumor 0
schema 设计得很完整,但一行数据都没有。 别把"设计得漂亮"当成"验证过" —— 它从来没有真正跑过分叉。