Skip to content

feat(export): derive a claim-drawn mutant battery from the recorded run - #16

Merged
utsab345 merged 12 commits into
utsab345:mainfrom
how2how2how2-arch:export-claim-drawn-battery
Oct 17, 2026
Merged

utsab345 merged 12 commits into
utsab345:mainfrom
how2how2how2-arch:export-claim-drawn-battery

Conversation

@how2how2how2-arch

@how2how2how2-arch how2how2how2-arch commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes the first half of #14: the claim-drawn mutant is now derived rather than hand-built, and stepfork score reports whether an exported expectation still rejects it.

The derivation is mechanical, and it uses two values the export already has: the expectation the generated test asserts, and the recorded run's output (RunEnd.output in the bundle). The claim-drawn row is that second value verbatim — the run's own output is the most plausible output of a variant implementation, because an implementation did produce it. A claim-pinned expectation rejects it (the run it came from is the bug, so the expectation must fail against it); a run-pinned expectation accepts it, and that acceptance is the finding: the generated test would pass against the code that produced the recording. The rest of the battery is controls — perturbations of the expectation's own leaves (an integer moved by one, a string suffixed, a value replaced by null) — which a run-pinned expectation rejects too, and which therefore make the contrast legible without carrying the verdict.

Two guards are deliberate, and both are tested:

  • the claim-drawn row is the recorded output whole, never a projection onto the expectation's shape. The generated test compares whole values, so a comparison that ignored keys the claim does not name would accept an expectation that is stricter than the run — a false run_pinned, the one reading this must not produce;
  • a perturbation that would land on the recorded value at that leaf is skipped. A row carrying the run's own value is a claim-drawn row wearing a code-drawn label.

stepfork score also reports pin coverage: which leaves carry a value the run did not produce, and which merely reproduce the run. A leaf that reproduces the run is not a defect — a fix does not change every field — but it is not evidence of anchoring either.

Changes

  • src/stepfork/export/mutants.py (new) — derive_battery, pin_coverage, recorded_output; the reasoning is in the module docstring.
  • src/stepfork/export/testfile.py (new) — reads the generated test's literals (TRACE_PATH, EXPECTATION, HAS_EXPECTATION, TRAJECTORY_EXPECTATION) with ast, plus a whitelist evaluator for the two path-expression shapes the generator writes. The file is parsed, never imported or executed; an expression outside those shapes is refused with a message that points at --bundle rather than guessed at.
  • src/stepfork/cli/score.py (new) — stepfork score TEST [--expect-output PATH] [--bundle PATH] [--json]. Exits 0 claim-pinned, 1 run-pinned, 2 cannot answer (no expectation, no recorded run, unreadable file).
  • src/stepfork/cli/main.py — registers the command.
  • src/stepfork/export/__init__.py — re-exports the derivation.
  • src/stepfork/export/scoring.py — ExpectationScore.summary() now leads with its verdict, so a caller can print one labelled line instead of prefixing a label to a sentence that already contains it. The existing assertions (run-pinned, cannot separate, no expectation to score) still hold.
  • src/stepfork/cli/export.py — _recorded_output moves to mutants.recorded_output: "the recorded run" had one definition and now has two callers, so it gets one definition (net −8 lines).
  • 3 test modules (39 tests) and docs/cli.md + docs/pytest-export.md.

One departure from my own #14 wording, flagged for you. That comment said "ship it with the exported test". I did not embed the battery in the generated file, because the documented workflow is to edit the expectation by hand: copy the recorded result into a separate expected.json, edit the fields that define the fix, review it. An embedded battery is a copy of a value the user is told to edit, so it goes stale silently; derived at score time it always matches the expectation and the bundle in front of it. This also leaves the generated template unchanged, so no existing exported test needs regenerating and no new trace format is involved. If you would rather have it embedded, say so and I will move it.

Testing

Built a venv with uv venv + uv pip install typer pytest pydantic rich hypothesis (the dependency groups do not resolve here: uv sync times out and the dev group pulls in langgraph), then:

  • PYTHONPATH=src python -m pytest tests/ --ignore=tests/unit/test_langgraph_integration.py → 549 passed, 4 failed. All four failures are the same missing optional extra — ModuleNotFoundError: No module named 'langchain_core' — in test_docs_langgraph_example, test_langgraph_agent (×2) and test_model_decision_demo, and none of them touch this change. The 39 tests added here are inside that 549.
  • ruff check src tests → clean · ruff format --check src tests → 117 files already formatted (I ran ruff format on the files I touched this time, so there should be no formatting follow-up).
  • mypy → 14 errors in 2 files, all pre-existing in this environment: src/stepfork/integrations/langgraph.py and tests/unit/test_langgraph_integration.py cannot resolve langchain_core/langgraph. I ran the same command on a pristine checkout (git stash) and it reports the same 14, so this change adds none.
  • End to end on the repo's own example: recorded a booking trace, exported with --expect-output examples/booking_agent/expected.json, then scored it → claim-pinned: rejected 7/7 mutants, pins: 1 live (selected_flight); 2 leaf(s) reproduce the run (destination, plan). The live pin is the field the generated test's own message names (behavior mismatch at 'selected_flight').

Checklist

  • Tests added/updated
  • pytest passes
  • ruff check passes
  • ruff format check passes
  • mypy passes
  • docs updated if necessary
  • no secrets/sensitive traces included

Still open from #14, untouched here: the tri-state verdict (COULD_NOT_EXECUTE) and the runner-owned receipt (exit status + bundle digest + test hash), plus the stepfork score / stepfork run --receipt CLI half you listed. This PR only covers the derived battery and the scoring command. Happy to send the verdict/receipt piece as the next PR whenever you want it.

@utsab345
utsab345 merged commit 6507281 into utsab345:main Oct 17, 2026
6 checks passed
@utsab345 utsab345 changed the title export: derive a claim-drawn mutant battery from the recorded run fix(cli,minimize): validate --mode upfront and reject invalid modes (exit 2) Oct 18, 2026
@utsab345 utsab345 changed the title fix(cli,minimize): validate --mode upfront and reject invalid modes (exit 2) export: derive a claim-drawn mutant battery from the recorded run Oct 18, 2026
@utsab345 utsab345 changed the title export: derive a claim-drawn mutant battery from the recorded run feat(export): derive a claim-drawn mutant battery from the recorded run Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants