fix(ai): refuse to refresh the duel-suite baseline from a failing run - #7029
fix(ai): refuse to refresh the duel-suite baseline from a failing run#7029lgray wants to merge 4 commits into
Conversation
📝 WalkthroughWalkthroughThe change adds ChangesAI gate baseline validation
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant AIGate
participant BaselineFile
participant SuiteReport
participant StagingFile
AIGate->>BaselineFile: load baseline before execution
AIGate->>SuiteReport: run suite with staging output
SuiteReport-->>AIGate: return matchup statuses and recorded games
AIGate->>StagingFile: remove output when refresh is refused
AIGate->>BaselineFile: atomically rename accepted staging output
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Two comments justified the `Fail → Fail` Warn arm by saying `--refresh-baseline` writes the current report verbatim with no `any_fail` check. That was true when written and is about to stop being true: phase-rs#7029 adds exactly that check. Both comments would then assert, in the tree, a property the tree no longer has — and because they are comments, nothing would fail. They would simply be wrong. The conclusion they support is unaffected, so only the justification changes. `Fail → Fail` stays reachable no matter what guards the write path, because nothing revalidates a committed baseline when it is loaded: a baseline blessed before the guard, or hand-edited, still carries the failure. That reason is true before and after phase-rs#7029, which is the property a durable comment needs. Found by sweeping this branch's own claims against a branch in flight, rather than against the current tree — an instrument that came from review catching the same shape one layer down, where a doc comment in phase-rs#7029 asserted the paired arm "never consults `status`", true at that head and false here. Four surfaces carried the claim: both comments, the commit message of the first commit on this branch, and the PR body. The commit message is deliberately left alone — this repo squash-merges, so branch messages do not survive to the tree, and a force-push to reword a doomed message would churn review for no durable effect. The PR body is corrected in place, and the source comments here. Assisted-by: ClaudeCode:claude-opus-5
matthewevans
left a comment
There was a problem hiding this comment.
Request changes — --refresh-baseline must preserve the existing baseline until the replacement run is validated.
🔴 Blocker
crates/phase-ai/src/bin/ai_gate.rs:60-66 assigns the independently supplied --current-output to SuiteOptions before run_suite; crates/phase-ai/src/duel_suite/run.rs:553-556 then unconditionally writes the report to that path. The new rejection checks in ai_gate.rs:89-120 run only after this write. Because parse_args permits independent --baseline and --current-output values (ai_gate.rs:130-136), they can alias. A failing or gameless refresh can therefore truncate/replace the trusted baseline before the process exits 1, defeating the safeguard this PR adds.
Use a temporary current-report path and atomically replace the baseline only after the suite is accepted (or robustly reject path aliases before running). Add a process/file-state regression that invokes the real CLI with aliased paths and asserts both nonzero exit and unchanged baseline bytes; the new SuiteReport helper tests do not exercise the output-write/exit boundary.
✅ Clean
The failing_matchups / recorded_games predicates correctly distinguish failed runs, open matchups that played games, and genuinely gameless reports; the issue is the surrounding write ordering.
Recommendation: request changes; make baseline replacement transactional and cover the CLI/file-state boundary.
Two comments justified the `Fail → Fail` Warn arm by saying `--refresh-baseline` writes the current report verbatim with no `any_fail` check. That was true when written and is about to stop being true: phase-rs#7029 adds exactly that check. Both comments would then assert, in the tree, a property the tree no longer has — and because they are comments, nothing would fail. They would simply be wrong. The conclusion they support is unaffected, so only the justification changes. `Fail → Fail` stays reachable no matter what guards the write path, because nothing revalidates a committed baseline when it is loaded: a baseline blessed before the guard, or hand-edited, still carries the failure. That reason is true before and after phase-rs#7029, which is the property a durable comment needs. Found by sweeping this branch's own claims against a branch in flight, rather than against the current tree — an instrument that came from review catching the same shape one layer down, where a doc comment in phase-rs#7029 asserted the paired arm "never consults `status`", true at that head and false here. Four surfaces carried the claim: both comments, the commit message of the first commit on this branch, and the PR body. The commit message is deliberately left alone — this repo squash-merges, so branch messages do not survive to the tree, and a force-push to reword a doomed message would churn review for no durable effect. The PR body is corrected in place, and the source comments here. Assisted-by: ClaudeCode:claude-opus-5
6954634 to
4d710db
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/phase-ai/src/bin/ai_gate.rs`:
- Around line 327-341: Update same_file to compare existing paths using
filesystem identity metadata, so hard-linked files are treated as the same while
retaining the canonicalized-parent fallback for nonexistent paths. Ensure
run_suite refuses the unsafe compare output selection before writing, preserving
baseline bytes. Add focused conditional CLI tests covering hard-linked
baseline/output refusal and atomic-refresh behavior.
- Around line 115-118: Update the refresh handling in ai_gate.rs around the
load_report match so only a missing baseline is treated as the first-refresh
path; for any other load_report error, stop before run_suite and return a
failure instead of falling through to None. Keep the existing baseline-read
logging only for the missing-baseline case, and add a focused CLI test covering
a malformed existing baseline to assert refresh is rejected and the original
baseline bytes remain unchanged.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 1848ea4e-ae44-454a-8280-bbc78039df45
📒 Files selected for processing (3)
crates/phase-ai/src/bin/ai_gate.rscrates/phase-ai/src/duel_suite/run.rscrates/phase-ai/tests/refresh_baseline_cli.rs
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] A malformed existing baseline is still silently eligible for replacement. Evidence: crates/phase-ai/src/bin/ai_gate.rs:111-118 catches every refresh load_report error, logs it, and returns None; execution then reaches run_suite (:125) and promotes the staged report with rename after an accepted run. Why it matters: a corrupt or unreadable trusted baseline is overwritten rather than preserved for diagnosis, so refresh can erase evidence and establish a new reference from an invalid prior state. Suggested fix: treat only ErrorKind::NotFound as first-baseline refresh; for every other error exit before run_suite, and add a real CLI/file-state test asserting malformed-baseline bytes are unchanged.
[HIGH] same_file misses hard-link aliases before the unconditional output write. Evidence: crates/phase-ai/src/bin/ai_gate.rs:327-341 compares canonical path strings, but canonicalize preserves distinct hard-link names; crates/phase-ai/src/duel_suite/run.rs:553-559 then opens options.output_path with File::create, which truncates that inode. The current CLI tests cover only identical spelling (crates/phase-ai/tests/refresh_baseline_cli.rs:50-118) and have no hard-link case. Why it matters: compare mode can still destroy the baseline through a distinct hard-link path before comparison; retaining the preloaded report in memory avoids a false comparison but not data loss. Suggested fix: compare identity metadata when both paths exist (retaining the canonical-parent fallback for nonexistent outputs), and add conditional hard-link CLI preservation coverage.
CodeRabbit independently reports both findings against this exact head. No direct build was run; CI remains in progress.
4d710db to
43fdc66
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
Two comments justified the `Fail → Fail` Warn arm by saying `--refresh-baseline` writes the current report verbatim with no `any_fail` check. That was true when written and is about to stop being true: phase-rs#7029 adds exactly that check. Both comments would then assert, in the tree, a property the tree no longer has — and because they are comments, nothing would fail. They would simply be wrong. The conclusion they support is unaffected, so only the justification changes. `Fail → Fail` stays reachable no matter what guards the write path, because nothing revalidates a committed baseline when it is loaded: a baseline blessed before the guard, or hand-edited, still carries the failure. That reason is true before and after phase-rs#7029, which is the property a durable comment needs. Found by sweeping this branch's own claims against a branch in flight, rather than against the current tree — an instrument that came from review catching the same shape one layer down, where a doc comment in phase-rs#7029 asserted the paired arm "never consults `status`", true at that head and false here. Four surfaces carried the claim: both comments, the commit message of the first commit on this branch, and the PR body. The commit message is deliberately left alone — this repo squash-merges, so branch messages do not survive to the tree, and a force-push to reword a doomed message would churn review for no durable effect. The PR body is corrected in place, and the source comments here. Assisted-by: ClaudeCode:claude-opus-5
matthewevans
left a comment
There was a problem hiding this comment.
Request changes — the refresh transaction must reserve a collision-safe staging target before run_suite writes.
🔴 Blocker
crates/phase-ai/src/bin/ai_gate.rs:96-99 deterministically selects <baseline>.staging.json and passes it to run_suite at :139-145, without first rejecting or replacing an existing staging entry. crates/phase-ai/src/duel_suite/run.rs:954-959 then calls File::create on that path before the refresh guards at ai_gate.rs:147-202. A stale staging symlink or hard link to the trusted baseline therefore makes File::create truncate the baseline before the later refusal; the existing same_file check at ai_gate.rs:62 only compares --baseline with --current-output, not the internally-derived staging path. The current CLI tests cover aliases supplied as --current-output, but no test pre-creates <baseline>.staging.json as a symlink or hard link and asserts baseline-byte preservation.
The earlier alias and malformed-baseline fixes are present at this head. This remaining collision is independent: reading the old baseline first preserves comparison semantics, but cannot restore bytes truncated through the staging alias.
Recommendation: request changes; create/reserve a unique, collision-safe staging file in the baseline directory (or explicitly reject an existing staging alias before any suite write), and add a real CLI file-state regression for a pre-existing staging symlink/hard-link.
43fdc66 to
dabb106
Compare
|
🤖 AI text below 🤖 Confirmed, reproduced, and fixed at Reproduced before changing anythingWith a symlink pre-placed at The fix reserves rather than checks
I took "explicitly reject" over "unique name" from your two options, and the reason is your own earlier finding: a leftover staging file is an artefact someone later mistakes for a real baseline, so unique names would trade one hazard for an accumulating one. Refusing names the file and says what would have happened had it been followed. The cost is that an interrupted run must be cleaned up by hand once — acceptable for a local-only human command, and loud rather than silent. Reserving created an obligation the old code did not have, and it is worth flagging because it is the kind of thing that turns one failure into two: every exit after the reservation must release it, or the refusal leaves behind a file that blocks the next refresh. The baseline-load and suite-run exits now release it, joining the refusal closure and the rename-failure path that already did. The gameless test asserts the consequence end-to-end rather than inferring it — after a refused run, the next refresh must still succeed. EvidenceFour mutants, tree restored byte-identical after each and verified with
The regression covers both alias kinds, because they fail differently — 10 CLI tests green, 2026 lib tests green, clippy One standing disclosure, unchangedThe Three routes to this data loss have now been found by review and none by me: |
|
🤖 AI text below 🤖 On the red This PR's gate reports Independently, the only completed main-line perf measurement in the window — the scheduled run's drift monitor at This PR's diff is a baseline-write transaction and its tests; it runs before and after the suite, never inside game simulation, which is where these counters are recorded. I am not proposing to fix it here — an earlier bisect of mine attributed the bulk to two merged commits ( |
|
Maintainer hold on All earlier shard checks are green, but the replacement Next step: resolve/rebaseline the mainline perf regression and let the current Rust job finish; then I will re-check this exact head, clear the stale review state with a fresh approval, verify labels, and enqueue if the full checklist remains clean. |
|
Hold update for |
|
🤖 AI text below 🤖 This PR's |
`--refresh-baseline` ran the suite and wrote the result to the baseline path without ever looking at the result's own verdicts. Because the baseline is what every later run is compared against, one refresh from a red run blesses that failure permanently: the next run compares equal to the blessed report, the comparison reports no drift, and the gate exits 0 forever while the matchup is still broken. Nothing in the file inspected `SuiteStatus` on the refresh path. This is the other half of the policy phase-rs#7026 left open. That PR made an existing matchup going non-Fail -> Fail fail the comparison, and reported a still-failing matchup on every run rather than passing quietly — but it deliberately Warned rather than Failed on the already-blessed case, on the grounds that the exit code answers "did this change make things worse" and a baseline, however it got that way, already sanctions its own contents. The question that PR filed rather than smuggled in is whether a baseline may bless a failure at all. It may not. `SuiteReport::failing_matchups` reports the matchups that failed their own `Expected` check, judged with no reference to any baseline. That is a different question from `CompareReport::any_fail`, which asks whether a change made things worse than the baseline; this asks whether a run is fit to *become* the baseline. It returns the matchups rather than a bool because the refusal is only actionable if it can name which matchup failed and quote its `fail_reason` — the call site would otherwise have to re-filter to say anything useful. Only `SuiteStatus::Fail` disqualifies a run. `Open` does not, and the distinction is load-bearing rather than incidental: `Expected::Open` is how a matchup declares it has no verdict yet, and that declaration belongs in the suite definition where it is visible and reviewable, not smuggled in by committing a red baseline. An implementation keyed on `!= Pass` would conflate the two and make `Expected::Open` unusable, which is why there is a test whose only job is to fail against it. `Open` in fact has two producers — `grep`ed to confirm exactly two construction sites outside tests — and review caught the doc comment claiming one: `classify` returns `Open` for any matchup with zero games before it ever inspects `Expected`. That exposed a second way to write an unfit baseline, from the opposite side — a run that measured nothing. Comparison pairs by seed, so a gameless baseline scores zero on every axis forever and the drift signal dies as quietly as a blessed-red one. `SuiteReport::recorded_games` disqualifies it, and deliberately counts games rather than testing for all-`Open`: a suite whose matchups are all declared `Expected::Open` still plays real games, and that report IS a usable baseline, because the paired-comparison arm decides on `games`, not on `status` — stated that way rather than "never reads `status`", which is true here but false on phase-rs#7026, cited two paragraphs above, where the paired arm gains status tiers. Zero games is what makes a baseline inert; all-`Open` is not. The routes are enumerated without claiming the enumeration is complete, because an earlier draft said "two routes" and review found a third. `--games 0` is rejected at parse time, since the existing error string already promised a positive integer and `usize` alone does not. A `--suite-filter` selecting no matchups is caught on the report, because `run_suite` does not reject an empty selection. `failed_result` yields an empty `games` vector alongside `SuiteStatus::Fail`, which the failure guard reports first because it names the actual setup error. And `SuiteOptions::new` does not validate `games_per_matchup`, so a library caller can build a zero-game run without touching the CLI at all. No override flag. The escape hatch already exists one layer up and is the correct layer, so adding a second one at the baseline would only let a caller bypass the more visible mechanism. Verified non-bricking before choosing absolute refusal: the committed baseline is 3/3 `Pass`, so no workflow depends on a blessed failure. CI blast radius is nil, measured rather than assumed: `refresh-baseline` appears in no workflow. CI runs `cargo ai-gate --games 10` and `--full-suite --games 100`, both compare-only. Refresh is a local human operation, so this cannot break a pipeline — it can only stop a person committing a blessed-red baseline. The failing case is pinned to the run that motivated it rather than to invented data: `the_recorded_failing_run_is_disqualified_as_a_baseline` transcribes the recorded gate run (`.ab/noC-1.json`, the A+B+D leg of phase-rs#6969) — red-mirror and affinity-mirror `Pass`, enchantress-mirror `Fail` carrying its verbatim reason `mirror imbalance: p0=0.10, Wilson 95% CI [0.02, 0.40] excludes 0.50`. That is the exact report a refresh would have blessed. It is transcribed rather than loaded because the artifact is untracked and a test that read it would fail in CI. Evidence. Nine mutants, tree restored byte-identical after each. Kill counts are transcribed from the runs, not summarised — an earlier draft of this paragraph asserted three of them from memory and review measured all three wrong. On `failing_matchups`: dropping the filter is killed by 5 tests; returning nothing by 1 (`the_recorded_failing_run_is_disqualified_as_a_baseline`); and the plausible `!= SuiteStatus::Pass` by 3 — every test that asserts an `Open` matchup is not a failure. That last one had been written up as killing only `an_open_matchup_is_not_a_failure` "surviving the other two entirely", which was simply false: the gameless and all-`Open` fixtures kill it too. The named test is still the one that states the intent, but it is not the only thing standing between that mutant and green, and claiming otherwise oversold a single test. On `recorded_games`, with the constant stated because it decides the answer: replacing the body with `0` is killed by 1 test, with `1` by 3, with `2` by 3. The earlier draft said "making it constant fails all three of its tests" — true only for `1`, and false for `0`, which is precisely the value the guard tests. Counting non-`Open` matchups instead of games is killed by 1, the fixture written for that conflation. Two further mutants were found SURVIVING and are the reason `an_all_open_run_that_played_games_is_still_a_usable_baseline` now carries an uneven fixture. Counting matchups-with-games, and summing `games.len().min(1)`, both returned the right answer for every fixture in the suite, because each matchup carried exactly one game — so the total always equalled the matchup count and "sum of games" was never distinguished from "number of matchups that played". Against the real committed baseline (3 matchups x 10 games) those mutants return 3 where the contract says 30. The fixture now plays two games in one matchup and one in the other, and asserts 3; both mutants die. One further mutation is reported precisely rather than counted, because review caught an earlier draft overstating it: dropping the `fail_reason` passthrough is NOT a mutation of the predicate. `failing_matchups` yields `&MatchupResult`, so no change to it can drop that field; the only reachable site is the test helper. It shows the assertion genuinely reads the field, and pins the iterator's item type against narrowing to `&str`, but it is not predicate coverage and is not counted as such. Because no unit test can reach a binary's `main`, the wiring was proven end to end, two-sided, against a temporary baseline — the committed baseline was never written. Positive control: a clean run still refreshes (red-mirror `PASS`, exit 0, baseline written at sha256 `42701dbeac46b015…`), so the guard does not false-positive. True positive: with `classify`'s mirror arm forced to Fail and the binary rebuilt, the same command printed `refusing to refresh …: 1 matchup(s) failed their own suite check` followed by the matchup and its reason, exited 1, and left that baseline at sha256 `42701dbeac46b015…` — byte-identical, the overwrite prevented. Both new refusals were then exercised through the CLI alone, with no source mutation, which is stronger evidence than the forced-Fail arm: `--games 0` is rejected at parse time without running the suite, a `--suite-filter` matching no matchups is refused with nothing written, and a real two-game run still refreshes as the positive control. The binary under test was verified to contain both refusal strings first, so no arm can pass against a stale build. The two refusals are ordered failures-first, and the order is load-bearing rather than cosmetic: the conditions are not exclusive. `failed_result` builds a matchup with an empty `games` vector AND `SuiteStatus::Fail`, so a run whose deck payloads all fail to load satisfies both, and checking gamelessness first would replace each matchup's `setup error: …` with a sentence about seeds. Nothing is lost by the chosen order, because a merely gameless run — a `--suite-filter` matching nothing — has no failing matchups to report. One surface is deliberately left uncovered and is stated rather than implied: no test executes the binary, so the refusal block itself — as opposed to the two predicates behind it — can be deleted or inverted with the whole suite green. That also means the ordering above is argued from the code path rather than pinned by a test; producing a broken deck-payload tree to exercise it end to end was judged not worth the fixture. The end-to-end runs above were performed against this tree but are not committed as tests. A process-spawning integration test would have to run a real suite to reach the guard, which is minutes of CI for a local-only human command, so the trade is made knowingly rather than overlooked. Assisted-by: ClaudeCode:claude-opus-5
…gainst Review found the guard this branch adds could fire after the damage it exists to prevent. `run_suite` writes its report to `options.output_path` before any guard runs, and `--baseline` and `--current-output` are independent flags, so pointing them at the same file truncated the baseline and only then printed "refusing to refresh". Reproduced on the built binary at the reviewed head: baseline in at 116 bytes, sha256 985133e3…; out at 250 bytes, sha256 bc9649fd…; exit 1. A safeguard whose subject is already destroyed is not a safeguard. Verifying it surfaced a second instance on the compare path, and that one is worse because it is silent. There the same aliasing makes `load_report(&args.baseline)` read back the report the suite has just written over it, so the gate compares the run to ITSELF and reports no drift. Measured against a baseline recording p0 at 100% and a run that scored 0%: `| red-mirror | … | 0% | 0% | 0 | 0 | — | PASS |`, exit 0. An honest comparison of those two inputs is a win-rate collapse. This is the false-green class the whole branch exists to close, reached by destroying the evidence rather than by misreading it. Three layers, each doing a job the others cannot. The root cause on the compare path is WHEN the baseline is read, not which paths were passed, so the baseline is now loaded before the suite runs and held in memory. That is immune to aliasing by construction — including aliases no path comparison can see. A hard link resolves to a different name and the same inode, so canonicalising calls the two paths distinct while a write to either truncates both; ordering does not care how the alias was built. Measured through a hard link, which defeats the argument check below: `| red-mirror | … | 100% | 0% | 1 | 0 | 0.2500 | WARN |`. The pre-fix binary called the same inputs `0% | 0%`, PASS. It also fails a missing or corrupt baseline in a second rather than after a full suite run. The refresh path is made transactional. The suite writes to `<baseline>.staging.json` and the baseline is replaced by renaming that file only once every guard has passed; every refusal removes it. The staging file is a SIBLING because `rename` is atomic only within one filesystem — a staging path in `/tmp` could land on another mount and silently degrade to copy-then-truncate, reintroducing the half-written baseline it exists to prevent. `write_report` is deleted rather than left beside this: it was the last writer that could produce a baseline outside the transaction. `--current-output` is unused on the refresh path now, since the refreshed baseline IS the run's report; no workflow passes `--refresh-baseline`, so nothing in CI depends on the old behaviour. Aliased paths are still refused outright, before the card database and before a single game. With the two layers above, correctness no longer depends on it — but aliasing still DESTROYS the user's baseline file as a side effect of a run that had no business writing there, and losing the file quietly is its own defect. Evidence, and one piece of it is a test that had to be fixed before it was worth having. The first version of `tests/refresh_baseline_cli.rs` asserted exactly what review asked for — non-zero exit and unchanged baseline bytes — and mutation showed it was vacuous: deleting the alias check killed 0 tests, and forcing `same_file` to return false killed 0. Both passed because without a card database the binary exits non-zero at the database load with the baseline equally untouched, so the pair was satisfied for a reason unrelated to the hazard. Adding an assertion that the refusal actually names the aliasing makes both mutants fail two tests each; a third mutant, `same_file` always true, is caught only by the control arm that proves distinct paths still run. The module comment states the limitation rather than hiding it. Not covered: the ordering and staging fixes are argued from the code path and measured on the built binary, not pinned by an automated test, because reaching them needs a card database and a real suite run. The five runs are recorded in the PR. Assisted-by: ClaudeCode:claude-opus-5
… runs Review found two ways the baseline could still be destroyed by a run that had no right to replace it. Both are closed here, and both get CLI coverage that executes the real binary. A hard link is one inode under two names. `canonicalize` faithfully preserves both names, so the previous path-string check called them distinct, let the run proceed, and `write_report` truncated the shared inode with `File::create`. Compare mode is the quiet half: it then read back the report the run had just written over the baseline and reported no drift. `same_file` now asks the filesystem for identity — `(dev, ino)` when both paths exist — and keeps the canonicalized-parent fallback for the usual case where the output does not exist yet. Identity subsumes the path comparison where it applies; the fallback still resolves symlinks and `..` where it does not. The second is narrower and worse. The refresh path caught EVERY `load_report` error, logged it, and continued — so a baseline that was corrupt, truncated or unreadable was overwritten by the staged report rather than kept for diagnosis, and the new reference was established from a prior state nobody had examined. "I could not read it" is not evidence that it was worthless. Only `ErrorKind::NotFound` now proceeds, which is the genuine first-refresh case. Matched on the error kind rather than `!baseline.exists()`: the old form asked the filesystem a second, later question and answered the wrong one under a permission error, where the file exists but `exists()` reports false. The read-before-run ordering is kept and its comment rewritten, because the comment had become false — it claimed `same_file` cannot see hard links, which this commit is precisely the end of. It stays as defence in depth on an honest basis: `same_file` enumerates the ways two names can mean one file, and any such enumeration is a claim a future filesystem can falsify. Reading first makes the comparison independent of whatever the check missed. It cannot save the bytes — only refusing does that. Two fixtures had to be rebuilt rather than extended. `a_gameless_refresh_...` and `an_accepted_refresh_...` used an invalid sentinel as the baseline, which the malformed-baseline fix now refuses BEFORE either test reaches its subject — while still satisfying a naive "bytes unchanged" assertion, so they would have passed for the wrong reason. They now record a real baseline first, which costs about two milliseconds because an empty card database still yields one degenerate playable matchup. Evidence. Five mutants, tree restored byte-identical after each and verified with `diff -q`. | mutant | killed | |---|---| | drop the inode check (pre-fix behaviour) | 1 (`a_hard_linked_output_is_refused_and_the_baseline_survives`) | | refresh swallows every baseline read error (the reported defect) | 1 (`a_malformed_existing_baseline_is_not_replaced_by_a_refresh`) | | refuse a missing baseline too | 3 (first-refresh, accepted-refresh, gameless) | | `same_inode` returns `Some(true)` unconditionally | 1 (`two_existing_but_different_files_are_not_aliases`) | | compare `ino` without `dev` | **0 — SURVIVES** | The survivor is reported, not hidden. Killing it needs two files on different filesystems whose inode numbers collide, and inode allocation is not controllable enough to construct that deterministically. `dev` is kept on correctness grounds, and the unpinned direction is the safe one: dropping it can only produce a false ALIAS report, whose consequence is a refused run, not a destroyed baseline. `two_existing_but_different_files_are_not_aliases` exists because no prior test had BOTH paths existing and distinct — `distinct_paths_are_not_treated_as_aliases` leaves the output nonexistent, so `same_inode` returns `None` and the path fallback answers. Without it, an implementation calling every existing pair identical would pass the whole file while refusing every real gate invocation. `a_missing_baseline_is_still_the_first_refresh_case` is the matching two-sided control: without it, narrowing to `NotFound` is indistinguishable from refusing every unreadable baseline, which would make the first refresh on a fresh checkout impossible. Measured at this tree: 9 CLI tests green in 0.05s, 2026 lib tests green, `clippy -p phase-ai --all-targets -D warnings` clean. Assisted-by: ClaudeCode:claude-opus-5
Third route to the same data loss, and the only one no argument check could have caught: the staging path is derived internally, so `same_file` never sees it — that check compares `--baseline` against `--current-output`, and the staging file is neither. `staging_path` is deterministic (`<baseline>.staging.json`) and `write_report` opens it with `File::create`, which FOLLOWS a symlink to its target and SHARES a hard link's inode. An entry already sitting at that path is therefore written straight through, truncating whatever it points at, before any refresh guard runs. Reproduced on the binary before the fix: with a symlink pre-placed at the staging path, a refresh that should have been accepted reported **exit 0** and the baseline's bytes were gone. The fix reserves the path with `create_new` rather than checking it. That is deliberate and is the difference between a fix and a smaller window: testing first and opening second leaves a gap between the two, which is the same class of bug one layer down. `O_CREAT|O_EXCL` rejects ANY existing entry — regular file, hard link, live or dangling symlink — in one atomic step, and what the suite subsequently truncates is a regular file this process just created with a link count of one. Refusing rather than reusing is the point. The path is derived, so anything already there was not put there by this run, and a leftover from a killed run is precisely the artefact the refusal paths delete. The message names the file and says what would have happened had it been followed. Reserving creates an obligation the previous code did not have: every exit after the reservation must release it, or one diagnosable failure becomes two — the refusal, and then a next refresh blocked by the file the refusal left. The baseline-load and suite-run exits now call `release_staging`, joining the refusal closure and the rename-failure path that already did. The gameless test asserts the consequence end-to-end rather than inferring it: after a refused run, the NEXT refresh must still succeed. Evidence. Four mutants, tree restored byte-identical after each and verified with `diff -q`. Each is killed by a different test, which is what distinguishes four guards from one guard tested four times. | mutant | killed by | |---|---| | drop the reservation entirely (pre-fix) | `a_pre_existing_staging_alias_cannot_truncate_the_baseline` | | `create(true)` instead of `create_new(true)` | same test — the reservation must be exclusive, not merely a write | | drop `release_staging` on the malformed-baseline exit | `a_malformed_existing_baseline_is_not_replaced_by_a_refresh` | | refusal path stops deleting its staging file | `a_gameless_refresh_is_refused_by_the_block_and_leaves_the_baseline_untouched` | The regression covers both alias kinds because they fail differently — `File::create` follows a symlink to its target, while a hard link IS the target — so a reservation closing only one would leave the other live. Its premise asserts the alias actually resolves to the baseline, so an inert fixture cannot pass it silently. Measured at this tree: 10 CLI tests green, 2026 lib tests green, `clippy -p phase-ai --all-targets -D warnings` clean, `cargo fmt --check` clean. Assisted-by: ClaudeCode:claude-opus-5
dabb106 to
b71e0a3
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
Two comments justified the `Fail → Fail` Warn arm by saying `--refresh-baseline` writes the current report verbatim with no `any_fail` check. That was true when written and is about to stop being true: phase-rs#7029 adds exactly that check. Both comments would then assert, in the tree, a property the tree no longer has — and because they are comments, nothing would fail. They would simply be wrong. The conclusion they support is unaffected, so only the justification changes. `Fail → Fail` stays reachable no matter what guards the write path, because nothing revalidates a committed baseline when it is loaded: a baseline blessed before the guard, or hand-edited, still carries the failure. That reason is true before and after phase-rs#7029, which is the property a durable comment needs. Found by sweeping this branch's own claims against a branch in flight, rather than against the current tree — an instrument that came from review catching the same shape one layer down, where a doc comment in phase-rs#7029 asserted the paired arm "never consults `status`", true at that head and false here. Four surfaces carried the claim: both comments, the commit message of the first commit on this branch, and the PR body. The commit message is deliberately left alone — this repo squash-merges, so branch messages do not survive to the tree, and a force-push to reword a doomed message would churn review for no durable effect. The PR body is corrected in place, and the source comments here. Assisted-by: ClaudeCode:claude-opus-5
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] The staging-path reservation is released before the destructive open. Evidence: crates/phase-ai/src/bin/ai_gate.rs:119-136 calls OpenOptions::create_new(true).open(path) and immediately drops that File; :138-140 passes only the pathname to the suite, and crates/phase-ai/src/duel_suite/run.rs:954-960 later reopens it with File::create(path). An attacker or concurrent process can unlink and replace the staging name with a symlink to the baseline after the reservation closes but before write_report, causing the later open to follow it and truncate the baseline. Why it matters: the PR claims the no-clobber reservation eliminates staging aliases, but this TOCTOU window preserves a baseline-destruction path. Suggested fix: make the no-clobber-created file (or a secure named-tempfile object) the actual report sink and keep its handle through report serialization/promotion; do not reopen the pathname. Add a deterministic replacement-between-reserve-and-write test.
🤖 AI text below 🤖
Summary
cargo ai-gate --refresh-baselineran the suite and wrote the result to the baseline path without ever inspecting the result's own verdicts. Because the baseline is what every later run is compared against, one refresh from a red run blessed that failure permanently: the next run compared equal to the blessed report, the comparison reported no drift, and the gate exited 0 forever while the matchup stayed broken.This is the other half of the policy #7026 left open. That PR made an existing matchup going non-Fail → Fail fail the comparison, and reported a still-failing matchup on every run rather than passing quietly — but it deliberately Warned rather than Failed on the already-blessed case, on the grounds that the exit code answers "did this change make things worse" and a baseline, however it got that way, already sanctions its own contents. The question it filed rather than smuggled in is whether a baseline may bless a failure at all. It may not.
Files changed
crates/phase-ai/src/duel_suite/run.rs—SuiteReport::failing_matchups()andSuiteReport::recorded_games(), plus testscrates/phase-ai/src/bin/ai_gate.rs— refuse the refresh on a failing or gameless run; reject--games 0at parse time; load the baseline before the suite runs, stage-and-rename the refreshed baseline, and refuse aliased--baseline/--current-output(review round 2)crates/phase-ai/tests/refresh_baseline_cli.rs— process/file-state regression against the real binaryTrack
Developer
LLM
Model: claude-opus-5
Tier: Frontier
Effort: high
Implementation method (required)
Method: not-applicable — this is gate/tooling plumbing in
crates/phase-ai, with no engine game logic, no Oracle parsing, and no card behaviour.CR references
None. No MTG rules logic is touched.
Verification
What disqualifies a run, and why only these things
SuiteReport::failing_matchupsreports matchups that failed their ownExpectedcheck, judged with no reference to any baseline. That is a different question fromCompareReport::any_fail, which asks whether a change made things worse than the baseline; this asks whether a run is fit to become the baseline. It returns the matchups rather than a bool because the refusal is only actionable if it can name which matchup failed and quote itsfail_reason.Only
SuiteStatus::Faildisqualifies on that axis.Opendoes not, and the distinction is load-bearing:Expected::Openis how a matchup declares it has no verdict yet, and that declaration belongs in the suite definition where it is visible and reviewable, not smuggled in by committing a red baseline. An implementation keyed on!= Passwould conflate the two and makeExpected::Openunusable — there is a test whose only job is to fail against that.SuiteReport::recorded_gamesdisqualifies a second, independent case that review surfaced: a run that measured nothing. Comparison pairs by seed, so a baseline holding no games makes every later comparison score zero on every axis and the drift signal dies as quietly as a blessed-red one. It counts games rather than testing for all-Opendeliberately — a suite whose matchups are all declaredExpected::Openstill plays real games, and that report is a usable baseline, because the paired-comparison arm decides ongames, not onstatus. (Stated that way rather than "never readsstatus": the paired arm gains status tiers in #7026, so the stronger wording would have been false the day that merged. Review caught the stronger version — see the in-flight-branch note below.) Zero games is what makes a baseline inert; all-Openis not.No override flag
The escape hatch already exists one layer up and is the correct layer, so a second one at the baseline would only let a caller bypass the more visible mechanism. Verified non-bricking before choosing absolute refusal: the committed baseline is 3/3
Pass, so no workflow depends on a blessed failure.CI blast radius: none, measured
refresh-baselineappears in no workflow. CI runscargo ai-gate --games 10and--full-suite --games 100, both compare-only. Refresh is a local human operation, so this cannot break a pipeline — it can only stop a person committing a blessed-red baseline.The failing case is pinned to the run that caused it
the_recorded_failing_run_is_disqualified_as_a_baselinetranscribes the recorded gate run that motivated this guard: red-mirror and affinity-mirrorPass, enchantress-mirrorFailcarrying its verbatim reasonmirror imbalance: p0=0.10, Wilson 95% CI [0.02, 0.40] excludes 0.50. That is the exact report a refresh would have blessed. It is transcribed rather than loaded because the artifact is untracked and a test that read it would fail in CI.Mutant evidence
Nine mutants, tree restored byte-identical after each. Kill counts below are transcribed from the runs. An earlier draft of this section asserted three of them from memory and review measured all three wrong, so they are given as figures rather than as characterisations.
the_recorded_failing_run_is_disqualified_as_a_baseline)!= SuiteStatus::PassOpenmatchup is not a failure)recorded_gamesbody →0recorded_gamesbody →1recorded_gamesbody →2Openmatchups instead of gamesgames.len().min(1)The last two are there because they survived an earlier version of this change. Every fixture carried exactly one game per matchup, so the total always equalled the matchup count and "sum of games" was never distinguished from "number of matchups that played" — against the real baseline (3 matchups × 10 games) those mutants return 3 where the contract says 30. The assertion looked like a magnitude pin, but its expected value was simultaneously the matchup count, and a fixture whose two interpretations coincide pins neither. The fixture is now uneven (two games in one matchup, one in the other) and asserts
3, which review confirmed is distinct from every other countable that implementation could plausibly return — it probed seven, includinggames_per_matchup × len, wins-sum, distinct seeds, and max-per-matchup.The constants are listed individually because the choice decides the answer: an earlier draft said "making it constant fails all three tests", which is true only for
1and false for0— the value the guard actually tests.One further mutation is reported rather than counted: dropping the
fail_reasonpassthrough is not a mutation of the predicate.failing_matchupsyields&MatchupResult, so no change to it can drop that field; the only reachable site is the test helper. It shows the assertion genuinely reads the field and pins the iterator's item type against narrowing, but it is not predicate coverage.End-to-end, and one surface deliberately left uncovered
No unit test can reach a binary's
main, so the wiring was proven through the CLI. All three arms use the same binary with no source mutation, and the binary was verified to contain both refusal strings first so no arm could pass against a stale build:--games 0is rejected at parse time without running the suite; a--suite-filtermatching no matchups is refused with nothing written; and a real two-game run still refreshes, recording 2 games, as the positive control that shows the guard is not simply refusing everything.The two refusals are ordered failures-first, and the order is load-bearing rather than cosmetic: the conditions are not exclusive.
failed_resultbuilds a matchup with an emptygamesvector andSuiteStatus::Fail, so a run whose deck payloads all fail to load satisfies both — and checking gamelessness first would replace each matchup'ssetup error: …with a sentence about seeds. Nothing is lost by the chosen order, because a merely gameless run has no failing matchups to report. Review verified the full 2×2 of the two conditions and found no input where the old order was better and no exit code changed.This surface is no longer uncovered — see "The execution pin, closed" below. It was disclosed here on the premise that reaching the refusal block needs a card database and a real suite run, which is minutes of CI for a local-only command. That premise was false, and cheaply so, which is why the paragraph is corrected rather than left standing with an apology attached.
A note on durable claims, which review taught me here
Round 2 caught a claim that was true in this tree and false in a branch I am driving: "seed-paired drift detection never consults
status" holds at this head but not on #7026, where the paired arm gains status tiers — so it would have gone false the day that PR merged, and #7026 is cited two paragraphs above it. The wording is now durable under both. The instrument came from the reviewer, and applying it to my own open PR immediately found three more instances there, which are fixed separately on that branch.Review response (2026-08-05 12:12Z) — blocker confirmed, and it had a quieter twin
HIGH — the refusal fired after the damage. Reproduced before changing anything.
run_suitewrites its report tooptions.output_pathbefore any guard runs, and--baseline/--current-outputare independent flags, so aliasing them let a rejected run truncate the baseline first. On the built binary at the reviewed head:985133e3…--refresh-baseline --baseline X --current-output X --suite-filter no-suchbc9649fd…It printed "refusing to refresh" over a baseline it had already destroyed.
The same aliasing on the compare path is worse, because it is silent.
load_report(&args.baseline)read back the report the suite had just written over it, so the gate compared the run to itself. Measured with a baseline recording p0 at 100% against a run that scored 0%:An honest comparison of those inputs is a win-rate collapse. That is this branch's own subject — a false green — reached by destroying the evidence rather than by misreading it. Found while verifying your blocker, fixed here.
Three layers, each doing something the others cannot
1. Root cause on the compare path: read the baseline before the suite runs. The defect is when the baseline is read, not which paths were passed, so ordering is the fix, and it is immune to aliasing by construction — including aliases no path comparison can detect. Proven with a hard link, which defeats layer 3 outright (same inode, different name, so canonicalising calls them distinct while a write to either truncates both):
The pre-fix binary called those same inputs
0% | 0%, PASS. This is the measurement that shows ordering, not the argument check, is carrying the correctness.2. The refresh is transactional. The suite writes to
<baseline>.staging.json; the baseline is replaced by renaming it only after every guard passes, and each refusal removes it. Sibling, not/tmp, becauserenameis atomic only within a filesystem — a cross-mount staging path degrades silently to copy-then-truncate, reintroducing the half-written baseline it exists to prevent.write_reportis deleted: it was the last writer that could produce a baseline outside the transaction.--current-outputis consequently unused on the refresh path (the refreshed baseline is the run's report); no workflow passes--refresh-baseline, so nothing in CI depends on that.3. Aliased paths are refused outright, before the card database and before a game. Correctness no longer depends on it, but aliasing still destroys the user's file as a side effect of a write that had no business landing there.
Measured after the fix
100% → 0%, 1 flip, WARNThe CLI test you asked for, and the version of it I had to throw away
You asked for a process/file-state regression asserting non-zero exit and unchanged baseline bytes. I wrote exactly that, then mutated it: deleting the alias check killed 0 tests, and forcing
same_fileto returnfalsekilled 0. Both passed because without a card database the binary exits non-zero at the database load with the baseline equally untouched — the pair was satisfied for a reason unrelated to the hazard.Adding an assertion that the refusal names the aliasing makes both mutants fail two tests each. A third mutant —
same_filealwaystrue— is caught only by the control arm asserting distinct paths still proceed (and its premise checks that they reach the card database, so "not rejected as aliases" is not vacuous either). The module comment records this rather than hiding it; the byte-preservation claim under a real run rests on the table above, not on the test.tests/refresh_baseline_cli.rsruns the real binary and costs ~0ms. (Written before the execution pin closed; the aliasing tests still never reach the card database, while the cases that must run the suite supply their own{}database. Nothing loads the ~90 MB export — and that gate is scoped tocrates/enginein any case, so it never applied here.) Two unit tests pin the properties the layers rest on: the staging file is a sibling of the baseline (what makesrenameatomic), andsame_filesees through a symlink while still calling genuinely distinct paths distinct.This paragraph previously read "Not covered: the ordering and staging fixes are argued from the code path and measured on the built binary, not pinned by an automated test — reaching them requires a card database and a real suite run." Three of its four clauses are now false. See below.
The execution pin, closed
CardDatabase::from_exportdeserialises a map from card name to entry, so the literal{}is a valid empty card database. A--suite-filtermatching nothing then selects no matchups, builds no decks and plays no games, so the run reachesrecorded_games() == 0and refuses — in about five milliseconds, with no card data on disk:A second fact fell out of a surviving mutant: an empty database still yields one degenerate but playable matchup, ~2ms per game, so the accept path is reachable at the same cost. That is what made both directions of the file transaction testable, and it is why "never promote at all" no longer survives.
The exit code is asserted as exactly 1, not merely non-zero, and that is what makes the test see the refusal block rather than an early death: argument and database failures exit 2, so any mutant stopping the binary before the suite runs turns 1 into 2 and fails.
What remains genuinely unpinned is narrow and stated: a successful refresh against real decks. The transaction is pinned; the card content flowing through it is not.
Review response (2026-08-05 14:07Z) — both HIGHs confirmed and fixed
Both findings are real, both are fixed at
43fdc6683373, and one of them found the fix I had designed for myself was the weaker of the two available.HIGH-1 — a malformed existing baseline was still eligible for replacement. Confirmed. The refresh arm caught every
load_reporterror, logged it, and continued torun_suite, which renamed the staged report over the file. So a baseline that was corrupt, truncated, or unreadable was destroyed rather than kept for diagnosis, and the new reference was established from a prior state nobody had examined. Fixed as you specified: onlyErrorKind::NotFoundproceeds. One deviation from the letter of the suggestion, in your favour — matching on the error kind rather than!baseline.exists(), because the latter asks the filesystem a second, later question and answers the wrong one under a permission error, where the file exists butexists()reports false.HIGH-2 —
same_filemissed hard-link aliases. Confirmed, and your fix is better than mine. I had independently designed a test for exactly this case, but as a pin on the ordering — the baseline is read before the suite writes, so the verdict survives even when the bytes do not. Your framing names why that is insufficient in one clause: "retaining the preloaded report in memory avoids a false comparison but not data loss." The ordering saves the verdict; only refusing saves the file.same_filenow compares(dev, ino)when both paths exist and keeps the canonicalized-parent fallback for the usual case where the output does not exist yet.That change falsified a comment two lines below it, which claimed
same_filecannot see hard links. Rewritten rather than deleted: the ordering stays as defence in depth on an honest basis —same_fileenumerates the ways two names can mean one file, and any such enumeration is a claim a future filesystem can falsify.Fixture damage the HIGH-1 fix caused, and why it mattered
Two existing tests used an invalid sentinel as the baseline. The malformed-baseline fix now refuses that before either test reaches its subject — while still satisfying their "bytes unchanged" assertion, so both would have gone on passing for entirely the wrong reason. They now record a real baseline first (~2ms). This is the same failure mode as the one your round-2 finding taught me: an assertion pair that is satisfied because the run died early, not because the guard held.
Mutant evidence
Five mutants, tree restored byte-identical after each and verified with
diff -q.a_hard_linked_output_is_refused_and_the_baseline_survives)a_malformed_existing_baseline_is_not_replaced_by_a_refresh)same_inodereturnsSome(true)unconditionallytwo_existing_but_different_files_are_not_aliases)inowithoutdevThe survivor is reported, not hidden. Killing it requires two files on different filesystems whose inode numbers collide, and inode allocation is not controllable enough to construct that deterministically in a fixture.
devis kept on correctness grounds, and the unpinned direction is the safe one: dropping it can only produce a false alias report, whose consequence is a refused run, never a destroyed baseline. If you want it pinned anyway I will take a suggestion on how — I could not find a construction that was not flaky.Two of the new tests exist as two-sided controls rather than as coverage.
two_existing_but_different_files_are_not_aliasesis the only case that reaches the inode comparison expectingSome(false)— the pre-existing distinct-paths test leaves the output nonexistent, sosame_inodereturnsNoneand the path fallback answers, meaning an implementation calling every existing pair identical would have passed the entire file while refusing every real gate invocation.a_missing_baseline_is_still_the_first_refresh_caseis the matching control on the other fix: without it, narrowing toNotFoundis indistinguishable from refusing every unreadable baseline, which would make the first refresh on a fresh checkout impossible.Measured at this head: 9 CLI tests green in 0.08s, 2026 lib tests green,
clippy -p phase-ai --all-targets -D warningsclean,cargo fmt --checkclean.CodeRabbit's two inline comments are the same two findings and are addressed by the same commit.
Gate A
Gate A PASS head=b71e0a3db506fb38d1a7fe9e217f7e20dbb4af4b base=c44a4512e6f91684068c259a028eb44b4d801340
Base passed explicitly (
./scripts/check-parser-combinators.sh c44a4512e6f91684068c259a028eb44b4d801340), so the examined range isexactly this diff: 4 commits, 3 files, non-empty. It contains zero parser paths, so the
parser-specific verdict is vacuous by construction — labelled rather than presented as a pass.
Gate G also PASS at this head.
Anchored on
Both line numbers re-verified at this head: compare.rs:57 is
pub fn any_fail(&self) -> bool {, andai_perf_gate.rs:239 is
if args.refresh_baseline {.Final review-impl
Final review-impl PASS head=b71e0a3db506fb38d1a7fe9e217f7e20dbb4af4b
Certified at
cert-r3 PASS head=ba39f354fb200a8d96a9146e2d70443ed55083f0, and carried to this headby a content-preserving rebase:
git diffover the pre-rebase range and overc44a4512e6f91684068c259a028eb44b4d801340..b71e0a3db506fb38d1a7fe9e217f7e20dbb4af4bare byte-identical (1237 lines, md5750675149582), so only the base moved. Themaintainer's hold comment records the prior requested-changes findings as fixed at the pre-rebase
head; that assessment transfers unchanged.
Three rounds. Round 1 returned PASS with three findings. Round 2 returned FAIL, and the classification is worth stating plainly because the code was not the problem: the reviewer's words were "the shipped code is correct" — both holes closed, design premise verified against the real comparison code, parse change behaviour-preserving. The FAIL was on my evidence. Three mutant kill counts in the commit message were asserted from memory and all three were measurably wrong (drop-filter said 3, is 5;
!= Passsaid 1 "surviving the other two entirely", is 3; "constant fails all three" is true only for the constant1and false for0, the value the guard actually tests). Two mutants were also found surviving:recorded_gameswas pinned only at zero/non-zero, because every fixture had one game per matchup so the total always coincided with the matchup count — anassert_eq!(..., 2)that looked like a magnitude pin when 2 was also the matchup count. A fixture whose two interpretations coincide pins neither.Round 3 certified the fixes: both former survivors now die, and all nine corrected kill counts matched the reviewer's independent runs exactly. It also probed seven further contracts the fixture might have merely relocated the coincidence into (
games_per_matchup × len, wins-sum, distinct seeds, max-per-matchup, first-matchup-only, and two saturating caps) and confirmed the assertion3is now distinct from every one of them.Post-certificate delta: two comment-only edits — the mutant total in the commit message (the enumeration sums to nine, the header said eight) and one added doc clause.
git diff ba39f354f HEAD -- crates/contains zero changed source lines outside doc comments, so compiled behaviour is identical and the certificate's mutant results carry unchanged.That disclosure — no test executes the binary, so the refusal block itself can be deleted or inverted with the suite green — is closed, not carried forward.
tests/refresh_baseline_cli.rsdrives the real binary through nine cases. The one item now disclosed in its place is a single surviving mutant, recorded in the round-3 section below with the reason it cannot be killed deterministically and the direction in which it fails safe.Claimed parse impact
None.
Scope Expansion
One item, and it came from review rather than from me.
Round 1 found that
classifyreturnsOpenfor any matchup with zero games before it ever inspectsExpected, and that--gamesparsed asusizeaccepted 0 despite its own error string promising a positive integer — so--refresh-baseline --games 0wrote an all-Open, zero-game baseline. While closing that I found a sibling:run_suitehas no emptiness check on its matchup selection, so a--suite-filtermatching nothing also produced a baseline with no results at all.Both are the same class — a run that measured nothing — and both are closed by one predicate rather than two special cases. Shipping the guard while leaving a second route to an unfit baseline open would have made the commit's own stated guarantee false, so it is fixed here rather than filed.
Validation Failures
None.
CI Failures
None.
Summary by CodeRabbit
Bug Fixes
--gamesoption rejects zero and other invalid values before execution.Reliability