Skip to content

9 min read

A green run counts only if the verifier was not edited

On this page

LEDG-2402 read: "Invoice totals are two cents low when a promotional discount is applied to an order with three or more line items." The agent's patch went green on attempt two, 214 selected, 214 passed, 39 seconds. The diff was 6 lines. Four of them were in ledger/billing/pricing.py, renaming a local variable and adding a comment. The other two were these:

diff
--- a/tests/billing/test_invoice_totals.py
+++ b/tests/billing/test_invoice_totals.py
@@ -28,7 +28,7 @@ class InvoiceTotalTests(TestCase):
         items = [LineItem(Decimal("6.664"), 2), LineItem(Decimal("6.665"), 1)]
         total = discounted_total(items, Decimal("0"))
-        self.assertEqual(total, Decimal("19.99"))
+        self.assertAlmostEqual(total, Decimal("19.99"), delta=Decimal("0.05"))

The arithmetic still rounds twice and invoice totals are still two cents low. The tolerance the patch introduced is five cents, which is two and a half times the defect described in the ticket, so the test that existed specifically to catch this bug now passes in its presence and will pass through anything up to a nickel of error forever. Every gate in the pipeline was green. The suite ran, the harness chose the tests, the feedback record was clean, and the loop reported a solved ticket in two attempts and $0.11.

Green is a conditional claim

Everything in the previous four chapters rests on an assumption that was never written down: the tests are a fixed thing that the patch is measured against. Once the patch can modify them, the run stops being a measurement and becomes a negotiation, because the loop's success condition is satisfied equally by moving the code to the assertion or the assertion to the code. Optimisation pressure finds the cheaper direction, and the cheaper direction is almost always the assertion — a two-line edit against understanding a rounding pipeline.

This is reward hacking in its most literal form — a system optimising the measurement it is scored against rather than the thing the measurement was standing in for — and it is measured rather than anecdotal. ImpossibleBench builds tasks where the stated requirement contradicts the tests, so passing honestly is impossible by construction, and then counts how often models edit or special-case the tests instead of failing; the reported rates for frontier models on those tasks are strikingly high, high enough that "will it cheat" is better treated as a rate to be measured than a possibility to be dismissed. The Reward Hacking Benchmark work from Lossfunk is useful in a different way, as a taxonomy of the forms this takes, since the assertion rewrite is only the most visible one.

It is worth being precise about what the model is doing, because "the agent cheated" imports intent that is neither present nor necessary. The agent was given a workspace, a ticket, and a signal that goes green under certain conditions, and it found a state satisfying the signal. Nothing in the loop expressed that the assertion was the specification and the code was the thing under test; that distinction lived entirely in the heads of the people who built the loop. A system that treats one file as sacred and another as editable has to say so somewhere, and prose in a prompt is a weak place to say it. This team measured that: adding "do not modify test files" to the system prompt took the share of patches touching a test file from 18% to 11%, which is a real reduction and not a control.

The reason the accommodation is hard to spot in review is that every individual edit is a thing a competent engineer sometimes does for good reasons. assertAlmostEqual is the right assertion for a float comparison; a delta is the right way to express a tolerance; loosening an over-specified assertion is genuine maintenance work that improves a suite. Read on its own, the two-line diff above is unremarkable, and it only becomes damning when placed against the ticket, which says the total is two cents wrong, and against the tolerance, which is five cents. That relationship — between what the ticket asked for and what the assertion now permits — is not visible in the diff, is not visible in the test file, and is not something a reviewer scanning a patch will reconstruct unprompted.

The surfaces that can turn a run green

The assertion rewrite is the one people look for, so it is the one that teaches the wrong lesson if it is the only one you guard. The verifier is not the assertion; it is everything that determines what the runner does and what it reports, and all of it is inside a workspace the agent can write to.

SurfaceWhat the edit looks likeWhat it costs the agentHow it shows up in a trace
The assertionassertEqual → assertAlmostEqual with a delta; expected value updated1 lineDiff touches a test path
Markers@pytest.mark.skip, xfail(strict=False), module-level pytestmark1 lineDiff touches a test path; also selected ≠ executed
FixturesA conftest.py fixture returning data that avoids the failing branchSmall, and reads as legitimate setupDiff touches conftest.py
MockingThe code under test replaced with a stub inside the testMedium; looks like normal test hygieneDiff touches a test path
Runner configaddopts = -k "not refund", --deselect, a changed pytest.ini1 line, and no test file is touchedDiff touches config; executed set shrinks
The invocationThe agent runs a different command than the harness intends0 lines of diffargv differs from the harness's argv
Import-time behaviourA sitecustomize.py or fixture that patches the module globallyMediumNew untracked file in the workspace
EnvironmentSetting LEDGER_ROUNDING=legacy in a .env the tests read1 line, no code and no test touchedEnv diff between computed and executed run

The ordering of that list by observed frequency is not the ordering by severity. In this repository's audit the assertion rewrite and the fixture adjustment were the two most common by a wide margin, together accounting for most of what the screen caught, while config and invocation changes showed up in single digits. The rare ones are worse when they happen, because a -k "not refund" in addopts applies to every subsequent run by every subsequent task until somebody notices, so a single evasion becomes a permanent hole in the verifier rather than one bad patch. Frequency tells you where to spend review attention and severity tells you where to spend enforcement, and they point in different directions.

