docs: 回写全仓库对契约套件的指向,以及 README、架构与 CHANGELOG

套件从 tests/contract/ 搬进 polyloop.testing 之后,全仓库 28 处引用要重新指过。修了 12 处,
其余在 design/(只增不改)与 scratch/(由人清理)里。

**CLAUDE.md 改了四处事实**:§0 权威表里行为契约的权威、§0 那句依赖规则的条数、§5 目录树与
模块数、§1.8 那句「谁断言公共 Protocol 的签名」。§1 的其余硬约束与 §2 的人类门一条没动。

**architecture.md**:分层图第 4 层加一格,装配层从三个变四个;代码地图加一行;第十节按代码
逐项重写——那笔「工具段渲染样式」的欠账**没有被数字对上盖掉**,加了 fingerprints 之后请求
的字段数恰好还是十一,而组成已经换过,所以那一节正面写着它仍然欠着;新增第十条依赖规则
(pytest 只在 testing 那个 extra 里,别处 import 它会让下游的生产环境一 import 本库就
ModuleNotFoundError),带静态与运行时两半;删掉「src/ 下一行代码都没有」那段过期状态说明;
决策索引补齐 0008 到 0016,其中四行原描述说的不是那份文档真正定的东西。

**migrations/dissect.md** 那笔「内存实现不存在」的欠账还掉了。

**压测那边**三条测试守的是一条已经撤销的公共契约,改名并写清它们现在守的是场景自己的选择。
AppWorld 那处刻意的偏离(不补三个反引号)留着不恢复——那条路径要模型输出被 stop 序列截断才
触发,而压测不配 stop 序列,恢复的收益不抵重跑一次压测的成本。但注释的理由改对了:它现在是
一笔有出处的欠账,不是一个决定。

