Files
PolyLoop/research-wiki/README.md
T
iomgaa 4f8812fa82 docs(design): 落成边界、续跑、公共 API 形状与停止语义四份决策
第 ② 阶段需求对齐与第 ③ 阶段架构的产出,代码尚未开始。

design/0001 定边界判据:三道测试(时机 / 信息 / 性质)全过才在界内,
外加「只认接缝、不认接缝后面是什么」与不夺走下游实验因子的排除条款。
design/0002 定步级续跑:不承诺原子性,承诺绝不静默丢失与不替工具猜幂等性;
先写意图再执行、结果 ID 预分配、重放策略由工具声明且默认绝不重放。
design/0003 定公共 API 形状:单一入口两个动词、五个接缝、三个伪接缝的排除理由、
分层与九条依赖规则。design/0004 定停止判定顺序、十个停止原因取值与步记录字段表。
0003 与 0004 需过 CLAUDE.md §2 人类门,已由项目负责人确认,状态转为已接受。

explanation/scope.md 与 explanation/architecture.md 是这四份决策的常青回写,
分层与模块边界的权威在 architecture.md,将来由 import-linter 契约机器断言。
migrations/ 下 dissect 是唯一的硬迁移验收,govdoc-saas 只做设计级对齐。

三道闸都过了:14 agent 对抗辩论定骨架,两轮硕士生阅读报的 30 余条已修完,
Codex 对抗审查抓出的两条致命问题(提交型完成被误判成环境故障、
崩溃恢复漏一个状态)已修,修完的形状还没送 Codex 复审。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:48:33 -04:00

24 KiB
Raw Permalink Blame History

research-wiki 是什么,各个目录收什么

这里是本项目除代码之外的绝大部分文档。这一份说明它怎么组织、你要写的东西该放哪。

两个例外留在仓库根目录,因为它们要在克隆仓库的第一眼就被看到:README.md(项目概览、消费者与 当前进度)和 CLAUDE.md(协作约定)。除这两份之外,新写的文档都进 research-wiki/

先说结论,赶时间的话看完这张表就够:

目录 收什么 会不会被改写
explanation/ 为什么这样设计
migrations/ 每个下游项目迁走什么、迁完算不算数
reference/ 查得到的事实:日志字段、遥测口径
guides/ 怎么做某件事:发布、排障、本地环境
design/ 动工前的方案与权衡,写完冻结 不会
scratch/ 一次性草稿,由人在每轮工作会话结束前清理 不适用

reference/(仓库根目录那个,不是本目录下的)不在表里:那是参考资料,不是本项目的文档, 理由见 ../CLAUDE.md §0。

下面解释这个划分是怎么来的,以及为什么值得遵守。


1. 两层,判据是「发现它不对了,你会改它还是留着它」

前五个目录分成两层,界线是这一句:

如果这份文档和事实对不上了,你会去把它改对,还是原样留着?

会去改对的是常青层explanation/migrations/reference/guides/)。它的职责是描述 当前的真实情况,一旦不符就是在说谎,必须修。「常青」(evergreen)是文档领域的常用说法, 指一份需要长期保持有效的文档,与之相对的是写完就归档的一次性文档。

原样留着的是记录层design/)。它记录的是某个时刻我们知道什么、决定了什么。就算后来 证明当初判断错了也不改——因为它的价值正在于保存「当初的判断是什么」。写完就冻结。

为什么判据是这一句,而不是「描述现在还是描述过去」。 后者听起来更直观,但切不动一类 很常见的文档:一个外部工具的固有行为(比如某个命令的输出会被缓冲),它既是现在的事实 也是当时的事实,按时间根本分不开。而用上面这句一问就清楚了——如果那个工具升级后行为变了, 你当然会去把文档改对,所以它属于常青层。

同理,一次设计决策做完的当天,它的理由既是「当时」也是「现在」,时间判据同样失效。但如果 三个月后这个决策被推翻,你不会回去改那份 design doc,而是新写一份——所以它属于记录层。

