fix(scoring): test the arithmetic this benchmark's numbers are made of - #42
Merged
Merged
Conversation
Phase 6 of the plan calls for mutation-testing the scoring logic, on the
grounds that a measurement tool's measurement is its product. Doing it put
tooltrace/scoring/builtin.py at a 57.4% mutation score: 23 of 54 mutants
survived, meaning 23 behaviours in the scorers that no test constrained.
Three findings, in order of severity.
**A scorer that could be inverted silently.** In `file_not_contains`, mutating
`in` to `not in` survived. That scorer would have scored 1.0 for a file
*containing* the forbidden text and 0.0 for a clean one -- doing exactly the
opposite of its name -- with the whole suite green.
**A constraint that has never worked.** Writing a boundary test for
`git_diff`'s `max_changed_files` crashed it:
changed = {line.split()[2] for line in ... if line.startswith("+++ b/")}
A git diff header is `+++ b/path`, two whitespace-separated fields, so `[2]`
raises IndexError on every real diff. The constraint could not return a score,
only crash. No shipped task uses it, which is why nothing noticed. Verified the
format against real `git diff HEAD` output rather than assuming.
**The pytest scorer's core calculation was entirely unconstrained.** Every
operator in
total = passed + failed + errors
ratio = passed / total if total else 0.0
score = 1.0 if ratio >= min_ratio and errors == 0 else round(ratio, 4)
survived mutation: the total could be computed by subtraction, the ratio by
multiplication, the threshold inverted, and the errors clause flipped from
`and` to `or`. It was untested because it was unreachable -- welded inside
`_tests_pass` behind a `subprocess.run` of a real pytest in a temp workspace.
Extracting `score_pytest_output` is what made the assertions possible.
Mutation score now 77.8% (42/54). Every comparison and arithmetic mutant is
killed. The 12 survivors are boolean short-circuits and bool constants, mostly
`isinstance(...) and ...` guards whose second operand is unreachable without
the first -- equivalent mutants rather than gaps. Stating that rather than
chasing the number: a mutation score is a diagnostic, not a target.
One of my own mistakes worth recording: my first attempt to kill the JSON-path
`Lt -> LtE` mutant used index 9 on a three-element array, where `9 < 3` and
`9 <= 3` are both false, so the mutant survived a test written specifically to
kill it. Only `idx == len` distinguishes them.
Verified: ruff, ruff format, mypy (73 files), 340 tests pass, coverage 85.48%
against the 80% floor.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 6 calls for mutation-testing the scoring logic, on the grounds that a measurement tool's measurement is its product. Doing it put
tooltrace/scoring/builtin.pyat a 57.4% mutation score — 23 of 54 mutants survived, meaning 23 behaviours no test constrained.Three findings, in order of severity.
1. A scorer that could be inverted silently
In
file_not_contains, mutatingin→not insurvived. That scorer would have returned 1.0 for a file containing the forbidden text and 0.0 for a clean one — doing exactly the opposite of its name, with the whole suite green.2. A constraint that has never worked
Writing a boundary test for
git_diff'smax_changed_filescrashed it:A git diff header is
+++ b/path— two whitespace-separated fields — so[2]raisesIndexErroron every real diff. The constraint could not return a score, only crash. No shipped task uses it, which is why nothing noticed. I verified the format against realgit diff HEADoutput rather than reasoning about it.3. The pytest scorer's core calculation was entirely unconstrained
Every operator survived:
The total could have been computed by subtraction, the ratio by multiplication, the threshold inverted. It was untested because it was unreachable — welded inside
_tests_passbehind asubprocess.runof a real pytest in a temp workspace. Extractingscore_pytest_outputis what made the assertions possible.Result
Mutation score 57.4% → 77.8% (42/54). Every comparison and arithmetic mutant is killed.
The 12 survivors are boolean short-circuits and bool constants, mostly
isinstance(...) and ...guards whose second operand is unreachable without the first — equivalent mutants rather than gaps. Saying so rather than chasing the number: a mutation score is a diagnostic, not a target.One of my own mistakes
My first attempt to kill the JSON-path
Lt → LtEmutant used index 9 on a three-element array, where9 < 3and9 <= 3are both false — so the mutant survived a test written specifically to kill it. Onlyidx == lendistinguishes them.Verification
ruff/ruff format --checkmypypytest