Files
PolyLoop/research-wiki/design/0014-contract-suite-distribution.md
T
iomgaa e701563a0b docs(design): 落定第一个下游提的五个缺口的三份方案
0014 契约套件怎么发给下游、0015 参数快照的内容契约、0016 动作执行接缝抛异常时的契约。
三份都过了 CLAUDE.md §3 的硕士生冷读,冷读抓到的八处「在讲文档自己」的句子、五处缺前置
知识、三处只写结论没写理由、三处参数两地取值不同,全部采纳。

0016 否掉了提 issue 那一方倾向的方案(库捕获执行器异常转 ENV_ERROR)。三层理由:日志
「不自洽」这个前提本身不成立——异常抛出时副作用状态未知,日志停在「动作意图有、结果无」
正是照实记录;库替它写一条步记录反而是在编造,而那条记录会让恢复把未知状态抹掉;最后
它会把执行器里一个 AttributeError 变成一批环境故障,run() 照常返回正常结果,调用方拿不到
任何异常。
2026-08-27 03:58:08 -04:00

36 KiB
Raw Blame History

Design 0014 · 契约套件怎么发给下游

日期 2026-08-26 · 状态 已接受(2026-08-26 项目负责人确认)

回答 实验室 Gitea 上 PolyLoop 仓库的两份 issue,都由下游项目 dissect2 提出:#1「契约测试 套件不随包发布,第一个下游拿不到准入标准」,#5「stores 的两笔欠账:承诺的内存实现不存在, 下游自实现的准入路径断了」。dissect2 是 dissect 的第二版,正在重建——这是提 issue 的那一方 在 #1 里的自述,本仓库里除这三份 design doc 之外没有第二处记着它,../migrations/dissect.md 写的仍然是同一个下游、用的还是旧名字。哪天那份迁移文档跟着改名,这句话就可以删了。

兑现 0003-public-api-shape.md 否决方案那一节里那句承诺——存储接缝改成必填,另外加一个 显式命名的、明确不提供恢复的内存实现。必填那一半做到了,那个实现至今没写,本文把它补上。

触及 ../../tests/contract/ 整个目录、../../src/polyloop/stores/../../pyproject.toml ../explanation/architecture.md 第七、八节,以及 polyloop/stores/ 的模块 docstring 与 ../../README.md 阶段⑤那两句「契约套件是它的准入标准」。不回写这几处,这份决策就是死的 (../README.md 第 3 节)。

最后那两句在套件搬走之后仍然成立,要改的只是指向——从 tests/contract/ 指到 polyloop.testing。提 issue 的那一方明确说过「说了准入标准但拿不到,比不说更糟」;套件发得 出去之后,那句话第一次真正成立。

这套东西要过 ../../CLAUDE.md §2 那道人类门,因为它新增的名字——契约基类、基类上的 fixture、 每一条用例的方法名——一旦发出去就是下游子类要继承的东西,改名的代价和改公共类型的字段一样。 确认在同一天完成。

几个词的最短解释

  • fixture——pytest 里一个被声明为「测试的输入」的函数。测试函数的形参名就是它要的 fixture 名,pytest 按名字找到那个函数、调用它、把返回值传进来。
  • conftest.py——一个约定名字的文件,放在测试目录里,pytest 自动加载它,里面定义的 fixture 对同目录及其子目录下的测试可见。
  • 收集——pytest 启动后扫描文件、找出哪些是测试的那个阶段。
  • 断言重写——pytest 在 import 测试模块时改写它的 assert 语句,失败时能打印出等号两边的 实际值。不被重写的模块只会抛一个不带任何值的 AssertionError
  • marker——给测试打的标签,用来分组和筛选,例如本仓库的 contract
  • xfail 与 XPASS——xfail 标记一条「预期会失败」的测试;被这么标记的测试如果居然通过了, pytest 报告成 XPASS,而 strict=True 让 XPASS 直接算失败。
  • entry point——Python 包在自己的元数据里声明的一个挂钩,装上之后别的程序能按名字找到它。 pytest 用的那个组名叫 pytest11,声明了它的包会被 pytest 当成插件自动加载。

背景

pip install polyloop 之后,site-packages 里没有 tests/pyproject.toml[tool.setuptools.packages.find] 只收 src/ 下面的包,而 tests/ 不在 src/ 下,所以那套 契约用例根本不进产物。这就是 issue #1。