为什么必须分开

因为两层的维护规则是互相排斥的,混在一起没法同时成立:

  • 常青层的规则是「改代码时必须同步改它」。
  • 记录层的规则是「写完就冻结」。一份三个月后被人改过的决策记录,已经没法回答 「我们当初为什么这么定」了——它变成了「我们现在觉得当初应该这么定」,而这两件事在 排查历史问题时差别很大。

同一个目录挂不上这两条规则。不分开的实际后果,CHSAnalyzer 的上一版演示过:因为没人区分 哪份该更新、哪份不该更新,结果是全都不更新。那一版的 API.md 落后代码 210 个提交, CLAUDE.md 落后 676 个提交,而两份文档当时都写着要保持同步。

两层的写法不一样,写成一样就说明有一层写错了

同一件事在两层各出现一次是正常的:分层是什么、接缝有哪些,design/ 里定过一遍, explanation/ 里还要描述一遍。重复的是事实,不重复的是理由。

design/ explanation/
回答什么 当初为什么这么定 现在到底是什么样
时间 有时间性:当时的处境、比较过哪几个、否决了什么 无时间性,只描述现在
主语 我们、这次决策 系统里的东西:这一层、这个接缝、这条规矩
备选方案 必须写全,含否决理由 不写——备选方案是历史
理由 完整论证 就地一两句,展开指回 design/
冲突时 冻结,可能已经过时 以它为准

记录层写成法条就走音了。 ADR 实践里这个反模式有个名字叫「Blueprint or Policy in Disguise」——本该是一份记录活动及其结果的日志,写着写着变成了菜谱或者法条那种命令式、 权威式的口吻。design doc 该像一份设计讨论的笔记:叙述、有处境、有取舍。

常青层写成自述也走音了。 主语一旦变成「这份文档」「这一节」「这里」,动词一旦变成 写作动作(不复述、列出来、说清楚、免得读者),语气就错了,哪怕那句话本身有道理。 改法是把主语换回系统。判据在语气,不在内容——那种句子往往真的带着信息,按「删掉之后信息 有没有少」来判会把它留下来,而它照样读着别扭。

常青文档里的决策索引只索引、不复述理由。 复述会漂移:旧文档冻结着,索引里那句转述 却跟着人的记忆变,几个月后两处就对不上,而机器查不出来。

2. 常青层:四类,各自要有更新触发点

前三类的划分借自 Google 的工程文档实践,以及 Diátaxis——一个把文档按「读者此刻想干什么」 分成教程 / 操作指南 / 参考 / 说明四类的文档组织框架,读作「迪亚塔克西斯」。两者对这几类的 切法基本一致。第四类 migrations/ 是本项目自己加的,理由在它自己那一节。

本项目故意没有「教程」那一类。教程是带零基础的人走完一遍完整流程的入门材料,它的成本 很高(每次流程变动都要重走一遍验证),而本项目目前的协作者都已经在项目里了,没有真正的 新手入门场景。等真的需要时再建 tutorial/

划分的意义在于一份文档不要同时追求两个目标——参考手册里插一段设计动机,查参数的人会 被打断;设计说明里塞满字段表格,想理解全局的人会被淹没。

explanation/ —— 为什么是这样

回答「能讲讲 X 吗」。分层为什么这么切、某个约束为什么存在、某个坑背后的机制是什么。 这类文档允许有观点,也应该写清楚考虑过哪些替代方案。

本项目最重要的一份常青文档 explanation/architecture.md 会在这里,它说明分层、依赖方向和 抽象接缝。它的触发点是第 1 档(由 pyproject.toml 的 import-linter 契约断言,文档和代码 对不上 make lint 直接失败)。

