feat(testing): 契约套件搬进 polyloop.testing 随包发布,五套全部接上实现

tests/ 不进 wheel,所以那套被 CLAUDE.md §0 称作「任何新适配器的准入标准」的用例,第一个
下游根本拿不到。**接法同时换掉**:pytest 的 conftest 只沿被收集文件的目录链查找,装在
site-packages 里的测试模块看不见下游的 conftest,原来那个「在自己的 conftest 里覆盖同名
fixture」的接法在发布之后走不通。改成继承契约基类,下游的子类定义在自己的目录链上。

**搬的过程中发现这套准入标准从来没被执行过。** test_model_client.py 有四条用例调用
records.model_call(...),而工厂里根本没有这个方法——它没炸是因为那个 fixture 默认 skip。
五个接缝里只有存储那套被真跑过(15 条跳过里有 15 条是这四套)。

所以这个提交的另一半是让它真的跑起来。存储接两个实现(一份契约同时验多个实现,正是换接法
换来的);动作执行接注册表分发器,外加一个有真实等待点的替身,否则那条取消用例的断言半边
永远走不到;模型调用接网关适配器,落在 integration,它连的是真网关;决策解释与事件出口各
接一个测试替身——替身住在 tests/ 里不进 wheel,下游拿不到,所以不违反「库不带默认实现」,
判据是下游拿不拿得到。

**一并清掉两类坏用例。** 五条函数体只有 docstring、一个断言都没有却报 PASSED 的假绿——一个
准入标准里出现假绿比出现跳过糟得多,下游看到全绿会以为验过了。以及一条端口从没承诺过的
长度断言(len(history_text) <= len(reply.content)):压测的 AppWorld 场景为了迁就它,刻意
不补被复刻的实现真的会补的三个反引号,注释里写着「补一个字符就违约」。七条「这一层验不了」
统一成无条件 skip,理由字符串写全「承诺是什么/为什么验不了/你该在哪儿自己验」。

