diff --git a/CHANGELOG.md b/CHANGELOG.md index 40ac9d6..180f04c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.` 进参数快照, +不透传给模型调用。 + +它和 `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.1(2026-08-11) 首个发布版本。十个模块全部落地,四层测试都在跑。 diff --git a/CLAUDE.md b/CLAUDE.md index 464aa8d..5c78dd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,8 @@ | 这类事实 | 权威处 | |---|---| | **哪些事归本库管、哪些不归**,以及判据 | `research-wiki/explanation/scope.md` | -| 分层、依赖方向、模块边界 | `research-wiki/explanation/architecture.md`,由 `pyproject.toml` 的 import-linter 契约机器断言。九条依赖规则里有两条落不进契约(「不许 import 任何第三方」不是可枚举清单,「import 之后 `sys.modules` 里没有谁」是运行时事实),它们是 `tests/unit/` 里的测试 | -| 公共 API 的行为契约:一次 `run` 到底保证什么、边界条件怎么结算 | `tests/contract/` 的公共契约套件。它同时是任何新适配器的准入标准 | +| 分层、依赖方向、模块边界 | `research-wiki/explanation/architecture.md`,由 `pyproject.toml` 的 import-linter 契约机器断言。十条依赖规则里有两条落不进契约、还有一条只有一半落得进(「不许 import 任何第三方」不是可枚举清单,「import 之后 `sys.modules` 里没有谁」是运行时事实),落不进的那些是 `tests/unit/` 里的测试 | +| 公共 API 的行为契约:一次 `run` 到底保证什么、边界条件怎么结算 | `src/polyloop/testing/` 的公共契约套件,随包发布。它同时是任何新适配器的准入标准 | | 公共类型的字段、不变量、枚举取值 | `src/polyloop/` 的代码与其测试。**不另写一份参考文档复述它们**——那份文档不重复代码的内容太少,而它腐烂的速度和代码一样快 | | 每个下游项目要迁走什么、迁完算不算数 | `research-wiki/migrations/` 下对应那份 | | 已定的决策及其理由 | `research-wiki/design/` 下相关编号最大的那份 | @@ -53,7 +53,7 @@ 6. **`asyncio.CancelledError` 永不捕获吞没。** 取消要能穿过模型调用与环境执行,in-flight 资源在 `finally` 释放。吞掉它的后果不是「取消失败」这么直白——是容器租约、连接和临时目录持续泄漏,而且一声不吭。 7. **禁止吞掉错误**(`except Exception: pass` 及其跨行形态)。由 ruff `S110` / `E722` 断言。 8. **测试绑行为,不绑实现。** 不写「断言某个内部类有哪些方法」这类测试——它只会让重构连坐。 - **公共 Protocol 的签名是例外**:它本身就是对下游的承诺,不是实现细节,所以 `tests/contract/` 断言它是应该的。判据是这个名字有没有对外承诺过——承诺过的改名是破坏性变更(§1.3),断言它就是在守那条承诺;没承诺过的改名只是重构,断言它就是在拖后腿。 + **公共 Protocol 的签名是例外**:它本身就是对下游的承诺,不是实现细节,所以 `src/polyloop/testing/` 断言它是应该的。判据是这个名字有没有对外承诺过——承诺过的改名是破坏性变更(§1.3),断言它就是在守那条承诺;没承诺过的改名只是重构,断言它就是在拖后腿。 **断言某个名字「不存在」也是允许的**,用来守住一次删除决策。一个已经被删掉的字段没法被重命名,拖不动测试。代价是它守的只是名字不是概念——换个名字把同一个概念加回来,测试照样绿,所以理由必须同时写在被删字段所在类型的 docstring 里。 9. **测试分层按「依赖什么」定,不按「叫什么」定。** 用测试替身的是 unit,连真 PolyGateway 的是 integration,打真实模型网关的是 e2e,验证公共 Protocol 行为一致性的是 contract。按名字分层的话,改个函数名就要挪测试文件;按依赖分,只要这个测试还是不连外部服务,它就一直待在原地。四层之间更细的界线在搭测试框架那个阶段定,现在不必较真。 10. **发布 = 合并 + push + tag + 构建 + 上传 registry + 验证已发布。只 bump 版本号不叫发布。** 教训来自 PolyGateway:1.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/ 查得到的事实:日志字段契约、遥测口径 (公共类型和枚举取值不在这里,权威见 §0 表格) ★ research-wiki/scratch/ 一次性草稿。进 git,但由人在每轮工作会话结束前清理(AI 不要自动删) -★ tests/contract/ 公共 Protocol 的行为一致性套件,是那份契约的权威(§0), - 也是任何新适配器的准入标准 +★ tests/contract/ 把库自带的实现接到契约套件上的那几个子类。套件本身不在这里 ★ tests/e2e/ 打真实模型网关,会产生真实费用。默认不跑,两道闸见 .env.example -★ src/polyloop/ 库本体,十个模块 +★ src/polyloop/ 库本体,十一个模块 +★ src/polyloop/testing/ 公共 Protocol 的行为一致性套件,是那份契约的权威(§0), + 也是任何新适配器的准入标准。它随包发布,下游装了就拿得到 ``` 常青层与记录层的分界、各类的更新触发点、`scratch/` 那条人工清理规则的已知风险,都在 `research-wiki/README.md`。 diff --git a/README.md b/README.md index c82dff0..47d73d3 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,18 @@ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/py "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` 之前。 **这台开发机上的注意事项**:它设了 `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)。 e2e 打真实模型网关、会产生真实费用,所以默认不跑:要 `POLYLOOP_E2E=1` 加显式 - `pytest -m e2e`,配置见 [.env.example](.env.example)。`tests/contract/` 那套公共行为 - 一致性用例接上了自带的存储实现,解释器与执行器那几条仍等下游把实现接进来 -- [x] ⑤ 实现 —— 十个模块全部落地,五个接缝都有调用点。**一处已知欠账**:`stores` 只有逐行 - 追加那一种形态,关系数据库那种由下游自己实现,契约套件是它的准入标准 + `pytest -m e2e`,配置见 [.env.example](.env.example)。那套公共行为一致性用例住在 + `polyloop.testing` 里随包发出去,五个接缝在本仓库都接上了实现跑起来,接点是 + `tests/contract/` 与 `tests/integration/` 下那几个继承契约基类的子类 +- [x] ⑤ 实现 —— 十一个模块全部落地,五个接缝都有调用点。`stores` 有两种形态:逐行追加进 + 本地文件的那个,和只留在进程内存里、进程一退就没了的那个。关系数据库那种仍然由下游 + 自己实现,契约套件是它的准入标准,而套件随包发布在 `polyloop.testing` 里 - [x] ⑥ 验收 —— 两件事都做完了。**一是自己造负载压**:照三个消费者将来的用法造负载,用真实 数据真的打模型跑完,看这个内核在这个量级上扛不扛得住。**这一步之所以必须自己做,是因为 三个消费者一个都还没到能用它的时候**,而「从没被任何人用过」是它当时最大的未验证项, diff --git a/research-wiki/explanation/architecture.md b/research-wiki/explanation/architecture.md index 97b8c63..572f5e0 100644 --- a/research-wiki/explanation/architecture.md +++ b/research-wiki/explanation/architecture.md @@ -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/` 下对应那份。 diff --git a/research-wiki/migrations/dissect.md b/research-wiki/migrations/dissect.md index 6f263a4..d0b05b9 100644 --- a/research-wiki/migrations/dissect.md +++ b/research-wiki/migrations/dissect.md @@ -210,11 +210,13 @@ PolyLoop 的日志按运行标识分文件(`../design/0011-jsonl-run-store.md` **要选一个存储实现并给它一个目录。** 存储接缝是必填的,不传就装配不起来。dissect 要恢复 能力,所以装 `JsonlRunStore`,给它一个目录。 -**「不提供恢复的内存实现」现在不存在。** `../design/0003` 的否决方案那一节定过:存储接缝 -必填,另外给一个明确命名的、不提供恢复的实现,好让「我不要恢复」成为一次看得见的选择而不是 -一个可以忘记传的参数。那个实现至今没写,`polyloop.stores` 里只有 `JsonlRunStore`。这一条对 -dissect 不构成阻塞——它本来就要恢复——但**不要照着那句话去找一个不存在的类**。真需要它的 -那天再补,补的时候要连同契约套件一起过。 +**「不提供恢复的内存实现」叫 `VolatileRunStore`。** `../design/0003` 的否决方案那一节定过: +存储接缝必填,另外给一个明确命名的、不提供恢复的实现,好让「我不要恢复」成为一次看得见的 +选择而不是一个可以忘记传的参数。它现在和 `JsonlRunStore` 一起住在 `polyloop.stores`,两个 +实现跑的是同一套契约套件。它不提供的是**跨进程恢复**:日志随进程一起消失,选它就是选「我 +不要跨进程恢复」。名字为什么落在「易失」而不是「不提供恢复」上,见 +`../design/0014-contract-suite-distribution.md` 决策五。这一条对 dissect 不构成阻塞——它本来 +就要恢复。 **模型绑定要从关键字参数还原。** dissect 现在给每次调用传五个关键字参数(账本、轮次、 阶段、题目、尝试序号)。库这边接的是一个字符串映射,所以适配器要做一次还原(把账本那个 diff --git a/src/polyloop/__init__.py b/src/polyloop/__init__.py index 6e8b221..2620ddf 100644 --- a/src/polyloop/__init__.py +++ b/src/polyloop/__init__.py @@ -9,7 +9,7 @@ import 时把网关连同它的 provider 目录一起拉起来,而不传存储 没有恢复能力。 分层与依赖方向见 `research-wiki/explanation/architecture.md`,一次运行到底保证什么见 -`tests/contract/`。 +`polyloop.testing` 那套契约套件。 """ #: 与 `pyproject.toml` 的 `project.version` 必须一致,由 `tests/unit/test_package.py` 断言。 diff --git a/tools/soak/faults.py b/tools/soak/faults.py index 20fec4f..40cb751 100644 --- a/tools/soak/faults.py +++ b/tools/soak/faults.py @@ -229,8 +229,9 @@ class LogRead: """一份 `.jsonl` 日志按行读回来的结果。 `torn` 说的是末尾有没有一段没被换行终结的字节。判据照存储那边的规矩:**看有没有被换行 - 终结,不看能不能解析**(`polyloop.stores` 的 `_parse`)。那段字节对应的那次写从来没有被 - 确认过,按契约它就是没发生。 + 终结,不看能不能解析**(`polyloop.stores` 那个逐行追加的实现,理由在 + `research-wiki/design/0011-jsonl-run-store.md`)。那段字节对应的那次写从来没有被确认过, + 按契约它就是没发生。 """ payloads: tuple[Mapping[str, object], ...] @@ -1423,7 +1424,7 @@ class AlwaysInvalidParser: """对任何模型输出都返回无效决策。解析失败连击那一类用它。 它不是「解析不出来」,是**声明这一步解释不了**——契约要求 `parse` 同步、不抛异常、解释 - 不出来时返回 `InvalidDecision`(`tests/contract/test_decision_parser.py`),这份实现照做。 + 不出来时返回 `InvalidDecision`(`polyloop.testing.DecisionParserContract`),这份实现照做。 """ def parameters(self) -> Mapping[str, str]: diff --git a/tools/soak/scenarios/appworld.py b/tools/soak/scenarios/appworld.py index 8960bb2..04fb414 100644 --- a/tools/soak/scenarios/appworld.py +++ b/tools/soak/scenarios/appworld.py @@ -173,11 +173,15 @@ class AppWorldParser: history_text=output, decision=InvalidDecision(explanation=_FENCE_NOT_ON_ITS_OWN_LINE), ) - # **这里与被复刻的 dissect 实现有一处刻意的不同**:dissect 在这一支给历史文本补回 - # 结尾的三反引号(`parser.py:133-136`),让那条 assistant 消息形态完整;我们不补, - # 因为公共契约要求 `len(history_text) <= len(reply.content)` - # (`tests/contract/test_decision_parser.py:30`),补一个字符就违约。代价可以接受: - # 压测不给模型配 stop 序列,这条路径基本不触发。代码抽取本身照旧兜底。 + # **这里与被复刻的 dissect 实现有一处不同**:dissect 在这一支给历史文本补回结尾的 + # 三反引号(`parser.py:133-136`),让那条 assistant 消息形态完整;这里不补。 + # 这处不同当初是为了迁就公共契约里一条「history_text 不长于模型原文」的断言,那条 + # 断言已经撤销(`polyloop.testing.DecisionParserContract` 里对应那条用例的 docstring + # 写着理由),所以它现在没有存在的必要,是一笔记在案的欠账,不是一条长期决定。 + # 留着不恢复是因为收益不抵成本:补回去要重跑一次打真实网关的压测,才能继续说 + # 2026-08-11 那批轨迹是当前这版场景跑出来的;而这条路径要模型输出被 stop 序列截断 + # 才走得到,压测不给模型配 stop 序列。哪天要重跑压测,顺手把它补回来。代码抽取本身 + # 照旧兜底。 return ParsedReply( history_text=output, decision=Action(text=code, tool_call=None), diff --git a/tools/soak/scenarios/govdoc.py b/tools/soak/scenarios/govdoc.py index 10ea0cc..c37bea6 100644 --- a/tools/soak/scenarios/govdoc.py +++ b/tools/soak/scenarios/govdoc.py @@ -750,8 +750,8 @@ class GovDocParser: """ 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 decision = self._decide(reply.content) return ParsedReply(history_text=history, decision=decision) diff --git a/tools/soak/tests/test_appworld_scenario.py b/tools/soak/tests/test_appworld_scenario.py index a3c4ef9..45c81b2 100644 --- a/tools/soak/tests/test_appworld_scenario.py +++ b/tools/soak/tests/test_appworld_scenario.py @@ -2,9 +2,11 @@ 分三块:解析器、提示词装配、动作执行器。 -解析器那块的前五条是 `tests/contract/test_decision_parser.py` 那份公共契约的逐条复刻。契约 -套件本身是给下游接自己的实现用的(在自己的 `conftest.py` 里覆盖 fixture),压测这边不接那 -套装配、只把五条断言照着写一遍——它是任何新适配器的准入标准,压测的适配器也是适配器。 +解析器那块的前五条照着 `polyloop.testing.DecisionParserContract` 那份公共契约写。契约套件 +本身是给下游继承基类、在自己的子类里覆盖必需 fixture 用的,压测这边不接那套装配、只把五条 +断言照着写一遍——它是任何新适配器的准入标准,压测的适配器也是适配器。其中一条另外多守了一件 +公共契约没要求的事:`history_text` 不长于模型原文。公共契约不断言长度(解析器有权改写那段 +文本,改写既可能截短也可能补写),那条断言守的是压测这一侧自己的选择——解析器一律只截不补。 执行器那块用一个假会话,不起容器:这里要验的是「环境返回什么 → 结果对象怎么填」这个映射, 而那个映射与容器里发生了什么无关。真起容器的那条链路由 `tools/soak/check_appworld.py` 走。 @@ -68,11 +70,13 @@ def test_parse_is_synchronous(): "什么代码都没有", # 一个围栏都没有 ], ) -def test_history_text_never_grows(content): - """`history_text` 不会比模型原文长(契约二)。 +def test_scenario_parser_only_trims_history_text(content): + """`history_text` 不会比模型原文长。这守的是这个场景自己的实现选择,不是公共契约。 - 这一条正是我们与被复刻的 dissect 实现分道的地方:dissect 在「未闭合围栏兜底」那一支给 - 历史文本补回结尾的三反引号,补一个字符就会让这条断言失败。 + 公共契约不断言长度:解析器有权改写回填历史的那段文本,改写既可能截短也可能补写 + (`polyloop.testing.DecisionParserContract`)。这个场景的解析器一律只截不补,所以长度 + 不会涨。它也正是我们与被复刻的 dissect 实现分道的地方:dissect 在「未闭合围栏兜底」那一 + 支给历史文本补回结尾的三反引号,补一个字符就会让这条断言失败。 """ 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(): """未闭合的围栏仍然抽得出代码,且历史文本不比原文长。 - dissect 在这一支补回结尾的三反引号,我们不补——公共契约要求 history_text 不长于原文。 + dissect 在这一支补回结尾的三反引号,我们不补——压测的解析器一律只截不补。 """ content = "我来查一下。\n\n```python\nprint(apis.api_docs.show_app_descriptions())" diff --git a/tools/soak/tests/test_faults.py b/tools/soak/tests/test_faults.py index fbf3c8d..87f7791 100644 --- a/tools/soak/tests/test_faults.py +++ b/tools/soak/tests/test_faults.py @@ -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="")) assert isinstance(parsed.decision, InvalidDecision) assert parsed.decision.explanation.strip() - # 契约:回填历史的那段不许比模型原文长。 + # 回填历史的那段不比模型原文长。公共契约不断言长度,这守的是这份注入用解析器自己的形状: + # 它原样回填模型原文,不拼接任何东西。 assert len(parsed.history_text) <= len("```python\nprint(1)\n```") assert parser.parameters()["kind"] == "always_invalid" diff --git a/tools/soak/tests/test_govdoc_scenario.py b/tools/soak/tests/test_govdoc_scenario.py index 7564d55..4e564f4 100644 --- a/tools/soak/tests/test_govdoc_scenario.py +++ b/tools/soak/tests/test_govdoc_scenario.py @@ -5,9 +5,11 @@ 脱敏那块里最重要的一条是「校验函数对未脱敏文本确实会抛异常」。一个永远返回通过的校验函数比 没有校验更糟:它会让所有人以为这道闸在守着,而它什么都没守。 -解析器那块的前五条是 `tests/contract/test_decision_parser.py` 那份公共契约的逐条复刻。契约 -套件本身是给下游接自己的实现用的(在自己的 `conftest.py` 里覆盖 fixture),压测这边不接那套 -装配、只把五条断言照着写一遍——它是任何新适配器的准入标准,压测的适配器也是适配器。 +解析器那块的前五条照着 `polyloop.testing.DecisionParserContract` 那份公共契约写。契约套件 +本身是给下游继承基类、在自己的子类里覆盖必需 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```"]) -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)) assert isinstance(parsed.history_text, str) assert len(parsed.history_text) <= len(content)