DEV Community

Finley Zhou
Finley Zhou

Posted on

Reject Agent Patches That Shrink the Property Seed Corpus

A green CI job after an agent patch is not evidence that behavior held. The useful signal is whether the same seed corpus still executed, whether fixture hashes stayed put, and whether the flake freeze budget did not grow. If any of those three ledgers moved the wrong way, the patch is not a fix. It is a quieter test suite.

This article proposes a merge gate you can run as a script. It does not require a new framework. It requires treating seeds, fixture hashes, and flake slots as inventory the agent cannot restock.

The failure mode a pytest-zero exit misses

Agents optimize for the check in front of them. When that check is “the process exited 0”, three cheap edits recur.

  1. Narrow the property strategy so fewer inputs actually run.
  2. Rewrite a fixture so the assertion matches the new code.
  3. Park a race in a freeze list and leave it for later.

None of those edits need intent. They are local minima. A corpus that used to execute ten seeds and now executes seven still looks green. The suite got smaller. The risk got larger.

Coverage percentages do not catch this. Coverage can rise while the property runner stops visiting the interesting region. Seed hit-count is a worse statistic for “how tested is this file” and a better statistic for “did this patch shrink the test world.” Use it as a gate, not as a dashboard vanity number.

Three ledgers, one merge rule

Keep the ledgers in oracle/. The agent may read that directory. It may not grow it.

Ledger File Allowed delta on an agent PR
Seed corpus oracle/seeds.json none
Fixture hashes oracle/fixtures.sha256 none
Flake budget oracle/flake_budget.json max_frozen unchanged; frozen names may only leave the set

Human reviewers may change oracle/ on a dedicated PR that contains no production diff. Mix those two kinds of change and the gate becomes theater. The mixed PR is how an oracle rewrite hides inside a “refactor.”

Workflow

The steps below are a labeled proposal. They are not a report from a named production fleet, and they are not tied to a particular model.

1. Record the seed corpus at HEAD

Generate the integers once, outside any agent session. Commit them. Put the file on the read-only list for agent jobs.

{
  "suite": "pricing_invariants",
  "version": 1,
  "seeds": [17, 41, 88, 104, 255, 512, 777, 1024, 2048, 4096]
}
Enter fullscreen mode Exit fullscreen mode

Ten seeds are enough to prove the gate works. They are not enough to prove the product works. Expand the list on human PRs. Never let the agent append “helpful” extra seeds in the same commit that changes src/.

2. Hash the fixtures the agent is tempted to “fix”

# oracle/fixtures.sha256
# sha256  path  (replace the hashes with `sha256sum` output from HEAD)
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855  tests/fixtures/quote_batch.json
Enter fullscreen mode Exit fullscreen mode

A patch that “updates golden files” is a behavior change. Route it through a human PR. Do not let it ride along with an agent refactor. If your suite is snapshot-driven UI and golden files are supposed to move, this ledger is the wrong tool. Skip this method rather than weakening it.

Compute the file on HEAD before the agent starts:

mkdir -p oracle tests/fixtures
git rev-parse HEAD
sha256sum tests/fixtures/quote_batch.json > oracle/fixtures.sha256
git add oracle/fixtures.sha256 && git status
Enter fullscreen mode Exit fullscreen mode

3. Give flaky tests a closed integer budget

{
  "max_frozen": 3,
  "frozen": ["tests/test_cache.py::test_eventual_hit"]
}
Enter fullscreen mode Exit fullscreen mode

max_frozen is a ceiling, not a suggestion. An agent patch may remove a name from frozen. It may not add one. It may not raise the ceiling. That is the entire flake policy. Expiry dates are a different design; this gate does not use them. A closed integer is easier to diff and harder to game with a far-future timestamp.

4. Run properties against the committed seeds, then count hits

Hypothesis (or RapidCheck, or any seeded runner) should not pick new seeds during an agent job. The gate iterates the committed list. Each seed is one example. That keeps HEAD and the patch comparable.

# tests/test_properties.py
# Proposal / unexecuted example. Replace quote_total with your invariant.

import os
from hypothesis import given, settings, seed as hyp_seed
from hypothesis import strategies as st

SEED = int(os.environ["ORACLE_SEED"])