这份文档会先于代码存在。 第 ③ 阶段的正题就是把架构理清楚,而理清楚的产物只能是文档; 如果要求文档必须落后于代码,那这个阶段就没有产物,只能并进实现阶段,变成「边写边想」。 代价是那段时间它没有机器兜底——契约要等 src/ 落地才写得出来。对策有两条:文档开头放一段 醒目的状态说明,写清楚它描述的是目标而不是现状;实现时在同一个提交里把守着这块代码的 那条契约加上,不是同一个 PR,是同一个提交,因为提交是能被单独回退的最小单位。

migrations/ —— 每个下游迁什么、迁完算不算数

一个下游项目一份。内容是删除清单(它那边哪些代码由本库继任)、组件映射(旧的哪个类对应 新的哪个接缝)、以及验收口径(迁完之后跑什么算通过)。

这一类单独设,而不是塞进 guides/,因为它的读者和用途都不一样。guides/ 的读者手上 有一件确定的活要干,看完就照做;migrations/ 的读者在判断「本库现在够不够用」——它同时是 本库的验收标准和边界证据。本项目的核心验收标准就是能不能搬回 dissect、能不能替代掉 GovDoc-SaaS 的 docagent-core/,把这件事藏在操作指南里会让它看起来像可选的工序。

触发点是第 2 档:改公共 API 时同一个提交里改它。它不设长度上限——删除清单本来就该写全, 砍长度只会让它变得不可信。

真正的验收不是这份文档,是把下游迁过来跑它的测试。 文档只是清单和审计记录;一份写着 「已完成迁移」而没有人真跑过下游测试的迁移文档,说明的只是我们相信自己做完了。

reference/ —— 查得到的事实

回答「X 的取值是什么」。日志字段契约、遥测口径。特点是读者已经知道自己要找什么,只是来 核对,所以它要准确、完整、好检索,不需要循循善诱。

有一类看起来该收在这里、但故意没有收的事实:公共类型的字段、不变量与枚举取值。它们的 权威是代码本身,不另写一份文档复述——那份文档不重复代码的内容太少,而它腐烂的速度和代码 一样快。哪类事实的权威在哪,../CLAUDE.md §0 的表格是总索引。

guides/ —— 怎么做某件事

回答「我要做 X,步骤是什么」。发布流程、本地环境搭建、排障。读者手上有活要干,所以只给 能达成目的的路径,不展开讲原理——原理放 explanation/,需要时链过去。

发布流程是这一类里最重要的一份,因为它的每一步都是欠账换来的:PolyGateway 有两个版本 完成了版本号 bump 与 CHANGELOG 却从未上传,registry 长期停在旧版,下游 pip install 拿不到 任何修复且无人发现。

每一类都必须写明更新触发点

「更新触发点」指的是:什么事情发生时,这份文档一定会被改。 这是常青文档不腐烂的唯一 可靠机制。没有触发点的常青文档,靠的就是「大家记得更新」,而这件事在 CHSAnalyzer 上一版 已经失败过一次。

触发点的强度分三档,能用强的就不用弱的:

  1. 机器断言(最强)。文档说的和代码不符,CI 直接失败。例如架构文档由 import-linter 契约 断言分层,日志字段契约由结构化日志的测试断言。
  2. 同 PR 同改(次强)。改某块代码时,改文档是同一个提交的一部分。这是 Google 的做法, 好处是不依赖任何人事后记得。
  3. 定期复审(最弱)。每三个月看一眼,并在文档头部更新「最后复审」日期。周期必须写死, 否则读者拿到那个日期也算不出文档过没过期,这一档就等于没有。三个月这个值取自 Google 的 做法,本身没有特别的道理,重点是它是个确定的数。只在前两档都做不到时才用这一档。

外部工具的固有行为(例如某个命令的缓冲方式)也属于常青层,它的触发点通常是第 2 档: 我们依赖它的那条规则改了、或者那个工具升级后行为变了,就在同一个提交里改这份文档。

写一份新的常青文档时,要在文档开头写明它属于哪一档、触发点具体是什么。 写在开头而不是 集中在一张索引表里,是因为索引表本身也会腐烂——它会漏掉新加的文档,而写在文档自己头上的 东西,改这份文档的人一定会看到。

