指出 AI 改代码后测试仍通过是因为 freeze 锁了测试名而非失败特征,提出用异常摘要哈希、种子日志、只读 fixture 树三重保真。
一个通过的 CI 任务并不能作为 Agent patch 合并的依据——当属性检查未设种子、当 fixtures 放在可写目录树中、或者当一个 flaky freeze 仅以 pytest node id 作为键时。上述三个漏洞会让一个 patch 看起来稳定,而预言机(oracle)却在漂移。正确做法是:用一次带种子的试验日志、用哈希锁定的 fixture 语料库、用绑定到异常摘要的 freeze 账本来给 patch 打分。
测试名只是标签,不是预言机。Agent 可以重命名 test_retry_backoff、拆分模块、或者把同样的断言封装到一个辅助函数里。只存储 tests/test_retry.py::test_retry_backoff 的 freeze 会在错误的 node 上失效,或者根本不会失效。用来支撑这个 freeze 的那个失败已经从账本中消失了。
未设种子的属性试验同样具有这种虚假信心的形状。本次运行通过,下次运行可能就失败。如果评分器无法重放产生反例的那个确切随机抽取,就没有任何东西可以绑定到这个 freeze 上。重放才是证据,颜色不是。
将三个产物存放在 patch 旁边,而不是内嵌在 patch 内部。把它们放在创作流程无法写入的目录树中。
一份属性试验日志,包含显式的 RNG 种子、试验次数、命中次数,以及当检查失败时的最小化输入。
一份 fixture 清单,对评分器允许读取的每一个人类拥有的输入文件做哈希。
一条 freeze 记录,以失败特征为键:node id + 种子 + 异常类型 + 消息体的摘要。
三者任一缺失,合并评分都是不完整的。不要把不完整的评分当作通过,要把它当作一个建议性缺口,让 patch 保持未合并状态。
将可写源目录树和预言机目录树分离。Agent 编写的测试可以存在,但不得计入评分。
repo/
src/ # agent may patch
tests/generated/ # agent-authored, never scored
oracle/
fixtures/ # human-owned, hashed
properties/ # seeded checks
freeze_ledger.json # signature-bound
manifest.sha256
评分器读取 oracle/。Agent 进程不得拥有该路径的写权限。一个触及 oracle/ 的 patch 是进程失败,不是测试失败。在评分之前就拒绝它。
基于文件内容计算清单,而非基于 mtime。内容哈希在 checkout 顺序变化后依然有效,时间戳则不然。
# oracle_hash.py
from __future__ import annotations
import hashlib
import json
from pathlib import Path
ORACLE = Path("oracle")
FIXTURES = ORACLE / "fixtures"
MANIFEST = ORACLE / "manifest.sha256"
def file_digest(path: Path) -> str:
h = hashlib.sha256()
h.update(path.read_bytes())
return h.hexdigest()
def build_manifest() -> dict[str, str]:
rows: dict[str, str] = {}
for path in sorted(FIXTURES.rglob("*")):
if path.is_file():
rel = path.relative_to(ORACLE).as_posix()
rows[rel] = file_digest(path)
return rows
def write_manifest() -> None:
payload = json.dumps(build_manifest(), indent=2, sort_keys=True) + "\n"
MANIFEST.write_text(payload)
def verify_manifest() -> list[str]:
expected = json.loads(MANIFEST.read_text())
actual = build_manifest()
problems: list[str] = []
for key in sorted(set(expected) | set(actual)):
if expected.get(key) != actual.get(key):
problems.append(f"hash mismatch: {key}")
return problems
if __name__ == "__main__":
import sys
mode = sys.argv[1] if len(sys.argv) > 1 else "verify"
if mode == "write":
write_manifest()
else:
problems = verify_manifest()
if problems:
print("\n".join(problems))
raise SystemExit(2)
print("oracle fixture hashes match")
仅在人类拥有的 checkout 上运行 python oracle_hash.py write。Agent patch 的 CI 运行 python oracle_hash.py verify。哈希不匹配是评分无效,不是 flaky。不要重试它,不要 freeze 它。
调用 random 时必须记录种子到试验日志中。一个无法重放的属性无法被 freeze。下面的 runner 是一个提议示例,不是经过实测的生产套件。
# oracle/properties/retry_budget.py
from __future__ import annotations
import json
import random
from dataclasses import asdict, dataclass
from pathlib import Path
@dataclass(frozen=True)
class TrialLog:
property_id: str
seed: int
trials: int
hits: int
counterexample: dict | None
def retry_budget(delay_ms: list[int], cap_ms: int) -> bool:
if not delay_ms:
return False
total = 0
for delay in delay_ms:
if delay < 0 or delay > cap_ms:
return False
total += delay
if total > cap_ms:
return False
return True
def run(seed: int, trials: int = 200) -> TrialLog:
rng = random.Random(seed)
hits = 0
counter = None
for _ in range(trials):
n = rng.randint(1, 8)
cap = rng.choice([50, 100, 250, 1000])
delays = [rng.randint(-5, cap + 40) for _ in range(n)]
ok = retry_budget(delays, cap)
# Count a hit only when the draw exercised a non-empty, mostly valid path.
if any(delay >= 0 for delay in delays):
hits += 1
if not ok and counter is None:
counter = {"delays": delays, "cap_ms": cap}
return TrialLog("retry_budget", seed, trials, hits, counter)
if __name__ == "__main__":
log = run(seed=20260921)
Path("oracle/trial_retry_budget.json").write_text(
json.dumps(asdict(log), indent=2) + "\n"
)
print(json.dumps(asdict(log)))
关键的字段是 seed、trials、hits 和 counterexample。hits == 0 的日志不是 freeze 的候选。没有种子的日志不计分。在试验日志中存档最小化输入,而不是塞进一个 pytest 名称里。
node id 只是一个字段,不是键。要把 skip 绑定到你实际观察到的那个失败。
# freeze_bind.py
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
LEDGER = Path("oracle/freeze_ledger.json")
def digest_message(message: str) -> str:
normalized = re.sub(r"\d+", "<n>", message.strip())
return hashlib.sha256(normalized.encode()).hexdigest()[:16]
def signature(nodeid: str, seed: int | None, exc_type: str, message: str) -> str:
body = f"{nodeid}|{seed}|{exc_type}|{digest_message(message)}"
return hashlib.sha256(body.encode()).hexdigest()[:24]
def load_ledger() -> dict:
if not LEDGER.exists():
return {"freezes": {}}
return json.loads(LEDGER.read_text())
def freeze_applies(
nodeid: str,
seed: int | None,
exc_type: str,
message: str,
today: str,
) -> bool:
recs = load_ledger()["freezes"]
sig = signature(nodeid, seed, exc_type, message)
rec = recs.get(sig)
if rec is None:
return False
return rec["expires_on"] >= today
对消息中的数字做归一化,这样时间戳不会在每次运行中产生新的 freeze。种子保留在键中。如果属性被重新设了种子,旧的 freeze 不再适用——这是刻意设计的。freeze 是重放保险,不是永久 skip。
提议的账本结构:
{
"freezes": {
"a1b2c3d4e5f6a7b8c9d0e1f2": {
"nodeid": "oracle/properties/test_retry_budget.py::test_replay",
"seed": 20260921,
"exc_type": "AssertionError",
"message_digest": "9f3c1a2b4d5e6f70",
"first_seen": "2026-09-21",
"expires_on": "2026-10-05",
"reason": "human: intermittent cap rounding on slow runner"
}
}
}
人类撰写的 reason 是必填项。Agent 输出不得插入行。自动生成的 freeze 是用额外 JSON 重现了 node-id 问题。
# ci/reject_oracle_writes.sh
set -euo pipefail
BASE="${1:-origin/main}"
CHANGED="$(git diff --name-only "$BASE"...HEAD)"
if printf '%s\n' "$CHANGED" | grep -E '^oracle/'; then
echo "reject: agent patch touched oracle/"
printf '%s\n' "$CHANGED"
exit 2
fi
python oracle_hash.py verify
在 pytest 之前运行此脚本。一个通过"修改账本"来"修复"flake 的 patch 不是修复,是预言机变更。Fail closed。
颜色把三个问题坍缩成了一个比特。把它们分开。下面的脚本是一个提议的评分器。
# score_patch.py
from __future__ import annotations
import json
from pathlib import Path
from freeze_bind import freeze_applies
from oracle_hash import verify_manifest
from oracle.properties.retry_budget import run
def score(today: str, seed: int = 20260921) -> dict:
hash_problems = verify_manifest()
if hash_problems:
return {"result": "void", "reason": hash_problems}
log = run(seed=seed)
Path("oracle/trial_retry_budget.json").write_text(
json.dumps(
{
"property_id": log.property_id,
"seed": log.seed,
"trials": log.trials,
"hits": log.hits,
"counterexample": log.counterexample,
},
indent=2,
)
+ "\n"
)
if log.hits == 0 or log.seed is None:
return {"result": "incomplete", "trial": log.property_id}
if log.counterexample is not None:
message = (
f"total delay exceeded cap {log.counterexample['cap_ms']}"
)
skipped = freeze_applies(
nodeid="oracle/properties/retry_budget.py::run",
seed=log.seed,
exc_type="AssertionError",
message=message,
today=today,
)
if skipped:
return {"result": "advisory_skip", "seed": log.seed}
return {"result": "fail", "counterexample": log.counterexample}
return {"result": "pass", "seed": log.seed, "hits": log.hits}
if __name__ == "__main__":
print(json.dumps(score(today="2026-09-21"), indent=2))
把 JSON 送入合并规则。不要把 advisory_skip 并入 pass。也不要将 incomplete 并入 fail。Incomplete 表示预言机没有运行,这是评分器的 bug 或空的生成器问题,不是 src/ 中的产品缺陷。
advisory_skip 行是特征绑定的意义所在。这个 skip 仅适用于那个异常摘要和种子。同一个 node id 上出现了新的异常类型仍然会让任务失败。同一个摘要但测试被重命名了,仍然需要新的人类 freeze。两种情况都不是静默变绿。
假设 runner 报告了一个反例:cap_ms = 1000,延迟之和超过 cap,种子为 20260921,异常类型为 AssertionError。账本键不是 test_replay,而是那个 node、那个 seed、那个异常类型以及归一化消息的签名。
如果下次运行抛出的是 TimeoutError,freeze 不适用。如果下次运行使用种子 20260922,freeze 不适用。如果 Agent 重写了断言文本导致摘要变化,freeze 不适用。Patch 必须要么通过重放,要么等人类记录一条新签名。
这个规则看起来很严格,它就是很严格。存活周期超过其所描述的失败的 flake freeze 会变成没有取证痕迹的 skip。Node-id freeze 就是这样发生的。Signature freeze 让这条痕迹变得显式。
创作 src/ 的 Agent 不应该是写入 oracle/trial_*.json 的进程。把这两者分开。本地工作站可以创作 patch。第二个环境应该 checkout 同一个 commit,验证哈希,运行带种子的属性,并应用账本。
声明:本文是 MonkeyCode 产品推广的一部分。
MonkeyCode 的免费模型访问和免费服务器选项契合这种分离——当你想要在不同机器上进行创作和评分、但又不想先搭建一个私有 runner 时。评分器仍然需要预言机目录树、种子和账本。它不需要提出 patch 的模型。如果你已经有了一个锁定的 CI worker,用那个 worker。产品在当前工作流中是可选的,但哈希、种子和签名产物不是。
哈希锁检测字节变化,但不检测后续提交了更弱 fixture 的人类。Signature freeze 检测匹配的异常,但不检测通过吞掉异常并返回默认值来"修复"的 patch。Seeded property 检测可重放的随机抽取,但不检测生成器分布之外的超纲错误。
此工作流还假设你有人类拥有的 fixture 和至少一个具有真实命中率的属性。空语料库和重言式检查产生看起来完整的日志但毫无意义。试验日志仍然会序列化,评分仍然是谎言。不要 freeze 一个从未命中的属性。不要哈希 Agent 被允许重写的目录。
特征归一化有代价。折叠数字可能会合并两个仅在错误码上有差异的不同 bug。如果你的消息携带了区分故障的枚举值,就把这些 token 排除在数字清洗之外,或者把 freeze 绑定到一个结构化的错误码字段而非自由文本。
不要在没有 flake 历史的新原型上安装 freeze ledger。如果期望 Agent 拥有测试目录树,就不要对 fixtures 做哈希。如果你的失败在异常类型层面是非确定性的——比如同一个 bug 既可能抛出 TimeoutError 也可能抛出 ConnectionError——就不要绑定特征。后一种情况,修掉这个竞态,或者把两个签名记录为一个人类审查对。不要从 Agent 输出中自动生成 freeze。
已经在 tests/ 下拒绝任何 Agent 编辑的团队可以采用哈希和种子步骤而不加账本。账本是为那些你拒绝忽略又拒绝按名称跳过的已知 flake 准备的。如果你无法配备人力来审查 freeze 行,就跳过账本。一个未使用的账本比没有更糟糕——它看起来像是控制手段,实际上行为却像一个静默白名单。
合并的问题不是 CI 变绿了没有,而是那个绿是否来自一次可重放的属性、一份未被触及的 fixture 哈希、以及一条仍然描述着同一个失败的 freeze。如果你无法指向这三样产物,你就没有评分。