它的分量来自 tests/contract/conftest.py 的模块 docstring 给这套东西定的位置:它不针对任何 具体实现,写的是「不管你怎么实现,都必须满足这些行为」;下游写完自己的存储或适配器,在自己的 conftest.py 里覆盖同名 fixture、返回自己的实现,跑一遍全绿就算合格。../../CLAUDE.md §0 把它列成「任何新适配器的准入标准」。一份拿不到的准入标准不成其为标准。

issue #5 是这件事的具体后果。polyloop/stores/ 的模块 docstring 与 ../../README.md 阶段⑤ 都写着关系数据库那种形态由下游自己实现、契约套件是它的准入标准,而准入标准发不出去,这条路 实际上是断的。同一份 issue 还记了另一笔:0003 承诺过的那个内存实现不存在, ../migrations/dissect.md 已经登记了这一条,并提醒读者不要照着那句承诺去找一个不存在的类。

但这件事比两份 issue 说的更严重,严重在另一个方向。

tests/contract/test_model_client.py 有四条测试调用 records.model_call(...) (第 21、34、47、55 行),而 tests/contract/conftest.py 里的 _Records 工厂没有这个方法。 它有 model_call_intentmodel_call_result,没有 model_call。这个错误至今没炸,是因为 model_client fixture 的函数体是一句 pytest.skip,那四条测试从库落地到今天一次都没有被执行过。

五个接缝里只有存储那一套被真跑过——它接的是库自带的逐行追加实现 JsonlRunStore。另外四套 全是跳过。2026-08-26 的基线是 283 通过、16 跳过、2 xfail;16 条跳过里有 15 条正是这四套接缝 (动作执行 4 条、决策解释 5 条、模型调用 5 条、事件出口 1 条),第 16 条是 e2e 那道默认关闭的闸。

所以现在的状态是:一份从没被执行过的准入标准,正准备发给第一个下游当准入标准用。下游接上去 的第一件事会是 AttributeError。所以这件事有两半:把套件发出去是一半,让套件真的被跑起来是 另一半,见决策六。

决策一:套件搬进 src/polyloop/testing/,接法从「覆盖同名 fixture」换成「继承基类」

下游写成这样:

from polyloop.testing import RunStoreContract

class TestMyPostgresStore(RunStoreContract):
    @pytest.fixture
    def store(self, pg_pool): return MyPostgresStore(pg_pool)

现在这个接法在包发布之后走不通。 pytest 的 conftest.py 只沿着被收集文件的目录链往上 查找。套件装在 site-packages 里,收集它的时候 pytest 看的是 site-packages 那条路径链,看不见 下游仓库里的 conftest.py,于是下游根本没有位置去覆盖那些 fixture。就算把文件原样发出去了, 下游还是得把它们拷进自己的 tests/ 才接得上——而拷贝正是 issue #1 明确说不想要的:拷出去的 那一份从此不跟着升级。

「让下游直接跑装在包里的那个测试模块」这条路同样不成立——pytest --pyargs polyloop.testing 是它的具体形状。一个装在包里的测试模块,收集时拿不到下游的实现实例:套件必须先有那个实例才有 东西可测,而这条命令没有任何位置能把它递进去。

继承基类为什么解决它。 下游的子类定义在下游自己的测试文件里,那个文件在下游仓库的目录链 上;fixture 在类作用域内覆盖同名 fixture 是 pytest 的原生行为,不需要任何 conftest.py 魔法。

顺带三个好处。一个接缝可以接多个实现,各写一个子类——现在的 fixture 覆盖法一个接缝只能接 一个实现,这正是库自带的注册表分发器至今没接上动作执行契约的原因。

那个分发器是 polyloop.tools 里的 RegistryExecutor:调用方把一组工具规格注册进 ToolRegistryregistry.executor() 从这个注册表派生出一个动作执行器,它按模型给出的工具名 查表、校验参数、调到那个工具的实现上。它是库唯一自带的动作执行接缝实现,另一种形态(把模型 输出的一整段代码交给一个已经开好的会话去跑)由下游自己写。tests/contract/conftest.py 里那条 fixture 的理由写着「接在这里会让套件只验得了那一种」,而那个限制是接法带来的,不是套件本身的。

下游仓库里多出一个看得见的文件,「我过了准入」不再是一个隐性状态,而是一段能被 review 的代码。库这边加一条用例,下游升级之后自动多跑一条。

