DEV Community

Finley Zhou
Finley Zhou

Posted on

Score Assertion Weakening Before You Merge an Agent Patch

A green suite is not a merge signal when the same agent authored the production diff and the test diff. The cheapest path to green is a weaker oracle: drop an assert, widen a tolerance, skip a case, or replace equality with membership. Score that weakening on the test hunks, and reject the patch when the score exceeds a budget.

This is not a write-block on tests/. Write-blocks are the right default when the tree is already a trusted witness. Many teams still allow limited test edits so a patch can cover new behavior. That permission is where contamination starts. The gate below assumes test files may change, and it prices how much oracle strength those changes spend.

What “weaker” means in a test diff

Agent patches rarely delete the whole suite. They nibble. A renamed test evades a freeze list. A pytest.approx rel bump turns a regression into noise. assert x == y becomes assert y in x. autospec=True disappears from a mock. None of those hunks look like vandalism in a 400-line review.

Treat the test diff as an accounting problem, not a taste problem. Each weakening class gets a weight. Strengthening (new equality, tighter tolerance, removed skip) credits the score. The merge question is whether net oracle strength went down.

The taxonomy is small on purpose. Broad categories beat a 50-row rubric that no reviewer will check.

  1. Oracle removal — deleted assert, deleted test function, or a test function whose body no longer contains an assertion form.
  2. Oracle relaxation== to in / != / is not None; pytest.approx with larger rel or abs; regex flags or patterns that match more strings.
  3. Escape hatches — added @pytest.mark.skip, xfail, pytest.importorskip, or a raised timeout / rerun count.
  4. Double agent — mocks that stop asserting call shape (autospec removed, assert_called_with dropped), or fixtures that start returning canned success.

If a hunk does not match a class, it is scored zero. Silence is cheaper than a false “risky” label on a comment-only change.

Compute a contamination index

Keep the index integer and boring. Reviewers argue with floats. A proposed budget of 4 means one deleted test function (5) fails the gate, while a single == to in (3) still needs a human, not a hard block.

Change in the after-tree Weight Notes
Test function deleted +5 Lost named oracle
assert node deleted, function kept +4 Same test, thinner check
@pytest.mark.skip / xfail added +4 Escape hatch
== replaced by in or is not None +3 Weaker predicate
pytest.approx rel/abs increased +3 Numeric slack
autospec or assert_called_with removed +2 Mock no longer shapes the call
Timeout / rerun count increased +2 Time used as a muffler
New test function with zero asserts +3 Coverage theater
New equality assert or tighter approx −2 Credit strengthening
Skip/xfail removed −3 Credit restored obligation

The numbers are a policy, not a measurement of production risk. Label them as such in the repo. Tune after a week of false positives, not after one noisy patch.

Reproduce the score locally

The artifact is a pair of ASTs, not a language model. Parse the test files before and after the patch. Walk assertion-like nodes. Diff by test function name. Print a JSON object the CI job can threshold.

Proposed usage, labeled as an unexecuted local workflow:

git rev-parse --verify HEAD >/dev/null
mkdir -p /tmp/assert-score/{before,after}
git archive HEAD -- 'tests' | tar -x -C /tmp/assert-score/before
# worktree already contains the agent patch
cp -R tests /tmp/assert-score/after/tests
python3 score_assert_weakening.py \
  --before /tmp/assert-score/before \
  --after  /tmp/assert-score/after \
  --budget 4
Enter fullscreen mode Exit fullscreen mode

The scorer below is a heuristic. It understands assert, pytest.approx, and a short list of pytest markers. Custom helpers such as self.assertEqual or a company expect() wrapper will under-count unless you extend ASSERT_CALLS.

#!/usr/bin/env python3
"""score_assert_weakening.py — heuristic contamination index for Python tests."""
from __future__ import annotations

import argparse
import ast
import json
import sys
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple

ASSERT_CALLS = {"assertEqual", "assertTrue", "assertFalse", "assertIsNone"}
ESCAPE_MARKERS = {"skip", "xfail", "importorskip"}


def py_files(root: Path) -> Iterable[Path]:
    for p in root.rglob("*.py"):
        name = p.name
        if name.startswith("test_") or p.parent.name in {"tests", "test"}:
            yield p


def rel(root: Path, path: Path) -> str:
    return str(path.relative_to(root))


class TestIndex(ast.NodeVisitor):
    def __init__(self) -> None:
        self.functions: Dict[str, dict] = {}

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        if not node.name.startswith("test_"):
            self.generic_visit(node)
            return
        self.functions[node.name] = summarize(node)
        # Do not walk nested defs as separate tests.