def quote_total(items, tax_bps: int) -> int:
    return sum(i["net"] for i in items) * (10_000 + tax_bps) // 10_000

@hyp_seed(SEED)
@settings(max_examples=1, deadline=None)
@given(
    items=st.lists(
        st.fixed_dictionaries({"net": st.integers(min_value=0, max_value=10_000)}),
        min_size=1,
        max_size=8,
    ),
    tax_bps=st.integers(0, 2500),
)
def test_tax_never_shrinks_net(items, tax_bps):
    net = sum(i["net"] for i in items)
    assert quote_total(items, tax_bps) >= net
Enter fullscreen mode Exit fullscreen mode

max_examples=1 looks wrong until you see the runner. The corpus, not the library default, is the budget. If the agent “tunes” the strategy so a seed can no longer draw, that seed becomes a miss. A miss is a failed merge, not a warning.

5. Parse the pytest summary. Treat skip as a miss.

Pytest exits 0 when tests skip. That hole lets an agent disable a property without touching oracle/. The gate must read the summary line, not the process code.

# tools/oracle_gate.py
# Proposal: local CI helper. Compare HEAD ledgers to the patch worktree.

from __future__ import annotations

import hashlib
import json
import os
import re
import subprocess
import sys
from pathlib import Path

ORACLE = Path("oracle")
SEED_FILE = ORACLE / "seeds.json"
HASH_FILE = ORACLE / "fixtures.sha256"
FLAKE_FILE = ORACLE / "flake_budget.json"
SUMMARY = re.compile(r"(\d+) (passed|skipped|failed|xfailed|xpassed|error)")

def changed_under(path: str) -> list[str]:
    r = subprocess.run(
        ["git", "diff", "--name-only", "HEAD", "--", path],
        check=True, capture_output=True, text=True,
    )
    return [n for n in r.stdout.splitlines() if n]

def fixture_hashes_ok() -> bool:
    for line in HASH_FILE.read_text().splitlines():
        if not line.strip() or line.startswith("#"):
            continue
        expected, rel = line.split()
        digest = hashlib.sha256(Path(rel).read_bytes()).hexdigest()
        if digest != expected:
            print(f"fixture hash drift: {rel}", file=sys.stderr)
            return False
    return True

def flake_budget_ok(base: dict, patch: dict) -> bool:
    if patch["max_frozen"] != base["max_frozen"]:
        print("flake ceiling changed", file=sys.stderr)
        return False
    if len(patch["frozen"]) > patch["max_frozen"]:
        print("frozen set exceeds ceiling", file=sys.stderr)
        return False
    extra = set(patch["frozen"]) - set(base["frozen"])
    if extra:
        print(f"new frozen tests: {sorted(extra)}", file=sys.stderr)
        return False
    return True

def classify_pytest(stdout: str, code: int) -> str:
    counts = {kind: int(n) for n, kind in SUMMARY.findall(stdout)}
    if code not in (0, 1):
        return "error"
    if counts.get("failed") or counts.get("error") or counts.get("xfailed"):
        return "miss"
    if counts.get("skipped") or counts.get("xpassed"):
        return "miss"
    if counts.get("passed", 0) >= 1:
        return "hit"
    return "miss"

def run_seeded(seeds: list[int]) -> list[int]:
    hits: list[int] = []
    for s in seeds:
        env = os.environ.copy()
        env["ORACLE_SEED"] = str(s)
        p = subprocess.run(
            [sys.executable, "-m", "pytest", "-q", "--tb=no",
             "tests/test_properties.py", "-p", "no:cacheprovider"],
            env=env, capture_output=True, text=True,
        )
        if classify_pytest(p.stdout + p.stderr, p.returncode) == "hit":
            hits.append(s)
        else:
            print(f"miss seed={s} exit={p.returncode}", file=sys.stderr)
    return hits