四家做同一件事的先例都是继承基类。 pandas 给第三方 ExtensionArray 作者的一致性套件、 fsspec 给第三方文件系统的套件、zarr 给第三方存储后端的套件、OpenTelemetry 给第三方 instrumentation 的套件,形态一致。zarr 和 OpenTelemetry 还把套件放在库自己的包里 (zarr/testing/)而不是 tests/ 下,理由和这里一样:tests/ 出不了发行包。唯一的例外是 SQLAlchemy 给第三方方言作者的那一套,它走「星号导入测试类」,下游用一行 from ... import * 把测试类拉进自己的模块;代价是它必须自带一个接管收集过程的钩子,复杂度高一个量级,而且 pytest 9 新增的 collect_imported_tests 配置项设成 false 时会让这条路彻底失效。

子包叫 testing 不是随手取的名字,它是这个生态里的通用叫法numpy.testingpandas.testingzarr.testing 都在同一个位置放同一类东西。下游看到这个名字就知道里面装的是 给测试用的东西、不是运行时的一部分,不必先读文档才敢判断能不能在生产代码里 import 它。这个包名 一旦发出去就是公共承诺(见代价一节),所以它值得是一个不用解释的名字。

决策二:基类的形状,七条

一、类名避开 Test 前缀,用 RunStoreContract 这种形状。pytest 的 python_classes 配置项 默认就是按 Test 前缀匹配测试类,所以基类本身不会被当成测试类直接收集,而下游写的 TestMyStore 会。

这道防线依赖下游没改 python_classes,而这条取舍是认下来的,不加第二道防线。 改了那个 配置项的下游会把基类本身也收集进去,那时每一条用例都在必需 fixture 上撞第三条里那个 NotImplementedError,而它的消息写着「在你的子类里覆盖这个 fixture」——一个直接跑到基类的人 看得懂这条报错。改 python_classes 的下游本来也极少,因为那会影响它自己所有的测试类。

不加防线是因为唯一的两条都比病更糟,两条都把一个响亮的失败换成一个静默的失败,而这个仓库 宁可要前者。把基类做成真正的抽象基类是第一条——inspect.isabstract 为真的类 pytest 不收集, 但子类没覆盖必需 fixture 时它仍然是抽象的,于是也不被收集,「忘了覆盖」从一条响亮的报错变成 零条测试跑过而报告全绿。给基类设 __test__ = False 是第二条——这个属性会被子类继承,下游 忘了在自己的子类上设回来,同样是静默零测试。

二、不继承 unittest.TestCase 那种类绕过 python_classes 判定,无论叫什么名字都会被 收集,于是第一条那道防线直接失效。

三、每个必需 fixture 都在基类里定义出来,函数体 raise NotImplementedError,消息里写明 「在你的子类里覆盖这个 fixture」以及它该返回什么。不定义的话,下游忘了覆盖时 pytest 报的是 fixture 'store' not found,后面跟着一整屏「available fixtures」列表,而那条错误指向的是 site-packages 里的库文件——一个刚接上准入套件的人看到这个,第一反应是库坏了。pandas 和 fsspec 各自独立采用了同一个改善手段,五家先例里只有这一条被两家共同验证过。

四、需要样本输入的 fixture,docstring 只写抽象形状和不变量,一个具体值都不给。 套件不认识 任何一家的动作语言:拿一家的代码围栏去喂另一家的 JSON 解析器,后者正确地返回「无效决策」,而 写死输入的套件会把这个正确行为判成失败。这条纪律现在只对决策解释接缝落实了——samples fixture 要求实现方提供两段模型输出,套件只断言拿到之后的形状。动作执行那一套仍然写死着输入,见决策六。 pandas 的同类 fixture 连退化情况都写进 docstring(某个 dtype 只有两个非空取值时第三个样本填 什么),那是把这种 fixture 做成契约、而不是做成一张许愿单的关键。

五、套件里一个自定义 marker 都不用。 下游开了 --strict-markers 而没有注册库用的那个 marker 的话,炸掉的是整个测试文件的收集,不是一条失败;而报错指向的同样是库的文件。 pandas、zarr、fsspec 三家都是靠一个自定义 marker 都不用躲过这件事的。实现之间的能力差异一律 走 fixture 加运行期 pytest.skip() 表达。本仓库自己要给这些用例打 contract 标记,打在自己 那几个子类文件上——那些文件在本仓库里,marker 也在本仓库注册。