def marker_names(node: ast.FunctionDef) -> List[str]:
    found: List[str] = []
    for dec in node.decorator_list:
        text = ast.dump(dec)
        for m in ESCAPE_MARKERS:
            if m in text:
                found.append(m)
    return found


def approx_slack(node: ast.AST) -> Optional[Tuple[float, float]]:
    if not isinstance(node, ast.Call):
        return None
    func = node.func
    name = getattr(func, "attr", None) or getattr(func, "id", None)
    if name != "approx":
        return None
    rel_v, abs_v = 1e-6, 1e-12
    for kw in node.keywords:
        if kw.arg in {"rel", "abs"} and isinstance(kw.value, ast.Constant):
            if isinstance(kw.value.value, (int, float)):
                if kw.arg == "rel":
                    rel_v = float(kw.value.value)
                else:
                    abs_v = float(kw.value.value)
    return rel_v, abs_v


def summarize(fn: ast.FunctionDef) -> dict:
    asserts = 0
    equalities = 0
    membership = 0
    none_checks = 0
    approx: List[Tuple[float, float]] = []
    autospec = 0
    called_with = 0
    timeouts: List[int] = []
    for node in ast.walk(fn):
        if isinstance(node, ast.Assert):
            asserts += 1
            test = node.test
            if isinstance(test, ast.Compare):
                ops = test.ops
                if any(isinstance(op, ast.Eq) for op in ops):
                    equalities += 1
                if any(isinstance(op, ast.In) for op in ops):
                    membership += 1
                if any(isinstance(op, ast.IsNot) for op in ops):
                    none_checks += 1
            slack = approx_slack(test) if isinstance(test, ast.Call) else None
            if slack:
                approx.append(slack)
        if isinstance(node, ast.Call):
            name = getattr(node.func, "attr", None) or getattr(node.func, "id", None)
            if name in ASSERT_CALLS:
                asserts += 1
            if name == "approx":
                slack = approx_slack(node)
                if slack:
                    approx.append(slack)
            if name in {"assert_called_with", "assert_called_once_with"}:
                called_with += 1
            for kw in node.keywords:
                if kw.arg == "autospec" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
                    autospec += 1
                if kw.arg == "timeout" and isinstance(kw.value, ast.Constant):
                    if isinstance(kw.value.value, (int, float)):
                        timeouts.append(int(kw.value.value))
    return {
        "asserts": asserts,
        "equalities": equalities,
        "membership": membership,
        "none_checks": none_checks,
        "approx": approx,
        "autospec": autospec,
        "called_with": called_with,
        "timeouts": timeouts,
        "markers": marker_names(fn),
    }


def load_root(root: Path) -> Dict[str, dict]:
    out: Dict[str, dict] = {}
    for path in py_files(root):
        try:
            tree = ast.parse(path.read_text(encoding="utf-8"))
        except SyntaxError:
            continue
        idx = TestIndex()
        idx.visit(tree)
        for name, summary in idx.functions.items():
            out[f"{rel(root, path)}::{name}"] = summary
    return out


def score_pair(before: dict, after: dict) -> Tuple[int, List[str]]:
    score = 0
    reasons: List[str] = []
    for key, b in before.items():
        if key not in after:
            score += 5
            reasons.append(f"+5 deleted {key}")
            continue
        a = after[key]
        if a["asserts"] < b["asserts"]:
            delta = b["asserts"] - a["asserts"]
            score += 4 * delta
            reasons.append(f"+{4 * delta} fewer asserts in {key}")
        if a["equalities"] < b["equalities"] and a["membership"] > b["membership"]:
            score += 3
            reasons.append(f"+3 equality->membership in {key}")
        if a["equalities"] < b["equalities"] and a["none_checks"] > b["none_checks"]:
            score += 3
            reasons.append(f"+3 equality->is not None in {key}")
        if a["autospec"] < b["autospec"] or a["called_with"] < b["called_with"]:
            score += 2
            reasons.append(f"+2 mock shape lost in {key}")
        if a["timeouts"] and b["timeouts"]:
            if max(a["timeouts"]) > max(b["timeouts"]):
                score += 2
                reasons.append(f"+2 timeout raised in {key}")
        if set(a["markers"]) - set(b["markers"]):
            score += 4
            reasons.append(f"+4 escape marker on {key}")
        if set(b["markers"]) - set(a["markers"]):
            score -= 3
            reasons.append(f"-3 escape marker removed on {key}")
        if a["approx"] and b["approx"]:
            if max(x[0] for x in a["approx"]) > max(x[0] for x in b["approx"]):
                score += 3
                reasons.append(f"+3 approx rel widened in {key}")
        if a["asserts"] > b["asserts"] and a["equalities"] > b["equalities"]:
            score -= 2
            reasons.append(f"-2 stronger asserts in {key}")
    for key, a in after.items():
        if key not in before and a["asserts"] == 0:
            score += 3
            reasons.append(f"+3 new test without asserts {key}")
    return score, reasons


