A green property test does not prove that an agent patch preserved the contract. It only proves that the current generator produced no counterexample. Shrink the generator, add a broad assume(), or rewrite the fixture the property reads, and the suite still passes.
Treat that as a merge defect. The control proposed here is a triple lock plus a freeze of the oracle, not of the test. Fingerprint the generator. Bound the assume-reject rate. Hash the fixtures the property consumes. Require an independent replay to agree. If any of those signals move, the oracle loses its vote until a human classifies the change.
The failure mode
Agent patches optimize for green CI. Property tests look rigorous, so they are an attractive target. The agent does not need to delete the check. It only needs to make the search space smaller than the bug.
Three edits show up in review. The strategy stops emitting the interesting cases. assume() rejects most draws, so the body almost never runs. The fixture changes while the assertion text stays the same.
None of those fail a naive pytest invocation. All three reduce what the test can still catch. A freeze that merely silences noise will hide the same edits. Freeze the oracle instead: keep collecting results, stop letting that check vote.
What this article does not claim
This is a proposed workflow and a runnable checker, not a production study. No pass-rate, flake-rate, or model-quality numbers are attached. If your merge gate already fingerprints generators and compares two runners, you do not need the rest.
The method is for repositories where an agent may edit tests and fixtures in the same patch as production code. It is not a substitute for review of the production diff. Label every default bound below as a starting value, not a measured optimum.
Three locks, one freeze
Keep three artifacts in source control, outside paths the agent is allowed to edit:
-
generators.lock.json— a fingerprint per property. -
fixtures.lock.json— hashes of files the properties read. -
oracle_freeze.json— oracles that currently cannot vote.
The merge predicate is then mechanical. A property may vote only if its fingerprint matches, its assume-reject rate stays inside a bound, its fixtures match, it is not frozen, and a second runner reports the same outcome. Everything else is information. It is not a yes.
1. Fingerprint the generator
Hash the AST of the function that builds inputs, not the assertion. Comments and local variable names should not move the fingerprint. Structure should. Harmless refactors will trip the lock. That is intended. A human can accept a new fingerprint. An agent cannot.
# oracle_lock.py — proposal you can run locally
from __future__ import annotations
import ast
import hashlib
import inspect
from collections.abc import Callable
def _normalize(src: str) -> str:
tree = ast.parse(src)
for node in ast.walk(tree):
for attr in ("lineno", "end_lineno", "col_offset", "end_col_offset"):
if hasattr(node, attr):
setattr(node, attr, 0)
return ast.dump(tree, include_attributes=False)
def fingerprint_fn(fn: Callable) -> str:
payload = _normalize(inspect.getsource(fn)).encode("utf-8")
return hashlib.sha256(payload).hexdigest()[:16]
Register each property by a stable name. Do not register by file path. Agents rename files.
PROPERTIES: dict[str, Callable] = {}
def property_case(name: str):
def deco(fn: Callable) -> Callable:
PROPERTIES[name] = fn
return fn
return deco
Write the lock file in CI from main, not from the patch branch. On the patch, only compare.
python oracle_lock.py write-generators --from-ref origin/main --out generators.lock.json
python oracle_lock.py check-generators --lock generators.lock.json
If the agent rewrote gen_orders() to skip cancelled orders, the fingerprint moves. The property can still be green. The lock is red.
2. Bound the assume-reject rate
A property that rejects most draws is barely a property. Record attempts, rejects, and completed trials. Fail the check when the reject rate rises by more than a fixed delta, even if every completed trial passed.
from dataclasses import dataclass
@dataclass
class TrialStats:
attempts: int = 0
rejected: int = 0
completed: int = 0
failed: int = 0
@property
def reject_rate(self) -> float:
return 0.0 if self.attempts == 0 else self.rejected / self.attempts
def run_property(fn, draws, assume) -> TrialStats:
stats = TrialStats()
for value in draws:
stats.attempts += 1
if not assume(value):
stats.rejected += 1
continue
stats.completed += 1
try:
fn(value)
except AssertionError:
stats.failed += 1
return stats
Store baseline rates next to the fingerprint. Use a fixed draw budget so two runners are comparable.
{
"prop_order_total_non_negative": {
"generator": "a1b2c3d4e5f60789",
"attempts": 200,
"rejected": 14,
"completed": 186
}
}
Decision rule, labeled as a default:
-
failed > 0— property red; the oracle may vote as a fail. -
completed == 0— treat as red, not as skip. -
reject_rate - baseline > 0.15— freeze the oracle. - generator fingerprint mismatch — freeze the oracle.
The 0.15 delta is a starting bound. Tighten it if your generators are deterministic. Loosen it only with a recorded reason in oracle_freeze.json.
3. Hash fixtures the property reads
Here the fixture is an input to a property, not an expected output. If the agent rewrites orders.json so every amount is already positive, prop_order_total_non_negative becomes cheap to satisfy. The assertion text does not have to change.
from pathlib import Path
def hash_tree(root: Path) -> dict[str, str]:
out = {}
for path in sorted(root.rglob("*")):
if path.is_file():
digest = hashlib.sha256(path.read_bytes()).hexdigest()
out[str(path.relative_to(root))] = digest
return out
Put property-owned fixtures under oracles/fixtures/. If the patch touches that directory, the fixture lock fails. The property is then ineligible to vote, whether or not it passed.
python oracle_lock.py check-fixtures --root oracles/fixtures --lock fixtures.lock.json
Seed the draws from a committed seed file in the same tree. Do not read wall-clock time or hostname inside a property. Those inputs make replay disagreement unreadable.
4. Replay on a second runner
One CI lane is not a witness. Cache, plugin order, time, and RNG can all make a single green run look stable. Replay the same property set on a second machine or container. Compare the JSON reports, not the log text.
def disagreement(merge: dict, replay: dict) -> list[str]:
names = sorted(set(merge) | set(replay))
bad = []
for name in names:
a, b = merge.get(name), replay.get(name)
if a is None or b is None or a["outcome"] != b["outcome"]:
bad.append(name)
elif abs(a["reject_rate"] - b["reject_rate"]) > 0.15:
bad.append(name)
return bad
Encode outcome as pass, fail, or vacuous. Do not encode skip as success. A missing name in either report is a disagreement.
If you do not already have spare hardware, a second environment is enough. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access is useful for drafting candidate properties from a spec; a human still has to accept the generator and the assume predicate before they enter the lock file. The free server option is one way to run the replay lane without waiting on the merge runner. Neither replaces the lock files.
Do not send production secrets to any hosted runner. Replay on synthetic fixtures only.
5. Freeze the oracle, keep the test
A freeze is not pytest.mark.skip. Skipping deletes the signal. The ledger records that the oracle is under dispute and must not vote.
{
"prop_order_total_non_negative": {
"frozen_at": "2026-09-19",
"reason": "generator_fingerprint_mismatch",
"merge_outcome": "pass",
"replay_outcome": "pass",
"vote": false
}
}
Allowed reasons, and only these:
generator_fingerprint_mismatchreject_rate_deltafixture_hash_mismatchrunner_disagreement-
vacuous(zero completed trials)
Unfreeze is a human edit. The agent patch that caused the freeze cannot clear it. CI should fail if oracle_freeze.json loses an entry without a matching reviewed commit on main.
Numbered merge procedure
- Compute generator fingerprints on the patch. Compare to
generators.lock.jsonfrommain. - Run properties with a fixed draw budget. Write
merge_report.json. - Compare reject rates and completed counts to the lock.
- Hash
oracles/fixturesagainstfixtures.lock.json. - Replay steps 2–4 on a second runner. Write
replay_report.json. - If any name disagrees, or any lock is red, append a freeze entry with
vote: false. - Merge only if every unfrozen oracle passed on both runners and no new freeze was required.
Minimal driver:
python oracle_lock.py check-generators --lock generators.lock.json
python oracle_lock.py check-fixtures --root oracles/fixtures --lock fixtures.lock.json
python oracle_lock.py run --budget 200 --seed oracles/seed.txt --out merge_report.json
python oracle_lock.py run --budget 200 --seed oracles/seed.txt --out replay_report.json # second host
python oracle_lock.py freeze --merge merge_report.json --replay replay_report.json --ledger oracle_freeze.json
python oracle_lock.py vote --ledger oracle_freeze.json --merge merge_report.json --replay replay_report.json
vote should print a single line: ALLOW or DENY. Wire that to the merge gate. Do not parse pytest exit codes alone. A frozen oracle that still fails is data for the reviewer. It is not a merge vote.
def vote(ledger: dict, merge: dict, replay: dict) -> str:
if disagreement(merge, replay):
return "DENY"
for name, row in merge.items():
frozen = ledger.get(name, {}).get("vote") is False
if frozen:
continue
if row["outcome"] != "pass" or replay[name]["outcome"] != "pass":
return "DENY"
return "ALLOW"
Decision table
| Signal | Test still green? | Oracle votes? | Human action |
|---|---|---|---|
| Assertion failed on either runner | no | yes, as fail | fix production or property |
| Generator fingerprint changed | often yes | no | accept new generator or revert |
| Reject rate up more than 0.15 | often yes | no | restore assumes or justify bound |
| Fixture hash changed | often yes | no | restore fixture or re-lock |
| Runners disagree | mixed | no | classify noise vs contract change |
| Zero completed trials | n/a | no | treat as vacuous, not skip |
| All locks match, both pass | yes | yes | merge may proceed |
The table is the artifact. If you adopt only one row, adopt the generator fingerprint. That row catches the cheapest way to fake a property.
Limitations and who should not use this
The fingerprint is brittle under refactor. Teams that rewrite generators weekly will freeze constantly. That is a process mismatch, not a checker bug.
The 0.15 reject-rate delta is arbitrary. It will miss a slow squeeze of the input domain. It will also false-freeze noisy filters. Record the bound next to the lock so it cannot drift in a comment.
Two runners do not create independence if they share a cache volume, a seeded RNG hidden in a plugin, or the same mutable temp directory. Copy the lock files. Do not copy .pytest_cache.
Do not use this workflow when:
- Tests are not allowed to block merge at all.
- The agent is forbidden from touching tests, and review already covers generator edits.
- Properties are not deterministic given a seed and a fixture tree.
- You need a statistical flake model. This ledger is a gate, not a reliability estimator.
Hosted replay is the wrong tool for data that cannot leave your network. Run the second lane on an internal runner in that case. Free-model drafts are the wrong source of truth for oracles. They are a source of candidates.
What to keep in the repository
Commit the three lock files, the checker, and a one-page oracles/README.md that lists who may unfreeze. Keep production tests where they are. The oracle tree is a contract the agent does not own.
If you already have a second runner, point it at this checker. The lock files are the product. The host that replays them is not.
Top comments (0)