六、「这一层验不了」统一成无条件 pytest.skip,两种现有写法都改。

套件里现在有两种装置在表达同一件事——「这条承诺是真的,但套件所在的这一层没有能力验证它」。 一种是 xfail(strict=True) 加一句 pytest.fail(...)tests/contract/test_run_store.py 里有 两条:原子写的「崩在中间时两者都不可见」那一半,以及前缀持久性。另一种是函数体只有一段 docstring、一个断言都没有tests/contract/test_action_executor.py 里两条(三个状态各自的 触发条件、动作被拒绝时那段观察由谁给),tests/contract/test_event_sink.py 里三条(一个连不上 后端就抛异常的出口仍然合规、投递失败不再转成事件从同一个出口重发、审计留痕由存储承担而不由 事件流承担)。

xfail 那种带一个陷阱:一个做得比库预期更好的下游实现会把一条 xfail(strict=True) 的测试跑通, 于是拿到 XPASS 判失败,而且下游取消不掉——子类上加一个类级 xfail 覆盖不了从函数级继承下来的 那个,唯一的出路是整条重写方法。现有这两条触发不了它,因为它们不接触任何实现,只调 pytest.fail,无条件失败。但套件发出去之后,下游跑准入时会看到两条与自己的实现毫无关系的 xfail,而报告里没有任何东西能让它判断那是库的已知缺口还是自己漏了什么。

空函数体那种更糟,糟在它在报告里是 PASSED。 2026-08-26 的契约层基线是 15 通过、15 跳过、 2 xfail,而那 15 条通过里有 5 条正是这种:它们一次都没有碰过任何实现,却和真的验过的那 10 条 在报告里长得一模一样。一个准入标准里出现假绿,比出现跳过糟得多——下游跑完看到全绿,会以为 自己的实现在这几条上被验过了。跳过至少诚实地说了「这条没验」。

语义上跳过也是最准的那一个。 这七条说的都不是「这个功能预期会失败」,更不是「这条已经 验过了」,而是「这一层没有能力验证它」,那就是跳过。七条一律改成无条件 pytest.skip,理由 字符串里写全三件事——这条承诺是什么、为什么这一层验不了、下游该在哪儿自己验。

统一之后契约层的报告是 10 通过、22 跳过、0 xfail:22 条里 15 条是接缝没有实现,另外 7 条是 这一批。原来选 xfail 是为了让那两条每次跑都被看见,跳过同样被看见——pytest -rs 把每一条的 理由逐行列出来,而那五条空函数体在报告里从来什么都不说。代价是这七条混在别的跳过里,不再 各自占一行 xfail 报告。

tests/contract/test_run_store.py 模块 docstring 里讲那两条的一节跟着改名,别留着 xfail 的 字样指向已经不存在的形状。test_action_executor.pytest_event_sink.py 的模块 docstring 里各有一句指向文末那几条说明,它们指向的形状同样变了,一并核对。

七、不把测试类和 fixture 类拆成两个 mixin。 fsspec 是那么做的,下游写 class TestMyStore(RunStoreContract, MyStoreFixtures)。它的收益是同一份 fixture 类能被多个 测试类复用,而本库五个接缝各自的 fixture 只有一到两个、彼此不共用,拆出来的第二个 mixin 会是 个空壳。将来某个接缝的 fixture 长到几个测试类都要用同一份时,这条拆分就值得回来做。

决策三:发一个 pytest11 entry point,只为换回断言重写

pyproject.toml 里声明 [project.entry-points.pytest11],指向 polyloop.testing 下一个极轻的 插件模块。

它换回来的是断言重写。 契约模块不在下游的 python_files 匹配范围里,pytest 默认不重写它的 断言语句,于是一条契约测试失败时下游看到的是光秃秃的 AssertionError:没有左右两边的值,没有 差异摘要。带了 pytest11 entry point 之后,pytest 把这个发行包整个标记为可重写,同一条失败就带 上完整的比对信息。

这是个纯静默的失败。 不做的话没有任何东西会报错,下游只会觉得这套契约的报错难读,而且永远 不会知道自己少了什么。SQLAlchemy 让下游在 conftest.py 里手写一行 pytest.register_assert_rewrite(...),并把它说成「压掉一个假警告」——那行才是它的断言输出可读 的真正原因。