如果三档都想不出来,那说明这份文档不该写成常青的——要么它其实是记录层的内容,要么它不该存在。

第一节必须从一个看得见的东西起步,词表不许挡在正文前面

这条是 CHSAnalyzer 踩出来的,它量了自己五份 explanation/ 文档,规律很干净:好读的三份都 从一个具体的东西开场(一个临床问题、一张处理线的图、一个「命令跑完了没有输出」的现象), 难读的两份都从一个抽象的定位开场(「队列入口那层代码非常薄」、整节在讲「我和另一份文档的 分界线在哪」)。而且和长度无关:五百多行那份没人说难,六百行那份读不动。差别不在长短, 在第一页给了读者什么。

所以定两条:

一、第一节必须从一个读者已经能看见的东西起步——一个真实的问题、一张图、一个会出错的 场景。让读者先站稳,再开始学词。不要用「本文和某某文档怎么分工」当第一节:那种内容 对已经读过别的文档、正在纠结该翻哪份的人有用,对第一次打开的人是纯负担,把它放到词表后面去。

二、词表不许挡在正文前面。 具体是两件事:正文开始前不要列一串「这些词请先去别处看」; 词表本身如果超过十条,要在开头说清「哪几个现在就得记住、其余读到再回来查」。

还要写明假设了什么背景知识,而不只是「要先读哪几份文档」。读者读不懂的时候,得能判断是 自己缺背景还是文档写得烂——不写清楚,他只会怪自己。

这条没有机器能查,靠 ../CLAUDE.md §3 那轮「硕士生阅读」评审时专门看一眼第一节。 评审时要额外问一句:这份文档假设的读者是谁,项目负责人算不算在内。 CHSAnalyzer 那边 四轮评审都是以「相关领域的硕士生」的角色做的,它们能读懂那份并发文档,而项目负责人读不懂—— 这说明评审的读者假设定窄了,而文档自己也没把这个假设写出来。

3. 记录层:design/

一份 design doc 记录一次决策:当时的处境、做了什么选择、否决了什么、代价是什么。

必须在动工前写。 不是因为流程要求,而是因为事后补写的方案文档会被已经知道的结果污染: 你会不自觉地把当初没想清楚的地方写得很笃定,把真正纠结过的备选方案一笔带过。那样写出来的 东西读起来像是一路顺理成章,也就失去了它唯一的用途——让后来的人看清当初在什么信息条件下 做的判断。

命名NNNN-短标题.md,四位编号递增,例如 0001-xxx.md。用递增编号而不是日期前缀, 是为了让 supersedes: 0001 这样的引用能指向一个短而稳定的名字;日期前缀在文档之间互相引用时 又长又难记。

目录为什么叫 design/ 而不是 adr/ 机制继承自 ADR 传统(见下一段),但这里装的不只是 架构决策——接缝取舍、公共类型的形状、验收口径的定法都放这里,而 adr/ 这个名字会让人以为 只收架构类的东西。design doc 是 Google 工程实践里的叫法,覆盖面更宽。

design/ 是中间产物,不是最终产物——这一条最容易漏,而且漏了不会有任何提示。 后续开发 对着的是常青层:写代码的人读 explanation/,不会为了写一行代码去翻 design/。所以一份 design doc 冻结的时候,它的结论必须已经落到两处之一:

  • 代码(含它的测试),或者
  • 常青文档——如果代码还轮不到写。

两处都没有,这份 design doc 就是死的:它自己说决策已接受,而权威处(../CLAUDE.md §0 那张表)还写着老样子甚至写着「还没定」,于是照权威处读,这件事至今没定。这不是「以后补」, 是当场就已经错了——两层直接矛盾,而 §0 明令冲突时禁止自行调和。

代码还没到写的时候,常青文档照样能写。 常青层的职责是描述当前的真实情况,而「这个机制的 形状已经定了、代码还不存在」本身就是一种真实情况——写清楚形状,再写明它还没有代码、缺口在哪, 就够了。

