Repository navigation
[Feature]: Score the exported expectation with a claim-drawn mutant, and make "could not replay" its own verdict #14
Description
Context
I posted two fixtures in the Stepfork HF thread (how do you reproduce LLM agent failures?, posted there as argszero) after reading docs/pytest-export.md, and the thread author (utsabdahal34 on the forum) invited a GitHub issue with them and the proposed improvements. Both fixtures are stdlib + pytest only, were re-run immediately before filing, and are pasted verbatim below. Nothing here asks for a change to the trace format.
Problem
1. The export validates the expectation's shape, not its discrimination.
docs/pytest-export.md already names the failure mode — "Do not treat the recorded result or the first output from changed code as an approved oracle automatically" — and --expect-output is the right contract for it. What is missing is a way to see whether a given expected.json is still anchored to the claim, or has drifted into restating the run it was written from.
Measured (Part A below). One expectation field, four mutants, two versions of the test:
- battery drawn from the code (3 mutants): claim-pinned expectation 3/3, run-pinned expectation 3/3 — identical, so the battery cannot tell them apart;
- the same battery plus one mutant drawn from the claim (4 mutants): 4/4 vs 3/4 — separated.
The plainest reading of the same result: the run-pinned expectation fails the implementation the claim is true of (expectation = the run (== 2026) → FAIL), and still passes a code-derived battery. "Watch it fail first" holds for both versions, so that discipline does not catch it either.
2. A replay that could not run is currently reported the same way as a regression that was detected.
Modelled on the exported template (the bundle is resolved when the test executes, in run_regression_case):
- bundle missing → pytest exits 1, summary
1 failed— the same status a consumer sees when the code under test is wrong (arms 1 and 3 below are indistinguishable by exit status or by the summary line); - bundle resolved at import time instead → exit 2,
1 error— distinguishable (arm 4).
Your own doc sends users down this path: "If moving the test to another project or CI, copy the bundle with it and verify TRACE_PATH resolves there." Today that failure reads as a detected regression rather than as an un-run check.
Related, for whenever a receipt is added: it must be written outside the tree under test. A receipt written by the generated test file reports {"verdict": "PASS"} while the process exits 1 (arm 2).
Proposed solution
- Derive at least one mutant from the expectation, not from the entrypoint. Take the claim fields in
expected.json(and the--expect-trajectoryassertions) and construct a variant that violates the claim while staying plausible; ship it with the exported test as a battery and report whether the suite kills it. A suite that kills every code-drawn mutant but survives the claim-drawn one is pinning the run. The cost is one extra row, and it needs no new trace format. - Make the verdict tri-state and runner-owned:
PASS/FAIL/COULD_NOT_EXECUTE. Resolve the bundle reference at import (or emit an explicit preflight), so an un-replayable case never lands in the same bucket as a detected regression; and let the receipt be produced by the runner — keyed on the child's exit status plus the bundle digest and the test-file hash — so nothing inside the tree under test can author a verdict.
Desired CLI/API example
$ stepfork score test_booking_regression.py --expect-output expected.json
claim-drawn mutant real_year (pin dropped, plausible value) killed
code-drawn mutants const / as_string / off_by_one killed 3/3
expectation anchors the claim (== 2024) OK$ stepfork run test_booking_regression.py --receipt run.json
COULD_NOT_EXECUTE # not PASS, not FAIL
$ cat run.json
{"verdict":"COULD_NOT_EXECUTE","exit_status":2,
"bundle_sha256":"...","test_sha256":"..."}Appendix — fixture and verbatim output
stepfork_fixture.py (stdlib + pytest; Part B shells out to pytest)
"""r188 fixture for the Stepfork issue: two measurements, re-run at send time.
PART A A mutant battery drawn from the code cannot tell an expectation that
pins the claim from one that pins the observed run; one mutant drawn
from the claim separates them.
PART B Who writes the verdict, and whether "could not replay" is
distinguishable from "the check ran and failed".
Stdlib + pytest. Part B shells out to real pytest.
"""
import json
import os
import subprocess
import sys
import tempfile
# ---------------------------------------------------------------- PART A
PIN = 2024 # the claim: the artefact's year is pinned by the requirement
OBSERVED = 2026 # what a re-run of the changed code produced
CORRECT = lambda: PIN # noqa: E731 the implementation the claim is true of
MUTANTS = [
("real_year (CLAIM-drawn: pin dropped, plausible year)", lambda: OBSERVED),
("const_2020 (code-drawn)", lambda: 2020),
("as_string (code-drawn)", lambda: "2024"),
("off_by_one (code-drawn)", lambda: PIN + 1),
]
TESTS = [
("expectation = the claim (== 2024)", lambda v: v == PIN),
("expectation = the run (== 2026)", lambda v: v == OBSERVED),
]
def part_a():
print("PART A - does the expectation still discriminate?")
print(" %-54s %-22s %s" % ("mutant", TESTS[0][0], TESTS[1][0]))
kills = {t[0]: 0 for t in TESTS}
code_kills = {t[0]: 0 for t in TESTS}
for mname, impl in MUTANTS:
row = []
claim_drawn = "CLAIM-drawn" in mname
for tname, test in TESTS:
k = not test(impl())
row.append("killed " if k else "survive")
if k:
kills[tname] += 1
if not claim_drawn:
code_kills[tname] += 1
print(" %-54s %-22s %s" % (mname, row[0], row[1]))
print()
print(" the implementation the claim is true of:")
for tname, test in TESTS:
print(" %-36s %s" % (tname, "pass" if test(CORRECT()) else "FAIL"))
print()
n_code = len(MUTANTS) - 1
print(" battery drawn from the CODE (%d mutants): claim-test %d/%d run-test %d/%d"
% (n_code, code_kills[TESTS[0][0]], n_code, code_kills[TESTS[1][0]], n_code))
print(" same battery + 1 mutant drawn from the CLAIM (%d mutants): claim-test %d/%d run-test %d/%d"
% (n_code + 1, kills[TESTS[0][0]], n_code + 1, kills[TESTS[1][0]], n_code + 1))
print()
# ---------------------------------------------------------------- PART B
APP_OK = """
def run_agent():
return {"destination": "Lisbon", "selected_flight": "B"}
"""
# (a) the generated regression test, roughly as Stepfork exports it: the bundle
# is resolved when the test runs, and an inability to replay raises inside
# the test function.
TEST_INFUNCTION = """\
import pathlib
TRACE_PATH = pathlib.Path(__file__).parent / "fixture.sftrace"
{receipt}
def test_stepfork_regression_failure():
if not TRACE_PATH.exists():
raise AssertionError("could not replay: bundle missing at %s" % TRACE_PATH)
from app import run_agent
got = run_agent()
assert got["selected_flight"] == "A", "expected A, got %s" % got["selected_flight"]
"""
# (b) the same test with the bundle resolved at import time, so "cannot replay"
# is a collection error rather than a failed test.
TEST_ATIMPORT = """\
import pathlib
TRACE_PATH = pathlib.Path(__file__).parent / "fixture.sftrace"
if not TRACE_PATH.exists():
raise RuntimeError("could not replay: bundle missing at %s" % TRACE_PATH)
{receipt}
def test_stepfork_regression_failure():
from app import run_agent
got = run_agent()
assert got["selected_flight"] == "A", "expected A, got %s" % got["selected_flight"]
"""
RUNNER = """\
import json, subprocess, sys, pathlib
tree = pathlib.Path(sys.argv[1])
p = subprocess.run([sys.executable, "-m", "pytest", "-q", "test_regression.py"],
cwd=tree, capture_output=True, text=True)
last = [l for l in p.stdout.strip().splitlines() if l.strip()][-1:] or [""]
print(" exit_status=%d pytest says: %s" % (p.returncode, last[0][:88]))
print(" runner receipt: %s" % json.dumps(
{"exit_status": p.returncode, "verdict": "PASS" if p.returncode == 0 else "FAIL"}))
"""
SELF_RECEIPT = ('pathlib.Path(__file__).parent.joinpath("receipt.json").write_text('
"'{\\\"verdict\\\": \\\"PASS\\\"}')")
def arm(label, template, bundle_present, self_written_receipt):
d = tempfile.mkdtemp(prefix="r188_")
tree = os.path.join(d, "tree")
os.makedirs(tree)
with open(os.path.join(tree, "app.py"), "w") as fh:
fh.write(APP_OK)
if bundle_present:
with open(os.path.join(tree, "fixture.sftrace"), "w") as fh:
fh.write("{}")
with open(os.path.join(tree, "test_regression.py"), "w") as fh:
fh.write(template.format(receipt=SELF_RECEIPT if self_written_receipt else ""))
runner = os.path.join(d, "run_regression.py")
with open(runner, "w") as fh:
fh.write(RUNNER)
print(" " + label)
subprocess.run([sys.executable, runner, tree], check=False)
inside = os.path.join(tree, "receipt.json")
if os.path.exists(inside):
print(" receipt written INSIDE the tree under test: %s" % open(inside).read())
def part_b():
print("PART B - who writes the verdict, and can 'could not replay' be told apart")
arm("arm 1: bundle present, the code is wrong (the honest case)",
TEST_INFUNCTION, True, False)
arm("arm 2: same failure, but the generated test writes its own receipt",
TEST_INFUNCTION, True, True)
arm("arm 3: the bundle is missing; the test raises when it tries to replay",
TEST_INFUNCTION, False, False)
arm("arm 4: the bundle is missing; the test resolves it at import",
TEST_ATIMPORT, False, False)
print()
if __name__ == "__main__":
part_a()
part_b()Output, verbatim:
exit_status=1 pytest says: 1 failed in 0.01s
runner receipt: {"exit_status": 1, "verdict": "FAIL"}
exit_status=1 pytest says: 1 failed in 0.01s
runner receipt: {"exit_status": 1, "verdict": "FAIL"}
exit_status=1 pytest says: 1 failed in 0.01s
runner receipt: {"exit_status": 1, "verdict": "FAIL"}
exit_status=2 pytest says: 1 error in 0.03s
runner receipt: {"exit_status": 2, "verdict": "FAIL"}
PART A - does the expectation still discriminate?
mutant expectation = the claim (== 2024) expectation = the run (== 2026)
real_year (CLAIM-drawn: pin dropped, plausible year) killed survive
const_2020 (code-drawn) killed killed
as_string (code-drawn) killed killed
off_by_one (code-drawn) killed killed
the implementation the claim is true of:
expectation = the claim (== 2024) pass
expectation = the run (== 2026) FAIL
battery drawn from the CODE (3 mutants): claim-test 3/3 run-test 3/3
same battery + 1 mutant drawn from the CLAIM (4 mutants): claim-test 4/4 run-test 3/4
PART B - who writes the verdict, and can 'could not replay' be told apart
arm 1: bundle present, the code is wrong (the honest case)
arm 2: same failure, but the generated test writes its own receipt
receipt written INSIDE the tree under test: {"verdict": "PASS"}
arm 3: the bundle is missing; the test raises when it tries to replay
arm 4: the bundle is missing; the test resolves it at import
Reproduce
python stepfork_fixture.py # Part A is pure stdlib; Part B shells out to pytestSource: the two fixtures were first posted in the HF thread linked above (argszero), at the author's request; this issue is the invited follow-up. Happy to turn the score half into a PR against stepfork/export/ if you want it — the battery only needs the expectation file, so it can live outside the trace contract.
Activity
utsab345 commentedon Oct 16, 2026
Thanks for opening this issue and including the reproducible fixtures. The distinction between an expectation that matches the recorded run and one that actually enforces the requirement is important.
I'd be happy to review a PR for the claim-based scoring portion first. Let's keep the initial scope focused on scoring exported expectations against a claim-derived mutant, with tests covering both the claim-pinned and run-pinned cases.
For the replay verdict and runner-owned receipt changes, I'd prefer to handle those as a separate follow-up so we can review the behavior and security boundaries independently.
Please go ahead with the scoring PR against
stepfork/export/. Thanks for offering to contribute!utsab345 commentedon Oct 16, 2026
Thanks @how2how2how2-arch for PR (#15). The scoring half is done.
Still open from this issue:
COULD_NOT_EXECUTE), keyed on exit status, bundle digest, test hashstepfork scoreandstepfork run --receiptIf you are up for it, go ahead and open the next PR for these. Reopening until then.
utsab345 commentedon Oct 17, 2026
Thanks @how2how2how2-arch for PR (#15) and PR (#16). The scoring half is done: the claim-drawn mutant is now derived (from the expectation and the recorded run), and the
stepfork scorecommand is in.Still open from this issue:
COULD_NOT_EXECUTE), keyed on exit status, bundle digest, test hash.stepfork run --receipt. Thestepfork scorehalf is already done.This issue is not completely fixed yet. If you are up for it, please go ahead and open the next PR to fix the remaining items. Keeping the issue open until then.
utsab345 commentedon Oct 19, 2026
Closing. Everything from this issue has landed except embedding the battery in the export, and #16 made that a deliberate choice: derive at score time instead.
What shipped:
stepfork scoreCLI, so a run-pinned expectation is separated from a claim-pinned one (4/4 vs 3/4, as measured in this issue).stepfork run --receiptreportsPASS,FAILorcould_not_execute, the bundle is preflighted before pytest starts so a missing bundle never reads as a detected regression, and the receipt is written by the runner after the child exits withexit_status,bundle_digestandtest_sha256.Resolution on the last item: the battery stays score-time derived,
stepfork score test.py --expect-output expected.jsonis the answer to the discrimination question. If we ever want the battery embedded in every exported test, that is a new issue.Thanks @how2how2how2-arch for the fixtures, the measurements and all three PRs.