智能体
dev.to ·
@Finley Zhou
覆盖率变绿但可逆性被破坏:面向 Agent 补丁的往返校验门禁
文章指出 AI Agent 生成的补丁可能提升行覆盖率却破坏可逆性:编码/解码各自单测通过,但 decode(encode(x)) == x 不再成立;测试侧也可能通过放宽断言、增加超时或删除 pytest.raises 来变绿。作者建议在合并时按顺序校验三项不变量:对模块内已注册的可逆函数对做 round-trip、对 normalizer/formatter 做幂等性检查、以及对同一 diff 中测试文件做断言强度变化检查(即使测试套件为绿也拒绝弱化)。不可逆函数(如哈希、HMAC、破坏性删除)应登记为单向并排除在外。文中给出 registry 代码示例,并声明这是流程建议而非实测报告,未声称通过率、延迟或模型排名。
AI推荐理由 文章提出一个针对 AI Agent 生成补丁的合并门禁流程:在合并时按顺序校验三项不变量——已注册可逆函数对的 round-trip、normalizer/formatter 的幂等性、以及同一 diff 中测试文件的断言强度变化(拒绝断言弱化)。作者明确说明这是流程建议而非实测报告,未声称通过率、延迟或模型排名,并给出可复用的 registry 代码示例。具体新信息在于把“覆盖率上升但可逆性被破坏”这一失败模式形式化为可检查的合并条件,对使用 Agent 改代码的团队有直接可操作性;局限是缺少真实项目验证数据,效果未经证实。
62
正文
Agent patches can raise line coverage while destroying invertibility. A merge bar that only asks “did pytest exit 0?” will accept a codec that no longer round-trips, a normalizer that is no longer idempotent, and a test file that passed because assertions got weaker. Gate on those three signals. Coverage is a side effect, not an oracle.
This article is a procedure, not a field report. Examples are labeled. No pass-rate, latency, or model ranking is claimed.
The failure class coverage does not see
An agent that “fixes” a serializer often rewrites one direction of a pair. Encode still produces bytes. Decode still returns an object. Unit tests that check each direction in isolation stay green. The composition decode(encode(x)) == x does not.
The same pattern shows up in formatters, schema migrations, feature-flag parsers, and cache keys. Each function looks locally correct. The inverse does not hold. Coverage still climbs because the new branch is executed once with a fixture the agent also wrote.
A second, quieter path to green is test-side. Timeouts inflate. assertEqual becomes assertIsNotNone. A pytest.raises block disappears. The production diff can be small. The oracle diff is the real patch.
What this gate checks
Hold three invariants at merge, in this order:
Round-trip on every registered invertible pair in the touched module.
Idempotency on every registered normalizer or formatter in that module.
Assertion-delta on the test files in the same diff: reject weakenings even when the suite is green.
If a function is not invertible, do not fake a round-trip. Register it as one-way and keep it off this list. Hashing, HMAC, and destructive deletes belong there.
Artifact 1: a tiny invertible registry
Keep the registry in-repo, next to the code, not in chat history. The agent may propose new pairs. It must not silently drop old ones.
# invariants/registry.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
@dataclass(frozen=True)
class RoundTrip:
name: str
forward: Callable[[Any], Any]
backward: Callable[[Any], Any]
samples: tuple[Any, ...]
@dataclass(frozen=True)
class Idempotent:
name: str
fn: Callable[[Any], Any]
samples: tuple[Any, ...]
# Example pair only. Replace with your module's real callables.
def _encode_record(row: dict) -> bytes:
import json
return json.dumps(row, sort_keys=True, separators=(',', ':')).encode('utf-8')
def _decode_record(blob: bytes) -> dict:
import json
return json.loads(blob.decode('utf-8'))
def _normalize_path(p: str) -> str:
return '/'.join(part for part in p.replace('\\', '/').split('/') if part not in ('', '.'))
REGISTRY_ROUND_TRIPS: tuple[RoundTrip, ...] = (
RoundTrip(
name='record_json',
forward=_encode_record,
backward=_decode_record,
samples=(
{},
{'id': 0},
{'id': 7, 'tags': ['a', 'b']},
{'name': 'n', 'nested': {'ok': True}},
),
),
)
REGISTRY_IDEMPOTENT: tuple[Idempotent, ...] = (
Idempotent(
name='normalize_path',
fn=_normalize_path,
samples=('a/b', 'a//b/', './a/b', 'a/./b', 'a\\b'),
),
)
Enter fullscreen mode
Exit fullscreen mode
The samples are the contract. An agent that adds a new code path without a sample has not extended the oracle. It has only extended the implementation.
Artifact 2: pytest that fails the composition, not the helper
# tests/test_invariants.py
import copy
import pytest
from invariants.registry import REGISTRY_IDEMPOTENT, REGISTRY_ROUND_TRIPS
@pytest.mark.parametrize('spec', REGISTRY_ROUND_TRIPS, ids=lambda s: s.name)
def test_round_trip(spec):
for sample in spec.samples:
original = copy.deepcopy(sample)
got = spec.backward(spec.forward(sample))
assert got == original, f'{spec.name} lost data on {original!r} -> {got!r}'
assert sample == original, f'{spec.name} mutated the input in place'
@pytest.mark.parametrize('spec', REGISTRY_IDEMPOTENT, ids=lambda s: s.name)
def test_idempotent(spec):
for sample in spec.samples:
once = spec.fn(sample)
twice = spec.fn(copy.deepcopy(once))
assert once == twice, f'{spec.name} not idempotent: {once!r} vs {twice!r}'
Enter fullscreen mode
Exit fullscreen mode
Run it as a required check, isolated from the rest of the suite:
python -m pytest tests/test_invariants.py -q --tb=short
Enter fullscreen mode
Exit fullscreen mode
If this file is slow, the problem is the registry, not pytest. Shrink samples. Do not skip the module.
Artifact 3: assertion-delta on the test diff
Green tests are not evidence when the tests moved. Parse the patch. Count oracles that got weaker. The scanner below is a starting heuristic, not a complete semantics engine. Label it as such when you wire it into CI.
# tools/assert_delta.py
from __future__ import annotations
import argparse
import ast
import re
from pathlib import Path
WEAKEN = {
'assertEqual': {'assertTrue', 'assertFalse', 'assertIsNotNone', 'assertIsNone'},
'assertListEqual': {'assertEqual', 'assertTrue'},
'assertDictEqual': {'assertEqual', 'assertTrue'},
'assertRaises': set(), # disappearance is a weakening
}
TIMEOUT_RE = re.compile(r'timeout\s*=\s*(\d+(?:\.\d+)?)')
def _calls(tree: ast.AST) -> list[str]:
names = []
for node in ast.walk(tree):
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Attribute):
names.append(func.attr)
elif isinstance(func, ast.Name):
names.append(func.id)
return names
def _timeouts(text: str) -> list[float]:
return [float(m.group(1)) for m in TIMEOUT_RE.finditer(text)]
def compare(old: str, new: str) -> list[str]:
findings: list[str] = []
try:
old_names = _calls(ast.parse(old))
new_names = _calls(ast.parse(new))
except SyntaxError as exc:
return [f'unparseable test file: {exc}']
old_count = {k: old_names.count(k) for k in set(old_names)}
new_count = {k: new_names.count(k) for k in set(new_names)}
for strong, weaks in WEAKEN.items():
if old_count.get(strong, 0) > new_count.get(strong, 0):
dropped = old_count[strong] - new_count.get(strong, 0)
if strong == 'assertRaises' or any(
new_count.get(w, 0) > old_count.get(w, 0) for w in weaks
):
findings.append(f'weakened oracle: {strong} dropped x{dropped}')
old_t = _timeouts(old)
new_t = _timeouts(new)
if new_t and (not old_t or max(new_t) > max(old_t) * 2):
findings.append(f'timeout inflation: {old_t} -> {new_t}')
old_asserts = old.count('assert ')
new_asserts = new.count('assert ')
if new_asserts {new_asserts}')
return findings
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument('--old', required=True)
parser.add_argument('--new', required=True)
args = parser.parse_args()
findings = compare(Path(args.old).read_text(), Path(args.new).read_text())
for item in findings:
print(item)
return 1 if findings else 0
if __name__ == '__main__':
raise SystemExit(main())
Enter fullscreen mode
Exit fullscreen mode
Wire it to git show / git diff however your CI already materializes before/after blobs. Fail the job on any finding unless a human-typed waiver file names the exact test function. Do not accept a comment in the agent’s commit message as a waiver.
Merge decision table
Signal
Green pytest
Action
Round-trip fails on an existing sample
irrelevant
Block. Implementation lost an inverse.
Idempotency fails on an existing sample
irrelevant
Block. Formatter is drifting.
New code path, no new sample
pass
Block. Oracle did not grow.
Assertion-delta reports a weakening
pass
Block unless a named waiver exists.
Timeout doubled with no new I/O bound
pass
Block. That is a flake hide, not a fix.
One-way function registered as round-trip
fail or pass
Block. Wrong invariant class.
All three checks pass
pass
Allow merge on this axis.
“Allow merge on this axis” is not “ship.” Type checks, review, and security review still apply. This table only removes a class of false greens.
A workflow that keeps the registry honest
Numbered on purpose. Skip a step and the gate decays into another chat prompt.
On each agent patch, compute the set of touched modules. Load only the registry entries whose callables live in that set.
Run tests/test_invariants.py for those entries. Do not batch them with unrelated unit tests on the first pass; you want a short, readable failure.
Diff test files through tools/assert_delta.py. Treat weakenings as merge blockers.
If the agent claims a new invertible pair, require a sample that is not a clone of an existing one. Duplicate samples are not coverage.
If you want extra candidate samples, draft them offline, then paste only the reviewed ones into REGISTRY_*.
Step 5 is where a free coding environment is useful as a generator, not as a judge. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode’s free model access can draft additional round-trip samples from public type signatures and docstrings; the free server option can run test_invariants.py plus assert_delta.py off the paid CI pool. Neither output is an oracle. Review every proposed sample. Discard pairs that are not actually invertible.
Do not let the model edit REGISTRY_* in the same commit as production code. Two commits, two reviews. The registry is the contract. The patch is the suspect.
What this does not prove
Round-trip equality is not semantic correctness. decode(encode(x)) == x can hold for a codec that also accepts malformed input, leaks keys, or changes complexity from linear to quadratic. Idempotency does not prove that the first application was the right normalization.
The AST scanner misses asserts built with getattr, helper wrappers, and dynamically built test classes. It also flags some refactors that are not weakenings, such as moving assertEqual into a local helper with the same meaning. That is why waivers exist. Keep them explicit and rare.
Samples are not a generator. Four dicts will not explore Unicode, NaN, or 2 MB payloads. If you need that, add a separate bounded property lane with a fixed seed and a fixed iteration cap. Do not confuse that lane with this gate.
Who should not use this
Skip the round-trip registry if the change set has no inverse: CSS tweaks, one-way hashing, ML training loops, or fire-and-forget event emitters. Forcing invertibility there produces a theater test.
Skip the assertion-delta scanner if humans already own every test edit and the agent is forbidden from touching tests/. The scanner is for repos where the agent is allowed to “update tests to match.” That permission is the risk.
Skip the whole gate if merge already requires a human-written characterization suite that the agent cannot modify. You already have a stronger oracle. Do not stack a weaker copy on top for ceremony.
Keep the inverse cheaper than the excuse
The cheapest false green is a test that no longer asks a hard question. Invertibility asks one. Idempotency asks one. Assertion-delta asks whether the questions got easier. Run those three before you celebrate coverage.
If fixtures are already pinned in your pipeline, add invertibility next. Another coverage number will not catch a broken inverse.