插件模块的边界定死。 它只提供一个空的配置钩子,自己只 import pytest 与标准库:不 import 任何存储实现、不 import session、不 import adapters。最后一条尤其硬——adapters 会把网关 连同它的 provider 目录一起拉起来,而「import polyloop 之后 sys.modules 里不许出现 polygateway」正是架构文档第七节那九条依赖规则的最后一条(规则九)要防的事,只不过这次的 触发路径不是 import polyloop,是 pytest 自动加载插件。这条边界不靠自觉:import-linter 的分层契约把 testingsessionstoresadapters 放在同一层且互不 import,这三条 import 写下去就红。

插件里不接管事件循环。 技术上可以在插件里钩住函数调用、对自家契约类的协程用 asyncio.run() 自己跑一遍,这样下游用什么 async 测试插件、什么模式都不影响。这条被否掉,理由是下游的 fixture 很可能是异步的——一个数据库存储实现的连接池就是。那个 fixture 在下游的事件循环里创建,而库自己 开的循环是另一个,跨循环使用 asyncio 对象会炸,而且炸得很难查,那正是这个仓库最不能接受的 那一类失败(../../CLAUDE.md §3 第三类)。库不接管事件循环,就不会和下游的任何 async 安排打架。

兜底要写进用法文档。 PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 这个环境变量在 CI 里很常见,一设 上 entry point 就不加载了。那时下游要自己写一行 pytest.register_assert_rewrite("polyloop.testing")

决策四:async 用例的事件循环归下游管

契约套件里的用例照常写成 async def,套件不做任何事件循环安排。testing extra 带上 pytest-asyncio,用法文档里写明要把 asyncio_mode 设成 "auto",或者下游自己给子类打上对应 的标记。

为什么不由库保证它在任何配置下都能跑,见决策三末尾那条:库一旦自己开循环,下游的异步 fixture 就跨循环了。

没配对的失败是响亮的。 pytest 会明说「async def 函数不被原生支持」,那条信息直接指向解法, 不是一条静默跳过。zarr 的 async 契约套件就是这么处理的,它是这五家先例里仅有的一个有异步接口的。

决策五:补上那个内存存储实现,命名为 VolatileRunStore

0003 承诺过它,../explanation/architecture.md 第七节的分层图里也画着「jsonl / 内存」两种, 而 polyloop/stores/ 里只有一个。

名字不叫「不提供恢复」,叫「易失」。 0003 的原话是「显式命名的、明确不提供恢复的内存 实现」,但一个真的读不回自己写过的东西的存储过不了自家的契约套件——套件第一条就要求写进去 的意图读得回来——而这一层的准入标准就是那套套件。一个过不了自家准入标准的实现不该存在。它真正 不提供的是跨进程恢复:进程一退,日志就没了。Volatile 说的正是这件事,而且它不撒谎。选它 就是选「我不要跨进程恢复」,这仍然是一次看得见的选择,0003 那句承诺的意图达到了。

写入走一遍编解码往返,不直接存对象引用。 五个记录类都是 frozen 的,但 RunStarted 带着的 参数快照是一个 Mapping,调用方构造完之后还能改自己手里那个 dict——存引用就有别名 bug,读回来 的快照会跟着调用方后来的改动变。逐行追加那个实现因为要序列化成 JSON 文本,天然免疫这件事。 往返让两个实现在契约上结构性等价,而不是靠人肉逐条对齐。

第二个理由是往返顺带校验了「这条记录编不编得出来」。 一个下游用易失存储跑自己的测试时, 如果构造了一条编不出来的记录,逐行追加那个实现会在写文件时炸;而一个只存对象引用的内存实现 完全不会炸。于是同一份测试在两个存储上行为不同,下游会以为自己的记录没问题,直到换成落盘的 那个实现才发现。往返让两个实现在这一点上也等价。(编解码路径上那道 schema major 校验在这里 确实不可能失败——刚编出来的就是当前版本。有用的是编得出来这件事本身,不是版本号。)

代价是每次写多一次编解码,而这个实现本来就是给测试和「不要恢复」那一档用的。

顺带把 stores 里那两张平行的表合成一张。 现在有「类型到标签」和「标签到解码器」两张表, 加上内存实现要用的「类型到解码器」就是三张,而三张表之间有一个谁也不检查的一致性要求:必须 覆盖同样那五个记录类。合成一张三元组的表,再从它派生出需要的几个视图,那个要求就不可能被违反。

