罗德岛通讯与技术部

时间线分支(世界线)

WORLDLINE BRANCHES

ZOOT 把这件事做成了 timeline.db(分支 + 提交 + 实体快照 + 跨线搬运)。

🚫 本文的四阶段实施方案已作废(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 行)写明了三件事:

  1. 现在是纯元数据,到不了模型面前get_context_messages() 是显式白名单,不会泄漏进 prompt)
  2. 没有任何读写路径消费它,这是刻意的埋点,不是死代码
  3. 理由是成本不对称:现在写这一行的成本 ≈ 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.pyAUTH_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 能分叉,是因为它的消息有稳定 uidfork_message_uid 指向"从这条消息开始分叉")。 我们的 L1 消息没有 uid —— 只有 tschunk_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/historybranch_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 设计得很完整,但一行数据都没有。 别把"设计得漂亮"当成"验证过" —— 它从来没有真正跑过分叉。