Dev.to AI 🤖 Ai 👁 0 📖 7 min read

Keep the Score Contract Out of the Agent's Write Set

A green CI job on an agent branch is not a score. It is a claim that the tree the agent left behind still exits zero. Those claims diverge as soon as the agent can delete tests, rewrite goldens, skip flakes without expi

A green CI job on an agent branch is not a score. It is a claim that the tree the agent left behind still exits zero.

Those claims diverge as soon as the agent can delete tests, rewrite goldens, skip flakes without expiry, starve a property budget, or hide suites behind addopts. Bind the parent SHA, fixture bytes, runner config, property budget, assertion floor, collected-test floor, and time-bounded flake leases in one contract file. Deny the agent that file. Evaluate the contract on a clean runner.

What has to be bound

A test name is a weak identity. A coverage percentage is weaker. Both move when the agent edits the suite instead of the code under test.

The contract should freeze the inputs the scorer reads and the floors the scorer must still meet. Label the numbers below as a proposal. Record them from the parent commit, never from the agent's branch.

Field Recorded on parent Agent write Failure if omitted
parent_sha yes deny mixed tree
fixture_hashes yes deny silent golden edit
runner_config_hashes yes deny hidden -k / --ignore
property_budget yes deny pass by starvation
assert_floor yes deny deleted assertions
collect_floor yes deny deleted tests
flake_leases yes, with expiry deny permanent skip

Floors copy the parent. If the parent suite is already tautological, the contract will faithfully preserve that weakness.

1. Record the parent, then deny the score paths

Check out the parent SHA before the agent runs. Write the contract from that tree. After the patch returns, reject any diff that touches the contract, the scorer, fixtures, or runner config.

#!/usr/bin/env bash
# record_parent_contract.sh — proposal
set -euo pipefail
PARENT="$(git rev-parse HEAD)"
python tools/score_lib.py record --parent "$PARENT" --out score_contract.json
#!/usr/bin/env bash
# deny_score_paths.sh — proposal
set -euo pipefail
: "${PARENT_SHA:?}"
DENY_REGEX='^(score_contract\.json|tools/score_lib\.py|tests/fixtures/|pytest\.ini|pyproject\.toml|conftest\.py|.*/conftest\.py)$'
changed="$(git diff --name-only "$PARENT_SHA"...HEAD)"
if echo "$changed" | grep -E "$DENY_REGEX"; then
  echo "score surface is in the agent write set" >&2
  exit 1
fi

If the patch rewrites tests/fixtures/ or pytest.ini, you are no longer scoring behavior. You are scoring a new oracle.

2. Hash the bytes the runner will read

Path denylists fail when the agent adds a sibling file the loader prefers. Hash content. Include fixtures and the files that change collection: pytest.ini, pyproject.toml, and every conftest.py.

# tools/score_lib.py — proposal (helpers)
from __future__ import annotations

import hashlib
from pathlib import Path

CONFIG_CANDIDATES = ("pytest.ini", "pyproject.toml", "conftest.py")