stores 拆成三个文件:模块 docstring 讲这一层装什么和不装什么,两个实现各占一个文件。 公共 import 路径不变。理由是现在那份模块 docstring 混着讲两件事——这一层的边界,以及逐行追加 那个实现的文件布局;加第二个实现之后这个混淆会加剧。

决策六:五套契约全部接上,一条用例都不留在从没被执行过的状态

发一份从没被执行过的准入标准出去,等于把背景那一节说的那个缺失方法直接交给下游。五个接缝各 接上什么、子类写在哪一层:

接缝 接哪个实现 子类落在
存储 逐行追加存储 JsonlRunStore 契约层
存储 易失存储 VolatileRunStore(决策五新写的) 契约层
动作执行 由工具注册表派生的分发器 RegistryExecutor 契约层
模型调用 PolyGateway 模型适配器 integration 层
决策解释 库没有实现,写一个最小的测试替身 契约层
事件出口 库没有实现,写一个最小的测试替身 契约层

模型适配器那一行落在 integration 而不是契约层,是因为它连的是真网关:按 ../../CLAUDE.md §1.9 的分层判据,测试归哪一层看它依赖什么,不看它叫什么。

接分发器要求先改动作执行那套契约的输入。 它现在把输入写死成一段文本形式的动作 (tests/contract/test_action_executor.py 第 51 行那句 raise RuntimeError()),那是「模型输出 一整段代码」那种动作语言;喂给一个按工具名分发的执行器,它正确地返回「未执行」,而套件会把这个 正确行为判成失败。改成由实现方提供两个样本:一个能正常执行完的动作,一个执行了但动作本身报错的 动作——两者的状态都该是「已执行」,这正是那条契约要断言的。这个改法就是决策二第四条那条纪律, 它本来就该对这套用例成立,只是当初没落实。

为什么要专门造两个替身,而不是等第一个下游接上来时这几条自然就被执行到了。 等下去的话, 那个下游会替我们撞上库自己的 bug,而它手上没有第二个实现做对照,判断不了到底是自己写错了还是 套件本身有问题。缺失的那个工厂方法正是这个形态:它表现成「实现一接上去就 AttributeError」, 而一个刚接上准入套件的人看到这个,第一反应是自己接错了,不是库错了。测试替身把这次撞击挪到 发布之前、挪到有能力判断的人手上——库这边的人知道套件和记录工厂都是自己写的,一眼看得出问题 在哪一侧。

测试替身不违反「库不带默认实现」那条。 那条禁的是 src/ 下出现一个默认实现——带了就等于 替某一家定了动作语言或投递协议,而下游装了包就能拿到它,就会有人直接用。测试替身住在 tests/ 里,不进 wheel,任何下游都拿不到。它存在的唯一目的是让套件的每一条用例至少被真的执行一次。 判据是「下游拿不拿得到」,不是「代码里有没有一个能跑的实现」。

换来的是五套契约的每一条用例都至少被执行过一次,records.* 上的每一处引用都被真的求值过。 缺失的那个工厂方法是靠人读代码撞出来的;这批测试替身之后,同类问题在提交之前就会红。

记录工厂本身照样要有一层单元测试,但它守的是工厂造出来的记录合不合法,不是「用例引用的 方法存不存在」。后者只有真的执行那条用例才查得出——一个测试引用了工厂里不存在的方法,工厂自己 的单元测试怎么写都看不见它。

否决的方案

tests/ 打进发行包,接法不动。 只解决 issue #1 的字面,解决不了 fixture 覆盖在 site-packages 里无处落脚这件事,下游拿到文件之后还是只能拷贝。见决策一。

让下游直接跑装在包里的那个测试模块pytest --pyargs polyloop.testing)。收集时拿不到 下游的实现实例。见决策一。

SQLAlchemy 那种星号导入测试类。 要自带一个接管收集的钩子,而且 pytest 9 的 collect_imported_tests 一关就失效。见决策一末尾。

继承 unittest.TestCase 来省掉「类名避开 Test 前缀」这条约定。 它让基类自己也被收集。 见决策二第二条。