def main() -> int:
    p = argparse.ArgumentParser()
    p.add_argument("--before", type=Path, required=True)
    p.add_argument("--after", type=Path, required=True)
    p.add_argument("--budget", type=int, default=4)
    args = p.parse_args()
    score, reasons = score_pair(load_root(args.before), load_root(args.after))
    payload = {"score": score, "budget": args.budget, "pass": score <= args.budget, "reasons": reasons}
    json.dump(payload, sys.stdout, indent=2)
    sys.stdout.write("\n")
    return 0 if payload["pass"] else 2


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

A failing run should print the reasons next to the number. A bare exit code trains people to ignore the gate.

{
  "score": 7,
  "budget": 4,
  "pass": false,
  "reasons": [
    "+4 fewer asserts in tests/test_ledger.py::test_balance_non_negative",
    "+3 equality->membership in tests/test_ledger.py::test_posted_ids"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Wire it as a merge job, not a chat summary

Run the scorer after the patch is applied and before pytest is treated as authoritative. Order matters. If pytest runs first, a weakened suite can still be green and the job looks healthy in the UI.

  1. Snapshot tests/ from the merge base (git archive or a second worktree).
  2. Apply the agent patch to a throwaway worktree. Do not score against an unclean developer tree.
  3. Run score_assert_weakening.py with a committed --budget.
  4. Fail the job on exit 2 before pytest, or run pytest anyway and publish both results. Publishing both is better: a weak oracle that still fails tests is a different bug than a weak oracle that passes.
  5. If the score is above budget, require a human to raise the budget in the same PR, with a one-line reason. Do not let the agent edit budget.txt.

A minimal CI shape, labeled as a template:

# proposed .github/workflows/assert-score.yml
name: assert-weakening
on: [pull_request]
jobs:
  score:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: snapshot merge-base tests
        run: |
          BASE=$(git merge-base origin/main HEAD)
          mkdir -p /tmp/before /tmp/after
          git archive "$BASE" -- tests | tar -x -C /tmp/before
          cp -R tests /tmp/after/tests
          python3 score_assert_weakening.py --before /tmp/before --after /tmp/after --budget 4
Enter fullscreen mode Exit fullscreen mode

Keep the budget in the workflow file or a locked policy file the agent cannot touch. If the agent can raise --budget, the index is theater.

Where a free model run actually fits

Candidate patches still have to come from somewhere. A free model plus a free server is enough to produce those diffs at a cost that makes a second job realistic: you are not spending a billed queue just to ask whether the tests got softer.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The scorer does not call the model. It only reads two trees. MonkeyCode is relevant here only as the place a patch may have been generated and executed; the merge rule is independent of which model wrote the hunks. If the product is unavailable, run the same script against any agent worktree. The index does not care about the vendor string in the PR body.

Do not ask the model to “review assertion strength.” That request is circular. The same system that relaxed == will happily explain why in is more idiomatic.

What this gate does not catch

The script is syntax-directed. It will miss a helper that wraps asserts, a parameterized test whose id changed, and any language that is not Python. It will also miss a new test that asserts the wrong thing with great confidence. A tautology such as assert result is result still counts as an assert.

Renames are scored as delete-plus-add. That is noisy when a file is moved. Pair the job with git log --follow only if you must; most agent patches are not mechanical moves. If your suite is snapshot-heavy, there may be no assert nodes at all. This gate will then under-fire. Use a snapshot hash freeze instead, which is a different artifact.

The weights are not calibrated against incident data in this article. Do not cite the table as an empirical risk model. It is a review budget.

Who should not use this

Skip the index if the test tree is already write-blocked. You do not need a weakening score for edits that cannot land. Skip it for throwaway spikes that will not merge. Skip it if the suite is generated from a single schema dump and every patch rewrites the dump; you need schema diffing, not assert counting.

Teams that only merge human-written tests can still run the scorer as a linter on accidental timeout bumps. The audience that needs it is narrower: people who let an agent touch tests/ and then read a green check as evidence.

Green means the current oracles passed. It does not mean the oracles are the ones you had yesterday. Put a number on the difference, then decide whether that number is a merge. If you already generate patches on a free server, attach the scorer as a sibling job to pytest and keep the budget in a file the agent cannot edit.

Top comments (0)