冻结:写完不改。决策变了就新写一份,旧的原样留着。这是从 ADRArchitecture Decision Record,架构决策记录,一种把每次架构决策单独存成一份不可修改文件的做法)里保留下来的 唯一一条机制。成本很低,但记录层的价值全靠它——只有旧文档还在,你才能看出决策是怎么演变的。

新文档和旧文档之间是什么关系,在头部写清楚

一份新的 design doc 很少是凭空长出来的,它多半跟已有的某几份有关系。只有「整份作废」 一种关系是不够用的——大多数时候动的只是旧文档里的一小块,而那份文档的其余部分还在生效。 所以关系词有五个,每个都写成 **关系词** 目标 + 具体到哪一节

关系词 什么时候用 必须同时写清楚
取代 推翻旧文档的某个结论 取代的是哪一节;那份文档的其余部分是不是仍然有效
补充 沿着旧文档的某条决策继续往下定 补充的是哪几条决策
回答 填掉旧文档明写「还没定」的坑 回答的是哪一节留的坑
触及 本文的结论要回写进哪份常青文档 回写到哪一节;不回写这份 design doc 就是死的
不取代任何文件 填的是一块从来没人填过的空白 为什么这块空白到现在才填

「取代」必须点名其余部分仍然有效,否则读者会以为整份旧文档作废了,连那些还在生效的决策 一起丢掉。

旧文档不回标。 被取代的那份不加任何指向新文档的痕迹——加了就是改内容,而记录层的价值 全在于它没被改过。读者靠常青文档里的决策索引找到当前有效的那份,而不是靠在 design/ 目录里翻。所以决策索引是常青文档的必备一节,它只索引不复述理由。

代价说清楚:直接跳进某一份旧 design doc 的人,有可能读到一个已经被取代的结论,而那份文档 里没有任何东西提示他。这是接受了的风险,换来的是记录层真的不可变。降低风险的办法是让 常青文档成为入口——任何一个问题,先在常青层找到答案,再顺着决策索引跳进 design。

状态字段

头部写 **日期** YYYY-MM-DD · **状态** X。状态只有两个取值。

待确认——这份 design doc 定的东西按 ../CLAUDE.md §2 要过人类门。这种文档必须在正文 最前面写明:在它被确认之前,什么不许做。 只写「待确认」是不够的,那让读者无从判断这份 文档算不算数;写清楚被阻塞的是哪件具体的事,这个状态才有操作意义。

已接受——确认之后改成 已接受(YYYY-MM-DD 项目负责人确认),并在正文留一句说明它 原来是待确认、为什么要过门。改状态字段不算破坏冻结规则,因为决策内容一个字没动。 不需要过门的 design doc 写完直接就是「已接受」。

explanation/architecture.md 的分工(这两份最容易搞混):改一次架构,两份都要动,但写的 东西不同。design doc 写「我们当时面对什么问题、比较了哪几个方案、为什么选了这个、放弃了什么」, 写完冻结。architecture.md 写「现在的分层长什么样、每层的职责和依赖方向是什么」,它永远只描述 当前状态,上一版的样子不在里面。简单说:想知道为什么变成今天这样去翻 design/,想知道 今天到底是什么样去读 architecture.md

什么算需要写 design doc:改公共类型的字段、改 Protocol 签名、改主循环的停止语义、改分层与 接缝,以及任何「选错了要花很大代价才能改回来」的决定。判断标准是能不能写出「否决的方案」—— 如果这件事只有一种做法,那它不是决策,不用写。

只有事实、没有决策的东西不属于这里。 比如「conda run 的输出会被缓冲两层」,它不是我们选的, 是 conda 本来就这样。这类内容属于 explanation/,因为它描述的是一个不会变的机制,而不是我们 某个时刻的选择。

reference/agent-core.md 不属于这里,也不属于任何一层。 它是别人写的架构提案,不是我们的 决策记录——放进 design/ 会让后来的人以为它被接受了。它留在仓库根目录的 reference/ 里, 和五个参考仓库同级,理由见 ../CLAUDE.md §0。