def main() -> int:
    oracle_names = changed_under("oracle")
    allowed = {"oracle/flake_budget.json"}
    extra = [n for n in oracle_names if n not in allowed]
    if extra:
        print(f"oracle paths are read-only: {extra}", file=sys.stderr)
        return 2
    if not fixture_hashes_ok():
        return 3
    base_flake = json.loads(os.environ["BASE_FLAKE_JSON"])
    patch_flake = json.loads(FLAKE_FILE.read_text())
    if not flake_budget_ok(base_flake, patch_flake):
        return 4
    seeds = json.loads(SEED_FILE.read_text())["seeds"]
    hits = run_seeded(seeds)
    receipt = {
        "seed_count": len(seeds),
        "hit_count": len(hits),
        "misses": [s for s in seeds if s not in hits],
        "frozen_count": len(patch_flake["frozen"]),
        "oracle_files_touched": len(oracle_names),
    }
    Path("oracle_receipt.json").write_text(json.dumps(receipt, indent=2) + "\n")
    if receipt["hit_count"] < receipt["seed_count"]:
        print(
            f"seed corpus shrank: {receipt['hit_count']}/{receipt['seed_count']}",
            file=sys.stderr,
        )
        return 5
    return 0

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

Command sketch against the current worktree:

export BASE_FLAKE_JSON="$(git show HEAD:oracle/flake_budget.json)"
python tools/oracle_gate.py
echo $?   # 0 only when every committed seed still hits
cat oracle_receipt.json
Enter fullscreen mode Exit fullscreen mode

Sanity-check the gate before you trust it. On HEAD, hit_count must equal seed_count. Then temporarily wrap the invariant in if tax_bps > 0: return net and run again. The second run must fail. If it does not, the gate is counting process exits. It is not counting seeds.

Decision table

Observation Merge? Why
All seeds hit, hashes match, frozen set same or smaller yes pinned corpus still ran
Pytest green, one seed missed no strategy, skip, or input domain shrank
Golden fixture rewritten, hashes drift no oracle moved with the code
New freeze name added no flake budget is closed
oracle/seeds.json edited next to src/ no mixed-intent diff
Human-only PR that only extends seeds yes, with review corpus maintenance is not an agent job
Property test skipped at runtime no skip is a miss, even with exit code 0

What to log instead of “CI green”

Four integers per agent PR are enough: seed_count, hit_count, frozen_count, oracle_files_touched. The merge rule collapses to one predicate.

hit_count == seed_count
and frozen_count <= base_frozen_count
and oracle_files_touched is 0 or a freeze-only shrink
Enter fullscreen mode Exit fullscreen mode

Anything else is a smaller test world wearing a green badge. Keep the corpus in version control. Keep the agent out of seeds.json and fixtures.sha256. Count hits.

Where a free model and a free server fit

The gate is the point. The model is only the author of a candidate diff.

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

MonkeyCode offers free model access and a free server option. That pair is enough to generate a patch on an isolated worktree and run oracle_gate.py beside it, with oracle/seeds.json mounted read-only. Use the box as a throwaway tree. Do not treat a passing chat reply as a merge receipt. If the gate cannot read the HEAD corpus, you are not testing the patch. You are testing the prompt.

A free server is not a stand-in for project CI hardware, browsers, or licensed data fixtures. Keep those jobs where they already run. The seed-hit gate is cheap and should be deterministic. That is the only reason it belongs on a spare box.

Limitations

This gate does not prove correctness. It proves that a pinned corpus still ran. Ten integers miss whole regions of the input space. Grow the list on human time. Do not grow it inside the agent loop.

It also assumes oracle/ is actually constrained. If the agent job runs git add -A, the policy is fiction. Put a CODEOWNERS rule, a CI path filter, and the changed_under("oracle") check on the same path. Redundancy is the feature.

Flake budgets do not fix races. They stop the suite from laundering races into policy. If a test is frozen, give it an owner in the tracker. Do not encode hope as max_frozen += 1.

Seeded properties assume a pure function or a hermetic fake. Shared clocks, live network, and unordered sets will miss for reasons that are not shrinkage. Stabilize time and IO first, or the receipt becomes noise.

Do not use this approach when:

  • The suite has no property tests and no stable fixtures. There is nothing to pin.
  • The product behavior is the fixture, and humans expect golden files to move on every PR.
  • You cannot keep agent commits and oracle commits in separate Git history.
  • Reviewers will not reject a mixed-intent PR. A script cannot outrun a culture that merges “also updated goldens.”

Pin the corpus. Count hits. Refuse shrinkage. The agent can change code. It cannot be the owner of the expected world that says the code is right.

Top comments (0)