feat(serialization): 落成记录的编解码与 schema 版本校验
一个 encode 对七个 decode_*。编码不需要知道目标类型(对象自己知道),解码需要 (一个字典什么都不知道),不对称是这个原因。 载荷就是记录类的字段,没有任何元信息键——没有类型标签、没有时间戳。哪一行是哪种记录由 存储实现自己解决,库不替它定文件布局;多塞一个键会让步记录的载荷不再和迁移前那份逐行 轨迹同形,而那边的验收标准是逐字段可比。 解码三条:带版本的三种记录(运行开始、步记录、运行结果)版本必须在场且认得,缺了或者 认不得都失败;记录类上的每个字段都必须在载荷里,有默认值的也一样(默认值补齐会把 「这件事没发生过」改写成「发生了但值为空」);多余键忽略(存储常要在同一个字典里塞 自己的东西,把它整个递回来解码是最自然的写法)。 第二条的直接后果写进了模块 docstring:往持久化结构里加字段必须同时抬 schema 版本。 CLAUDE.md §1.3 那条「新增字段必带默认值」管的是 Python 构造器,持久化这一侧由 §1.4 管。 SchemaVersionError 与 DecodeError 分开:一个说去升级库,一个说去查数据。压成一个的话, 一次例行升级漏做会被读成数据损坏,然后有人去修数据。
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
"""记录编解码的行为。
|
||||
|
||||
两件事在这里被守住:**往返之后逐字段相等**(存进去再读回来还是同一条记录),以及
|
||||
**解码宁可失败也不猜**(缺版本、认不得的版本、缺字段、类型不对,一律报错)。
|
||||
|
||||
第二件比第一件重要。一个被猜着修好的日志会让后面每一个基于它的判断都建立在编造的数据上,
|
||||
而那些判断不会报错——它们只是错,而且要到统计阶段才看得出来。
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from polyloop.serialization import (
|
||||
DecodeError,
|
||||
SchemaVersionError,
|
||||
decode_intent,
|
||||
decode_model_call_result,
|
||||
decode_run_finished,
|
||||
decode_run_result,
|
||||
decode_run_started,
|
||||
decode_step_completed,
|
||||
decode_step_record,
|
||||
encode,
|
||||
)
|
||||
from polyloop.types import (
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
ActionOutcome,
|
||||
ActionStatus,
|
||||
Intent,
|
||||
IntentKind,
|
||||
ModelCallResult,
|
||||
ModelReply,
|
||||
ReplayPolicy,
|
||||
RunFinished,
|
||||
RunResult,
|
||||
RunStarted,
|
||||
StepCompleted,
|
||||
StepRecord,
|
||||
StopReason,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _step(**overrides: object) -> StepRecord:
|
||||
fields: dict[str, object] = {
|
||||
"step_idx": 3,
|
||||
"raw_output": "```python\nprint(1)\n```",
|
||||
"content_chars": 24,
|
||||
"thinking_chars": 0,
|
||||
"action": "print(1)",
|
||||
"parse_ok": True,
|
||||
"parse_error": None,
|
||||
"observation": "1\n",
|
||||
"observation_is_synthetic": False,
|
||||
"observation_truncated_chars": 0,
|
||||
"prompt_chars": 1200,
|
||||
"call_id": "call-7",
|
||||
"step_wall_ms": 812,
|
||||
"tool_name": "run_python",
|
||||
"tool_arguments": '{"code": "print(1)"}',
|
||||
"action_status": ActionStatus.EXECUTED,
|
||||
"env_reported_completion": False,
|
||||
}
|
||||
fields.update(overrides)
|
||||
return StepRecord(**fields) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _outcome() -> ActionOutcome:
|
||||
return ActionOutcome(
|
||||
status=ActionStatus.EXECUTED,
|
||||
observation="1\n",
|
||||
observation_is_synthetic=False,
|
||||
env_reported_completion=False,
|
||||
observation_truncated_chars=0,
|
||||
)
|
||||
|
||||
|
||||
def _result() -> RunResult:
|
||||
return RunResult(
|
||||
run_id="run-1",
|
||||
stop_reason=StopReason.TASK_COMPLETED,
|
||||
final_answer="42",
|
||||
steps=(_step(step_idx=0), _step(step_idx=1)),
|
||||
event_delivery_failures=2,
|
||||
)
|
||||
|
||||
|
||||
ROUND_TRIPS = [
|
||||
pytest.param(
|
||||
RunStarted(run_id="run-1", parameter_snapshot={"model.name": "x", "request.budget": "8"}),
|
||||
decode_run_started,
|
||||
id="run_started",
|
||||
),
|
||||
pytest.param(
|
||||
Intent(
|
||||
run_id="run-1",
|
||||
kind=IntentKind.ACTION,
|
||||
call_index=4,
|
||||
result_id="res-4",
|
||||
replay_policy=ReplayPolicy.SAFE,
|
||||
),
|
||||
decode_intent,
|
||||
id="intent",
|
||||
),
|
||||
pytest.param(
|
||||
ModelCallResult(
|
||||
run_id="run-1",
|
||||
result_id="res-4",
|
||||
reply=ModelReply(call_id="call-7", content="hi", thinking="hmm"),
|
||||
failure=None,
|
||||
),
|
||||
decode_model_call_result,
|
||||
id="model_call_result_with_reply",
|
||||
),
|
||||
pytest.param(
|
||||
ModelCallResult(run_id="run-1", result_id="res-4", reply=None, failure="连不上"),
|
||||
decode_model_call_result,
|
||||
id="model_call_result_with_failure",
|
||||
),
|
||||
pytest.param(
|
||||
StepCompleted(run_id="run-1", result_id="res-4", action_outcome=_outcome(), step=_step()),
|
||||
decode_step_completed,
|
||||
id="step_completed_with_action",
|
||||
),
|
||||
pytest.param(
|
||||
StepCompleted(
|
||||
run_id="run-1",
|
||||
result_id=None,
|
||||
action_outcome=None,
|
||||
step=_step(action=None, parse_ok=False, parse_error="没有代码块", action_status=None),
|
||||
),
|
||||
decode_step_completed,
|
||||
id="step_completed_without_action",
|
||||
),
|
||||
pytest.param(
|
||||
RunFinished(run_id="run-1", result=_result()), decode_run_finished, id="run_finished"
|
||||
),
|
||||
pytest.param(_step(), decode_step_record, id="step_record"),
|
||||
pytest.param(_result(), decode_run_result, id="run_result"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("record", "decoder"), ROUND_TRIPS)
|
||||
def test_round_trip_is_field_for_field_equal(record: object, decoder: object) -> None:
|
||||
"""存进去再读回来还是同一条记录。"""
|
||||
assert decoder(encode(record)) == record # type: ignore[operator]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("record", "decoder"), ROUND_TRIPS)
|
||||
def test_payload_is_json_serialisable(record: object, decoder: object) -> None:
|
||||
"""载荷要能落进一行 jsonl 或者一个数据库字段,不许出现只有 Python 认得的类型。"""
|
||||
assert decoder(json.loads(json.dumps(encode(record)))) == record # type: ignore[operator]
|
||||
|
||||
|
||||
def test_payload_keys_are_exactly_the_record_fields() -> None:
|
||||
"""载荷里没有元信息键:没有类型标签、没有时间戳、没有写入者标识。
|
||||
|
||||
哪一行是哪种记录由存储实现自己解决,库不替它定文件布局。多塞一个键的直接后果是一条
|
||||
步记录的载荷不再和迁移前那份逐行轨迹同形,而那边的验收标准是「逐字段可比」。
|
||||
"""
|
||||
payload = encode(_step())
|
||||
|
||||
assert set(payload) == {
|
||||
"step_idx",
|
||||
"raw_output",
|
||||
"content_chars",
|
||||
"thinking_chars",
|
||||
"action",
|
||||
"parse_ok",
|
||||
"parse_error",
|
||||
"observation",
|
||||
"observation_is_synthetic",
|
||||
"observation_truncated_chars",
|
||||
"prompt_chars",
|
||||
"call_id",
|
||||
"step_wall_ms",
|
||||
"tool_name",
|
||||
"tool_arguments",
|
||||
"action_status",
|
||||
"env_reported_completion",
|
||||
"schema_version",
|
||||
}
|
||||
|
||||
|
||||
def test_encode_refuses_a_type_it_does_not_know() -> None:
|
||||
with pytest.raises(TypeError):
|
||||
encode({"run_id": "run-1"}) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_enums_are_encoded_as_their_string_value() -> None:
|
||||
"""枚举落成字符串,那份文件离开这个库也得读得懂。"""
|
||||
payload = encode(_result())
|
||||
|
||||
assert payload["stop_reason"] == "task_completed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 版本
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("record", "decoder"),
|
||||
[
|
||||
pytest.param(_step(), decode_step_record, id="step_record"),
|
||||
pytest.param(_result(), decode_run_result, id="run_result"),
|
||||
pytest.param(
|
||||
RunStarted(run_id="run-1", parameter_snapshot={}), decode_run_started, id="run_started"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_a_payload_without_a_version_is_refused(record: object, decoder: object) -> None:
|
||||
"""缺版本直接失败,不走数据类上那个默认值。
|
||||
|
||||
那个默认值是给构造用的。读取时「这条记录是哪个版本写的」只有载荷知道,靠默认值补齐会把
|
||||
「这是旧版本」和「这条没写版本」压成同一个答案。
|
||||
"""
|
||||
payload = encode(record)
|
||||
del payload["schema_version"]
|
||||
|
||||
with pytest.raises(SchemaVersionError, match="schema_version"):
|
||||
decoder(payload) # type: ignore[operator]
|
||||
|
||||
|
||||
def test_an_unknown_version_is_refused() -> None:
|
||||
payload = encode(_step())
|
||||
payload["schema_version"] = CURRENT_SCHEMA_VERSION + 7
|
||||
|
||||
with pytest.raises(SchemaVersionError, match="不是本库认得的版本"):
|
||||
decode_step_record(payload)
|
||||
|
||||
|
||||
def test_a_version_error_is_distinguishable_from_a_corrupt_payload() -> None:
|
||||
"""版本错和数据坏要人做的事完全不同:一个去升级库,一个去查数据。
|
||||
|
||||
压成一个异常的话,一次例行升级漏做会被读成数据损坏,然后有人去修数据。
|
||||
"""
|
||||
stale = encode(_step())
|
||||
stale["schema_version"] = CURRENT_SCHEMA_VERSION + 7
|
||||
corrupt = encode(_step())
|
||||
del corrupt["observation"]
|
||||
|
||||
assert issubclass(SchemaVersionError, DecodeError)
|
||||
with pytest.raises(SchemaVersionError):
|
||||
decode_step_record(stale)
|
||||
with pytest.raises(DecodeError) as caught:
|
||||
decode_step_record(corrupt)
|
||||
assert not isinstance(caught.value, SchemaVersionError)
|
||||
|
||||
|
||||
def test_the_records_that_do_not_carry_a_version_decode_without_one() -> None:
|
||||
"""意图、模型调用结果、逐步结果、运行结束不带自己的版本。
|
||||
|
||||
它们只在恢复时被库自己读,而恢复读的是整份日志,版本由运行开始那一条统一交代。给每一条
|
||||
都带一个版本会让同一份日志里出现好几个可以各自演进的版本号。
|
||||
"""
|
||||
payload = encode(
|
||||
Intent(
|
||||
run_id="run-1",
|
||||
kind=IntentKind.MODEL_CALL,
|
||||
call_index=0,
|
||||
result_id="res-0",
|
||||
replay_policy=ReplayPolicy.NEVER,
|
||||
)
|
||||
)
|
||||
|
||||
assert "schema_version" not in payload
|
||||
assert decode_intent(payload).call_index == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 缺字段、多字段、类型不对
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_missing_field_fails_even_when_the_dataclass_has_a_default() -> None:
|
||||
"""少一个字段就是损坏,不拿默认值补。
|
||||
|
||||
补齐会把「这件事没发生过」改写成「发生了但值为空」,而这种损坏要到统计阶段才暴露。
|
||||
直接后果是:往持久化结构里加字段必须同时抬 schema 版本。
|
||||
"""
|
||||
payload = encode(_step())
|
||||
del payload["tool_name"]
|
||||
|
||||
with pytest.raises(DecodeError, match="tool_name"):
|
||||
decode_step_record(payload)
|
||||
|
||||
|
||||
def test_extra_keys_are_ignored() -> None:
|
||||
"""存储实现常常要在同一个字典里塞自己的东西,把它整个递回来解码是最自然的写法。"""
|
||||
payload = encode(_step())
|
||||
payload["record"] = "step"
|
||||
payload["written_at"] = "2026-08-10T00:00:00Z"
|
||||
|
||||
assert decode_step_record(payload) == _step()
|
||||
|
||||
|
||||
def test_a_wrong_type_is_refused() -> None:
|
||||
payload = encode(_step())
|
||||
payload["step_idx"] = "3"
|
||||
|
||||
with pytest.raises(DecodeError, match="整数"):
|
||||
decode_step_record(payload)
|
||||
|
||||
|
||||
def test_a_boolean_is_not_an_integer() -> None:
|
||||
"""布尔是整数的子类,不排掉的话一个被写坏成 true 的计数会静默读成 1。"""
|
||||
payload = encode(_step())
|
||||
payload["prompt_chars"] = True
|
||||
|
||||
with pytest.raises(DecodeError, match="整数"):
|
||||
decode_step_record(payload)
|
||||
|
||||
|
||||
def test_an_unrecognised_enum_value_is_refused() -> None:
|
||||
"""认不得的枚举取值是版本歪了的信号,不是可以跳过的一行。"""
|
||||
payload = encode(_result())
|
||||
payload["stop_reason"] = "gave_up"
|
||||
|
||||
with pytest.raises(DecodeError, match="StopReason"):
|
||||
decode_run_result(payload)
|
||||
|
||||
|
||||
def test_a_parameter_snapshot_must_be_strings_all_the_way() -> None:
|
||||
"""快照是逐字段比对用的,值一旦不是字符串,比对就要先猜怎么归一化。"""
|
||||
payload = encode(RunStarted(run_id="run-1", parameter_snapshot={"a": "1"}))
|
||||
payload["parameter_snapshot"] = {"a": 1}
|
||||
|
||||
with pytest.raises(DecodeError, match="字符串"):
|
||||
decode_run_started(payload)
|
||||
|
||||
|
||||
def test_the_step_completed_invariant_survives_a_round_trip() -> None:
|
||||
"""`result_id` 与 `action_outcome` 那条不变量由记录类自己校验,解码不绕过它。
|
||||
|
||||
一条「有动作结果却没有意图」的记录正是四态表里那一档日志损坏,让它解出来就等于把损坏
|
||||
洗成合法数据。
|
||||
"""
|
||||
payload = encode(
|
||||
StepCompleted(run_id="run-1", result_id="res-4", action_outcome=_outcome(), step=_step())
|
||||
)
|
||||
payload["result_id"] = None
|
||||
|
||||
with pytest.raises(ValueError, match="result_id"):
|
||||
decode_step_completed(payload)
|
||||
|
||||
|
||||
def test_a_nested_payload_of_the_wrong_shape_is_refused() -> None:
|
||||
payload = encode(RunFinished(run_id="run-1", result=_result()))
|
||||
payload["result"] = "跑完了"
|
||||
|
||||
with pytest.raises(DecodeError, match="嵌套载荷"):
|
||||
decode_run_finished(payload)
|
||||
|
||||
|
||||
def test_a_broken_step_inside_a_run_result_is_refused() -> None:
|
||||
"""整份结果里坏了一步,整份就解不出来,不静默丢掉那一步。"""
|
||||
payload = encode(_result())
|
||||
payload["steps"][1]["observation"] = 7 # type: ignore[index]
|
||||
|
||||
with pytest.raises(DecodeError, match="observation"):
|
||||
decode_run_result(payload)
|
||||
Reference in New Issue
Block a user