def sha256_file(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def hash_tree(root: Path) -> dict[str, str]:
    out: dict[str, str] = {}
    if not root.exists():
        return out
    for path in sorted(p for p in root.rglob("*") if p.is_file()):
        out[path.as_posix()] = sha256_file(path)
    return out


def hash_runner_config(repo: Path) -> dict[str, str]:
    out: dict[str, str] = {}
    for name in CONFIG_CANDIDATES:
        path = repo / name
        if path.is_file():
            out[path.as_posix()] = sha256_file(path)
    for path in sorted(repo.rglob("conftest.py")):
        out[path.as_posix()] = sha256_file(path)
    return out

Store both maps on the contract. The scorer recomputes them after checkout and requires equality. A blank line in a golden JSON is a miss. That is intended.

3. Lease flakes. Do not skip them.

A skip with no expiry is a deleted test with better manners. Put leases in the contract with an RFC 3339 expires_at. The scorer may deselect a nodeid only while the lease is valid. An expired lease is a hard failure, not a quiet re-enable you might miss in the log.

{
  "parent_sha": "REPLACE_WITH_PARENT_SHA",
  "fixture_root": "tests/fixtures",
  "fixture_hashes": {},
  "runner_config_hashes": {},
  "property_budget": {
    "max_examples": 100,
    "deadline_seconds": 30
  },
  "assert_floor": 0,
  "collect_floor": 0,
  "flake_leases": [
    {
      "nodeid": "tests/test_retry.py::test_backoff_jitter",
      "reason": "upstream 503 body changed; cassette refresh pending",
      "expires_at": "2026-09-25T00:00:00Z"
    }
  ]
}

Proposed lease policy, not a measured SLA: 72 hours, one reason string, no renewals from the agent. Humans renew. Agents do not. Replace the sample expires_at from your recorder. Do not copy it.

4. Count collection and assertions on the parent

Collection count is the cheapest cardinality gate. Assertion count is a blunt density gate. Neither proves correctness. Both catch the common move of deleting the uncomfortable half of the suite.

# tools/score_lib.py — proposal (floors)
import ast
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path

ASSERT_ATTRS = {
    "assertEqual",
    "assertTrue",
    "assertFalse",
    "assertRaises",
    "assertAlmostEqual",
}


def count_asserts(tree: Path) -> int:
    total = 0
    for path in tree.rglob("test_*.py"):
        node = ast.parse(path.read_text(encoding="utf-8"))
        for item in ast.walk(node):
            if isinstance(item, ast.Assert):
                total += 1
            elif isinstance(item, ast.Call) and isinstance(item.func, ast.Attribute):
                if item.func.attr in ASSERT_ATTRS:
                    total += 1
    return total


def collect_count() -> int:
    proc = subprocess.run(
        [sys.executable, "-m", "pytest", "--collect-only", "-q"],
        check=True,
        capture_output=True,
        text=True,
    )
    return sum(1 for ln in proc.stdout.splitlines() if "::" in ln)


def record_contract(parent: str, out: Path) -> None:
    contract = {
        "parent_sha": parent,
        "recorded_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "fixture_root": "tests/fixtures",
        "fixture_hashes": hash_tree(Path("tests/fixtures")),
        "runner_config_hashes": hash_runner_config(Path(".")),
        "property_budget": {"max_examples": 100, "deadline_seconds": 30},
        "assert_floor": count_asserts(Path("tests")),
        "collect_floor": collect_count(),
        "flake_leases": [],
    }
    out.write_text(json.dumps(contract, indent=2) + "\n", encoding="utf-8")

Record on the parent. If the agent branch collects fewer nodeids or asserts fewer times, fail closed. Do not average the two branches.

5. Pass the property budget as runner flags

Agents starve property checks by lowering max_examples, wrapping tests in xfail, or deleting deadlines. The budget lives in the contract. The runner passes it in. Test code does not get a vote.

# tools/score_lib.py — proposal (score)
from datetime import datetime, timezone


def active_skips(leases: list[dict]) -> list[str]:
    now = datetime.now(timezone.utc)
    skip: list[str] = []
    for lease in leases:
        expires = datetime.fromisoformat(lease["expires_at"].replace("Z", "+00:00"))
        if expires <= now:
            raise SystemExit(f"expired flake lease: {lease['nodeid']}")
        skip.append(lease["nodeid"])
    return skip


def score() -> int:
    contract = json.loads(Path("score_contract.json").read_text(encoding="utf-8"))
    if hash_tree(Path(contract["fixture_root"])) != contract["fixture_hashes"]:
        raise SystemExit("fixture hash mismatch")
    if hash_runner_config(Path(".")) != contract["runner_config_hashes"]:
        raise SystemExit("runner config hash mismatch")
    if count_asserts(Path("tests")) < contract["assert_floor"]:
        raise SystemExit("assertion floor missed")
    if collect_count() < contract["collect_floor"]:
        raise SystemExit("collect floor missed")

    budget = contract["property_budget"]
    cmd = [
        sys.executable, "-m", "pytest", "-q", "--maxfail=1",
        f"--timeout={budget['deadline_seconds']}",
    ]
    for nodeid in active_skips(contract["flake_leases"]):
        cmd.extend(["--deselect", nodeid])
    # Optional: export HYPOTHESIS_MAX_EXAMPLES from budget["max_examples"]
    return subprocess.call(cmd)

--timeout needs pytest-timeout if you adopt that plugin. Without it, wrap the process with timeout(1) on Linux. The contract still owns the number.

A property check that cannot see the recorded example count is not the check you bound on the parent. Do not accept a starved run as success.

Wire the two verbs from one main:

def main() -> None:
    if sys.argv[1] == "record":
        parent = sys.argv[sys.argv.index("--parent") + 1]
        out = Path(sys.argv[sys.argv.index("--out") + 1])
        record_contract(parent, out)
        return
    if sys.argv[1] == "score":
        raise SystemExit(score())
    raise SystemExit("usage: score_lib.py record|score")


if __name__ == "__main__":
    main()

6. Evaluate the contract on a clean checkout

Laptop pytest caches, Hypothesis example databases, and leftover .env files leak into scores. The contract is portable on purpose: copy score_contract.json plus the agent diff onto a runner that does not have your home directory.

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

A practical split is to propose the patch with free model access, then run python tools/score_lib.py score on a free server option so the model host and the score host are not the same process. That split is the method. A self-hosted VM with a fresh checkout implements the same isolation. Isolation removes one contamination class. It does not make a weak contract strong.

Limitations

This workflow does not replace review. A patch can keep every assertion and still change meaning.

It does not help if the parent suite is already hollow. Floors copy the parent. Garbage in, garbage frozen.

It does not belong on suites whose flakes are the product: live market feeds, hardware-in-the-loop, chatty third-party sandboxes. Lease files will rot. Use a dedicated queue instead.

Do not use it when the agent's job is to update tests and code in one unconstrained turn. The contract assumes tests are a score surface, not a coworking document.

Hash equality is brittle by design. Formatter-only fixture rewrites will fail the score. That is cheaper than accepting a rewritten oracle.

Close

The agent may edit production code. It may not edit the score contract, the scorer, the fixture bytes, or the runner config those hashes cover. Record floors on the parent. Expire skips. Spend the property budget. Run that bundle on a clean checkout.

If you already have a throwaway machine for scoring, point it at score_lib.py score. The contract is the artifact. The runner is just a tree that cannot see your laptop.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.