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
+96
View File
@@ -9,6 +9,102 @@
## 未发布 ## 未发布
**版本号在真的要发布的那一刻才定,这里不提前写。** 只 bump 版本号不叫发布(`CLAUDE.md`
§1.10):PolyGateway 的 1.0.6 与 1.1.0 都完成了版本号 bump 与 CHANGELOG,却从未上传,registry
长期停在 1.0.5,下游 `pip install` 拿不到任何修复且无人发现。提前把号写进这一段就是在重演
那个形态——读到号的人会以为那一版已经在 registry 上,而它不在。
这一批改动回应的是下游项目在 PolyLoop 仓库上提的五个 issue。
### 契约套件随包发布
五个接缝的公共行为一致性用例从 `tests/contract/` 搬进 `polyloop.testing`,跟着 wheel 一起
装到下游去。**`pip install polyloop` 之后 site-packages 里没有 `tests/`**,所以在此之前那套
被称作「任何新适配器的准入标准」的用例,第一个下游根本拿不到。
**接法同时换了**:原来是在自己的 `conftest.py` 里覆盖同名 fixture,现在是继承契约基类。
```python
from polyloop.testing import RunStoreContract
class TestMyPostgresStore(RunStoreContract):
@pytest.fixture
def store(self, pg_pool): return MyPostgresStore(pg_pool)
```
换掉是因为覆盖同名 fixture 在装到 site-packages 之后无处落脚——pytest 的 `conftest.py` 只沿着
被收集文件的目录链往上找,而套件所在的那条链在 site-packages 里,看不见下游仓库里的
`conftest.py`。继承则不需要任何 `conftest.py` 魔法:子类定义在下游自己的测试文件里。顺带一个
接缝可以接多个实现,各写一个子类。
跑它要装 `polyloop[testing]`pytest 与 pytest-asyncio,版本只写下界,不和下游已经在用的
pytest 打架)。async 用例的事件循环归下游管:把 `asyncio_mode` 设成 `"auto"`,或者自己给子类
打标记。本包还声明了一个 pytest 插件入口,它唯一的作用是让 pytest 重写这些模块里的
`assert`,失败时打印出等号两边的实际值。
**基类名、基类上的 fixture 名、每一条用例的方法名从此是公共承诺**,改名的代价和改公共类型的
字段一样。
### 新增一个内存存储实现
`polyloop.stores.VolatileRunStore`:日志攒在进程内存里,进程一退就没了。它不提供的是**跨进程
恢复**,不是「读不回来」——同一个进程里写进去的意图照样读得回来,它和逐行追加那个实现跑的是
同一套存储契约。桶里存的是编码后的载荷、读的时候才解码,所以调用方后来改自己手里那个 dict
改不到已经写下去的快照,读回来的日志被就地改动也污染不了存储本身——落盘那个实现每次都重新
解析文件,天然如此,这个实现靠同一条路径对齐它。一条字段类型不对的记录在两个实现上的下场也
一样:写得进去,读的时候抛同一个解码错误。
对下游的意义是测试和「我不要跨进程恢复」那一档不必再自己写一个存储:存储接缝是必填的,
在此之前不想落盘的人只能自己造一个。关系数据库那种形态仍然由下游自己实现。
### `RunRequest` 新增 `fingerprints` 字段
`Mapping[str, str]`,默认空映射,**已有代码不受影响**。它记的是这次运行用的材料是哪一版——
提示词模板的哈希、技能库的版本这类——全部键值以 `request.fingerprint.<name>` 进参数快照,
不透传给模型调用。
它和 `model_binding` 的分界是「坐标还是配方版本」:绑定记这次运行属于哪一格(哪个账本、
第几轮、哪道题),指纹记这次用的材料是哪一版。**一条指纹都没有时快照里一个键都不写**,
所以今天已经在跑的配置算出来的快照逐字节不变。
同时补上一道校验:`fingerprints``model_binding` 的键值必须都是字符串,在构造请求时就拒绝
非字符串;五个接缝 `parameters()` 上报的键值在聚合成快照时同样校验。在此之前这类值要等到续跑
读日志的那一刻才炸——那时这次运行已经跑完、钱已经花了。
### ⚠ 参数快照里注入内容的键形状变了
**这是这一批里唯一一处会让已有行为变化的改动。** 注入的条目标识原来拍平成一个键
`request.injected_entry_ids`,现在按通道分组,一个通道一个键
`request.injected_entry_ids.<通道名>`
**用旧版本跑了一半的运行,升级之后 `resume` 会抛 `ParameterDriftError`。** 失败是响亮的,不是
静默的:错误信息把漂移的键逐个列出来,能看到旧的那个键消失、新的那几个键出现。处置有两条,
和快照里任何一项变了时一样——那次运行重新开始,或者接受它跑不完。跑完了的运行不受影响,
参数快照只在续跑时被比对。
改形状是因为拍平之后「声明了这个通道但一条都没选中」和「压根没有这个通道」得出同一个结果,
而有下游要比较的正是这两种情形。分组之后前者是一个值为空串的键,后者是这个键不存在。通道
之间按通道名字典序,通道内的顺序和贴进提示词的顺序一致。
**快照的键集合从来不是公共承诺**,所以这不是 `CLAUDE.md` §1.3 意义上的破坏性变更:下游换一个
存储实现、改一个接缝的 `parameters()` 返回什么,快照照样会变、续跑照样报漂移,这本来就是这
套设计的一部分。
### 动作执行接缝的契约补上「抛异常时会怎样」
`ActionExecutor` 的 docstring 原来只说了动作本身报错算「已执行」,实现方干脆不返回、直接抛出
时会怎样一个字都没写,而库这一侧不捕获。现在正面写清三条:环境自己坏了(连不上、协议不对、
会话没了)返回 `ActionStatus.ENV_ERROR`,不要以异常表达;真抛出来的异常库不捕获,原样穿出
`run()``resume()``asyncio.CancelledError` 必须原样穿过。
**行为没有变,变的是它被写下来了。** 库不把执行器抛出的异常转成 `ENV_ERROR`,是因为那样会
把适配器自己的 bug 伪装成环境故障送进下游的统计,而且要替这一步编一条步记录——那条记录会被
恢复读成「上一步走完了」,把一个真正未知的状态抹成一个具体的值。异常抛出时日志停在「动作
意图有、步记录无」,恢复照实把它读成状态未知。
把可预期的环境异常(连接超时、会话已关闭)捕获并返回 `ENV_ERROR` 是适配器的正常工作,不算
`CLAUDE.md` §1.7 禁的那种吞错误——错误没有被吞,它变成了一个明确的状态值。
## 1.0.12026-08-11 ## 1.0.12026-08-11
首个发布版本。十个模块全部落地,四层测试都在跑。 首个发布版本。十个模块全部落地,四层测试都在跑。
+7 -6
View File
@@ -17,8 +17,8 @@
| 这类事实 | 权威处 | | 这类事实 | 权威处 |
|---|---| |---|---|
| **哪些事归本库管、哪些不归**,以及判据 | `research-wiki/explanation/scope.md` | | **哪些事归本库管、哪些不归**,以及判据 | `research-wiki/explanation/scope.md` |
| 分层、依赖方向、模块边界 | `research-wiki/explanation/architecture.md`,由 `pyproject.toml` 的 import-linter 契约机器断言。条依赖规则里有两条落不进契约(「不许 import 任何第三方」不是可枚举清单,「import 之后 `sys.modules` 里没有谁」是运行时事实),它们`tests/unit/` 里的测试 | | 分层、依赖方向、模块边界 | `research-wiki/explanation/architecture.md`,由 `pyproject.toml` 的 import-linter 契约机器断言。条依赖规则里有两条落不进契约、还有一条只有一半落得进(「不许 import 任何第三方」不是可枚举清单,「import 之后 `sys.modules` 里没有谁」是运行时事实),落不进的那些`tests/unit/` 里的测试 |
| 公共 API 的行为契约:一次 `run` 到底保证什么、边界条件怎么结算 | `tests/contract/` 的公共契约套件。它同时是任何新适配器的准入标准 | | 公共 API 的行为契约:一次 `run` 到底保证什么、边界条件怎么结算 | `src/polyloop/testing/` 的公共契约套件,随包发布。它同时是任何新适配器的准入标准 |
| 公共类型的字段、不变量、枚举取值 | `src/polyloop/` 的代码与其测试。**不另写一份参考文档复述它们**——那份文档不重复代码的内容太少,而它腐烂的速度和代码一样快 | | 公共类型的字段、不变量、枚举取值 | `src/polyloop/` 的代码与其测试。**不另写一份参考文档复述它们**——那份文档不重复代码的内容太少,而它腐烂的速度和代码一样快 |
| 每个下游项目要迁走什么、迁完算不算数 | `research-wiki/migrations/` 下对应那份 | | 每个下游项目要迁走什么、迁完算不算数 | `research-wiki/migrations/` 下对应那份 |
| 已定的决策及其理由 | `research-wiki/design/` 下相关编号最大的那份 | | 已定的决策及其理由 | `research-wiki/design/` 下相关编号最大的那份 |
@@ -53,7 +53,7 @@
6. **`asyncio.CancelledError` 永不捕获吞没。** 取消要能穿过模型调用与环境执行,in-flight 资源在 `finally` 释放。吞掉它的后果不是「取消失败」这么直白——是容器租约、连接和临时目录持续泄漏,而且一声不吭。 6. **`asyncio.CancelledError` 永不捕获吞没。** 取消要能穿过模型调用与环境执行,in-flight 资源在 `finally` 释放。吞掉它的后果不是「取消失败」这么直白——是容器租约、连接和临时目录持续泄漏,而且一声不吭。
7. **禁止吞掉错误**`except Exception: pass` 及其跨行形态)。由 ruff `S110` / `E722` 断言。 7. **禁止吞掉错误**`except Exception: pass` 及其跨行形态)。由 ruff `S110` / `E722` 断言。
8. **测试绑行为,不绑实现。** 不写「断言某个内部类有哪些方法」这类测试——它只会让重构连坐。 8. **测试绑行为,不绑实现。** 不写「断言某个内部类有哪些方法」这类测试——它只会让重构连坐。
**公共 Protocol 的签名是例外**:它本身就是对下游的承诺,不是实现细节,所以 `tests/contract/` 断言它是应该的。判据是这个名字有没有对外承诺过——承诺过的改名是破坏性变更(§1.3),断言它就是在守那条承诺;没承诺过的改名只是重构,断言它就是在拖后腿。 **公共 Protocol 的签名是例外**:它本身就是对下游的承诺,不是实现细节,所以 `src/polyloop/testing/` 断言它是应该的。判据是这个名字有没有对外承诺过——承诺过的改名是破坏性变更(§1.3),断言它就是在守那条承诺;没承诺过的改名只是重构,断言它就是在拖后腿。
**断言某个名字「不存在」也是允许的**,用来守住一次删除决策。一个已经被删掉的字段没法被重命名,拖不动测试。代价是它守的只是名字不是概念——换个名字把同一个概念加回来,测试照样绿,所以理由必须同时写在被删字段所在类型的 docstring 里。 **断言某个名字「不存在」也是允许的**,用来守住一次删除决策。一个已经被删掉的字段没法被重命名,拖不动测试。代价是它守的只是名字不是概念——换个名字把同一个概念加回来,测试照样绿,所以理由必须同时写在被删字段所在类型的 docstring 里。
9. **测试分层按「依赖什么」定,不按「叫什么」定。** 用测试替身的是 unit,连真 PolyGateway 的是 integration,打真实模型网关的是 e2e,验证公共 Protocol 行为一致性的是 contract。按名字分层的话,改个函数名就要挪测试文件;按依赖分,只要这个测试还是不连外部服务,它就一直待在原地。四层之间更细的界线在搭测试框架那个阶段定,现在不必较真。 9. **测试分层按「依赖什么」定,不按「叫什么」定。** 用测试替身的是 unit,连真 PolyGateway 的是 integration,打真实模型网关的是 e2e,验证公共 Protocol 行为一致性的是 contract。按名字分层的话,改个函数名就要挪测试文件;按依赖分,只要这个测试还是不连外部服务,它就一直待在原地。四层之间更细的界线在搭测试框架那个阶段定,现在不必较真。
10. **发布 = 合并 + push + tag + 构建 + 上传 registry + 验证已发布。只 bump 版本号不叫发布。** 教训来自 PolyGateway1.0.6 与 1.1.0 都完成了版本号 bump 与 CHANGELOG,却从未上传,registry 长期停在 1.0.5——下游 `pip install` 拿不到任何修复,且无人发现。**那次的补救只写了文档、没有回补上传,所以那两个版本到今天仍然不在 registry 上**,而 dissect 的依赖恰好钉在那个空区间里、装不上。这说明记下教训不等于修好问题。完整步骤与全部已知的坑见 `research-wiki/guides/releasing.md` 10. **发布 = 合并 + push + tag + 构建 + 上传 registry + 验证已发布。只 bump 版本号不叫发布。** 教训来自 PolyGateway1.0.6 与 1.1.0 都完成了版本号 bump 与 CHANGELOG,却从未上传,registry 长期停在 1.0.5——下游 `pip install` 拿不到任何修复,且无人发现。**那次的补救只写了文档、没有回补上传,所以那两个版本到今天仍然不在 registry 上**,而 dissect 的依赖恰好钉在那个空区间里、装不上。这说明记下教训不等于修好问题。完整步骤与全部已知的坑见 `research-wiki/guides/releasing.md`
@@ -122,10 +122,11 @@ Codex 是 OpenAI 的编码模型,本仓库通过 `codex` 插件调用它。**
★ research-wiki/reference/ 查得到的事实:日志字段契约、遥测口径 ★ research-wiki/reference/ 查得到的事实:日志字段契约、遥测口径
(公共类型和枚举取值不在这里,权威见 §0 表格) (公共类型和枚举取值不在这里,权威见 §0 表格)
★ research-wiki/scratch/ 一次性草稿。进 git,但由人在每轮工作会话结束前清理(AI 不要自动删) ★ research-wiki/scratch/ 一次性草稿。进 git,但由人在每轮工作会话结束前清理(AI 不要自动删)
★ tests/contract/ 公共 Protocol 的行为一致性套件,是那份契约的权威(§0), ★ tests/contract/ 把库自带的实现接到契约套件上的那几个子类。套件本身不在这里
也是任何新适配器的准入标准
★ tests/e2e/ 打真实模型网关,会产生真实费用。默认不跑,两道闸见 .env.example ★ tests/e2e/ 打真实模型网关,会产生真实费用。默认不跑,两道闸见 .env.example
★ src/polyloop/ 库本体,十个模块 ★ src/polyloop/ 库本体,十个模块
★ src/polyloop/testing/ 公共 Protocol 的行为一致性套件,是那份契约的权威(§0),
也是任何新适配器的准入标准。它随包发布,下游装了就拿得到
``` ```
常青层与记录层的分界、各类的更新触发点、`scratch/` 那条人工清理规则的已知风险,都在 `research-wiki/README.md` 常青层与记录层的分界、各类的更新触发点、`scratch/` 那条人工清理规则的已知风险,都在 `research-wiki/README.md`
+19 -5
View File
@@ -25,6 +25,18 @@ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/py
"polyloop[gateway]==1.0.*" "polyloop[gateway]==1.0.*"
``` ```
契约套件随包发布,它要的 pytest 也是一个单独的 extra
```bash
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polyloop[testing]==1.0.*"
```
**只有自己实现了某个接缝、要拿库这边的契约套件验它的时候才装这个。** 存储、模型调用、决策
解释、动作执行、事件出口五处允许换实现,谁换了谁就得证明自己那份还满足接缝的行为契约,而
证明的方式就是继承 `polyloop.testing` 里对应的基类跑一遍。只调 `run` / `resume`、五个接缝
全用库自带或别人写好的实现的项目,不需要它。
写进 `requirements.txt` 的话,那一行 `--extra-index-url` 必须排在 `polyloop` 之前。 写进 `requirements.txt` 的话,那一行 `--extra-index-url` 必须排在 `polyloop` 之前。
**这台开发机上的注意事项**:它设了 `http_proxy` 指向一个到不了外面的本地代理,走代理会失败, **这台开发机上的注意事项**:它设了 `http_proxy` 指向一个到不了外面的本地代理,走代理会失败,
@@ -32,7 +44,7 @@ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/py
## 现状 ## 现状
十个模块全部落地,四层测试都在跑。**还没有任何下游项目真的用过它**——这是它现在最大的未验证项, 个模块全部落地,四层测试都在跑。**还没有任何下游项目真的用过它**——这是它现在最大的未验证项,
下面的阶段清单是唯一的进度权威。 下面的阶段清单是唯一的进度权威。
## 消费者与验收标准 ## 消费者与验收标准
@@ -61,10 +73,12 @@ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/py
架构文档先于代码存在,此期间它是一份规格而不是描述,文档开头须写明这一点 架构文档先于代码存在,此期间它是一份规格而不是描述,文档开头须写明这一点
- [x] ④ 测试框架 —— 四层都在跑(划分判据是「依赖什么」,见 [CLAUDE.md](CLAUDE.md) §1.9)。 - [x] ④ 测试框架 —— 四层都在跑(划分判据是「依赖什么」,见 [CLAUDE.md](CLAUDE.md) §1.9)。
e2e 打真实模型网关、会产生真实费用,所以默认不跑:要 `POLYLOOP_E2E=1` 加显式 e2e 打真实模型网关、会产生真实费用,所以默认不跑:要 `POLYLOOP_E2E=1` 加显式
`pytest -m e2e`,配置见 [.env.example](.env.example)。`tests/contract/` 那套公共行为 `pytest -m e2e`,配置见 [.env.example](.env.example)。那套公共行为一致性用例住在
一致性用例接上了自带的存储实现,解释器与执行器那几条仍等下游把实现接进来 `polyloop.testing` 里随包发出去,五个接缝在本仓库都接上了实现跑起来,接点是
- [x] ⑤ 实现 —— 十个模块全部落地,五个接缝都有调用点。**一处已知欠账**:`stores` 只有逐行 `tests/contract/``tests/integration/` 下那几个继承契约基类的子类
追加那一种形态,关系数据库那种由下游自己实现,契约套件是它的准入标准 - [x] ⑤ 实现 —— 十一个模块全部落地,五个接缝都有调用点。`stores` 有两种形态:逐行追加进
本地文件的那个,和只留在进程内存里、进程一退就没了的那个。关系数据库那种仍然由下游
自己实现,契约套件是它的准入标准,而套件随包发布在 `polyloop.testing`
- [x] ⑥ 验收 —— 两件事都做完了。**一是自己造负载压**:照三个消费者将来的用法造负载,用真实 - [x] ⑥ 验收 —— 两件事都做完了。**一是自己造负载压**:照三个消费者将来的用法造负载,用真实
数据真的打模型跑完,看这个内核在这个量级上扛不扛得住。**这一步之所以必须自己做,是因为 数据真的打模型跑完,看这个内核在这个量级上扛不扛得住。**这一步之所以必须自己做,是因为
三个消费者一个都还没到能用它的时候**,而「从没被任何人用过」是它当时最大的未验证项, 三个消费者一个都还没到能用它的时候**,而「从没被任何人用过」是它当时最大的未验证项,
+75 -40
View File
@@ -5,15 +5,7 @@
> 第 3 档是定期复审。能用强的就不用弱的,完整说明见 `../README.md`。 > 第 3 档是定期复审。能用强的就不用弱的,完整说明见 `../README.md`。
> >
> 本文件的第七、八、九节是**第 1 档**:那三节讲的分层、模块边界与抽象接缝由 `pyproject.toml` > 本文件的第七、八、九节是**第 1 档**:那三节讲的分层、模块边界与抽象接缝由 `pyproject.toml`
> 的 import-linter 契约与 `tests/contract/` 断言。**其余章节是第 2 档**。 > 的 import-linter 契约与 `polyloop.testing` 那套契约套件断言。**其余章节是第 2 档**。
>
> **当前状态:本文件描述的是目标结构,`src/` 下一行代码都没有。** 常青层本该描述当前真实
> 情况,而这份在代码之前就存在。接受这个例外的理由与它的过期条件见
> `../../README.md` 的阶段清单第 ③ 条。`src/` 落地完成后删除本段。
>
> 由此带来一个读者必须知道的约定:**后文以现在时提到的 `polyloop/` 路径,指的是落地之后
> 该内容所在的位置**,不一定是现在就能打开的模块。这么写是为了让这份文档在代码落地那天
> 不需要逐句改时态。
> >
> **本文件与 design doc 冲突时以本文件为准。** design doc 写完就冻结,它记录的是当时定了 > **本文件与 design doc 冲突时以本文件为准。** design doc 写完就冻结,它记录的是当时定了
> 什么;本文件描述的是现在是什么样。两者对同一件事都会提到,这是有意的——但理由只在 > 什么;本文件描述的是现在是什么样。两者对同一件事都会提到,这是有意的——但理由只在
@@ -247,13 +239,14 @@ PolyLoop 把这个循环收成一份。它治理的单位是**一次运行**:
`A ──▶ B` 读作「A 的代码里写了 `from polyloop.B import ...`」,也就是 A 依赖 B。 `A ──▶ B` 读作「A 的代码里写了 `from polyloop.B import ...`」,也就是 A 依赖 B。
``` ```
第 4 层 ┌───────────────┐ ┌───────────────┐ ┌────────────────┐ 第 4 层 ┌──────────── ┌────────────┐ ┌────────────┐ ┌────────────┐
装配层 │ session stores adapters │ 装配层 │ session │ stores │ adapters │ │ testing
定义、请求 │ │ jsonl / 内存│ PolyGateway │ 定义、请求 │ │jsonl / 内存│ PolyGateway│ │ 契约套件
run、resume │ │ 存储实现 │ │ 适配器 │ run、resume│ │ 存储实现 │ │ 适配器 │ │ 准入基类
└───────────────┘ └───────────────┘ └────────────────┘ └──────────── └────────────┘ └────────────┘ └────────────┘
个互不 import:session 不认识任何具体实现,具体实现也不 个互不 import:session 不认识任何具体实现,具体实现也不
认识 session。把它们装到一起的是调用方,不是库自己 认识 session,而契约套件三个都不认识——它只认接缝定义。把它们
装到一起的是调用方,不是库自己
第 3 层 ┌───────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ 第 3 层 ┌───────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐
@@ -277,8 +270,9 @@ PolyLoop 把这个循环收成一份。它治理的单位是**一次运行**:
不必逐层往下传——分层禁止的是往上,不是要求一层一层往下 不必逐层往下传——分层禁止的是往上,不是要求一层一层往下
``` ```
第 4 层里 `stores``adapters` 只够到第 2 层(它们 import `ports` 去实现那些 Protocol 第 4 层里 `stores``adapters``testing` 只够到第 2 层(前两个 import `ports` 去实现那些
再 import `types` 用那些数据类型),不需要碰第 3 层。 Protocol再 import `types` 用那些数据类型;契约套件 import 同样这两处,用来给下游的实现出
题),不需要碰第 3 层。
**存储实现在上面而不是下面,这一点最容易画反。** 直觉上存储是底层设施,该垫在最底下; **存储实现在上面而不是下面,这一点最容易画反。** 直觉上存储是底层设施,该垫在最底下;
但按 import 方向,是存储实现去 import 接口定义,所以它在接口之上。库的核心不认识任何具体 但按 import 方向,是存储实现去 import 接口定义,所以它在接口之上。库的核心不认识任何具体
@@ -287,19 +281,25 @@ PolyLoop 把这个循环收成一份。它治理的单位是**一次运行**:
判据只有一句:**`polyloop.types` 是依赖图的汇点**——所有箭头最终都指向它,没有一根从它出去。 判据只有一句:**`polyloop.types` 是依赖图的汇点**——所有箭头最终都指向它,没有一根从它出去。
它就是「字段只增不删不改名」保护的那份合同本身。 它就是「字段只增不删不改名」保护的那份合同本身。
### 条依赖规则 ### 条依赖规则
这些规则在代码里是**看不见的**——打开 `polyloop/ports/` 只会看到一堆正常的 Protocol, 这些规则在代码里是**看不见的**——打开 `polyloop/ports/` 只会看到一堆正常的 Protocol,
看不到那里缺了什么。所以必须写下来,并且每一条都有对应的机器断言。 看不到那里缺了什么。所以必须写下来,并且每一条都有对应的机器断言。
**一、分层,自高向低**:装配层(`session``stores``adapters`> 逻辑层(`tools` **一、分层,自高向低**:装配层(`session``stores``adapters``testing`> 逻辑层(`tools`
`_assembly``_stopping``_recovery``serialization`> `ports` > `types`。低层不许 `_assembly``_stopping``_recovery``serialization`> `ports` > `types`。低层不许
import 高层。 import 高层。
**二、装配层那个互相独立。** `session` 不许 import `stores``adapters`,反过来也不许 **二、装配层那个互相独立。** `session``stores``adapters``testing` 两两之间都不许
这条不能靠分层规则表达——同一层的模块在分层契约里默认是可以互相 import 的,要另立一条独立性 import。这条不能靠分层规则表达——同一层的模块在分层契约里默认是可以互相 import 的,要另立
契约。它守的是「库不顺手提供任何默认实现」:`session` 一旦 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** **三、`tools`、`_assembly`、`_stopping`、`_recovery`、`serialization` 五者互不 import**
不设豁免。它们之间的编织只能发生在 `session` 里。理由是这五个模块各自要能被单独测穷, 不设豁免。它们之间的编织只能发生在 `session` 里。理由是这五个模块各自要能被单独测穷,
@@ -330,11 +330,18 @@ import 高层。
**八、`ports` 不许 import 其余任何 `polyloop` 模块。** 第一条已覆盖,单列是为了让违规信息 **八、`ports` 不许 import 其余任何 `polyloop` 模块。** 第一条已覆盖,单列是为了让违规信息
直接指向「接缝定义模块被污染了」,而不是一条泛泛的分层报错。 直接指向「接缝定义模块被污染了」,而不是一条泛泛的分层报错。
**九、`import polyloop` 之后,`sys.modules` 里不许出现 `polygateway`。** 这条由契约测试 **九、`import polyloop` 之后,`sys.modules` 里不许出现 `polygateway`。** 这条由测试断言,
断言,不是 import-linter。顶层只再导出五个公开模块;`stores``adapters` 必须显式 不是 import-linter。顶层只再导出五个公开模块;`stores``adapters` 必须显式
import。理由是一个「顺手提供的默认模型客户端」会让每个进程在 import 时把网关连同它的 import。理由是一个「顺手提供的默认模型客户端」会让每个进程在 import 时把网关连同它的
provider 目录一起拉起来。 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/_recovery/` | 否 | 恢复状态判定与运行身份校验 |
| `polyloop/stores/` | 是,须显式 import | 库自带的存储实现 | | `polyloop/stores/` | 是,须显式 import | 库自带的存储实现 |
| `polyloop/adapters/` | 是,须显式 import | PolyGateway 模型适配器 | | `polyloop/adapters/` | 是,须显式 import | PolyGateway 模型适配器 |
| `polyloop/testing/` | 是,须显式 import | 五个接缝的契约套件:每个接缝一个基类,下游继承它验自己的实现 |
`polyloop/types/` 的读者是所有人:dissect 的 runner 读步记录与停止原因把轨迹头拼回去, `polyloop/types/` 的读者是所有人:dissect 的 runner 读步记录与停止原因把轨迹头拼回去,
GovDoc 的编排层读停止原因决定这个阶段算不算可挽救,写任何适配器的人都要构造这里的结构体。 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/` 下有哪些位置,和第八节那张表逐行对得上。表里有一行
在代码里找不到、或者代码里多出一个没写进表的位置,都算失败。没有这一条,新加的模块会 在代码里找不到、或者代码里多出一个没写进表的位置,都算失败。没有这一条,新加的模块会
悄悄绕过第七节的分层规则——一个规则里没提到的模块,等于没有任何约束。 悄悄绕过第七节的分层规则——一个规则里没提到的模块,等于没有任何约束。
- **第九节**——一个断言检查接缝的数量和位置没有悄悄增长;另一个断言检查 `polyloop/` - **第九节**——一个断言检查接缝的数量和位置没有悄悄增长;另一个断言检查 `polyloop/`
不出现重试、限流、熔断的实现。 不出现重试、限流、熔断的实现。
**写到哪一步了:一条都没** 这些断言要等 `src/` 落地才写得出来,在那之前这三节没有机器 **写到哪一步了:第七节那十条已经全部有断言,第八、九节还一条都没** 那两节现在没有机器
兜底,只能靠人在实现时逐条对照。这是「架构文档先于代码存在」这个安排最实在的代价 兜底,只能靠人在改代码时逐条对照——表里多一行少一行、接缝悄悄变成六个,CI 都不会响
其余章节是第 2 档:改相关代码时,改本文件是同一个提交的一部分。 其余章节是第 2 档:改相关代码时,改本文件是同一个提交的一部分。
@@ -530,8 +559,8 @@ dissect 的每一次运行都是论文数据点,这个性质对结构提了几
**公共类型的字段与枚举取值不在本文件里。** 英文名与签名定在 **公共类型的字段与枚举取值不在本文件里。** 英文名与签名定在
`../design/0006-public-names-and-signatures.md`,行为契约定在 `../design/0007-seam-behaviour.md` `../design/0006-public-names-and-signatures.md`,行为契约定在 `../design/0007-seam-behaviour.md`
落地之后权威转移到 `src/polyloop/` 的代码与 `tests/contract/`。本文件只给五个接缝的 落地之后权威转移到 `src/polyloop/` 的代码与 `polyloop/testing/` 那套契约套件。本文件只给五个
Protocol 名与模块归属,不复述字段表——按 `../../CLAUDE.md` §0,那种复述腐烂的速度和代码一样快。 接缝的 Protocol 名与模块归属,不复述字段表——按 `../../CLAUDE.md` §0,那种复述腐烂的速度和代码一样快。
**停止判定的顺序、停止原因的取值、两个预算计数的语义、步记录的字段清单不在本文件里。** **停止判定的顺序、停止原因的取值、两个预算计数的语义、步记录的字段清单不在本文件里。**
这四样已经定了,在 `../design/0004-stopping-and-step-record.md`,字段表经 这四样已经定了,在 `../design/0004-stopping-and-step-record.md`,字段表经
@@ -539,9 +568,6 @@ Protocol 名与模块归属,不复述字段表——按 `../../CLAUDE.md` §0
`../../CLAUDE.md` §0,公共类型的字段与枚举取值的权威是 `src/polyloop/`,不另写参考文档复述。 `../../CLAUDE.md` §0,公共类型的字段与枚举取值的权威是 `src/polyloop/`,不另写参考文档复述。
那份 design doc 记的是第一版为什么定成这样,不是查字段的地方。 那份 design doc 记的是第一版为什么定成这样,不是查字段的地方。
**事件出口的事件类型还没定**,所以这个接缝的契约套件现在写不了。方向已经定了——观察走
事件流、干预走具名回调——但事件集与回调清单要独立成篇。
**多模态内容的规模度量没有答案。** 消息内容是块序列而不是裸字符串,第一版只定义文本块, **多模态内容的规模度量没有答案。** 消息内容是块序列而不是裸字符串,第一版只定义文本块,
它的度量是准确的字符数。将来加图片块时必须同时给出它的度量定义,以及上下文上限在混合 它的度量是准确的字符数。将来加图片块时必须同时给出它的度量定义,以及上下文上限在混合
内容下的语义。 内容下的语义。
@@ -562,6 +588,15 @@ Protocol 名与模块归属,不复述字段表——按 `../../CLAUDE.md` §0
| 存储的方法为什么这么切、为什么要前缀持久性、步记录那三处为什么改 | `../design/0005-storage-atomicity-and-record-fields.md` | | 存储的方法为什么这么切、为什么要前缀持久性、步记录那三处为什么改 | `../design/0005-storage-atomicity-and-record-fields.md` |
| 公共类型与接缝叫什么、字段是什么形状、类型分到哪个模块 | `../design/0006-public-names-and-signatures.md` | | 公共类型与接缝叫什么、字段是什么形状、类型分到哪个模块 | `../design/0006-public-names-and-signatures.md` |
| 三个动作状态什么时候赋上、动作被拒绝时观察从哪来、解释器能不能抛异常 | `../design/0007-seam-behaviour.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`,那份是常青的,会随新消费者 边界的当前裁决清单(哪些在界内、哪些在界外)在 `scope.md`,那份是常青的,会随新消费者
接入而更新。每个下游要迁什么、迁完算不算数在 `../migrations/` 下对应那份。 接入而更新。每个下游要迁什么、迁完算不算数在 `../migrations/` 下对应那份。
+7 -5
View File
@@ -210,11 +210,13 @@ PolyLoop 的日志按运行标识分文件(`../design/0011-jsonl-run-store.md`
**要选一个存储实现并给它一个目录。** 存储接缝是必填的,不传就装配不起来。dissect 要恢复 **要选一个存储实现并给它一个目录。** 存储接缝是必填的,不传就装配不起来。dissect 要恢复
能力,所以装 `JsonlRunStore`,给它一个目录。 能力,所以装 `JsonlRunStore`,给它一个目录。
**「不提供恢复的内存实现」现在不存在。** `../design/0003` 的否决方案那一节定过:存储接缝 **「不提供恢复的内存实现」`VolatileRunStore`。** `../design/0003` 的否决方案那一节定过:
必填,另外给一个明确命名的、不提供恢复的实现,好让「我不要恢复」成为一次看得见的选择而不是 存储接缝必填,另外给一个明确命名的、不提供恢复的实现,好让「我不要恢复」成为一次看得见的
一个可以忘记传的参数。那个实现至今没写,`polyloop.stores` 里只有 `JsonlRunStore`。这一条对 选择而不是一个可以忘记传的参数。它现在和 `JsonlRunStore` 一起住在 `polyloop.stores`,两个
dissect 不构成阻塞——它本来就要恢复——但**不要照着那句话去找一个不存在的类**。真需要它的 实现跑的是同一套契约套件。它不提供的是**跨进程恢复**:日志随进程一起消失,选它就是选「我
那天再补,补的时候要连同契约套件一起过。 不要跨进程恢复」。名字为什么落在「易失」而不是「不提供恢复」上,见
`../design/0014-contract-suite-distribution.md` 决策五。这一条对 dissect 不构成阻塞——它本来
就要恢复。
**模型绑定要从关键字参数还原。** dissect 现在给每次调用传五个关键字参数(账本、轮次、 **模型绑定要从关键字参数还原。** dissect 现在给每次调用传五个关键字参数(账本、轮次、
阶段、题目、尝试序号)。库这边接的是一个字符串映射,所以适配器要做一次还原(把账本那个 阶段、题目、尝试序号)。库这边接的是一个字符串映射,所以适配器要做一次还原(把账本那个
+1 -1
View File
@@ -9,7 +9,7 @@ import 时把网关连同它的 provider 目录一起拉起来,而不传存储
没有恢复能力。 没有恢复能力。
分层与依赖方向见 `research-wiki/explanation/architecture.md`,一次运行到底保证什么见 分层与依赖方向见 `research-wiki/explanation/architecture.md`,一次运行到底保证什么见
`tests/contract/` `polyloop.testing` 那套契约套件
""" """
#: 与 `pyproject.toml` 的 `project.version` 必须一致,由 `tests/unit/test_package.py` 断言。 #: 与 `pyproject.toml` 的 `project.version` 必须一致,由 `tests/unit/test_package.py` 断言。
+4 -3
View File
@@ -229,8 +229,9 @@ class LogRead:
"""一份 `.jsonl` 日志按行读回来的结果。 """一份 `.jsonl` 日志按行读回来的结果。
`torn` 说的是末尾有没有一段没被换行终结的字节。判据照存储那边的规矩:**看有没有被换行 `torn` 说的是末尾有没有一段没被换行终结的字节。判据照存储那边的规矩:**看有没有被换行
终结,不看能不能解析**`polyloop.stores` 的 `_parse`)。那段字节对应的那次写从来没有被 终结,不看能不能解析**`polyloop.stores` 那个逐行追加的实现,理由在
确认过,按契约它就是没发生。 `research-wiki/design/0011-jsonl-run-store.md`)。那段字节对应的那次写从来没有被确认过,
按契约它就是没发生。
""" """
payloads: tuple[Mapping[str, object], ...] payloads: tuple[Mapping[str, object], ...]
@@ -1423,7 +1424,7 @@ class AlwaysInvalidParser:
"""对任何模型输出都返回无效决策。解析失败连击那一类用它。 """对任何模型输出都返回无效决策。解析失败连击那一类用它。
它不是「解析不出来」,是**声明这一步解释不了**——契约要求 `parse` 同步、不抛异常、解释 它不是「解析不出来」,是**声明这一步解释不了**——契约要求 `parse` 同步、不抛异常、解释
不出来时返回 `InvalidDecision``tests/contract/test_decision_parser.py`),这份实现照做。 不出来时返回 `InvalidDecision``polyloop.testing.DecisionParserContract`),这份实现照做。
""" """
def parameters(self) -> Mapping[str, str]: def parameters(self) -> Mapping[str, str]:
+9 -5
View File
@@ -173,11 +173,15 @@ class AppWorldParser:
history_text=output, history_text=output,
decision=InvalidDecision(explanation=_FENCE_NOT_ON_ITS_OWN_LINE), decision=InvalidDecision(explanation=_FENCE_NOT_ON_ITS_OWN_LINE),
) )
# **这里与被复刻的 dissect 实现有一处刻意的不同**:dissect 在这一支给历史文本补回 # **这里与被复刻的 dissect 实现有一处不同**:dissect 在这一支给历史文本补回结尾的
# 结尾的三反引号(`parser.py:133-136`),让那条 assistant 消息形态完整;我们不补 # 三反引号(`parser.py:133-136`),让那条 assistant 消息形态完整;这里不补
# 因为公共契约要求 `len(history_text) <= len(reply.content)` # 这处不同当初是为了迁就公共契约里一条「history_text 不长于模型原文」的断言,那条
# `tests/contract/test_decision_parser.py:30`),补一个字符就违约。代价可以接受: # 断言已经撤销(`polyloop.testing.DecisionParserContract` 里对应那条用例的 docstring
# 压测不给模型配 stop 序列,这条路径基本不触发。代码抽取本身照旧兜底 # 写着理由),所以它现在没有存在的必要,是一笔记在案的欠账,不是一条长期决定
# 留着不恢复是因为收益不抵成本:补回去要重跑一次打真实网关的压测,才能继续说
# 2026-08-11 那批轨迹是当前这版场景跑出来的;而这条路径要模型输出被 stop 序列截断
# 才走得到,压测不给模型配 stop 序列。哪天要重跑压测,顺手把它补回来。代码抽取本身
# 照旧兜底。
return ParsedReply( return ParsedReply(
history_text=output, history_text=output,
decision=Action(text=code, tool_call=None), decision=Action(text=code, tool_call=None),
+2 -2
View File
@@ -750,8 +750,8 @@ class GovDocParser:
""" """
def parse(self, reply: ModelReply) -> ParsedReply: def parse(self, reply: ModelReply) -> ParsedReply:
# 契约要求 `len(history_text) <= len(reply.content)``tests/contract/ # 压测这一侧的解析器一律只截不补,所以直接用原文,不拼接任何东西。公共契约
# test_decision_parser.py:30`),所以直接用原文,不拼接任何东西 # `polyloop.testing.DecisionParserContract`)两种都放行,这条是压测自己的选择
history = reply.content history = reply.content
decision = self._decide(reply.content) decision = self._decide(reply.content)
return ParsedReply(history_text=history, decision=decision) return ParsedReply(history_text=history, decision=decision)
+12 -8
View File
@@ -2,9 +2,11 @@
分三块:解析器、提示词装配、动作执行器。 分三块:解析器、提示词装配、动作执行器。
解析器那块的前五条是 `tests/contract/test_decision_parser.py` 那份公共契约的逐条复刻。契约 解析器那块的前五条照着 `polyloop.testing.DecisionParserContract` 那份公共契约。契约套件
套件本身是给下游接自己的实现用的(在自己的 `conftest.py` 里覆盖 fixture,压测这边不接那 本身是给下游继承基类、在自己的子类里覆盖必需 fixture 用的,压测这边不接那套装配、只把五条
套装配、只把五条断言照着写一遍——它是任何新适配器的准入标准,压测的适配器也是适配器。 断言照着写一遍——它是任何新适配器的准入标准,压测的适配器也是适配器。其中一条另外多守了一件
公共契约没要求的事:`history_text` 不长于模型原文。公共契约不断言长度(解析器有权改写那段
文本,改写既可能截短也可能补写),那条断言守的是压测这一侧自己的选择——解析器一律只截不补。
执行器那块用一个假会话,不起容器:这里要验的是「环境返回什么 → 结果对象怎么填」这个映射, 执行器那块用一个假会话,不起容器:这里要验的是「环境返回什么 → 结果对象怎么填」这个映射,
而那个映射与容器里发生了什么无关。真起容器的那条链路由 `tools/soak/check_appworld.py` 走。 而那个映射与容器里发生了什么无关。真起容器的那条链路由 `tools/soak/check_appworld.py` 走。
@@ -68,11 +70,13 @@ def test_parse_is_synchronous():
"什么代码都没有", # 一个围栏都没有 "什么代码都没有", # 一个围栏都没有
], ],
) )
def test_history_text_never_grows(content): def test_scenario_parser_only_trims_history_text(content):
"""`history_text` 不会比模型原文长(契约二) """`history_text` 不会比模型原文长。这守的是这个场景自己的实现选择,不是公共契约
这一条正是我们与被复刻的 dissect 实现分道的地方:dissect 在「未闭合围栏兜底」那一支给 公共契约不断言长度:解析器有权改写回填历史的那段文本,改写既可能截短也可能补写
历史文本补回结尾的三反引号,补一个字符就会让这条断言失败。 `polyloop.testing.DecisionParserContract`)。这个场景的解析器一律只截不补,所以长度
不会涨。它也正是我们与被复刻的 dissect 实现分道的地方:dissect 在「未闭合围栏兜底」那一
支给历史文本补回结尾的三反引号,补一个字符就会让这条断言失败。
""" """
parsed = AppWorldParser().parse(_reply(content)) parsed = AppWorldParser().parse(_reply(content))
@@ -213,7 +217,7 @@ def test_empty_fence_is_a_failure_with_its_own_explanation():
def test_unclosed_fence_still_yields_code(): def test_unclosed_fence_still_yields_code():
"""未闭合的围栏仍然抽得出代码,且历史文本不比原文长。 """未闭合的围栏仍然抽得出代码,且历史文本不比原文长。
dissect 在这一支补回结尾的三反引号,我们不补——公共契约要求 history_text 不长于原文 dissect 在这一支补回结尾的三反引号,我们不补——压测的解析器一律只截不补
""" """
content = "我来查一下。\n\n```python\nprint(apis.api_docs.show_app_descriptions())" content = "我来查一下。\n\n```python\nprint(apis.api_docs.show_app_descriptions())"
+2 -1
View File
@@ -604,7 +604,8 @@ def test_always_invalid_parser_is_sync_and_never_raises() -> None:
parsed = parser.parse(ModelReply(call_id=None, content="```python\nprint(1)\n```", thinking="")) parsed = parser.parse(ModelReply(call_id=None, content="```python\nprint(1)\n```", thinking=""))
assert isinstance(parsed.decision, InvalidDecision) assert isinstance(parsed.decision, InvalidDecision)
assert parsed.decision.explanation.strip() assert parsed.decision.explanation.strip()
# 契约:回填历史的那段不比模型原文长。 # 回填历史的那段不比模型原文长。公共契约不断言长度,这守的是这份注入用解析器自己的形状:
# 它原样回填模型原文,不拼接任何东西。
assert len(parsed.history_text) <= len("```python\nprint(1)\n```") assert len(parsed.history_text) <= len("```python\nprint(1)\n```")
assert parser.parameters()["kind"] == "always_invalid" assert parser.parameters()["kind"] == "always_invalid"
+12 -4
View File
@@ -5,9 +5,11 @@
脱敏那块里最重要的一条是「校验函数对未脱敏文本确实会抛异常」。一个永远返回通过的校验函数比 脱敏那块里最重要的一条是「校验函数对未脱敏文本确实会抛异常」。一个永远返回通过的校验函数比
没有校验更糟:它会让所有人以为这道闸在守着,而它什么都没守。 没有校验更糟:它会让所有人以为这道闸在守着,而它什么都没守。
解析器那块的前五条是 `tests/contract/test_decision_parser.py` 那份公共契约的逐条复刻。契约 解析器那块的前五条照着 `polyloop.testing.DecisionParserContract` 那份公共契约。契约套件
套件本身是给下游接自己的实现用的(在自己的 `conftest.py` 里覆盖 fixture,压测这边不接那套 本身是给下游继承基类、在自己的子类里覆盖必需 fixture 用的,压测这边不接那套装配、只把五条
装配、只把五条断言照着写一遍——它是任何新适配器的准入标准,压测的适配器也是适配器。 断言照着写一遍——它是任何新适配器的准入标准,压测的适配器也是适配器。其中一条另外多守了一件
公共契约没要求的事:`history_text` 不长于模型原文。公共契约不断言长度(解析器有权改写那段
文本,改写既可能截短也可能补写),那条断言守的是压测这一侧自己的选择——解析器一律只截不补。
用真实数据的那几条在数据目录不在时跳过而不是失败:那份数据是另一个项目的工作副本,不在本仓库 用真实数据的那几条在数据目录不在时跳过而不是失败:那份数据是另一个项目的工作副本,不在本仓库
里,换一台机器就没有。 里,换一台机器就没有。
@@ -178,7 +180,13 @@ def test_contract_1_parse_is_synchronous():
@pytest.mark.parametrize("content", [_ACTION_REPLY, _INVALID_REPLY, "", "```json\n{}\n```"]) @pytest.mark.parametrize("content", [_ACTION_REPLY, _INVALID_REPLY, "", "```json\n{}\n```"])
def test_contract_2_history_text_is_not_longer(content: str): def test_scenario_parser_only_trims_history_text(content: str):
"""`history_text` 不会比模型原文长。这守的是这个场景自己的实现选择,不是公共契约。
公共契约不断言长度:解析器有权改写回填历史的那段文本,改写既可能截短也可能补写
`polyloop.testing.DecisionParserContract`)。这个场景的解析器直接回填模型原文、不拼接
任何东西,所以长度不会涨。
"""
parsed = GovDocParser().parse(_reply(content)) parsed = GovDocParser().parse(_reply(content))
assert isinstance(parsed.history_text, str) assert isinstance(parsed.history_text, str)
assert len(parsed.history_text) <= len(content) assert len(parsed.history_text) <= len(content)