把基类做成真正的抽象基类,或者给它设 __test__ = False 这两条是给「下游改了 python_classes」加第二道防线的仅有做法,两条都把一个响亮的失败换成静默零测试。见决策二 第一条。

不定义必需 fixture,靠 pytest 的「fixture not found」报错提示下游。 那条报错指向库文件, 而且带一整屏无关列表。见决策二第三条。

给能力差异用自定义 marker。 下游的 --strict-markers 会让整个文件的收集失败。 见决策二第五条。

把 fixture 拆成独立的 mixin 类。 本库每个接缝的 fixture 只有一到两个、彼此不共用,拆出来 是个空壳。见决策二第七条。将来某个接缝的 fixture 长起来时可以回来拆。

在 pytest11 插件里接管事件循环,替下游跑协程。 下游的异步 fixture 会跨循环,而跨循环的 asyncio 对象炸得很难查。见决策三。

把那个内存实现命名成「不提供恢复」的字面意思。 一个读不回自己写过的东西的存储过不了自家的 契约套件。见决策五。

代价

polyloop.testing 的基类名、fixture 名、用例方法名从此是公共承诺。 下游的子类按它们写。 改 fixture 名的后果至少是响亮的:一个被改了名的 fixture 覆盖不到任何东西,而基类里那个 raise NotImplementedError 的默认实现会立刻失败。用例方法名不同——下游要豁免某一条时按名字 重绑它,改名之后那个豁免会悄悄失效,跑出来照样全绿。

entry point 让每个装了本库的项目在 pytest 启动时多 import 三处东西。 插件被加载时,Python 会先把 polyloop.testing 这个父包执行一遍,而它 re-export 五个契约基类,于是 portstypes 一并被 import 进来。这三处都是纯类型定义,没有 I/O 也没有网络;但它确实发生在每一个装了本库的 下游项目的每一次 pytest 启动上,包括根本不用契约套件的那些。不做惰性 import 把这点开销省掉, 是因为那要在包的 __getattr__ 上做手脚,而 ../../CLAUDE.md §6 要求显式优于隐式,省下的几 毫秒不值这个魔法。

pytest 进了 extra,版本只写下界。 这和 dev 那一组「必须钉死」的规矩正好相反,理由也正好 相反:dev 是本仓库自己的工具链,钉死是为了本地和 CI 一致;testing 装在下游的环境里,钉死会 和下游自己的 pytest 版本打架。这个区别要写进 pyproject.toml 的注释,不然下次有人会「顺手统一 一下」。

契约套件从此有两个读者,一个是本仓库的开发者,一个是下游的实现者,而后者手上没有本仓库的 上下文。写用例时要照后者写:报错信息里不能出现只有本仓库开发者看得懂的指代。

../explanation/architecture.md 第七节的分层图、第八节的代码地图、第九节的依赖规则都要跟着 改,而那三节是本仓库唯一由机器断言的架构描述。polyloop.testing 是一个新的公开模块,它 import portstypes,它在分层里的位置和它与其余模块的独立性都要落进 import-linter 契约。

留给后续的

决策解释接缝与事件出口那两套用例跑起来了,但跑的是库自己写的测试替身。 替身按套件的期望 写,所以它证明得了「这几条用例执行得下去、引用的东西都存在」,证明不了「一个真实的下游实现 接上来也说得通」——一条只对替身成立的用例,要等第一个下游接上自己的解析器那天才暴露,而那时 问题会表现成「库的套件有 bug」。这一半的成本仍然由第一个下游承担。

契约套件没有版本协商。 下游装的是哪一版库,跑的就是哪一版套件。库加一条更严的用例时,一个 原本合格的下游实现会在升级之后变红,而它自己什么都没改。这件事该不该有一个宽限机制——比如让 新用例先以警告出现一个 minor 版本——等第一个下游真的撞上再说。

落地时的修订

上面的决策确认之后、写代码的过程中改了三处:两处在决策二里,一处在决策五里。其余决策照原样 落地。

模型调用那套契约多了一个 failing_call fixture

决策二第四条那条纪律——需要样本输入的 fixture 由实现方提供、一个具体值都不写死——当时只对 决策解释和动作执行两套点了名。模型调用那套当时靠的是一个约定俗成的暗号:result_id 等于某个 特定串时,实现方就该让这次调用失败。

