feat(stores): 落成逐行追加的日志存储,契约套件第一次真的在跑
design 0011(待确认)定了六条:一次运行一个文件且文件名就是运行标识(不转义不哈希,按标识 去目录里找文件是最自然的用法;标识必须是安全文件名,否则 ../ 会把文件写到目录外面);一行 一条记录加一个 record 类型标签(serialization 编出来的载荷没有元信息键,标签是存储这层加的, record 从此是保留键);第一条解不开的行就是日志结尾、它后面还有内容就是损坏;fsync 只在 运行开始、两条意图、运行结束四处(其余两处靠前缀持久性兜);写入走 to_thread;运行开始记录 用 O_EXCL 兜住跨进程撞车。 契约套件里那条 test_step_without_an_action_is_still_recorded 转成真断言——它标着 xfail 的 理由是「StepCompleted.result_id 在 0006 里是必填字符串」,而 0006 决策七早就把它改成可为空 并加了不变量。xfail 8→7,跳过 24→14。 原子写「一起不可见」那一半按契约套件的点名在这一层补上了:给实现留一个可注入的故障点 (一个可替换的「把这些字节写进去」),测试把它换成写一半就抛异常,断言那条记录整条不可见。 前缀持久性仍然验不了(掉电才看得出来),继续登记为已知缺口。 调研三条实据写进了 0011:两个下游 fsync 全仓零处(一个的 SQLite 还开着 synchronous=NORMAL), 所以这条比它们都严、代价是每步两次 fsync;一个下游的轨迹检查器同样是「碰到第一条坏行就放弃 整个文件」;另一个下游踩过「文件名少一维导致两个阶段静默互相覆盖」,O_EXCL 把那类静默覆盖 变成显式失败。这几条我自己逐条核过——那份调研的 subagent 承认它编过一句「我抽查过了」。 migrations/dissect.md 登记两条:运行标识要带齐现在文件名里那五维,以及这份意图日志和它那份 逐步轨迹是两样东西不要混。
This commit is contained in:
+169
-34
@@ -6,83 +6,218 @@
|
||||
|
||||
**接法**:下游在自己的 `conftest.py` 里覆盖同名 fixture,返回自己的实现。
|
||||
|
||||
**现在这套测试全部跳过**,因为 `polyloop` 下还没有任何公共类型与 Protocol
|
||||
(`research-wiki/design/0006-public-names-and-signatures.md` 还没过人类门)。跳过的理由写在
|
||||
每个 fixture 里,读跳过原因就能知道缺的是哪一块。
|
||||
|
||||
**为什么在实现之前就写它**:写一条契约测试要求把每一次调用逐字写出来——方法叫什么、参数
|
||||
填什么、返回值怎么取。散文里读着通顺的地方,落到这一步就会露出来。前三轮文档评审抓不到的
|
||||
洞,几乎全是这么冒出来的。
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
import pytest
|
||||
|
||||
#: 公共类型与 Protocol 落地之前,套件里的每一条都缺同一样东西。
|
||||
_NOT_YET = (
|
||||
"polyloop 的公共类型与 Protocol 还没落地(design/0006 待确认)。"
|
||||
"这条契约要断言的行为已经写在测试的 docstring 里,落地之后去掉这个跳过即可。"
|
||||
from polyloop.ports import Action, Event, ToolCall
|
||||
from polyloop.stores import JsonlRunStore
|
||||
from polyloop.types import (
|
||||
ActionOutcome,
|
||||
ActionStatus,
|
||||
Intent,
|
||||
IntentKind,
|
||||
ModelCallResult,
|
||||
ModelReply,
|
||||
ReplayPolicy,
|
||||
RunFinished,
|
||||
RunResult,
|
||||
RunStarted,
|
||||
StepCompleted,
|
||||
StepRecord,
|
||||
StopReason,
|
||||
)
|
||||
|
||||
#: 还没有默认实现的那几个接缝,套件里对应的测试全部跳过。
|
||||
_NO_IMPLEMENTATION = (
|
||||
"库不带这个接缝的默认实现——带了就等于替某一家定了它的协议。"
|
||||
"下游在自己的 conftest.py 里覆盖这个 fixture,把自己的实现接进来跑。"
|
||||
)
|
||||
|
||||
|
||||
class _Records:
|
||||
"""构造各类记录的工厂。
|
||||
|
||||
它不是被测对象,是让测试正文读得懂的一层薄封装:`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_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) -> Event:
|
||||
return Event()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
def records() -> _Records:
|
||||
return _Records()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path) -> JsonlRunStore:
|
||||
"""被测的存储接缝实现。
|
||||
|
||||
下游覆盖这个 fixture,返回自己的实例。每次调用应当返回一个**空的**存储——套件里的每条
|
||||
测试都假设自己面对一份干净的日志,共用状态会让测试之间的顺序变成隐式依赖。
|
||||
默认接的是库自带的那个逐行追加实现。下游覆盖这个 fixture,返回自己的实例。
|
||||
|
||||
每次调用返回一个**空的**存储:套件里每条测试都假设自己面对一份干净的日志,共用状态会让
|
||||
测试之间的顺序变成隐式依赖。`tmp_path` 每条测试一个新目录,这一条自动成立。
|
||||
"""
|
||||
pytest.skip(_NOT_YET)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def records():
|
||||
"""构造各类记录的辅助工厂。
|
||||
|
||||
它不是被测对象,是让测试正文读得懂的一层薄封装:`records.model_call_intent(...)` 比
|
||||
直接写一长串构造参数更能看出这条测试在断言什么。工厂本身由库提供,因为记录类的字段
|
||||
是库的公共承诺,下游不该为了跑契约测试去手写构造。
|
||||
"""
|
||||
pytest.skip(_NOT_YET)
|
||||
return JsonlRunStore(directory=tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def samples():
|
||||
"""被测解释器认得的几段模型输出,由实现方提供。
|
||||
|
||||
**套件不许自己写死输入。** 库不带默认解释器实现,也就不认识任何一家的动作语言:拿
|
||||
dissect 的 Python 代码围栏去喂 GovDoc 的 JSON 解析器,后者正确地返回「无效决策」,
|
||||
而写死输入的套件会把这个正确行为判成失败。
|
||||
**套件不许自己写死输入。** 库不带默认解释器实现,也就不认识任何一家的动作语言:拿一家的
|
||||
代码围栏去喂另一家的 JSON 解析器,后者正确地返回「无效决策」,而写死输入的套件会把这个
|
||||
正确行为判成失败。
|
||||
|
||||
实现方要提供两段:`yields_an_action`(一段能被解释成动作的模型回复)与 `yields_invalid`
|
||||
(一段解释不出动作的)。这不是给套件开后门——「我这套语言里什么算合法动作」本来就只有
|
||||
实现方答得出,套件断言的是**拿到之后的形状**,不是输入长什么样。
|
||||
"""
|
||||
pytest.skip(_NOT_YET)
|
||||
pytest.skip(_NO_IMPLEMENTATION)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def action_executor():
|
||||
"""被测的动作执行接缝实现。"""
|
||||
pytest.skip(_NOT_YET)
|
||||
"""被测的动作执行接缝实现。
|
||||
|
||||
库自带一个由工具注册表派生的分发器(`polyloop.tools.ToolRegistry.executor`),但它只覆盖
|
||||
「工具调用」那一种动作语言;把它接在这里会让套件只验得了那一种,所以默认仍然留空。
|
||||
"""
|
||||
pytest.skip(_NO_IMPLEMENTATION)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def decision_parser():
|
||||
"""被测的决策解释接缝实现。"""
|
||||
pytest.skip(_NOT_YET)
|
||||
pytest.skip(_NO_IMPLEMENTATION)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_client():
|
||||
"""被测的模型调用接缝实现。
|
||||
|
||||
注意这一层的契约测试**不打真实网关**——那是 e2e 的事。这里断言的是返回结构体的形状
|
||||
与失败时的表达方式,用一个受控的替身就能验。
|
||||
注意这一层的契约测试**不打真实网关**——那是 e2e 的事。这里断言的是返回结构体的形状与
|
||||
失败时的表达方式,用一个受控的替身就能验。
|
||||
"""
|
||||
pytest.skip(_NOT_YET)
|
||||
pytest.skip(_NO_IMPLEMENTATION)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_sink():
|
||||
"""被测的事件出口实现。"""
|
||||
pytest.skip(_NOT_YET)
|
||||
pytest.skip(_NO_IMPLEMENTATION)
|
||||
|
||||
Reference in New Issue
Block a user