4. scratch/ —— 一次性文档

计划、草稿、调研笔记。进 git(方便协作时互相看见),但由项目负责人在每轮工作会话结束前 手动清理

两点说明,因为这条规则完全靠人执行,含糊就等于没有:

  • **「每轮工作会话」**指一次连续的开发工作从开始到告一段落,通常就是和 AI 协作者的一次对话。 不是按 PR 算,也不是按天算。
  • 清理的是人,不是 AI。 AI 协作者不要自动删 scratch/ 下的任何东西——哪份草稿已经没用了、 哪份还要接着写,只有正在做这件事的人知道。

这里必须把风险讲明白:这条规则没有任何机器兜底。 CHSAnalyzer 上一版的 plans/ 目录攒到 49,222 行、2,757 个复选框其中 87% 永远没有被勾上,靠的也是同一句「记得定期清理」。之所以还是 这么定,是因为自动删除别人正在用的草稿风险更大。选择这条路就是接受了这个风险。

5. 复述规则:论证可以复述,参数不许复述

文档写作里有两个常被混为一谈的目标,本项目明确区分它们:

  • DRY(每个事实只写一次)——这是代码的原则。用在文档上会伤害可读性,因为人类读者 希望在一份文档里把事情读懂,而不是在几份文档之间反复跳转。
  • 权威来源(每个事实有唯一权威版本)——这是文档的原则。它规定的是冲突时信谁, 并不禁止复述

Google 在他们的工程实践里对这一点说得很直接:「这常常导致一些信息的重复,但这种重复是有目的的: 为了清晰。」

所以本项目的规则是:

论证可以复述,参数不许复述。

区别在于会不会漂移。同一个道理在两处各讲一遍,两处不会互相矛盾——最多其中一处写得不如另一处好, 而读者少跳转一次是实打实的收益。但同一个数字写在两处,迟早有一处被改、另一处没改。

「参数」指的是当前生效的取值:阈值数字、路径、文件名、类型名、枚举取值、命令行的具体形状。 这些只在权威来源里出现一次,别处引用它。哪些事实的权威来源在哪,见 ../CLAUDE.md §0 的表格。

历史数字不算参数,不受这条约束。比如「CHSAnalyzer 上一版的 API.md 落后代码 210 个提交」, 这个 210 是已经发生的事实,不会再变,也就不会漂移;它是论证的一部分,哪里需要就可以在哪里写。 会漂移的只有「当前生效」的那类值——因为它们将来会被改。

代价是: 允许复述论证,就等于接受了一种新的漂移——两处的道理打架。比如一处写「为了 可复现所以整份冻结」,另一处写「为了省内存所以只留引用」,两处都没有数字错误,但结论已经 矛盾了。这类冲突机器查不出来,只能靠 ../CLAUDE.md §3 那轮独立评审。这是接受重复必须付的账。

6. 文档质量为什么不进 CI

CHSAnalyzer 曾经有过一个 check_doc_consistency.py,用正则断言文档之间的一致性,已经删除。 本项目不再造一个。

原因是它能查的(文件行数、目录树标注对不对)用一条 shell 命令就能看,而它查不了的 (这段话读不读得懂、这个决策有没有写理由)才是文档真正的腐烂形态。

这不是工程没做到位,而是原理上做不到。Google 在同一件事上的自我评价是:「测试可以自动化, 而文档自动化的方案往往是缺失的」,以及「文档必然是主观的;文档的质量不由作者衡量,而由读者 衡量,而且往往是异步地衡量」。质量的度量发生在读者脑子里,而且延迟发生,CI 拿不到这个信号。

所以文档质量走 ../CLAUDE.md §3 的独立评审:一个没参与过讨论的 subagent,只拿到改后的文档, 纯靠文档读懂。先问它读的时候发生了什么,再问三类具体问题——哪句话读不懂、哪个决策只写了 结论没写理由、同一个参数在两处取值不同。