CHANGELOG 攒在「未发布」段,版本号不提前写(§1.10)。
This commit is contained in:
2026-08-27 03:59:39 -04:00
parent 1fac387e75
commit c4e5732587
12 changed files with 246 additions and 80 deletions
+75 -40
View File
@@ -5,15 +5,7 @@
> 第 3 档是定期复审。能用强的就不用弱的,完整说明见 `../README.md`。
>
> 本文件的第七、八、九节是**第 1 档**:那三节讲的分层、模块边界与抽象接缝由 `pyproject.toml`
> 的 import-linter 契约与 `tests/contract/` 断言。**其余章节是第 2 档**。
>
> **当前状态:本文件描述的是目标结构,`src/` 下一行代码都没有。** 常青层本该描述当前真实
> 情况,而这份在代码之前就存在。接受这个例外的理由与它的过期条件见
> `../../README.md` 的阶段清单第 ③ 条。`src/` 落地完成后删除本段。
>
> 由此带来一个读者必须知道的约定:**后文以现在时提到的 `polyloop/` 路径,指的是落地之后
> 该内容所在的位置**,不一定是现在就能打开的模块。这么写是为了让这份文档在代码落地那天
> 不需要逐句改时态。
> 的 import-linter 契约与 `polyloop.testing` 那套契约套件断言。**其余章节是第 2 档**。
>
> **本文件与 design doc 冲突时以本文件为准。** design doc 写完就冻结,它记录的是当时定了
> 什么;本文件描述的是现在是什么样。两者对同一件事都会提到,这是有意的——但理由只在
@@ -247,13 +239,14 @@ PolyLoop 把这个循环收成一份。它治理的单位是**一次运行**:
`A ──▶ B` 读作「A 的代码里写了 `from polyloop.B import ...`」,也就是 A 依赖 B。
```
第 4 层 ┌───────────────┐ ┌───────────────┐ ┌────────────────┐
装配层 │ session stores adapters │
定义、请求 │ │ jsonl / 内存│ PolyGateway
run、resume │ │ 存储实现 │ │ 适配器
└───────────────┘ └───────────────┘ └────────────────┘
个互不 import:session 不认识任何具体实现,具体实现也不
认识 session。把它们装到一起的是调用方,不是库自己
第 4 层 ┌──────────── ┌────────────┐ ┌────────────┐ ┌────────────┐
装配层 │ session │ stores │ adapters │ │ testing
│ 定义、请求 │ │jsonl / 内存│ PolyGateway│ │ 契约套件
│ run、resume│ │ 存储实现 │ │ 适配器 │ │ 准入基类
└──────────── └────────────┘ └────────────┘ └────────────┘
个互不 import:session 不认识任何具体实现,具体实现也不
认识 session,而契约套件三个都不认识——它只认接缝定义。把它们
装到一起的是调用方,不是库自己
第 3 层 ┌───────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐
@@ -277,8 +270,9 @@ PolyLoop 把这个循环收成一份。它治理的单位是**一次运行**:
不必逐层往下传——分层禁止的是往上,不是要求一层一层往下
```
第 4 层里 `stores``adapters` 只够到第 2 层(它们 import `ports` 去实现那些 Protocol
再 import `types` 用那些数据类型),不需要碰第 3 层。
第 4 层里 `stores``adapters``testing` 只够到第 2 层(前两个 import `ports` 去实现那些
Protocol再 import `types` 用那些数据类型;契约套件 import 同样这两处,用来给下游的实现出
题),不需要碰第 3 层。
**存储实现在上面而不是下面,这一点最容易画反。** 直觉上存储是底层设施,该垫在最底下;
但按 import 方向,是存储实现去 import 接口定义,所以它在接口之上。库的核心不认识任何具体
@@ -287,19 +281,25 @@ PolyLoop 把这个循环收成一份。它治理的单位是**一次运行**:
判据只有一句:**`polyloop.types` 是依赖图的汇点**——所有箭头最终都指向它,没有一根从它出去。
它就是「字段只增不删不改名」保护的那份合同本身。
### 条依赖规则
### 条依赖规则
这些规则在代码里是**看不见的**——打开 `polyloop/ports/` 只会看到一堆正常的 Protocol,
看不到那里缺了什么。所以必须写下来,并且每一条都有对应的机器断言。
**一、分层,自高向低**:装配层(`session``stores``adapters`> 逻辑层(`tools`
**一、分层,自高向低**:装配层(`session``stores``adapters``testing`> 逻辑层(`tools`
`_assembly``_stopping``_recovery``serialization`> `ports` > `types`。低层不许
import 高层。
**二、装配层那个互相独立。** `session` 不许 import `stores``adapters`,反过来也不许
这条不能靠分层规则表达——同一层的模块在分层契约里默认是可以互相 import 的,要另立一条独立性
契约。它守的是「库不顺手提供任何默认实现」:`session` 一旦 import 了某个存储实现,那个实现
就成了隐式默认,而不传存储的人不会知道自己这次运行没有恢复能力。
**二、装配层那个互相独立。** `session``stores``adapters``testing` 两两之间都不许
import。这条不能靠分层规则表达——同一层的模块在分层契约里默认是可以互相 import 的,要另立
一条独立性契约。它守的是「库不顺手提供任何默认实现」:`session` 一旦 import 了某个存储实现,
那个实现就成了隐式默认,而不传存储的人不会知道自己这次运行没有恢复能力。
契约套件落在这一层、并且同样被这条独立性契约管住,有它自己的一条理由:本库声明了一个 pytest
插件入口,所以每一个装了本库的下游项目在 pytest 启动时都会加载 `polyloop.testing`。它一旦
import `adapters`,那次加载就会把网关连同它的 provider 目录一起拉起来——那正是规则九要防的
事,只不过触发路径不是 `import polyloop`,而是 pytest 自动加载插件
`../design/0014-contract-suite-distribution.md` 决策三)。
**三、`tools`、`_assembly`、`_stopping`、`_recovery`、`serialization` 五者互不 import**
不设豁免。它们之间的编织只能发生在 `session` 里。理由是这五个模块各自要能被单独测穷,
@@ -330,11 +330,18 @@ import 高层。
**八、`ports` 不许 import 其余任何 `polyloop` 模块。** 第一条已覆盖,单列是为了让违规信息
直接指向「接缝定义模块被污染了」,而不是一条泛泛的分层报错。
**九、`import polyloop` 之后,`sys.modules` 里不许出现 `polygateway`。** 这条由契约测试
断言,不是 import-linter。顶层只再导出五个公开模块;`stores``adapters` 必须显式
**九、`import polyloop` 之后,`sys.modules` 里不许出现 `polygateway`。** 这条由测试断言,
不是 import-linter。顶层只再导出五个公开模块;`stores``adapters` 必须显式
import。理由是一个「顺手提供的默认模型客户端」会让每个进程在 import 时把网关连同它的
provider 目录一起拉起来。
**十、除 `polyloop.testing` 外一切不许 import `pytest`,且 `import polyloop` 之后
`sys.modules` 里也不许出现 `pytest`。** 这条有两半,形状和规则五加规则九那一对相同:静态
那半是一条 import-linter 契约,运行时那半是一条测试。守的事很具体——pytest 只住在 `testing`
这个 extra 里,核心的运行时依赖是空的,所以别处 import 它,下游的生产环境一 `import polyloop`
`ModuleNotFoundError`,而生产环境通常根本没装 pytest。契约套件自己顶层就 import pytest
所以它和 `stores``adapters` 同一档,不进顶层的再导出,必须显式 import。
## 八、代码地图:每个模块装什么
| 位置 | 公开 | 装什么 |
@@ -349,11 +356,18 @@ provider 目录一起拉起来。
| `polyloop/_recovery/` | 否 | 恢复状态判定与运行身份校验 |
| `polyloop/stores/` | 是,须显式 import | 库自带的存储实现 |
| `polyloop/adapters/` | 是,须显式 import | PolyGateway 模型适配器 |
| `polyloop/testing/` | 是,须显式 import | 五个接缝的契约套件:每个接缝一个基类,下游继承它验自己的实现 |
`polyloop/types/` 的读者是所有人:dissect 的 runner 读步记录与停止原因把轨迹头拼回去,
GovDoc 的编排层读停止原因决定这个阶段算不算可挽救,写任何适配器的人都要构造这里的结构体。
`polyloop/ports/` 的读者是写适配器的人和 `tests/contract/`。它用 `typing.Protocol` 而不是
`polyloop/testing/` 住在包里而不是 `tests/` 下,因为 `tests/` 不进发行包:`pip install polyloop`
之后 site-packages 里没有它,而这套用例正是下游实现接缝时的准入标准,拿不到的准入标准不成其为
标准。下游的接法是继承基类、在自己的子类里覆盖那几个必需 fixture;它不进 `polyloop/__init__.py`
因为它顶层就 import pytest,而 pytest 只在 `testing` 这个 extra 里,核心的运行时依赖是空的
`../design/0014-contract-suite-distribution.md` 决策一)。
`polyloop/ports/` 的读者是写适配器的人和 `polyloop/testing/`。它用 `typing.Protocol` 而不是
抽象基类,理由是下游的对象往往已经是它自己的类、还要同时满足项目自己更宽的接口;只有
结构化子类型能让同一个对象同时满足库的窄视图和项目的宽视图。
@@ -459,9 +473,21 @@ GovDoc 的编排层读停止原因决定这个阶段算不算可挽救,写任
文本。它是不可变的,可以被并发复用。它还有一个只读方法,把四个接缝各自上报的参数聚合成
一份快照——那是方法不是字段,因为聚合要向接缝逐个发问,而构造定义之前定义还不存在。
**请求**每次运行构造一个,持有这次运行独有的十一样数据:运行标识、预算、动作执行、本次
可见的工具集、上下文各段、注入内容、模型绑定、模型调用的重放策略、观察包装模板、工具段
渲染样式、取消收尾时限。它构造廉价——无 I/O、无网络校验、无哈希计算。
**请求**每次运行构造一个,持有这次运行独有的十一样数据:运行标识、预算、动作执行接缝、本次
可见的工具集、上下文各段、按通道分组的注入内容、模型绑定、这次用的材料是哪一版(指纹)、
模型调用的重放策略、观察包装模板、取消收尾时限。它构造廉价——无 I/O、无网络校验、无哈希计算。
模型绑定和指纹形状相同:都是下游自己定键名的字符串映射,都整个进参数快照,库都不解释里面
装的是什么。含义不同。绑定记的是这次运行属于哪一格(哪个账本、第几轮、哪道题),库还把它
原样透传给每次模型调用;指纹记的是这次用的材料是哪一版(提示词模板的哈希、技能库的版本
这类),只进快照,不透传。两者混在一个字段里事后分不开:一组键值里既有「第 3 轮」又有一个
sha,要靠键名的命名约定去猜哪个是哪个,而命名约定不在任何一处被断言
`../design/0015-parameter-snapshot-contract.md` 决策一)。
**「工具段渲染样式」不在这十一样里,代码里也没有任何对应物。** `0003` 决策三的请求字段表
列了它,而它从第一版落地起就没有被实现,这笔欠账今天仍然欠着——它不是被哪次改动还掉的。
请求的字段数确实还是十一,但其中那一格现在是指纹:数字对得上,组成已经换过
`0015` 的「留给后续的」记着这件事)。
切点是「跨运行变不变」。预算只在请求这一处,不设「定义给默认值、请求可覆盖」——两处取值
意味着「这次到底跑的什么设置」要对照两个地方才答得出来。
@@ -511,18 +537,21 @@ dissect 的每一次运行都是论文数据点,这个性质对结构提了几
## 十三、这份文档靠什么不腐烂
第七、八、九节将来由机器断言,每一节各有对应的检查:
第七、八、九节各有对应的机器检查:
- **第七节**——条依赖规则,前八条各一条 import-linter 契约(第一条分层契约,第二、三条
是独立性契约,其余是禁止型契约),第九条一个契约测试。
- **第七节**——条依赖规则。规则一、二、三由同一条分层契约表达,规则八在那条里已经被覆盖
另单列一条只为让违规信息直接指向 `ports`,规则四、五、七各一条禁止型契约,规则十的静态那半
也是一条禁止型契约。剩下的落在 `tests/unit/` 的测试上:规则六(不许 import 任何第三方)
写不成契约,因为「任何第三方」不是一份可枚举的清单;规则九和规则十的运行时那半写不成契约,
因为 `sys.modules` 里有谁是运行时事实,不是静态图上的边。
- **第八节**——一个断言检查 `polyloop/` 下有哪些位置,和第八节那张表逐行对得上。表里有一行
在代码里找不到、或者代码里多出一个没写进表的位置,都算失败。没有这一条,新加的模块会
悄悄绕过第七节的分层规则——一个规则里没提到的模块,等于没有任何约束。
- **第九节**——一个断言检查接缝的数量和位置没有悄悄增长;另一个断言检查 `polyloop/`
不出现重试、限流、熔断的实现。
**写到哪一步了:一条都没** 这些断言要等 `src/` 落地才写得出来,在那之前这三节没有机器
兜底,只能靠人在实现时逐条对照。这是「架构文档先于代码存在」这个安排最实在的代价
**写到哪一步了:第七节那十条已经全部有断言,第八、九节还一条都没** 那两节现在没有机器
兜底,只能靠人在改代码时逐条对照——表里多一行少一行、接缝悄悄变成六个,CI 都不会响
其余章节是第 2 档:改相关代码时,改本文件是同一个提交的一部分。
@@ -530,8 +559,8 @@ dissect 的每一次运行都是论文数据点,这个性质对结构提了几
**公共类型的字段与枚举取值不在本文件里。** 英文名与签名定在
`../design/0006-public-names-and-signatures.md`,行为契约定在 `../design/0007-seam-behaviour.md`
落地之后权威转移到 `src/polyloop/` 的代码与 `tests/contract/`。本文件只给五个接缝的
Protocol 名与模块归属,不复述字段表——按 `../../CLAUDE.md` §0,那种复述腐烂的速度和代码一样快。
落地之后权威转移到 `src/polyloop/` 的代码与 `polyloop/testing/` 那套契约套件。本文件只给五个
接缝的 Protocol 名与模块归属,不复述字段表——按 `../../CLAUDE.md` §0,那种复述腐烂的速度和代码一样快。
**停止判定的顺序、停止原因的取值、两个预算计数的语义、步记录的字段清单不在本文件里。**
这四样已经定了,在 `../design/0004-stopping-and-step-record.md`,字段表经
@@ -539,9 +568,6 @@ Protocol 名与模块归属,不复述字段表——按 `../../CLAUDE.md` §0
`../../CLAUDE.md` §0,公共类型的字段与枚举取值的权威是 `src/polyloop/`,不另写参考文档复述。
那份 design doc 记的是第一版为什么定成这样,不是查字段的地方。
**事件出口的事件类型还没定**,所以这个接缝的契约套件现在写不了。方向已经定了——观察走
事件流、干预走具名回调——但事件集与回调清单要独立成篇。
**多模态内容的规模度量没有答案。** 消息内容是块序列而不是裸字符串,第一版只定义文本块,
它的度量是准确的字符数。将来加图片块时必须同时给出它的度量定义,以及上下文上限在混合
内容下的语义。
@@ -562,6 +588,15 @@ Protocol 名与模块归属,不复述字段表——按 `../../CLAUDE.md` §0
| 存储的方法为什么这么切、为什么要前缀持久性、步记录那三处为什么改 | `../design/0005-storage-atomicity-and-record-fields.md` |
| 公共类型与接缝叫什么、字段是什么形状、类型分到哪个模块 | `../design/0006-public-names-and-signatures.md` |
| 三个动作状态什么时候赋上、动作被拒绝时观察从哪来、解释器能不能抛异常 | `../design/0007-seam-behaviour.md` |
| 工具的实现挂在哪个字段上、它缺了什么时候报错、注册表派生的执行器怎么填动作结果 | `../design/0008-tool-handlers.md` |
| 字段顺序算不算一份对外承诺、公共数据类为什么一律只收关键字参数 | `../design/0009-keyword-only-public-types.md` |
| 上下文按什么顺序拼、注入槽为什么在那个位置、规模怎么量 | `../design/0010-context-assembly.md` |
| 逐行追加那个存储的文件布局、坏行怎么算、`fsync` 落在哪几处 | `../design/0011-jsonl-run-store.md` |
| 适配器为什么不自己装配网关客户端、消息怎么拼成一次网关调用、网关的异常为什么原样穿出 | `../design/0012-gateway-model-client.md` |
| 事件集为什么只有一个取值、事件带的是什么、具名回调清单为什么现在是空的 | `../design/0013-event-set-and-callbacks.md` |
| 契约套件怎么发给下游、下游怎么接上它、内存存储实现为什么叫易失 | `../design/0014-contract-suite-distribution.md` |
| 配方版本记进请求的哪个字段、注入的通道维度为什么保留、快照取值为什么必须是字符串 | `../design/0015-parameter-snapshot-contract.md` |
| 环境故障为什么走返回值、动作执行接缝抛异常时库为什么不接管、模型调用那侧为什么反而捕获 | `../design/0016-action-executor-failure.md` |
边界的当前裁决清单(哪些在界内、哪些在界外)在 `scope.md`,那份是常青的,会随新消费者
接入而更新。每个下游要迁什么、迁完算不算数在 `../migrations/` 下对应那份。