**发一个 pytest11 entry point,只为换回断言重写。** 契约模块不在下游的 python_files 里,
默认不被重写,于是一条契约失败时下游看到的是光秃秃的 AssertionError。不做的话没有任何东西
会报错,纯静默退化。实测过:editable 安装下 entry point 注册了但重写不生效(RECORD 里没有
包文件),要装真 wheel 才验得出来。
This commit is contained in:
2026-08-27 03:59:16 -04:00
parent 3492a2994a
commit 1fac387e75
22 changed files with 1538 additions and 632 deletions
+81
View File
@@ -0,0 +1,81 @@
"""五个接缝的契约套件,随包发布,给下游当准入标准用。
每个接缝一个基类。这套用例不针对任何具体实现,写的是「不管你怎么实现,都必须满足这些
行为」——所以它自己不造实现,只声明「你得提供什么」。写完自己的存储或适配器之后,在自己的
测试文件里继承对应的基类、覆盖那几个必需 fixture,跑一遍全绿就算合格。
# tests/test_my_store.py
import pytest
from polyloop.testing import RunStoreContract
from myproject.storage import MyPostgresStore
class TestMyPostgresStore(RunStoreContract):
@pytest.fixture
def store(self, pg_pool):
return MyPostgresStore(pg_pool)
子类的类名要以 `Test` 开头,pytest 才收集它。基类自己叫 `...Contract` 正是为了不被收集:
pytest 按 `Test` 前缀匹配测试类,一个叫 `RunStoreContract` 的基类不会被当成测试类跑一遍,
于是它那些 `raise NotImplementedError` 的默认 fixture 也就不会失败。
**装它**`pip install "polyloop[testing]"`。
**必需的 fixture 在基类里都有一个默认实现,函数体是 `raise NotImplementedError`**,报错消息
写着该覆盖什么、该返回什么。忘了覆盖时看到的是这条消息,而不是 pytest 那句
`fixture 'store' not found` 加一整屏 available fixtures 列表——后者指向的是 site-packages 里
的库文件,第一反应会是库坏了。
**样本输入由实现方提供**,见 `DecisionParserContract.reply_samples`、
`ActionExecutorContract.action_samples` 与 `ModelClientContract.failing_call` 的 docstring。
套件不认识任何一家的动作语言,也不知道一家实现要怎样才会失败:拿一家的代码围栏去喂另一家的
JSON 解析器,后者正确地返回「无效决策」,而写死输入的套件会把这个正确行为判成失败。
**实现之间的能力差异走运行期跳过,不走失败。** 取消那两条就是这么处理的——一个在单个事件
循环 tick 之内就返回的实现根本没有机会吞掉取消,那条契约对它无从谈起,跳过的理由字符串会
说清楚这一点。看到这种跳过不必去改自己的实现。
**async 用例的事件循环归下游管。** 套件里的用例照常写成 `async def`,套件不做任何事件循环
安排。所以要么把 `asyncio_mode` 设成 `"auto"``pyproject.toml` 的
`[tool.pytest.ini_options]` 里),要么自己给子类打上对应的标记。没配对的失败是响亮的:
pytest 会明说 async def 函数不被原生支持,那条信息直接指向解法。
库不替下游安排循环,是因为下游的 fixture 很可能是异步的——一个数据库存储实现的连接池就是。
那个 fixture 在下游的循环里创建,库自己开的循环是另一个,跨循环使用 asyncio 对象会炸,而且
炸得很难查。
**`PYTEST_DISABLE_PLUGIN_AUTOLOAD=1` 的环境要多写一行。** 本包声明了一个 pytest 插件入口,
它唯一的作用是让 pytest 重写这些模块里的 `assert`,失败时打印出等号两边的实际值。那个环境
变量一设上,入口就不加载了,契约失败会退化成光秃秃的 `AssertionError`。这时在自己的
`conftest.py` 顶上补一行:
pytest.register_assert_rewrite("polyloop.testing")
**套件里一个自定义 marker 都不用。** 开了 `--strict-markers` 而没注册那个 marker 的话,炸掉
的是整个测试文件的收集,不是一条失败,而报错指向的同样是库的文件。要给这些用例分组就在自己
的子类文件上打标记,那个文件和那个 marker 都在你自己的仓库里。
**这里的每个名字都是公共承诺**:基类名、基类上的 fixture 名、每一条用例的方法名。下游的子类
按它们写,改名的代价和改公共类型的字段一样。用例方法名尤其要紧——下游要豁免某一条时按名字
重绑它,改名之后那个豁免会悄悄失效,跑出来照样全绿。
设计与取舍见 `research-wiki/design/0014-contract-suite-distribution.md`。
"""
from polyloop.testing._action_executor import ActionExecutorContract
from polyloop.testing._decision_parser import DecisionParserContract
from polyloop.testing._event_sink import EventSinkContract
from polyloop.testing._model_client import ModelClientContract
from polyloop.testing._records import RecordFactory
from polyloop.testing._run_store import RunStoreContract
__all__ = [
"ActionExecutorContract",
"DecisionParserContract",
"EventSinkContract",
"ModelClientContract",
"RecordFactory",
"RunStoreContract",
]
+195
View File
@@ -0,0 +1,195 @@
"""动作执行接缝的行为契约。
两个已知形态差别很大:一个把一段代码交给已经开好的容器会话、状态恒为「已执行」,一个查
工具注册表分发、工具不存在或参数不合法时返回「未执行」。下面每一条都要对两者同时成立。
## 写这套用例时撞出来的两个问题,`design/0007` 决策一与决策二答了
三个状态取值各自在什么条件下被赋上、返回「未执行」时那段观察由谁给。两条的答案都落在
**库这一侧**,所以它们的断言不在这个接缝的契约里,见文末那两条说明。
## 实现方不返回结果、直接抛出时会怎样,`design/0016` 决策一答了
环境自己坏了走返回值,抛出来的异常库不接管。这一条同样验不到这一层,说明在文末第三条。
"""
import asyncio
from typing import Any
import pytest
from polyloop.ports import ActionExecutor
from polyloop.testing._records import ContractBase
class ActionExecutorContract(ContractBase):
"""动作执行接缝的准入套件。下游继承它,覆盖 `action_executor` 与 `action_samples`。"""
@pytest.fixture
def action_executor(self) -> ActionExecutor:
"""被测的动作执行接缝实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。"""
raise NotImplementedError(
"在你的子类里覆盖 `action_executor` fixture,返回一个 ActionExecutor 实现的实例。"
)
@pytest.fixture
def action_samples(self) -> Any:
"""被测执行器认得的两个动作,由实现方提供。
**套件不许自己写死输入。** 动作语言是实现方定的:一段代码、一次工具调用、一条命令。
拿一段文本形式的代码去喂一个按工具名分发的执行器,它正确地返回「未执行」,而写死输入
的套件会把这个正确行为判成失败。
返回一个带两个属性的对象,两个属性都是 `polyloop.ports.Action`
- `executes_cleanly`——跑得完,动作本身不报错。
- `executes_but_errors`——跑得完,但动作本身报错(代码抛异常、命令返回非零)。
**两个动作执行完的状态都该是 `ActionStatus.EXECUTED`。** 这正是契约要断言的:动作本身
报错是正常观察,不是环境故障。给一个会被判成「未执行」的动作,红的原因就与实现的对错
无关了。
**两个动作都不许触碰真实的外部副作用**——删文件、发请求、花钱。套件会真的执行它们,
而且会在执行到一半时取消其中一个,那时副作用做了多少是未知的。
套件会执行同一个动作不止一次,两次的观察不必逐字相同,但状态必须相同。
**退化情况:一个不存在「动作本身会报错」这种情形的执行器仍然要给出
`executes_but_errors`。** 比如一个在执行之前就把不合法的动作全挡掉的实现,它可以给一个
观察里带着错误信息、状态仍是「已执行」的动作。真的构造不出来,就在子类里重写用到它的
那条用例并写明理由。
"""
raise NotImplementedError(
"在你的子类里覆盖 `action_samples` fixture,返回一个带 `executes_cleanly` 与 "
"`executes_but_errors` 两个 Action 属性的对象。"
)
async def test_returns_all_five_fields(self, action_executor, action_samples):
"""返回值必须带齐五个字段,一个都不能省。
库拿这五个字段填步记录里对应的五列。少一个,那一列就只能填默认值,而默认值与真实值在
轨迹里长得一模一样——事后没有任何办法把「执行器没给」和「值确实是这个」分开。
"""
outcome = await action_executor.execute(action_samples.executes_cleanly)
assert outcome.status is not None
assert isinstance(outcome.observation, str)
assert isinstance(outcome.observation_is_synthetic, bool)
assert isinstance(outcome.env_reported_completion, bool)
assert isinstance(outcome.observation_truncated_chars, int)
async def test_completion_signal_is_a_plain_boolean(self, action_executor, action_samples):
"""完成信号是布尔,没有第三个取值,恒为「未完成」不是故障。
没有环境完成信号的环境就是这么返回的——GovDoc 全部、dissect 的几个环境都是。初稿把它
定成「可为空表示取不到」并把空值判为环境故障,照那个写法 GovDoc 的每一次运行都会在
第一步撞环境故障终止。
"""
outcome = await action_executor.execute(action_samples.executes_cleanly)
assert outcome.env_reported_completion in (True, False)
async def test_action_error_is_a_normal_observation_not_an_env_error(
self, action_executor, action_samples, records
):
"""动作本身报错是正常观察,要原样回喂让模型自己纠正,不是环境故障。
代码抛异常、命令返回非零,都属于这一类。判成环境故障会让一次运行在模型本来能自我纠正
的地方直接终止,而轨迹上看不出它本可以继续。只有环境自己坏了(连不上、协议不对)才
另算。
"""
outcome = await action_executor.execute(action_samples.executes_but_errors)
assert outcome.status == records.action_status.EXECUTED
assert outcome.observation != ""
async def test_cancellation_propagates_and_is_not_swallowed(
self, action_executor, action_samples
):
"""取消要能穿过动作执行,`CancelledError` 不许被捕获吞没。
吞掉它的后果不是「取消失败」这么直白——是容器租约、连接和临时目录持续泄漏,而且一声
不吭。这条是 `CLAUDE.md` §1.6,对每一个执行器实现都成立。
**`cancel()` 返回 `False` 是能力判定,不是失败。** 一个在单个事件循环 tick 之内就返回
的执行器,取消发出去时它已经跑完,没有任何机会吞掉取消——这条契约对它无从谈起。把它
判成不合格会让这条用例的成败取决于实现跑得多快,而一个偶发变红的准入标准会训练下游
忽略红,那比少验一条糟。
"""
task = asyncio.ensure_future(action_executor.execute(action_samples.executes_cleanly))
await asyncio.sleep(0)
if not task.cancel():
pytest.skip(
"承诺:取消要能穿过动作执行,CancelledError 不许被捕获吞没。"
"这一次验不了——被测实现在一个事件循环 tick 之内就返回了,取消发出去时它已经跑完,"
"根本没有机会吞掉取消。跑得太快不是违约,这条契约对它无从谈起,不必去改实现。"
"自己验:只有在实现真的有等待点(网络往返、子进程、容器会话)时这条才验得出来。"
)
with pytest.raises(asyncio.CancelledError):
await task
def test_status_trigger_conditions_are_asserted_against_the_library_not_here(self):
"""三个状态的触发条件(`design/0007` 决策一)验不到这一层,原因在这里。
触发条件是**执行器自己的判断**:动作真的跑过了记 `EXECUTED`(哪怕它报错),没进执行
记 `NOT_EXECUTED`,环境自己坏了记 `ENV_ERROR`。这套件面对的是一个任意实现,没有办法
逼它进入后两档——拿一个「几乎不可能存在的工具名」去探,会把 dissect 那种动作语言里
根本没有工具名、状态恒为 `EXECUTED` 的合法实现判成不合格。
库这一侧的连带后果是能验的,也验了:`ENV_ERROR` 必然导致 `StopReason.ENV_ERROR`、
`NOT_EXECUTED` 不终止运行,两条由库自己的测试守着,不在这个接缝的契约里。
这条留成一条跳过,是为了让下一个想在这儿补断言的人先看到上面那段。
"""
pytest.skip(
"承诺:动作跑过了记 EXECUTED(哪怕它报错),没进执行记 NOT_EXECUTED"
"环境自己坏了记 ENV_ERROR。"
"这一层验不了——套件面对的是一个任意实现,没有办法逼它进入后两档,"
"而拿一个不存在的工具名去探,会把状态恒为 EXECUTED 的合法实现判成不合格。"
"自己验:在你自己实现的测试里,用你那套动作语言各造一个走到后两档的动作。"
"后两档在库这一侧的连带后果由库自己的测试守着。"
)
def test_failure_is_expressed_as_a_return_value_asserted_in_the_library_not_here(self):
"""环境故障走返回值、抛出的异常库不接管(`design/0016` 决策一),验不到这一层。
环境自己坏了——连不上、协议不对、开好的会话没了——执行器返回 `ActionStatus.ENV_ERROR`
不要以异常表达。实现方真的抛了异常,库不捕获,异常原样穿出 `run()` 与 `resume()`:调用方
拿到的是那个异常本身,不是一个正常返回的运行结果。
这套件面对的是一个任意实现,没有办法逼它进入环境故障那一档——真去把它的网络掐掉既不
可移植,也会把那些根本没有网络的合法实现判成不合格。这和三个状态的触发条件验不到那一层
是同一个原因。
`asyncio.CancelledError` 是那个必须穿过去的异常,它验得到,断言在取消那条用例里。库为什么
不像模型调用接缝那样把这个异常接住,见 `research-wiki/design/0016-action-executor-failure.md`。
这条留成一条跳过,是为了让下一个想在这儿补断言的人先看到上面那段。
"""
pytest.skip(
"承诺:环境自己坏了走 ActionStatus.ENV_ERROR 返回值,不要以异常表达;"
"实现方真的抛了异常,库不捕获,异常原样穿出 run() 与 resume()。"
"这一层验不了——套件面对的是一个任意实现,没有办法逼它进入环境故障那一档,"
"而真去把它的网络掐掉既不可移植,也会把根本没有网络的合法实现判成不合格。"
"自己验:在你自己实现的测试里,用你那套动作语言造一个真的走到环境故障的动作。"
"抛出的异常穿出 run()、日志停在「动作意图有、结果无」、续跑判成状态未知,"
"这些连带后果由库自己的测试守着。"
)
def test_the_observation_substitution_is_asserted_in_the_library_not_here(self):
"""动作被拒绝时那段观察由库合成(`design/0007` 决策二),而判定发生在库这一侧。
执行器照常填自己的 `observation`——它不该知道库会不会采用,也不必知道:那段文本仍然
随「一步走完」记录原样落盘,被拒绝那一档下它是日志里唯一的拒绝说明
`design/0013` 决策六)。库只是不让它进历史,因为模型看得见的东西必须能进参数快照。
「库替换了它」是整次运行的行为,不在这个接缝的契约里。
"""
pytest.skip(
"承诺:动作被拒绝时进历史的那段观察由库合成,执行器填的那段仍原样落盘。"
"这一层验不了——替换发生在库这一侧,要跑完一次完整运行再读日志才看得见,"
"而这个接缝只看得见一个执行器实现。"
"自己验:不用验。执行器照常填自己的 observation 就是合规的,"
"「库替换了它」由库自己的测试守着。"
)
+130
View File
@@ -0,0 +1,130 @@
"""决策解释接缝的行为契约。
两个已知形态:一个从代码围栏里抽 Python 源码,一个从 JSON 里抽工具名与参数。库不带任何
默认实现——带了就等于替某一家定了动作语言。
## 写这套用例时撞出来的那个问题,`design/0007` 决策三答了
模型输出完全无法解释时,解释器返回「无效决策」,不抛异常。下面最后一条断言它。
"""
from typing import Any
import pytest
from polyloop.ports import DecisionParser, InvalidDecision
from polyloop.testing._records import ContractBase
class DecisionParserContract(ContractBase):
"""决策解释接缝的准入套件。下游继承它,覆盖 `decision_parser` 与 `reply_samples`。"""
@pytest.fixture
def decision_parser(self) -> DecisionParser:
"""被测的决策解释接缝实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。"""
raise NotImplementedError(
"在你的子类里覆盖 `decision_parser` fixture,返回一个 DecisionParser 实现的实例。"
)
@pytest.fixture
def reply_samples(self) -> Any:
"""被测解释器认得的两段模型回复,由实现方提供。
**套件不许自己写死输入。** 库不带默认解释器实现,也就不认识任何一家的动作语言:拿一家的
代码围栏去喂另一家的 JSON 解析器,后者正确地返回「无效决策」,而写死输入的套件会把这个
正确行为判成失败。这不是给套件开后门——「我这套语言里什么算合法动作」本来就只有实现方
答得出,套件断言的是**拿到之后的形状**,不是输入长什么样。
返回一个带两个属性的对象,两个属性都是 `polyloop.types.ModelReply`
- `yields_an_action`——喂给被测解释器会走到动作那一支的回复。
- `yields_invalid`——喂给被测解释器会走到「无效决策」那一支的回复。
套件会对同一个样本调用 `parse` 不止一次,两次的结果必须一样:一个靠内部计数器换答案
的解释器会让用例的成败取决于它们的执行顺序,而 pytest 的执行顺序不是承诺。
**`yields_an_action` 的解释过程不许有外部副作用**——套件只是解释它,不会执行解释出来
的动作,但一个在 `parse` 里就发请求的实现会在跑准入时真的发出去。
**退化情况:一个「什么都解释得出来」的实现仍然要给出 `yields_invalid`。** 找不到就
说明这个实现把无效决策那一支变成了死代码,而契约要求那一支存在——模型输出不合格式是
每天都在发生的事,那一支迟早会被走到。真的构造不出来,就在子类里重写用到它的那两条
用例并写明理由;不要拿一个其实解释得出动作的回复来充数,那样套件照样绿,而绿的含义
从「这一支对」变成了「这一支没验」。
"""
raise NotImplementedError(
"在你的子类里覆盖 `reply_samples` fixture,返回一个带 `yields_an_action` 与 "
"`yields_invalid` 两个 ModelReply 属性的对象。"
)
def test_parse_is_synchronous(self, decision_parser, reply_samples):
"""`parse` 是同步的,不是协程。
解释一次模型回复是纯计算,没有等待点。写成协程会让每个只想写测试替身的下游多套一层
`async def`,也会诱导实现方在里面做 I/O——而这个接缝一旦做起 I/O,「恢复时重新解释
被打断的那一步」就不再是安全操作了。
"""
parsed = decision_parser.parse(reply_samples.yields_an_action)
assert not hasattr(parsed, "__await__")
def test_history_text_is_what_goes_back_into_the_conversation(
self, decision_parser, reply_samples
):
"""`history_text` 是这一步回填进历史的那段文本,可以与模型原文不同。
解释器有权改写它:dissect 的解析器把第一个代码围栏之后的内容整段丢掉,因为模型常在
代码块后面编造「执行结果」。库这边只有模型原文,照它回填,模型下一轮会看见自己编的
那段,而迁移前它看不见。
**不断言它不长于模型原文。** 端口承诺的只有「可以改写」,而改写既可能是截短也可能是
补写:把一次 JSON 工具调用规范化成一段人读得懂的 assistant 文本、给被 stop 序列截断的
代码围栏补回结尾的三反引号,两种都没有违反端口的任何一条,长度却都会超过原文。断言
长度就是在准入标准里凭空多加一条端口没写过的约束,那些实现会在这里变红,而它们没有
任何地方需要改。
**输入由被测实现自己提供**,不由套件写死。库不带默认实现,也就不认识任何一家的动作
语言——拿 dissect 的代码围栏去喂 GovDoc 的 JSON 解析器,它正确地返回「无效决策」,
而套件会把这个正确行为判成失败。
"""
parsed = decision_parser.parse(reply_samples.yields_an_action)
assert isinstance(parsed.history_text, str)
def test_invalid_decision_explanation_is_what_is_fed_back(self, decision_parser, reply_samples):
"""无效决策的说明文本**就是**回喂给模型的那段观察,不是从一个固定串里取。
dissect 的解析器对五种解析失败各有一条对症说明(没有代码块、空的未闭合块、闭合围栏后
跟了别的内容、多块策略下第一块为空、拼接策略下全空)。压成一句会改掉它的实验条件——
模型收到的纠错信息变了,它的纠错行为也就变了。
"""
parsed = decision_parser.parse(reply_samples.yields_invalid)
assert isinstance(parsed.decision.explanation, str)
assert parsed.decision.explanation != ""
def test_action_carries_its_trace_form(self, decision_parser, reply_samples):
"""动作分支要带「这一步的动作在轨迹里长什么样」,由实现方决定内容,库原样填进步记录。
dissect 传那段 Python 源码,GovDoc 传序列化后的参数。没有这个字段,dissect 轨迹里那一列
会被库改写,而那个文件是它的反思模型的唯一输入界面。
"""
parsed = decision_parser.parse(reply_samples.yields_an_action)
assert isinstance(parsed.decision.text, str)
def test_unparseable_output_returns_invalid_decision_rather_than_raising(
self, decision_parser, reply_samples
):
"""模型输出完全无法解释时返回「无效决策」,不抛异常(`design/0007` 决策三)。
两条路后果完全不同:返回无效决策,那一步照常留痕、说明文本回喂给模型、循环继续;抛
异常,库要么把它翻译成某个停止原因终止整次运行,要么让它穿出去炸掉调用方。
dissect 的解析器不抛异常,所以它撞不到这个分歧。但契约测试是**任何新适配器的准入
标准**,所以这条要正面断言,不能靠「反正没人这么写」。
"""
parsed = decision_parser.parse(reply_samples.yields_invalid)
assert isinstance(parsed.decision, InvalidDecision)
assert parsed.decision.explanation != ""
+86
View File
@@ -0,0 +1,86 @@
"""事件出口的行为契约。
两个已知形态差别在可靠性要求上:一个把进度逐步回写业务数据库供前端轮询(要求低延迟、
可以丢),一个把审计事件送进日志管道(要求不丢、可以慢)。
## 写这套用例时撞出来的问题,`design/0013` 答了
「发出去的事件里有什么」当时验不了,因为 `Event` 只有一个名字没有字段。现在事件集定下来了,
而答案把这里的两条用例都挪走了——它们要断言的行为都在库那一侧,不在出口这一侧,见文末
那两条说明。
"""
import pytest
from polyloop.ports import EventSink
from polyloop.testing._records import ContractBase
class EventSinkContract(ContractBase):
"""事件出口的准入套件。下游继承它,覆盖 `event_sink`。"""
@pytest.fixture
def event_sink(self) -> EventSink:
"""被测的事件出口实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。"""
raise NotImplementedError(
"在你的子类里覆盖 `event_sink` fixture,返回一个 EventSink 实现的实例。"
)
async def test_emit_accepts_an_event(self, event_sink, records):
"""能收下一个事件,正常路径不抛异常。"""
await event_sink.emit(records.event())
def test_a_raising_sink_is_compliant_so_this_layer_asserts_nothing(self):
"""**这一层不断言「emit 不抛」——一个后端连不上时抛异常的出口是合规实现。**
契约写的是「投递失败由**库**捕获、记日志、把失败计数加一,然后继续跑」,所以要断言的
行为在库那一侧,不在出口这一侧。原来这里写了一条 `await emit(...)` 不抛的断言,那会把
一个完全合法的审计 sink 判失败——它在日志管道不可用时抛 `ConnectionError`,而库本来就
该接住。
「库接住了失败并继续跑」属于整次运行的行为,不在这个接缝的契约里。这条留成一条跳过,
是为了让下一个想在这儿加断言的人先看到这段。
"""
pytest.skip(
"承诺:投递失败由库捕获、记日志、计数加一,然后继续跑,所以一个会抛异常的出口"
"是合规实现。"
"这一层验不了——「库接住了失败还在跑」要跑完一次完整运行才看得见,"
"而这个接缝只看得见一个出口实现。"
"自己验:不用验,也不要在这里补一条「emit 不抛」的断言——那会把一个在后端不可用时"
"抛异常的合法出口判成不合格。"
)
def test_the_no_re_emission_guarantee_is_asserted_in_the_library_not_here(self):
"""投递失败不再转成一条事件从同一个出口发出去(`design/0013` 决策七)。
那会自我喂食:一个持续失败的出口会让失败处理路径变成递归,而递归的表现是进程卡住或
栈溢出,不是一条错误日志。
**要断言的是库有没有再发一次,那是整次运行的行为**,所以断言由库自己的测试守着——
那边用一个恒抛异常的出口跑完一次运行,验出口收到的条数恰好等于步数。这个接缝自己
看不到「库发了几次」。
"""
pytest.skip(
"承诺:投递失败不会被转成一条事件从同一个出口再发一次。"
"这一层验不了——「库发了几次」是整次运行的行为,一个出口实现自己数不出来。"
"自己验:不用验,这条约束的是库不是出口;库那边用一个恒抛异常的出口跑完一次运行,"
"验出口收到的条数恰好等于步数。"
)
def test_the_audit_trail_is_asserted_against_the_log_not_here(self):
"""审计纪律由存储承担,不由事件流承担(`design/0013` 决策二)。
GovDoc 有一条硬纪律:agent 的原始输出、修复后的输出、恢复来源全程留痕,禁止静默修复。
这条测试原来断言「事件要同时带原文与修复后的文本」,而那个前提是错的——事件流可丢,
一件只存在于可丢通道里的事实撑不起「禁止静默修复」。
两份文本在意图日志里各有位置:原文在模型调用结果记录的回复里,修复后的那份是步记录的
`raw_output`。断言落在库那边,因为要跑完一次完整运行再把日志读回来,而这个接缝的契约
只看得见一个出口实现。
"""
pytest.skip(
"承诺:原始输出与修复后的输出全程留痕,而承担它的是意图日志,不是事件流——"
"事件流可丢,一件只存在于可丢通道里的事实撑不起「禁止静默修复」。"
"这一层验不了——要跑完一次完整运行再把日志读回来,而这个接缝只看得见一个出口实现。"
"自己验:靠存储接缝那套契约(RunStoreContract),不要指望事件里带着这两份文本。"
)
+121
View File
@@ -0,0 +1,121 @@
"""模型调用接缝的行为契约。
**这一层不打真实网关**——那是 e2e 的事。这里断言的是返回结构体的形状与失败的表达方式,
用一个受控替身就能验。
两个已知形态:一个按三本账各记一条并自己按价格表算成本,一个在调用外面套退避并累加本次
运行的 token。
"""
import asyncio
import inspect
import pytest
from polyloop.ports import ModelCall, ModelClient
from polyloop.testing._records import ContractBase
class ModelClientContract(ContractBase):
"""模型调用接缝的准入套件。下游继承它,覆盖 `model_client` 与 `failing_call`。"""
@pytest.fixture
def model_client(self) -> ModelClient:
"""被测的模型调用接缝实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。"""
raise NotImplementedError(
"在你的子类里覆盖 `model_client` fixture,返回一个 ModelClient 实现的实例。"
)
@pytest.fixture
def failing_call(self) -> ModelCall:
"""一次拿去调用被测客户端会失败的调用,由实现方提供。
**套件不许自己写死它。** 一个实现要怎样才会失败,只有它自己知道:可能是一个不存在的
模型名、一条空得过不了校验的消息序列、一个指向黑洞的端点。套件这边随便定一个暗号
(比如约定 `result_id` 等于某个特定串时就该抛),等于替所有实现定了一份它们从没同意过
的协议——一个真网关适配器不认识那个暗号,于是它调用成功、用例判它不合格,而它其实是
对的。
返回一个 `polyloop.ports.ModelCall`。拿它调用被测客户端**必须抛异常**,抛什么类型由
实现定,契约只要求「抛」。
**这次调用不许真的花钱**,也不许在失败之前留下外部副作用。失败要发生在调用真的打出去
之前或之中,最省的做法是让它在入参校验那一关就挂掉。
**它和别的用例共用同一个 `model_client` 实例**,所以失败之后那个实例必须还能接着服务:
一次失败的调用把客户端弄成不可用,本身就不合格。
**退化情况:不存在「无论如何都不会失败」的合规实现。** 契约规定失败以异常表达,不以
「返回一个内容为空的正常回复」表达——库靠这个区分基础设施故障与「模型真的回了空字符
串」,而这两者在分析里属于完全不同的类别。所以一个给不出 `failing_call` 的实现,要么
是把失败吞成了空回复(那就是不合格,正是这条用例要抓的),要么是还没想过失败路径。
真的构造不出来时,最接近的合规做法是让实现在入参校验那一关抛,而不是把这条用例重写掉。
"""
raise NotImplementedError(
"在你的子类里覆盖 `failing_call` fixture,返回一个拿去调用被测客户端会抛异常的 "
"ModelCall。"
)
async def test_returns_three_fields(self, model_client, records):
"""返回三个字段:调用标识、可见回复、推理段。
可见回复与推理段的长度由库自己数字符,不从任何用量对象取——实测中转网关会用本地分词器
补算并整体替换用量对象,把明细一起吃掉,某次标定里 24 次调用的推理 token 全部没上报。
"""
reply = await model_client.call(records.model_call(call_index=0, result_id="m0"))
assert isinstance(reply.content, str)
assert isinstance(reply.thinking, str)
assert reply.call_id is None or isinstance(reply.call_id, str)
async def test_call_id_is_never_an_empty_string(self, model_client, records):
"""调用标识可以是「没有」,但绝不能是空串。
它是轨迹与账目之间唯一的连接键。空串是个「看起来合法」的键,连表时静默匹配不上;显式
的「没有」至少能被筛出来。它为空的合法含义只有一个:调用在记账之前就失败了。
"""
reply = await model_client.call(records.model_call(call_index=0, result_id="m0"))
assert reply.call_id != ""
async def test_failure_is_expressed_as_an_exception(self, model_client, failing_call):
"""调用失败以异常表达,不以「返回一个空回复」表达。
库接住它、翻译成模型故障、记一条调用标识为空的步。如果失败被表达成一个内容为空串的
正常返回,库没有任何办法把它和「模型真的回了空字符串」分开——而后者是模型行为,前者
是基础设施故障,两者在分析里属于完全不同的类别。
"""
with pytest.raises(Exception): # noqa: B017 具体异常类型归实现,契约只要求「抛」
await model_client.call(failing_call)
async def test_cancellation_propagates_and_is_not_swallowed(self, model_client, records):
"""取消要能穿过模型调用,`CancelledError` 不许被捕获吞没。
**`cancel()` 返回 `False` 是能力判定,不是失败。** 一个在单个事件循环 tick 之内就返回
的客户端(一个受控替身就是),取消发出去时它已经跑完,没有任何机会吞掉取消。把它判成
不合格会让这条用例的成败取决于实现跑得多快,而一个偶发变红的准入标准会训练下游忽略红。
"""
task = asyncio.ensure_future(
model_client.call(records.model_call(call_index=0, result_id="m0"))
)
await asyncio.sleep(0)
if not task.cancel():
pytest.skip(
"承诺:取消要能穿过模型调用,CancelledError 不许被捕获吞没。"
"这一次验不了——被测实现在一个事件循环 tick 之内就返回了,取消发出去时它已经跑完,"
"根本没有机会吞掉取消。跑得太快不是违约,这条契约对它无从谈起,不必去改实现。"
"自己验:只有在实现真的有等待点(网络往返、子进程、容器会话)时这条才验得出来。"
)
with pytest.raises(asyncio.CancelledError):
await task
def test_signature_carries_no_retry_or_rate_limit_parameters(self, model_client):
"""签名里不出现重试次数、退避时长、限流配额。
出现即意味着库在治理一次模型调用,而那归 PolyGateway`CLAUDE.md` §1.5)。这条断言的是
名字,不是行为——按 §1.8,公共 Protocol 的签名本身就是对下游的承诺,断言它是应该的。
"""
names = set(inspect.signature(model_client.call).parameters)
assert not (names & {"retries", "max_retries", "backoff", "timeout", "rate_limit"})
+26
View File
@@ -0,0 +1,26 @@
"""pytest 插件入口。它什么都不做,存在的理由全在这段话里。
`pyproject.toml` 的 `[project.entry-points.pytest11]` 指向这个模块。pytest 启动时会先扫一遍
所有声明了 `pytest11` 入口的发行包,把它们的每一个 `.py` 文件标记为**断言可重写**,然后才
加载插件本身。换回来的就是这个标记:契约模块不在下游的 `python_files` 匹配范围里,默认不被
重写,于是一条契约用例失败时下游看到的是光秃秃的 `AssertionError`——没有等号两边的值,没有
差异摘要。
**删掉这个模块不会有任何东西报错**,下游只会觉得这套契约的报错难读,而且永远不会知道自己
少了什么。这是一次纯静默的退化,所以它值得一个只有 docstring 的模块。
**这个模块只 import pytest 与标准库。** 不 import 任何存储实现、不 import `polyloop.session`、
不 import `polyloop.adapters`——最后一条尤其硬:`adapters` 会把网关连同它的 provider 目录一起
拉起来,而 pytest 自动加载插件是那条依赖规则的一条新触发路径。这条边界不靠自觉,
import-linter 的分层契约与「除 testing 外一切禁止 import pytest」两条契约把它钉住。
**这里不接管事件循环**,理由见 `research-wiki/design/0014-contract-suite-distribution.md`
决策三末尾:下游的异步 fixture 在它自己的循环里创建,库另开一个循环会让那些 asyncio 对象
跨循环,而跨循环的对象炸得很难查。
"""
import pytest
def pytest_configure(config: pytest.Config) -> None:
"""空钩子。断言重写在 pytest 加载插件之前就按发行包做完了,这里没有要补的事。"""
+193
View File
@@ -0,0 +1,193 @@
"""构造各类记录的工厂,以及把它递给用例的那个共同父类。"""
from collections.abc import Mapping
import pytest
from polyloop.ports import Action, Event, EventKind, ModelCall, ToolCall
from polyloop.types import (
ActionOutcome,
ActionStatus,
Intent,
IntentKind,
Message,
ModelCallResult,
ModelReply,
ReplayPolicy,
Role,
RunFinished,
RunResult,
RunStarted,
StepCompleted,
StepRecord,
StopReason,
TextBlock,
)
class RecordFactory:
"""构造各类记录的工厂。
它不是被测对象,是让测试正文读得懂的一层薄封装:`records.model_call_intent(...)` 比直接
写一长串构造参数更能看出这条测试在断言什么。工厂随套件一起走,因为记录类的字段是库的
公共承诺,下游不该为了跑契约测试去手写构造。
"""
#: 让测试写 `records.action_status.EXECUTED` 而不必自己 import 那个枚举。
action_status = ActionStatus
def reply(self, *, call_id: str | None = "call-1", content: str = "hi") -> ModelReply:
return ModelReply(call_id=call_id, content=content, thinking="")
def model_call(
self,
*,
call_index: int,
result_id: str,
run_id: str = "r1",
messages: tuple[Message, ...] | None = None,
binding: Mapping[str, str] | None = None,
) -> ModelCall:
"""造一次交给模型调用接缝的调用。
`run_id` 有默认值,而记录类的那几个工厂方法一律要求显式传——区别在于这个壳不进任何
一份日志,没有哪条用例靠它区分两个运行的记录。记录类那边的默认值会让
「两个运行互相看不见对方」那条用例悄悄退化成「同一个运行写了两次」。
`messages` 默认给一条用户消息而不是空序列:一个真实的实现拿到空消息序列多半直接
拒绝,于是这条用例验的就变成了它的入参校验,不是它的返回结构。
"""
return ModelCall(
messages=(
messages
if messages is not None
else (Message(role=Role.USER, content=(TextBlock(text="你好"),)),)
),
call_index=call_index,
run_id=run_id,
result_id=result_id,
binding=dict(binding) if binding is not None else {},
)
def model_call_intent(self, *, run_id: str, call_index: int, result_id: str) -> Intent:
return Intent(
run_id=run_id,
kind=IntentKind.MODEL_CALL,
call_index=call_index,
result_id=result_id,
replay_policy=ReplayPolicy.NEVER,
)
def action_intent(
self,
*,
run_id: str,
call_index: int,
result_id: str,
replay_policy: ReplayPolicy = ReplayPolicy.NEVER,
) -> Intent:
return Intent(
run_id=run_id,
kind=IntentKind.ACTION,
call_index=call_index,
result_id=result_id,
replay_policy=replay_policy,
)
def model_call_result(
self,
*,
run_id: str,
result_id: str,
reply: ModelReply | None = None,
failure: str | None = None,
) -> ModelCallResult:
"""`reply` 与 `failure` 恰好一个有值,两个都不给时默认造一条成功的。"""
if reply is None and failure is None:
reply = self.reply()
return ModelCallResult(run_id=run_id, result_id=result_id, reply=reply, failure=failure)
def outcome(
self,
*,
status: ActionStatus = ActionStatus.EXECUTED,
observation: str = "环境的输出",
env_reported_completion: bool = False,
) -> ActionOutcome:
return ActionOutcome(
status=status,
observation=observation,
observation_is_synthetic=False,
env_reported_completion=env_reported_completion,
observation_truncated_chars=0,
)
def step(self, *, step_idx: int = 0, parse_ok: bool = True) -> StepRecord:
return StepRecord(
step_idx=step_idx,
raw_output="模型说的话",
content_chars=5,
thinking_chars=0,
action="做点事" if parse_ok else None,
parse_ok=parse_ok,
parse_error=None if parse_ok else "解释不出动作",
observation="环境的输出",
observation_is_synthetic=False,
observation_truncated_chars=0,
prompt_chars=100,
call_id="call-1",
step_wall_ms=12,
)
def step_completed(
self,
*,
run_id: str,
result_id: str | None,
action_outcome: ActionOutcome | None,
step: StepRecord,
) -> StepCompleted:
return StepCompleted(
run_id=run_id, result_id=result_id, action_outcome=action_outcome, step=step
)
def run_started(self, *, run_id: str, parameter_snapshot: Mapping[str, str]) -> RunStarted:
return RunStarted(run_id=run_id, parameter_snapshot=dict(parameter_snapshot))
def result(
self, *, run_id: str, stop_reason: StopReason = StopReason.TASK_COMPLETED
) -> RunResult:
return RunResult(
run_id=run_id, stop_reason=stop_reason, final_answer="42", steps=(self.step(),)
)
def run_finished(self, *, run_id: str, result: RunResult) -> RunFinished:
return RunFinished(run_id=run_id, result=result)
def action(self, *, text: str = "做点事", tool_name: str | None = None) -> Action:
return Action(
text=text,
tool_call=None if tool_name is None else ToolCall(name=tool_name, arguments={}),
)
def event(self, *, run_id: str = "run-1", step_idx: int = 0) -> Event:
return Event(
kind=EventKind.STEP_FINISHED,
run_id=run_id,
model_binding={"item": "a"},
step=self.step(step_idx=step_idx),
)
class ContractBase:
"""五个契约基类的共同父类,只为把记录工厂递给用例。
它不是 `research-wiki/design/0014-contract-suite-distribution.md` 决策二第七条否掉的那种
fixture mixin——那条否的是让下游在自己的子类上多继承一个 fixture 类。这个父类下游既看不见
也用不着:`records` 由套件自己提供,没有哪个实现需要覆盖它。
"""
@pytest.fixture
def records(self) -> RecordFactory:
"""记录工厂。套件自己提供,不需要在子类里覆盖。"""
return RecordFactory()
+247
View File
@@ -0,0 +1,247 @@
"""存储接缝的行为契约。
这套用例是「一次运行的日志到底保证什么」的权威(`CLAUDE.md` §0)。两个已知实现形态差别
很大——一个逐行追加本地文件,一个写关系数据库——所以下面每一条都只说行为,不碰形态。
**行为的理由不在这里。** 崩溃恢复为什么这么设计见 `design/0002`,写入粒度与前缀持久性见
`design/0005`。这里只断言结果。
## 无条件跳过的那两条
它们是**已知没有机器兜底的承诺**,不是还没写的测试。写成跳过而不是一句注释,是为了让它们
在每次跑套件时都被看见——`pytest -rs` 会把跳过的理由列出来。
不写成 `xfail`,因为一个做得比库预期更好的实现会把这种测试跑通,于是拿到 XPASS 判失败,
而下游取消不掉:子类上加一个类级 xfail 覆盖不了从函数级继承下来的那个,唯一的出路是整条
重写方法。语义上跳过也更准——这两条说的不是「这个功能预期会失败」,而是「套件所在的这一层
没有能力验证它」。
剩下那条曾经答不上的——没有动作的步 `StepCompleted.result_id` 填什么——已经由 `design/0006`
决策七答掉(可为空,且为空当且仅当动作结果也为空),对应的测试已经改写成真断言。
"""
import pytest
from polyloop.ports import RunStore
from polyloop.testing._records import ContractBase
class RunStoreContract(ContractBase):
"""存储接缝的准入套件。下游继承它,覆盖 `store`。"""
@pytest.fixture
def store(self) -> RunStore:
"""被测的存储接缝实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。
**每次调用要返回一个空的存储。** 套件里每条用例都假设自己面对一份干净的日志,共用
状态会让用例之间变成隐式的顺序依赖——那种依赖只在换个顺序跑的那天才暴露,而那天
通常是加了一条新用例之后,于是错误看起来来自那条新用例。
"""
raise NotImplementedError(
"在你的子类里覆盖 `store` fixture,返回一个 RunStore 实现的实例;"
"每次调用都要返回一个空的存储。"
)
# ----------------------------------------------------------------------
# 一、写进去的读得回来
# ----------------------------------------------------------------------
async def test_written_intent_is_readable(self, store, records):
"""写一条意图,读回整份日志时它必须在里面。
这是全套最基本的一条:意图日志的全部意义是「比进程活得久」,写了读不回来,后面每一条
恢复语义都建立在空气上。
"""
intent = records.model_call_intent(run_id="r1", call_index=0, result_id="m0")
await store.write_intent(intent)
log = await store.read_log("r1")
assert intent in log.intents
async def test_log_of_unknown_run_is_empty_not_an_error(self, store):
"""读一个从没写过的运行标识,得到一份空日志,而不是异常。
`run` 在开工前要判断「这个标识是不是已经有日志了」,靠的就是这一条。如果读不存在的
运行会抛异常,那个判断就得写成捕获异常——而捕获异常来做流程控制,会把真正的存储故障
一起吞掉。
"""
log = await store.read_log("never-written")
assert log.started is None
assert log.intents == ()
assert log.finished is None
async def test_two_runs_do_not_leak_into_each_other(self, store, records):
"""两个运行标识各写各的,互相看不见对方的记录。
端口不持有「当前运行」的隐式状态,这条测试是那个要求的外部可观测形式。一个有隐式当前
运行的实现会在并发下把 A 的意图写进 B 的日志,而那种错在单线程测试里永远不出现。
"""
a = records.model_call_intent(run_id="run-a", call_index=0, result_id="m0")
b = records.model_call_intent(run_id="run-b", call_index=0, result_id="m0")
await store.write_intent(a)
await store.write_intent(b)
assert (await store.read_log("run-a")).intents == (a,)
assert (await store.read_log("run-b")).intents == (b,)
# ----------------------------------------------------------------------
# 二、四态:恢复靠「意图有没有 / 结果有没有」判定
# ----------------------------------------------------------------------
async def test_intent_without_result_is_readable_as_such(self, store, records):
"""写了意图、没写结果,读回来必须能看出「这个 ID 没有结果」。
这是四态表里「状态未知」那一档的输入。存储不负责判定,但它必须让判定问得出口——恢复
要按预分配的 ID 精确地问,而不是模糊匹配去猜哪条结果对应哪次执行。
"""
intent = records.action_intent(run_id="r1", call_index=0, result_id="a0")
await store.write_intent(intent)
log = await store.read_log("r1")
assert intent in log.intents
assert all(step.result_id != "a0" for step in log.steps)
async def test_result_without_intent_is_visible_to_the_reader(self, store, records):
"""只写结果不写意图,读回来必须原样可见,存储自己不许修复也不许拒收。
「有结果没意图」是日志损坏,处置是拒绝续跑——但那个判断归恢复逻辑,不归存储。存储在
这里悄悄补一条意图或者拒绝这次写入,都会让损坏变得不可见,而不可见的损坏会被当成
正常数据继续用下去。
"""
result = records.model_call_result(run_id="r1", result_id="orphan", reply=records.reply())
await store.write_model_call_result(result)
log = await store.read_log("r1")
assert log.model_results == (result,)
assert log.intents == ()
async def test_failed_model_call_is_recorded_as_a_result_not_as_nothing(self, store, records):
"""模型调用失败也要落一条结果记录,否则恢复会把它读成「状态未知」。
失败这件事是确定的:调用发出去了、失败了、库记了一条步。如果这时不写结果条目,恢复
只看见「意图有、结果无」,走重放策略——而这次调用的状态一点都不未知。下游按停止原因
做的统计会照单收下这个错误。
"""
result = records.model_call_result(
run_id="r1", result_id="m0", reply=None, failure="连接超时"
)
await store.write_model_call_result(result)
(readback,) = (await store.read_log("r1")).model_results
assert readback.reply is None
assert readback.failure == "连接超时"
# ----------------------------------------------------------------------
# 三、原子写
# ----------------------------------------------------------------------
async def test_action_result_and_step_land_together(self, store, records):
"""动作结果与步记录一次原子落地:读回来要么两者都在,要么都不在。
不原子的话,崩在两者之间会让那一步的历史文本永远丢失,而恢复判定会把它读成「执行完了,
跳过」——恢复出来的消息序列比不中断跑完时少一轮,后面每一步都跟着偏。
**这条测试只能验「一起可见」,验不了「一起不可见」。** 见本文件末尾那条。
"""
step = records.step_completed(
run_id="r1",
result_id="a0",
action_outcome=records.outcome(),
step=records.step(step_idx=0),
)
await store.write_step_completed(step)
log = await store.read_log("r1")
assert log.steps == (step,)
assert log.steps[0].action_outcome is not None
async def test_step_without_an_action_is_still_recorded(self, store, records):
"""没有动作的步照样留痕:解析失败、模型调用失败、最终回答三种都算一步。
预算对等要求它们计入步数——它们确实消耗了一次模型调用。丢掉那一步还会丢掉模型在出故障时
说了什么,而那正是排查「环境坏了还是模型写了危险代码」最需要的。
这条曾经写不出来:那时 `StepCompleted.result_id` 是必填字符串,而这一步没写过动作意图、
没有预分配的 ID,随便编一个会让恢复读到一条对不上任何意图的记录,按四态表最后一行判成
日志损坏。`design/0006` 决策七把它改成可为空,并要求**它为空当且仅当动作结果也为空**,
这个洞才补上。存储要能原样存下这个形状。
"""
step = records.step_completed(
run_id="r1", result_id=None, action_outcome=None, step=records.step(step_idx=0)
)
await store.write_step_completed(step)
log = await store.read_log("r1")
assert log.steps == (step,)
assert log.steps[0].result_id is None
assert log.steps[0].action_outcome is None
# ----------------------------------------------------------------------
# 四、运行的开始与结束
# ----------------------------------------------------------------------
async def test_run_finished_is_visible_before_the_result_is_returned(self, store, records):
"""「这次运行结束了」这个标记由库写下,而且写在把结果交给调用方之前。
另一条路有个具体的失败场景:结果由项目落盘的话,「跑完了、库返回了、项目存的时候崩了」
这种情况下,重启后日志显示最后一步有结果、没有结束标记,而项目那边什么都没有。续跑会
重复执行最后一步的副作用,不续跑就丢掉一次已经花完钱的运行。歧义来自结果跨了两个存储。
"""
finished = records.run_finished(run_id="r1", result=records.result(run_id="r1"))
await store.write_run_finished(finished)
assert (await store.read_log("r1")).finished == finished
async def test_run_started_carries_the_parameter_snapshot(self, store, records):
"""运行开始记录带着这次的参数快照,续跑时拿它与当前装配比对。
没有它,用同一个运行标识换一份定义续跑,前几步与后几步会来自两个不同的配置而全程零
报错——那正是要到统计阶段才分不清哪些行是真的那类损坏。
"""
started = records.run_started(run_id="r1", parameter_snapshot={"model": "m-1"})
await store.write_run_started(started)
assert (await store.read_log("r1")).started.parameter_snapshot == {"model": "m-1"}
# ----------------------------------------------------------------------
# 五、这套测试**验不了**的两条承诺
# ----------------------------------------------------------------------
def test_atomicity_under_crash_is_not_checkable_here(self):
"""原子性的另一半——「崩在中间时两者都不可见」——这一层验不了。
要验它得在写入过程中把进程杀掉,而契约测试跑在一个进程里、面对的是一个已经装配好的
实现,没有位置插入那次崩溃。给端口加一个「故意在这里失败」的钩子能验,但那个钩子会
变成公共 API 的一部分,而它只为测试存在。
结论是这条承诺**没有机器兜底**,只能靠 `CLAUDE.md` §3 那轮对抗审查看实现。把这件事
写成一条跳过而不是一句注释,是为了让它在每次跑套件时都被看见。
"""
pytest.skip(
"承诺:动作结果与步记录一起落地,崩在中间时两者都不可见。"
"这一层验不了——套件跑在一个进程里、面对一个装配好的实现,没有位置插入那次崩溃,"
"而为它加一个「故意在这里失败」的钩子会把测试用的东西变成公共 API。"
"自己验:在你自己实现的单元测试里用可注入的故障点覆盖它,"
"或者按 CLAUDE.md §3 第四类走一轮对抗审查看实现。"
)
def test_prefix_durability_is_not_checkable_here(self):
"""前缀持久性同样验不了,理由更硬一层。
它说的是「第 k 次写入被确认持久时,前 k-1 次也已经持久」,而「已经持久」是掉电之后
才看得出来的性质。在一个进程里读得回来,不等于它落了盘。
"""
pytest.skip(
"承诺:第 k 次写入被确认持久时,第 1 到 k-1 次也已经持久。"
"这一层验不了——「已经持久」是掉电之后才看得出来的性质,"
"在一个进程里读得回来不等于它落了盘。"
"自己验:它实际是对实现形态的约束(同一文件的追加写、同一连接上顺序提交的事务"
"天然满足它),靠评审看你的实现属不属于这种形态。"
)