这条对真适配器不成立。它不认识那个暗号,于是那次调用正常成功,用例判它不合格,而它其实是 对的——这正是第四条那句「拿一家的样本去喂另一家」的同一个形态,只是这次写死的不是输入内容 而是一个失败信号。所以第三个样本 fixture 按同一条纪律加上了。

ModelClientContract.failing_call 返回一个 polyloop.ports.ModelCall,拿它调用被测客户端 必须抛异常;抛什么类型由实现定,契约只要求「抛」。三条附加约束写在它的 docstring 里:这次 调用不许真的花钱,也不许在失败之前留下外部副作用,最省的做法是让它在入参校验那一关就挂掉; 它和别的用例共用同一个 model_client 实例,所以失败之后那个实例必须还能接着服务。

退化情况也写进了 docstring:不存在「无论如何都不会失败」的合规实现。契约规定失败以异常表达, 不以「返回一个内容为空的正常回复」表达——库靠这个区分基础设施故障与「模型真的回了空字符串」。 所以一个给不出这个 fixture 的实现,要么是把失败吞成了空回复(那就是不合格,正是这条用例要 抓的),要么是还没想过失败路径。

决策二第六条那组数字描述的是套件搬走、还没接实现的那一刻

「10 通过、22 跳过、0 xfail」说的是七条统一成 pytest.skip 之后、五套契约还一个实现都没接 上时的报告。它不是终态。

接上实现之后是另一组。2026-08-26,pytest tests/contract -q 的报告是 29 通过、10 跳过、 0 xfail。多出来的通过数来自决策六那张表:同一套存储用例在两个实现上各跑一遍,动作执行接上 由工具注册表派生的分发器,决策解释与事件出口各接一个测试替身。模型调用那一套按同一张表落在 integration 层,不计在这个数里。

剩下的 10 条跳过全是「这一层验不了」那一类。决策二第六条统一过来的那七条占其中 9 条——存储 那两条(原子写崩在中间、前缀持久性)在两个实现上各跳一次。第 10 条是取消:被测执行器在一个 事件循环 tick 之内就返回,取消发出去时它已经跑完,根本没有机会吞掉取消,那条契约对它无从 谈起。

决策五第二条理由里「写的时候炸」那半不成立

那一条说编解码往返顺带校验了「这条记录编不编得出来」,判据是:一个下游用易失存储跑自己的 测试时构造了一条编不出来的记录,逐行追加那个实现会在写文件时炸,而一个只存对象引用的内存 实现完全不会。前半句是错的——逐行追加那个实现在写入时只做 json.dumps,一条字段类型不对的 记录仍然是合法 JSON,写得进去,要到 read_log 解码时才炸。

落地时按「两个实现一致地在读的时候才炸」改了实现:写入只编码不解码,读取现解一遍。解一遍 再把结果丢掉确实能让坏记录在写入时就炸,但那样一来同一条记录在两个实现上一个写得进去一个 写不进去,同一段下游代码在易失存储上写就红、换成落盘存储要到读才红。等价的对齐点是读—— 两边都放行,两边都在 read_log 抛同一个解码错误。

防别名那一半照旧成立,而且这么改之后更干净:桶里存的是编出来的载荷,read_log 每次现解出 一批新对象,于是写这一侧(调用方构造完之后接着改自己手里那个 dict)和读这一侧(读回来之后 改它就倒着改掉存储里那一份)的别名同时堵上。

登记一条缺口:独占开新运行不是端口承诺

两个自带存储实现都把「同一个运行标识不许开第二次」做成了必须——逐行追加那个靠 O_EXCL 独占创建文件,易失那个靠一句显式检查。理由是驱动入口在开工前的那次「先读后写」挡不住两个 进程同时开同一个标识,而同一个标识被重开之后,交错的记录序会让恢复读到同一步的两条意图。

polyloop.ports.RunStore 没把这条写成端口承诺,契约套件里也没有对应用例。于是一个不做 独占的下游存储能把整套准入标准跑全绿,接上驱动入口之后那个并发窗口照样敞开。这不是这批改动 引入的——套件还住在 tests/ 里的时候缺口就在;变化的是它现在是对外的准入标准,而一条准入 标准没查的事,下游有理由认为不需要查。

补进契约要单独决定,不搭这批的车:那是往一份已经发出去的准入标准里加一条更严的用例,一个 原本合格的下游实现会在升级之后变红,而它自己什么都没改。「留给后续的」那一节记的版本协商 问题正是这个形态。