A green build proves only that no assertion failed. It does not prove the assertions were still there, still compared anything, or still could fail. Agents find that path fast: the cheapest route to green is rarely a fix, it is a quieter test.
I've written before about fixtures that move, tautological tests, and gates that reject nothing. This post is about the sibling failure: an existing test that survives the patch but loses its teeth. Below is a gate that measures oracle strength before and after a patch and blocks the weakening.
Two kinds of green, only one of them is a fix
| Edit in the patch | Direction |
|---|---|
assert total == 41 → assert total
|
WEAKER |
pytest.approx(x, rel=1e-9) → rel=1e-3
|
WEAKER |
pytest.raises(ValueError) → pytest.raises(Exception)
|
WEAKER |
@pytest.mark.xfail added to a failing test |
BLOCKER |
new assert resp.status_code == 200 in the same file |
STRONGER |
A patch that changes only the second column is not a fix. It is a muted test. And unlike a deleted test — which CI usually notices — a weakened assertion is invisible to every tool in the default chain.
Step 1: snapshot oracle strength per test
The metric I use is deliberately crude and deterministic: how many assertions, how many of them are real comparisons, the widest numeric tolerance, how broad the expected exception is, and how many skip-family markers the test carries. Crude is the point. A crude metric that runs on every patch beats a semantic one that runs on none.
Reference implementation (Python 3.10+, stdlib only):
# oracle_ledger.py — reference implementation, not production-hardened
from __future__ import annotations
import argparse, ast, json
from dataclasses import dataclass, asdict
from pathlib import Path
DEFAULT_REL = 1e-6 # pytest.approx default; see pytest API reference
@dataclass
class Oracle:
test: str
assertions: int = 0
comparisons: int = 0
truthy_only: int = 0
widest_rel: float | None = None
broad_raises: int = 0
skip_markers: int = 0
class Visitor(ast.NodeVisitor):
def __init__(self) -> None:
self.o = Oracle(test="")
def visit_Assert(self, node: ast.Assert) -> None:
self.o.assertions += 1
if isinstance(node.test, ast.Compare):
self.o.comparisons += 1
elif isinstance(node.test, (ast.Name, ast.Attribute, ast.UnaryOp)):
self.o.truthy_only += 1
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
try:
name = ast.unparse(node.func)
except Exception:
return
if name.endswith("approx"):
rel = DEFAULT_REL
for kw in node.keywords:
if kw.arg == "rel":
try:
rel = float(ast.literal_eval(kw.value))
except Exception:
rel = float("inf") # computed tolerance: unknown, treat as wide
if self.o.widest_rel is None or rel > self.o.widest_rel:
self.o.widest_rel = rel
if name.endswith("raises") and node.args:
if ast.unparse(node.args[0]) in {"Exception", "BaseException"}:
self.o.broad_raises += 1
self.generic_visit(node)
Collection and classification are two small functions. Note the inf fallback: a tolerance computed at runtime cannot be judged statically, so it is flagged as wide and a human looks at it.
def markers(fn: ast.AST) -> int:
n = 0
for dec in getattr(fn, "decorator_list", []):
text = ast.unparse(dec)
if any(m in text for m in ("mark.skip", "mark.skipif", "mark.xfail")):
n += 1
return n
def collect(root: Path) -> dict[str, Oracle]:
out: dict[str, Oracle] = {}
for path in sorted(root.rglob("test_*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
v = Visitor()
v.o.test = f"{path.relative_to(root)}::{node.name}"
for child in node.body:
v.visit(child)
v.o.skip_markers = markers(node)
out[v.o.test] = v.o
return out
def classify(base: Oracle, head: Oracle) -> list[str]:
flags: list[str] = []
if head.assertions < base.assertions: flags.append("WEAKER:assertions")
if head.comparisons < base.comparisons: flags.append("WEAKER:comparisons")
if head.truthy_only > base.truthy_only: flags.append("WEAKER:truthiness")
if base.widest_rel is not None and head.widest_rel is not None and head.widest_rel > base.widest_rel:
flags.append("WEAKER:tolerance")
if head.broad_raises > base.broad_raises: flags.append("WEAKER:raises-scope")
if head.skip_markers > base.skip_markers: flags.append("BLOCKER:marker")
if head.comparisons > base.comparisons: flags.append("STRONGER:comparisons")
return flags
Step 2: apply one rule, not ten
The gate rule is single: a test may lose strength only if the same test file gains strength in the same patch. Deletion of a test is a hard block. A new skip-family marker is a hard block.
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base", type=Path, required=True)
ap.add_argument("--head", type=Path, required=True)
ap.add_argument("--out", type=Path, default=Path("ledger.json"))
a = ap.parse_args()
base, head = collect(a.base), collect(a.head)
entries = []
for name in sorted(set(base) | set(head)):
b, h = base.get(name), head.get(name)
if b is None:
entries.append({"test": name, "flags": ["NEW:test"], "action": "review"})
continue
if h is None:
entries.append({"test": name, "flags": ["BLOCKER:deleted"], "action": "block"})
continue
flags = classify(b, h)
weakened = any(f.startswith(("WEAKER", "BLOCKER")) for f in flags)
if any(f.startswith("BLOCKER") for f in flags):
action = "block"
elif weakened and "STRONGER:comparisons" not in flags:
action = "review"
else:
action = "pass"
entries.append({"test": name, "base": asdict(b), "head": asdict(h),
"flags": flags, "action": action})
a.out.write_text(json.dumps(entries, indent=2))
blocked = sum(e["action"] == "block" for e in entries)
review = sum(e["action"] == "review" for e in entries)
print(f"tests={len(entries)} blocked={blocked} review={review}")
return 1 if blocked else 0
if __name__ == "__main__":
raise SystemExit(main())
Run it against a pristine tree and the agent's worktree. Keeping the pristine copy outside the repo matters — an agent that can edit the baseline can pass any gate.
git worktree add ../pristine-gate origin/main
python oracle_ledger.py --base ../pristine-gate/tests --head tests --out ledger.json
An illustrative entry for the tolerance case looks like this — widest_rel widened from 1e-9 to 1e-3 with no compensating comparison:
{ "test": "tests/test_billing.py::test_rounding",
"flags": ["WEAKER:tolerance"],
"action": "review" }
Step 3: check the collection hook, not just the tests
AST diffing on test files misses the quietest evasion: a conftest hook that deselects or skips by pattern. That is not a heuristic you can automate safely, so make it loud instead.
git diff -U0 origin/main -- '**/conftest.py' | grep -E '^\+.*(collection_modifyitems|add_marker|\.skip\()' \
&& echo 'conftest collection logic touched: human review required'
Treat any hit as a manual review trigger, not as proof of wrongdoing. Platform-conditional skips are legitimate and common.
Step 4: use the delta as the repair brief
The ledger is more useful as a prompt than as a verdict. Instead of telling a model "the test fails, fix it," hand it the structured delta and one instruction: restore the comparison the patch removed, or explain why the removed comparison was wrong.
This is where MonkeyCode participates in my loop: I use its free model access to turn a WEAKER:tolerance entry into a candidate counter-test, and its free server option to run the gate and the suite on the same machine the reviewer model talks to.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The honesty boundary: I am relying on the operator's statement that free model access and a free server option exist. I am not asserting specific model names, quotas, hardware, latency, or permanence — check the current offering before you build a pipeline on it. The gate above works with any reviewer model; swapping the backend is a config change, not a rewrite.
Step 5: the workflow, end to end
- Freeze the baseline:
git worktree add ../pristine-gate <base-sha>before the agent starts. - Let the agent patch only the worktree, never
../pristine-gate. - Run
oracle_ledger.py --base ../pristine-gate/tests --head tests; exit code1on any block-level entry. - Run the conftest grep; any hit goes to a human, not to the agent.
- For each
reviewentry, send only that entry's JSON to the reviewer model and ask for a counter-test that would fail on the pre-patch code. - Re-run steps 3–5 once. If the same entry survives two rounds, escalate — repeated failure is a signal about the patch, not about the prompt.
Why step 6 exists: a loop that retries forever converts a weak gate into a slow one. One retry, then a human.
Limitations, and who should skip this
AST counting is a proxy. Assertions moved into helper functions look like deletions. Property-based tests collapse into a single assert and score as weak. Rewriting a test in a different style can trip WEAKER with no real loss of coverage. Expect a nonzero false-positive rate and budget review time for it.
The gate also cannot tell you whether the assertion was correct before the patch. It only measures that something measurable was removed.
Skip this approach if:
- you have no stable base revision to diff against, so every run flags the whole suite;
- your suite already carries many skip and xfail markers — baseline that debt first, or the gate reports history instead of the patch;
- tests are generated at build time and never exist as files for the AST to read;
- the change is a deliberate test refactor, in which case diff the refactor separately from the fix and let the gate see only the fix.
What to take away
Assertion count is a weak signal on its own, but a delta in assertion count, comparison kind, tolerance, and marker state is a strong one — and it is cheap enough to run on every patch. If a patch cannot make the existing test pass without making the existing test quieter, that is the finding. Report it as a finding, not as a failure, and let the reviewer model propose the counter-test.
If you want to try the reviewer loop without wiring a paid backend first, MonkeyCode's free model access and free server option are the two pieces this workflow actually consumes — the model that drafts the counter-test and the machine that runs pytest.
Top comments (0)