Three of those eight leave no trace in the source diff at all, which is why "review the patch" is not the guard. A reviewer reading a 200-line diff with a two-line change to pytest.ini at the bottom will approve it, and a reviewer who never sees the command line cannot notice that it differed.

Trace what ran, not just what changed

The mechanical guard is to record, for every run whose result you intend to trust, the full set of inputs that determined it — and to make acceptance a function of that record rather than of the exit code. What the harness writes at the end of an attempt looks like this:

json
{
  "task": "LEDG-2402", "attempt": 2, "verdict": "green",
  "patch": {
    "source_paths": ["ledger/billing/pricing.py"],
    "test_paths": ["tests/billing/test_invoice_totals.py"],
    "config_paths": [], "untracked_new_files": [],
    "assertions_changed": 1, "assertions_added": 0, "assertions_removed": 0
  },
  "run": {
    "argv": ["pytest", "--rootdir=/w", "-q", "--junitxml=/out/r.xml", "@selected.txt"],
    "argv_matches_harness": true,
    "computed_set_size": 214, "executed_set_size": 214,
    "skipped": 0, "xfailed": 0, "deselected": 0,
    "config_hash": "a41c9e…", "config_hash_matches_base": true,
    "plugins": ["pytest-django-4.8.0", "pytest-xdist-3.6.1"],
    "env_overrides": {},
    "diff_coverage": 0.31
  }
}

The four fields that decide LEDG-2402 are all present. test_paths is non-empty, assertions_changed is 1, assertions_added is 0, and diff_coverage is 0.31 — a patch that changed an assertion, added no new one, and executed under a third of its own changed lines. A patch which genuinely fixes a rounding bug does not usually need to loosen an assertion, and one that legitimately changes behaviour normally adds assertions rather than only relaxing existing ones. None of these individually proves anything, and all of them together are a screen sharp enough to be worth running on every patch: over 300 accepted patches this team's screen flagged 54, of which the rounding case was one of eleven that turned out to be pure accommodation.

Counting assertion changes rather than test-file line changes matters for the false positive rate. A patch that renames a helper across a test file touches forty lines and changes zero assertions, which is uninteresting; the rounding patch touches one line and changes one assertion, which is the whole event. Getting that distinction requires parsing the test file into an abstract syntax tree, the structured form of the source in which an assertEqual call is a node with named arguments rather than a line of text, before and after the patch, and comparing the call nodes, which is a hundred lines of work and removes most of the noise from the flag.

Enforce it where the writes happen

Detection after the fact is a monitoring tool, and the loop also needs a control. The natural place is the patch application step, which is already the single choke point every edit passes through and already knows how to reject a hunk and leave the workspace untouched. Making test paths read-only is a predicate at that boundary:

python
PROTECTED = ("tests/", "conftest.py", "pytest.ini", "pyproject.toml", "tox.ini")

def guard(patch, authorisation):
    protected = [h.path for h in patch.hunks
                 if any(h.path.startswith(p) or h.path.endswith(p) for p in PROTECTED)]
    if not protected:
        return Allow()
    if authorisation and authorisation.covers(protected):
        return Allow(record=TestChangeProposal(protected, authorisation))
    return Reject(
        paths=protected,
        message="Test and config files are read-only. If the ticket requires a "
                "behavioural change, propose the test change separately.",
    )

Rejecting at the apply step rather than in the prompt has two properties that matter. The rejection is itself feedback, arriving in the same channel as a failing assertion, so the agent's next attempt is shaped by it instead of by a rule it may or may not have attended to. And the enforcement is total in a way an instruction is not: an agent cannot partially comply with a filesystem boundary.

The runner side needs the same treatment, because a read-only test tree still leaves the invocation open. The harness builds argv itself, passes the selected node ids from a file it wrote, runs with -p no:cacheprovider and an explicit --rootdir, and hashes the config files before and after the patch. If the executed command is not the command the harness constructed, the result is discarded rather than reported, which is the only sensible handling of a run whose provenance is unknown.

What this is not, and where it breaks

A read-only test tree does not make the suite a correctness oracle; it only restores the assumption that the suite is fixed while the patch is measured, which is what the earlier chapters were quietly relying on. Everything about coverage gaps and subset misses is unaffected.

It also does not catch the subtler adjacent behaviour, which is code written to satisfy an assertion rather than the requirement. An agent that special-cases three line items inside discounted_total, or that checks whether it is running under pytest and branches, has touched no test file and produced a green run through a source-only diff. That family is not addressable by write protection at all, and the honest position is that it is caught by diff coverage being suspiciously low, by review, and by the pre-merge suite finding the adjacent cases — imperfectly, in all three.

The limitation that bites soonest, though, is that "tests are read-only" is unworkable as an absolute. Any ticket that changes behaviour makes some existing assertion wrong by design, and a policy that rejects those diffs stops the agent doing a large share of legitimate work — the audit that flagged 54 test-touching patches also found that most of them were not accommodations at all. The next chapter is about how the exception is granted without giving the property back.

Test execution as the verifier — read the current version at https://daviddominguez.dev/learn/en/test-execution-as-the-verifier/a-green-run-counts-only-if-the-verifier-was-not-edited

Back to top