fix(ai): make the duel-suite comparator see the regressions it was blind to - #7026
fix(ai): make the duel-suite comparator see the regressions it was blind to#7026lgray wants to merge 8 commits into
Conversation
📝 WalkthroughWalkthroughThe duel-suite comparator rejects incompatible workloads, tracks expanded matchup metrics, applies additional verdict rules, and renders rectangular Markdown output. Gate commands emit refusal reports and use centralized exit-code handling. Tests cover comparison, rendering, and CLI behavior. ChangesDuel comparison analysis
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant GateCommand
participant Comparator
participant PairedSeedAnalysis
participant MarkdownOutput
GateCommand->>Comparator: load and compare reports
Comparator->>Comparator: validate workload compatibility
Comparator->>PairedSeedAnalysis: calculate matchup metrics
PairedSeedAnalysis-->>Comparator: return metrics and verdicts
Comparator->>MarkdownOutput: render comparison or refusal report
MarkdownOutput-->>GateCommand: return stdout body and exit code
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 |
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/duel_suite/compare.rs`:
- Around line 409-412: Update the paired comparison flow around
paired_seed_shift and CompareRow so baseline games whose seeds are absent from
current are counted as unpaired instead of skipped silently. Include that
counter in the verdict chain with a Warn outcome when unpaired > 0, and add the
corresponding verdict column in render_markdown; alternatively, make compare
return CompareError when the reports’ base_seed values differ.
- Around line 574-583: Escape pipe characters in the reason continuation emitted
by the row-rendering logic before interpolating reason into the markdown cell,
so free-form fail_reason text cannot add columns. Update the code around the
reason handling in the comparison table formatter; preserve the existing
styling, continuation layout, and column padding.
🪄 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: 9c2d218c-3580-492e-9ca3-521e55d5aba6
📒 Files selected for processing (1)
crates/phase-ai/src/duel_suite/compare.rs
matthewevans
left a comment
There was a problem hiding this comment.
Blocked — the comparator can still report PASS for incompatible paired-seed workloads.
🔴 Blocker
[HIGH] The paired-seed gate still accepts incomparable reports and can return PASS without comparing the full sample. Evidence: crates/phase-ai/src/duel_suite/compare.rs:127-132 rejects only schema, while :400-412 maps current games by seed and silently skips unmatched baseline seeds; SuiteReport carries the omitted workload contract in crates/phase-ai/src/duel_suite/run.rs:78-88 (base_seed, games_per_matchup). A changed seed makes the same numeric seed represent a different randomized game; fewer current games skips baseline samples, and extra current games are never traversed, so the counters/sign tests at compare.rs:253-361 can stay zero and return Pass. The analogous perf comparator rejects workload mismatches at crates/phase-ai/src/duel_suite/perf.rs:579-600. This is a false-green in the CI gate this PR is hardening. Add a typed workload-mismatch guard before row classification for at least base_seed and games_per_matchup (and every other pairing-defining configuration); test both missing-baseline and extra-current samples through compare/any_fail.
🟡 Non-blocking
[LOW] Markdown report cells are not escaped. Evidence: compare.rs:557-580 interpolates report-provided matchup/exercise/reason text into pipe-delimited rows; fail_reason is deserialized and included at :190-195, :293-298, and :344-348, while ai_duel compare accepts JSON reports at bin/ai_duel.rs:721-753. A | makes the generated diagnostics non-rectangular. Use one markdown-cell encoder for every report-provided cell and add a pipe-bearing fixture.
✅ Clean
The decisive↔draw classifications are exhaustive and reuse the existing sign-test path; CodeRabbit’s two current-head findings were independently confirmed.
Recommendation: request changes. Address workload compatibility first, then add the markdown cell encoding hardening.
f4ee39e to
f9a8a2f
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. |
|
🤖 AI text below 🤖 Both findings reproduced independently at HIGH — partial comparison could report PASSConfirmed exactly as described. Worth stating plainly: I knew about that skip. An earlier revision of this PR deliberately leaned on it, giving a rendering fixture a current-only seed so the two win-rate cells would differ "without touching a flip counter". I used the hole as a convenience and never asked what else it admitted. That fixture is re-derived here onto matched seed sets, and now asserts both unpaired counters are zero so the dependency cannot quietly return. A census of all 27 test fns confirmed it was the only fixture with asymmetric seed sets. Two defects hid behind one symptom, and they need opposite remedies. A different A different
Disclosure — the nightly, and a decision that is yours
I did not gate LOW — unescaped cellsConfirmed, and it indicted one of my own tests. That test needed fixing before it proved anything: its first measurement counted Verification at
|
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
crates/phase-ai/src/duel_suite/compare.rs (3)
541-550: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winThe decisive→decisive arms enumerate seats 0 and 1, so any other seat pair is classified as
unchanged.
(Some(0), Some(1))and(Some(1), Some(0))are the only recognized flips. Every other decisive→decisive pair falls into the(Some(_), Some(_))arm and incrementsunchanged. If a winner value other than 0 or 1 ever appears, a real winner change reports as no movement — the same blind spot this rewrite closes on the draw axis.The classification does not need to know seat numbers. Compare the two winners directly and derive the direction from the baseline seat. This handles the class rather than the two-player special case, and it removes the
Some(_)/Some(_)catch-all that the seat gap hides behind.♻️ Seat-agnostic classification
match (baseline_game.winner, current_game.winner) { - (Some(0), Some(1)) => flipped_w_to_l += 1, - (Some(1), Some(0)) => flipped_l_to_w += 1, (Some(_), None) => decisive_to_draw += 1, (None, Some(_)) => draw_to_decisive += 1, - // Same winner, or drawn on both sides. Non-0/1 seat pairs land here as they always - // have — the duel suite is two-player, and changing that classification is out of - // this change's scope. - (Some(_), Some(_)) | (None, None) => unchanged += 1, + // Winner changed seats. `w_to_l` is measured from p0's perspective: p0 held the win + // and lost it, or p0 did not hold it and gained it. Seat-agnostic, so a third seat + // cannot be swept into `unchanged`. + (Some(before), Some(after)) if before != after => { + if before == P0_SEAT { + flipped_w_to_l += 1; + } else if after == P0_SEAT { + flipped_l_to_w += 1; + } + } + // Same winner, or drawn on both sides. + (Some(_), Some(_)) | (None, None) => unchanged += 1, }If
winneris guaranteed to beSome(0),Some(1), orNoneby construction, encode that guarantee in the type instead. AOption<Seat>withSeat::{P0, P1}makes the match exhaustive over a known enum and lets the compiler reject the third-seat case at the source.🤖 Prompt for 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. In `@crates/phase-ai/src/duel_suite/compare.rs` around lines 541 - 550, Update the winner classification match in the duel comparison logic to compare decisive winners directly rather than enumerating only seats 0 and 1. For any `(Some(baseline), Some(current))` pair, increment unchanged when the seats match; otherwise derive the flip direction from the baseline winner, so non-0/1 seat changes are classified correctly and the `Some(_)`/`Some(_)` catch-all is removed.Source: Coding guidelines
97-101: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider a typed field discriminant instead of
&'static str.
WorkloadMismatch.fieldis stringly typed. A small enum (for exampleWorkloadField::{BaseSeed, Difficulty}) carries the same information, makes callers match exhaustively, and prevents a typo from creating an unmatchable error. The doc comment citesPerfCompareError::WorkloadMismatchas precedent; if that type already uses a typed discriminant, this site should follow it.♻️ Proposed typed discriminant
+#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkloadField { + BaseSeed, + Difficulty, +} + +impl WorkloadField { + const fn as_str(self) -> &'static str { + match self { + Self::BaseSeed => "base_seed", + Self::Difficulty => "difficulty", + } + } +}WorkloadMismatch { - field: &'static str, + field: WorkloadField, baseline: String, current: String, },#!/bin/bash # Check whether PerfCompareError::WorkloadMismatch uses a typed discriminant or a string field. rg -nP -C 6 '\bWorkloadMismatch\b' --type=rust🤖 Prompt for 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. In `@crates/phase-ai/src/duel_suite/compare.rs` around lines 97 - 101, Replace the &'static str field in WorkloadMismatch with a dedicated typed discriminant enum, such as WorkloadField with variants for each supported workload field. Update all constructors, pattern matches, formatting, and comparisons involving WorkloadMismatch to use the enum and ensure matches are exhaustive, following the existing PerfCompareError::WorkloadMismatch representation if it already defines an equivalent type.
331-357: 🧹 Nitpick | 🔵 TrivialTwo independent sign tests at α=0.05 raise the per-row false-Fail rate.
Each paired game contributes to exactly one axis: a win/loss flip, a draw transition, or
unchanged. The win/loss sign test and the draw sign test therefore run on disjoint samples and are independent under the null hypothesis. A row that has flips on both axes now has a family-wise false-Fail probability of about 9.75% rather than 5%. Across the three matchups in the live gate, that compounds.Most rows have a flat draw axis today, so
draw_sign_test_pisNoneand the second arm cannot fire. The rate only rises on rows that move on both axes. Consider whether the draw arm should use a tighter α, or whether the two axes should share one corrected threshold, before the gate becomes noisy.🤖 Prompt for 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. In `@crates/phase-ai/src/duel_suite/compare.rs` around lines 331 - 357, Adjust the paired regression decision logic in the comparison flow around the `paired.sign_test_p` and `paired.draw_sign_test_p` checks to control family-wise false-Fail errors across the two independent sign tests. Use a shared multiple-comparison correction or a stricter per-axis threshold, while preserving the existing directional requirements and failure messages in both `CompareStatus::Fail` branches.Source: Path instructions
🤖 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/duel_suite/compare.rs`:
- Around line 1550-1561: Remove the copied introductory documentation from the
test’s doc comment, including the claims about column values, frozen or
transposed cells, and distinct numeric axes. Keep the test-specific “Condition:
only unpaired_baseline moves” description and its explanation of the zero-valued
axes unchanged.
- Around line 735-737: Update md_cell to escape backslashes before escaping pipe
characters, preserving the required ordering so existing backslashes cannot
neutralize pipe escaping. Extend report_supplied_pipes_cannot_add_columns with
an input containing a backslash before a pipe and ensure its separator-counting
logic correctly handles escaped backslashes.
---
Nitpick comments:
In `@crates/phase-ai/src/duel_suite/compare.rs`:
- Around line 541-550: Update the winner classification match in the duel
comparison logic to compare decisive winners directly rather than enumerating
only seats 0 and 1. For any `(Some(baseline), Some(current))` pair, increment
unchanged when the seats match; otherwise derive the flip direction from the
baseline winner, so non-0/1 seat changes are classified correctly and the
`Some(_)`/`Some(_)` catch-all is removed.
- Around line 97-101: Replace the &'static str field in WorkloadMismatch with a
dedicated typed discriminant enum, such as WorkloadField with variants for each
supported workload field. Update all constructors, pattern matches, formatting,
and comparisons involving WorkloadMismatch to use the enum and ensure matches
are exhaustive, following the existing PerfCompareError::WorkloadMismatch
representation if it already defines an equivalent type.
- Around line 331-357: Adjust the paired regression decision logic in the
comparison flow around the `paired.sign_test_p` and `paired.draw_sign_test_p`
checks to control family-wise false-Fail errors across the two independent sign
tests. Use a shared multiple-comparison correction or a stricter per-axis
threshold, while preserving the existing directional requirements and failure
messages in both `CompareStatus::Fail` branches.
🪄 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: 6f254bde-8fa5-4a51-bdcb-6a3fc0b52693
📒 Files selected for processing (1)
crates/phase-ai/src/duel_suite/compare.rs
| /// **Every column carries the value it claims to.** Round 3 of review measured that the table | ||
| /// was pinned by header *label* only: freezing the `Δ avg turns` cell to a constant, and | ||
| /// swapping the `dec→draw` / `draw→dec` cells so the recorded incident would print its counters | ||
| /// backwards, both survived the entire suite. A column that renders the wrong number defeats | ||
| /// the invariant exactly as thoroughly as a missing one, since columns are the only surface | ||
| /// that survives first-match-wins reason suppression. | ||
| /// | ||
| /// The fixture gives every numeric axis a DISTINCT value (2, 3, 4, 1, and two different | ||
| /// p-values), so no pair of cells can be transposed without changing the rendered text. | ||
| /// Condition: only `unpaired_baseline` moves. Every paired seed is UNCHANGED, so every | ||
| /// other axis reads zero — before this arm existed the row scored zero on everything and | ||
| /// returned Pass while half its samples went unexamined. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
The doc comment's first paragraph describes a different test.
"Every column carries the value it claims to", the Δ avg turns freeze, the dec→draw/draw→dec transposition, and "the fixture gives every numeric axis a DISTINCT value (2, 3, 4, 1, and two different p-values)" all describe markdown_cells_carry_their_own_column_values at Line 1741. This test uses a four-game fixture in which every axis except unpaired_baseline reads zero, which is what the closing "Condition:" sentence states.
A reader who trusts the first paragraph could delete markdown_cells_carry_their_own_column_values and believe this test still covers cell-value binding. Remove the copied paragraph and keep the condition sentence.
🤖 Prompt for 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.
In `@crates/phase-ai/src/duel_suite/compare.rs` around lines 1550 - 1561, Remove
the copied introductory documentation from the test’s doc comment, including the
claims about column values, frozen or transposed cells, and distinct numeric
axes. Keep the test-specific “Condition: only unpaired_baseline moves”
description and its explanation of the zero-valued axes unchanged.
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — the workload mismatch remains a successful, unsurfaced partial comparison in the actual gate.
🔴 Blocker
[HIGH] The comparator still exits successfully for a mismatched sample workload, so its CI gate can declare no regression after comparing only a subset of the run. Evidence: crates/phase-ai/src/duel_suite/compare.rs:166-171 deliberately excludes games_per_matchup from WorkloadMismatch; :438-461 turns unmatched samples into Warn; CompareReport::any_fail at :77-80 ignores Warn; and crates/phase-ai/src/bin/ai_gate.rs:102-112 exits nonzero only on any_fail. run.rs:659-662 uses games_per_matchup as the averaging/classification denominator. The pinned test at compare.rs:1722-1737 requires this mismatch to remain non-failing. Nightly runs 100 games against the committed 10-game baseline (.github/workflows/ai-gate.yml:78-87) and publishes its report only on failure (:89-105), so the partial-comparison warning is neither gate-failing nor visible in the drift issue. Make games_per_matchup mismatch actionable (hard incompatibility with preserved diagnostics, or align nightly baseline/workload) and add end-to-end exit/report assertions.
🟡 Non-blocking
[MED] md_cell mishandles a backslash immediately before a pipe. Evidence: compare.rs:735-737 escapes only |; a \\| input becomes \\\\|, leaving a Markdown separator after backslash consumption. The test :1851-1856 lacks that fixture and :1873-1876 treats every pipe preceded by any backslash as safe. Escape backslashes before pipes and test odd/even backslash runs with parser-equivalent separator counting.
[LOW] The docstring at compare.rs:1551-1558 describes a different test than :1559-1594 exercises. Remove the copied distinct-axis description and retain the unpaired-specific explanation.
✅ Clean
The original unmatched-seed and ordinary pipe findings are addressed; current CodeRabbit backslash/stale-documentation findings were independently confirmed.
Recommendation: request changes. The workload mismatch must not remain a green, hidden partial comparison.
f9a8a2f to
3c96462
Compare
|
🤖 AI text below 🤖 Answers to the three CodeRabbit nitpicks. The two actionable comments from the same review ( Seat-agnostic classification ( Measured: the suite builds exactly two AI players — Typed discriminant for The comment's own condition is "if that type already uses a typed discriminant, this site should follow it." It does not: Family-wise error rate across the two sign tests ( Confirmed, not conceded on trust: each paired game lands on exactly one axis (a W/L flip, a draw transition, or Exposure measured rather than guessed: in the live gate run at this head ( Two further facts bound the remedy. Both Fail arms are directionally gated as well as p-gated ( Tightening α trades false-Fails for false-greens, and false-greens are the failure class this PR exists to close. Picking the threshold needs the paired-seed report the repo requires for any gate-threshold change, against a baseline refresh — neither of which is in this PR's scope. Filed rather than guessed. If you want it here instead, say so and I will do the Holm variant keyed on the number of active axes, with the report attached. |
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — this current head makes the existing nightly workload permanently incompatible with its committed baseline.
🔴 Blocker
[HIGH] The new games_per_matchup refusal correctly prevents a partial comparison, but the repository's own nightly now always supplies incompatible reports. Evidence: crates/phase-ai/src/duel_suite/compare.rs:205-211 rejects differing workloads; crates/phase-ai/baselines/suite-baseline.json records games_per_matchup: 10; and .github/workflows/ai-gate.yml:78-87 runs cargo ai-gate --full-suite --games 100 without selecting a matching baseline. gate_verdict returns exit code 2 on that error (compare.rs:833-838), so the nightly will fail and publish a refusal rather than measure drift. The one default baseline refresh path cannot make both the 10-game PR gate and 100-game nightly comparable. Suggested fix: commit a workload-specific 100-game baseline and select it in nightly, or use one workload/baseline consistently; cover both workflow invocations against their selected baseline.
🟡 Non-blocking
[MED] The new exit/report coverage stops at gate_verdict, not the command/process boundary that redirects stdout in CI. Evidence: compare.rs:1843-1853 calls gate_verdict directly, while crates/phase-ai/src/bin/ai_gate.rs:104-115 is responsible for printing and exiting. A regression in main can again split nonzero exit from report output while every added unit test remains green. Suggested fix: add a CLI/integration test that runs the binary with deliberately mismatched report inputs and asserts both nonzero status and the refusal body on stdout.
Recommendation: request changes. Reconcile the nightly and PR workloads with their baselines, then bind the actual binary's status/output contract.
3c96462 to
edc1c1e
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. |
|
🤖 AI text below 🤖 Correction to the remedy I was about to offer — a baseline refresh cannot fix this, and I should have checked before proposing it. I was going to offer to regenerate Both jobs compare against the same default baseline at different workloads:
With a symmetric That constraint is not something this PR can settle, because every way out of it edits
I'll implement either the moment you say which, including the recording run with the paired-seed report attached so the refresh is auditable rather than a blind overwrite. My weak preference is 2, because it lets the nightly keep the larger sample that makes it worth running at all — but it adds a file, and you own that trade. What this PR does in the meantime is deliberate and I am not asking you to soften it: the nightly fails, loudly, with a body naming both directions and the constraint above. It was previously green while comparing 10 of every 100 games it played. A red job with an accurate explanation is the better of the two, and it is what your review asked for. The error text carries the shared-baseline warning generically ("whichever workload is chosen, every invocation that compares against this baseline has to use that one"), so the first person to read a failing nightly does not have to rediscover this the way I did — by being told. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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_duel.rs`:
- Around line 746-756: Update both load_report error arms in the report-loading
flow near compare_reports to call render_error_markdown(&e) before returning
exit code 2, ensuring unreadable or invalid baseline/current reports produce the
required stdout artifact. Add a gate_cli test case covering a missing or
malformed report and verify the failure output is rendered to stdout.
🪄 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: b7e6055d-93f5-4986-9355-f9efc286bbbd
📒 Files selected for processing (7)
crates/phase-ai/src/bin/ai_duel.rscrates/phase-ai/src/bin/ai_gate.rscrates/phase-ai/src/bin/ai_perf_gate.rscrates/phase-ai/src/duel_suite/compare.rscrates/phase-ai/src/duel_suite/mod.rscrates/phase-ai/src/duel_suite/perf.rscrates/phase-ai/tests/gate_cli.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- crates/phase-ai/src/duel_suite/compare.rs
`--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
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — the current head makes the scheduled AI gate permanently incompatible with its committed baseline, and one stdout-refusal path remains untested and unwired.
🔴 Blockers
[HIGH] The new games_per_matchup refusal is correct for avoiding a partial comparison, but the repository's own scheduled gate now always supplies an incompatible workload. Evidence: .github/workflows/ai-gate.yml:50 runs the required quick gate with --games 10; .github/workflows/ai-gate.yml:87 runs the nightly with --full-suite --games 100; the sole committed default baseline declares games_per_matchup: 10; and compare.rs:155-185 now rejects that mismatch. Why it matters: every nightly fails before producing a drift comparison; changing the shared baseline to 100 would instead make the required 10-game PR gate fail. Suggested fix: make workload selection/baseline selection compatible for both callers (for example, workload-specific baselines selected by the workflow, or one shared workload), and add paired coverage that exercises both invocations against the chosen baselines.
[MED] ai-duel compare bypasses the shared stdout renderer when either load_report call fails. Evidence: ai_duel.rs:731-744 returns after eprintln!, while ai_duel.rs:752-756 alone routes comparison errors through emit_gate_verdict; gate_cli.rs:100-147 covers only workload mismatch and a comparable pair. Why it matters: redirected stdout remains empty for unreadable or malformed baseline/current reports, violating the documented gate-report contract. Suggested fix: route both load failures through render_error_markdown/the same output authority and add missing- and malformed-report process tests asserting non-zero exit plus a non-empty stdout refusal body.
No direct build was run; CI status is not sufficient to resolve these behavioral incompatibilities.
|
🤖 AI text below 🤖 Taking your first option: a workload-specific 100-game baseline, selected by the nightly. Recording is underway; the workflow change and the baseline land together, baseline first. Two corrections to what I said earlier in this PR, both to my own numbers. My cost estimate was wrong by an order of magnitude. I wrote "hundreds of CPU-hours" for a 32×100 recording. My own measured rate — 15–39 s/game — multiplies out to 13–35 CPU-hours, not hundreds. I asserted that figure without doing the multiplication, which is exactly the kind of claim I have no business making in a PR that argues for measurement. And the corrected figure is itself built on an unrepresentative sample. The 15–39 s/game came from That sample doubles as the thing that decides the shape of the fix: if the full recording is not affordable, a 100-game nightly baseline is not the right answer regardless of how tidy it looks, and I would come back to you with the alternative rather than quietly run something for a day. What is already done and pushed (head Retracted: my earlier offer to regenerate the existing default baseline at |
|
🤖 AI text below 🤖 Before recording the 100-game baseline I measured what it would cost, and the measurement says the option cannot work — for a reason that is not about the baseline at all. Your nightly has not completed a run in at least five days. The nightly times out every night
The workflow already has an arm for it — Why that makes a 100-game baseline unusableA baseline is only worth what the run compared against it is worth. If the nightly cannot finish It also means the consequence I disclosed in this PR — "the nightly will now fail every night until the workloads are aligned" — was overstated in the direction that flatters my change. It was already not producing a drift signal. My refusal would have replaced a silent timeout with a loud refusal, which is still an improvement, but not the one I described. What it would cost to record, measuredOne game per matchup across all 32, six workers, on a loaded box:
Extrapolating the 30 that finished gives ~62 CPU-hours for 32×100. Including a lower bound for the two that didn't gives ≥151 CPU-hours and no upper bound, because I still do not know when they end. The distribution is heavy-tailed, so my earlier figures — "hundreds of CPU-hours", then the corrected "13–35" — were both projections from A single game of What I am asking you to decideThe real defect is the nightly's workload, not its baseline — fixing the baseline while leaving
I will implement whichever you pick, including the recording and the Meanwhile the rest of this PR stands on its own: the comparator no longer returns a green verdict from a partial sample, and the exit/report contract is bound at the process boundary. The required PR gate passes on CI at this head — the failing check on this branch is the decision-cost perf gate, which fails identically on |
`--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
edc1c1e to
375a5a8
Compare
|
🤖 AI text below 🤖 MED fixed and pushed ( MED —
|
| mutant | result |
|---|---|
| revert the baseline-side fix only | killed |
| revert the current-side fix only | killed |
| publish the body to stderr (the original defect's shape) | killed |
| empty the remedy, leaving the envelope | killed |
The first two are why both inputs are covered rather than one.
Scope boundary, stated because "the same recipe" has an edge. run_repro_report in ai-perf-gate also exits 2 from a stderr-only load failure and is deliberately unchanged: its stdout is print_repro_margin — the margin gate's own report, not the drift-issue body this contract governs. Putting a comparison refusal there would emit it into an output nothing parses as one. The two usage errors in run_compare are left alone for the same kind of reason: a mistyped argument has no report to publish.
HIGH — the workload incompatibility
Your diagnosis is right and I am not arguing it. What I want to put beside it is the measurement from my previous comment, because it changes which of your two remedies is even available: the nightly has ended cancelled at its 300-minute timeout for five consecutive nights and has never once completed --full-suite --games 100.
So "every nightly fails before producing a drift comparison" is already true, and has been for at least five days, for a reason that predates this PR. What this PR changes is that the failure becomes legible — a refusal naming the mismatched field instead of a verdict quietly built from 10% of the sample. That is the unmasking, not the cause.
Both remedies you name need a judgement that is yours, not mine:
- Workload-specific baselines. Needs a 32×100 baseline to exist. I measured what recording one costs before attempting it and stopped: the distribution is heavy-tailed, two matchups did not finish a single game in 33 minutes, and any mean-based projection off the fast matchups is invalid. I am bounding those two now (details below) so the number is a measurement rather than a floor.
- One shared workload. Needs someone to decide what the nightly is for — if it exists to be a deeper sample than the PR gate, cutting it to
--games 10makes it a slower duplicate of the required check.
I will execute either as soon as you pick, including the ai-gate.yml change, baseline first. I am not picking for you: it is your workflow and the question is what it is meant to measure.
In flight: the two straggler matchups (niv-mirror, delver-vs-green) are running as single games under a 4-hour bound. At the time of writing both are past 90 minutes for one game. I will post the completed figure rather than the current floor — quoting a floor as though it were a measurement is what made my first cost estimate wrong, and I would rather be late than repeat it. That single-game duration also looks like a pre-existing AI-search worst case worth its own issue, independent of this PR.
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — this current head makes the scheduled AI gate incompatible with its committed baseline.
🔴 Blocker
[HIGH] games_per_matchup is now correctly treated as a comparison incompatibility, but the repository invokes the same default 10-game baseline with two incompatible workloads. Evidence: crates/phase-ai/src/duel_suite/compare.rs:205-211 returns WorkloadMismatch when the reports differ; crates/phase-ai/baselines/suite-baseline.json records games_per_matchup: 10; .github/workflows/ai-gate.yml:49-50 runs the quick gate with --games 10; and .github/workflows/ai-gate.yml:78-87 runs nightly with --full-suite --games 100 without choosing another baseline. Why it matters: the 100-game nightly now exits with a refusal instead of measuring drift, while changing the only default baseline to 100 would make the required 10-game gate incompatible. Suggested fix: select workload-compatible baselines for both invocations, or use one common workload/baseline, and add coverage that exercises each invocation against its selected baseline.
Recommendation: request changes. Resolve the workload/baseline contract for both the quick gate and nightly before this comparator refusal lands.
`--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
|
🤖 AI text below 🤖 A second, independent line of evidence that nothing is watching The
|
| counter | baseline | main @ 5c8d12bf5fb1 |
main/baseline |
|---|---|---|---|
| attackable_player_sweeps | 830 | 2640 | 3.18x |
| crew_eligibility_scans | 7337 | 12027 | 1.64x |
| layers_full_eval | 3495 | 15877 | 4.54x |
| legend_rule_mode_gate_scans | 10274 | 20926 | 2.04x |
| mana_aura_trigger_scans | 14286 | 26841 | 1.88x |
| restriction_static_mode_gate_scans | 46421 | 89324 | 1.92x |
| sba_battlefield_snapshot_builds | 10205 | 20853 | 2.04x |
| state_clone_for_legality | 6489 | 17786 | 2.74x |
The gate's band is base*1.05 + 64. Every row above clears it several times over, so the perf gate fails on main itself, with no PR involved.
How much of this PR's failure is inherited
Three of the eight are byte-identical between main and this PR — attackable_player_sweeps 2640, crew_eligibility_scans 12027, layers_full_eval 15877. Those are inherited in full, measured rather than argued.
The other five read higher on this branch than on that main snapshot. I am not claiming those are proven inherited, because the snapshot is from 11:14Z and this branch is based on a later main, so the difference is consistent with continued drift in between but is not demonstrated by this instrument. What I can say structurally is that this PR's diff touches the suite comparator, ai-duel's stdout, and one remedy string — all of which run after games are simulated, while these are engine counters recorded during simulation. A cross-branch control is in flight on #7029 (an unrelated change to the same crate); if it fails the same rows, neither PR caused them.
Why this belongs in this thread
You asked, reasonably, how the workload and the baseline should be made compatible. This is the same question arriving from a second direction: the counters have drifted 1.9x-4.5x past the committed baseline on main, and the only reason nobody has had to act on it is that the job which would have said so has not completed a run in five days. Earlier in this thread I showed the nightly timing out; this shows what it would have been reporting had it run.
One counter is worth singling out for whoever picks up the perf regression: attackable_player_sweeps was 1265 in a CI run on 2026-08-03 and is 2640 on main now. I flag it as indicative rather than controlled — the earlier figure came from a PR-branch run, not a main-line one — but a counter roughly doubling in two days against a static baseline is the shape of something still moving, not something that settled.
I have no fix to offer here: this is a pre-existing engine/AI-decision cost regression, and my earlier bisect attributed the bulk of it to two merged commits (31c260f50 #6826, 8121fd1c6 #6842) rather than to anything in this PR. It is raised so the workload decision is made with it visible.
|
🤖 AI text below 🤖 The cross-branch control landed, and it closes the part of my previous comment I said was unproven. I claimed three of the eight counters were provably inherited (byte-identical to #7029 has now run the same gate. It is an unrelated change to the same crate (a baseline-write transaction fix; no comparator, no
Byte-identical on all eight, and the same tally on both: Two different diffs producing identical values across all 29 counters means the counters are invariant to both diffs. All eight failures are inherited, not caused by either PR — the five I would not previously assert are now settled by measurement rather than by the structural argument I offered as a substitute. Taken with the earlier finding that |
|
🤖 AI text below 🤖 The tail measurement concluded — by hitting its bound, not by finishing. Neither game completed in four hours. I said earlier I would post a completed figure rather than the
One game each. The other 30 matchups completed their single game in the same run; these two were still running when the 4-hour watchdog killed the process ( Slow, not hung, established two ways so it is not an inference: at a direct sample partway through, each worker thread had accrued 2h04m of CPU at roughly 90% duty — so they were computing, not starved — and What this means for the nightly, with no extrapolationI can make one claim here that requires no projection at all.
The nightly's 3200-game workload therefore provably contains at least two individual games that each need more than four hours, against a 300-minute total budget ( A correction to something I nearly posted. I had drafted a much larger figure on the basis that games within a matchup run sequentially, which was true when I first read that code. It is not true now — Caveat, running in the conservative direction: this was measured on a contended box under What I am not claimingThe honest answer to "how long does one This looks like a pre-existing AI-search worst case rather than anything to do with either of my PRs, and it deserves its own issue. I am happy to open one with the seeds, the durations, and the bisect context — say the word. |
|
🤖 AI text below 🤖 Cross-link: the decision-cost investigation referenced in this thread is now posted on #6967 (#6967 (comment)) — it independently confirms the break window from the profiling comment there, credits #6998's wall-clock fix, and defers all baseline questions to #7044. |
`--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
…ind to `paired_seed_shift` classified only `Some(0)→Some(1)` and `Some(1)→Some(0)`; every `Some(_) → None` fell into a `_ => unchanged` wildcard. Measured on the committed baseline versus a real recorded gate run (`.ab/noC-1.json`, the A+B+D leg of phase-rs#6969): `enchantress-mirror` went 4/6/0 to 1/1/8 — eight of ten games stopped having a winner — and the comparison reported `unchanged=10`, `flips=0`, `sign_test_p=None`, `CompareStatus::Pass`. A branch that made the AI stall or loop every game would pass this gate silently. The same recorded run carried a SECOND hole: its enchantress-mirror row has `status: "Fail"` with `fail_reason: "mirror imbalance: p0=0.10, Wilson 95% CI [0.02, 0.40] excludes 0.50"`, and the comparison still printed `0 FAIL, 0 WARN, 3 PASS`. `classify_row`'s paired branch read the game outcomes and nothing else, so an existing matchup that newly failed its own suite check did not fail the gate. Only the new-matchup arm ever looked at `SuiteStatus`. The draw axis and the suite-status axis both become first-class peers of the win/loss axis: - `PairedSeedShift` and `CompareRow` gain `decisive_to_draw`, `draw_to_decisive`, and `draw_sign_test_p`, the last computed by the existing `sign_test_mid_p_upper_tail` on the draw axis — reused, not reimplemented. - The classification match is exhaustive with no `_` arm, so a future `winner` representation breaks the build instead of silently rejoining `unchanged`. That wildcard is how this defect survived. - Two verdict tiers: draw FAIL after W/L FAIL, draw WARN after W/L WARN. The tier order is load-bearing, and the invariant it buys is narrower than "nothing moves": nothing that reaches Fail today changes verdict, and nothing with a flat draw axis changes verdict at any severity — but a W/L *Warn* does escalate to Fail when the draw axis is significantly negative, because the draw Fail arm sits above the W/L Warn arm. That escalation is intended; suppressing a significant draw regression because the win/loss axis also wobbled insignificantly would reintroduce the same blindness one case narrower. `draw_regression_escalates_an_insignificant_win_loss_warn` pins it, and a reorder mutant that demotes it to Warn flips that test and only it. - Asymmetric by construction: `decisive→draw` dominating is the regression signal and can reach Fail; `draw→decisive` is an improvement and can never Fail (tier 2 requires decisive→draw to dominate) but still Warns, because a comparator silent about games that started resolving would hide a behavior change. - The suite-status axis: an existing matchup going non-Fail → Fail is a Fail here, carrying the matchup's own `fail_reason`; recovery (Fail → non-Fail) is a Warn, never a Fail, the same asymmetry as the draw axis. Keyed on `Fail` specifically rather than on any status change, because `Fail` is the only status this file already acts on — the new-matchup arm matches `SuiteStatus::Fail` and treats `Pass`/`Open` alike. Same authority, same vocabulary, extended from new matchups to existing ones. The Fail arm sits below both outcome Fail arms so everything failing today keeps its more specific reason string. A matchup that was already failing in the baseline and is still failing Warns every run rather than passing quietly — reachable, not theoretical, since `--refresh-baseline` writes the current report verbatim with no `any_fail` check. Warn and not Fail is deliberate: the exit code answers "did this change make things worse", and the baseline already sanctions that state; whether a baseline may bless a failure at all belongs with the refresh guard in `bin/ai_gate.rs`. - The table gains `dec→draw`, `draw→dec`, `draw sign p`, `Δ avg turns`, and `suite status` columns; `CompareRow` carries `avg_turn_delta` and `suite_status_shift`. Rendering moves out of `println!` into `render_markdown() -> String` over a `COLUMNS` constant, so the invariant "every axis the chain can decide on owns a column" is enforced by tests instead of asserted in a comment — while it was inlined, both new columns could be deleted with the whole suite green. The verdict chain is first-match-wins, so a firing arm suppresses every other arm's reason string: a W/L Warn used to hide the row's draw movement, and a draw Warn hides the mirror avg-turn drift the same way. Columns are the only surface that survives that suppression, so every axis the chain can decide on now owns one. `mirror_drift_magnitude_survives_a_shadowing_reason` pins the shadowed case. Evidence. Thirty mutants, each flipping a distinct set of the module's 26 tests, tree restored byte-identical after every run. The verdict chain: dropping either draw tier, dropping the status arm, reordering either Fail arm below the W/L Warn arm, restoring the pre-fix classification, firing on any movement regardless of direction, making `Fail->Fail` silent again, reading the draw statistic in one fixed direction. Then four rounds of review, each finding the previous round's blind spot one layer down. Round 3 showed the RENDERING was pinned by header label only — freezing the `Δ avg turns` cell, or transposing the `dec→draw`/`draw→dec` cells so the recorded incident prints its counters backwards, survived the whole suite. Round 4 showed the same for the two WIN/LOSS reason strings (transposing them reports a 10-0 regression as `W→L=0 L→W=10`), for the `suite status` column's shift branch, for the `—` fallbacks that New and Removed rows print, and for the avg-turn arm's `Expected::Mirror` guard. Round 5 showed the `PASS` label itself was unbound — WARN, FAIL, NEW and REMOVED are each pinned by a cell assertion, and mutating `status_str`'s `Pass` arm survived the whole suite, so a gate that rendered every row as garbage would still read as reviewed. All now die. Three of my own fixes were themselves defective and are recorded rather than quietly corrected: the separator check `.split('|').filter(|s| !s.is_empty())` drops every segment exactly when the fill is empty, so its mutant survived a second time; the first cell-transposition fixture rendered `45%` in both win-rate columns, so swapping them changed nothing; and the `—` fallback sweep pinned five of its six sites, missing `draw sign p` — which is the fallback the real gate renders on every row today, since a matchup with a flat draw axis has no statistic to report. A guard is not a guard until its mutant dies, and a sweep by recipe is not a sweep until every site of that recipe is covered. The invariant the tier order buys is stated to its exact edge and verified over **1,327,104 paired inputs** against the compiled base — including seeds present in only one report, duplicate seeds, empty game vectors, zero-matchup reports, NaN and infinite turn deltas, and every `Expected` variant. Zero violations of either clause, zero `Fail->X`, zero `X->Pass`: head severity is monotone >= base on every input, and all 287,712 escalations are attributable to a significant draw regression or a non-Fail->Fail status shift. The acceptance run is the real comparator over the two recorded reports — `enchantress-mirror` moves PASS -> FAIL (`decisive→draw=8 draw→decisive=0 sign-test p=0.0020`, the exact 1/512), while `red-mirror` and `affinity-mirror` measure 0 on the draw axis and do not move. BASE and POST artifacts were diffed and differ, so this is not a stale binary. `noc1_enchantress_row_carries_both_holes` pins the recorded incident whole, asserting BOTH holes are present so a future edit that closes one and reopens the other cannot pass it. The live gate stays green: `ai-gate --games 10` against the committed baseline reports `0 FAIL, 0 WARN, 3 PASS`, and structurally so — all three matchups have a flat draw axis and an unchanged suite status, so every new guard is false and the chain takes exactly the pre-change path. `baselines/suite-baseline.json` is untouched. Assisted-by: ClaudeCode:claude-opus-5
…ples
Review found the paired-seed gate could still return Pass without comparing the
full sample. `compare` validated only `schema_version`, while `paired_seed_shift`
built a seed→game map from the current report and `continue`d past any baseline
seed missing from it — and, because pairing walks the baseline, never visited
extra current games at all. Two reports sharing no seeds scored zero on every
counter and passed. This is a false-green in the gate the rest of this PR hardens.
Two different defects hide behind one symptom, and they need opposite remedies.
A different `base_seed` or `difficulty` makes the same seed NUMBER denote a
different game, so pairing by it compares unrelated samples and calls the
difference drift. Nothing downstream can rescue that, so `compare` now returns
`CompareError::WorkloadMismatch { field, baseline, current }` before classifying
any row. That is one parameterized variant rather than a sibling per field,
mirroring `PerfCompareError::WorkloadMismatch`, which already solved this exact
problem for the perf comparator — the guard is reuse, not invention.
A different `games_per_matchup` is not that. It changes how many seeds exist, not
what a seed means, so the samples that do pair remain comparable. It is also live:
the nightly runs `--games 100` against a `games_per_matchup: 10` baseline, so
erroring would convert a working job into a hard failure — and since the report
goes to stdout while compare errors go to stderr, the workflow's empty-report
branch would fail the job outright rather than opening the drift issue it exists
to open. Gating it would break a live workflow to make a point that counting makes
better. `PairedSeedShift` and `CompareRow` therefore carry `unpaired_baseline` and
`unpaired_current`, each with a column, and a Warn arm fires when either is
non-zero. The nightly keeps working and starts saying out loud that it discards 90
of every 100 samples per matchup — true today, and invisible until now.
`card_data_hash` is deliberately not gated either: the committed baseline's hash
matches no card-data present on a current checkout, so gating it would fail every
run immediately. The perf comparator reaches the same conclusion, carrying
card-data hashes as informational fields rather than as a guard.
The new arm is LAST among the Warns, and the position is argued in both
directions. Not higher, because every arm above reports measured drift while this
one reports reduced coverage — and since the nightly satisfies this condition on
every row every night, ranking it above the drift arms would replace every real
headline with a coverage notice. Not absent, because without it the zero-counter
Pass survives. `an_unpaired_sample_does_not_shadow_a_real_drift_reason` pins the
position; promoting the arm above the W/L arm flips that test and only that test.
The tier-order invariant comment is restated rather than patched. Its clause 2 was
false the moment this arm landed — it said a row with equal draw counters and no
`Fail` takes the pre-change path, which stopped being true for a row with unmatched
seeds. That is the THIRD draft of that comment falsified by a later axis, always
the same way: a precondition enumerated over the axes that existed when it was
written. It now names all three conjuncts and says why one exists per guard, and a
new clause 0 records that mismatched workloads no longer produce a verdict at all —
including pairs that would previously have reached Fail, which is intended, because
a verdict built by pairing seeds across different workloads only looked like one.
`markdown_cells_carry_their_own_column_values` is re-derived rather than adjusted.
It made its two win-rate cells differ by giving the current report an extra seed
absent from the baseline and leaning on the skip to swallow it — the fixture was
built ON the defect. It now separates the rates through the seed-8 outcome on
matched seed sets, leaving all four counters at (2, 3, 4, 1), and asserts
`(unpaired_baseline, unpaired_current) == (0, 0)` so a future edit cannot silently
reintroduce the dependency. A census of all 27 test fns found this was the only
fixture with asymmetric seed sets.
Evidence. Eight mutants, tree restored byte-identical after each. Dropping either
workload guard fails only that guard's test. Refusing every pair fails 29 tests
including `matching_workloads_are_compared_normally`, the control arm that exists
so a reject-everything mutant cannot masquerade as a working guard. Adding the
`games_per_matchup` gate fails `a_different_games_per_matchup_is_reported_not_refused`,
so the deliberate non-gate is pinned and cannot be "fixed" by a later reader.
Reverting to the silent skip, and dropping the current-side count, each fail their
own direction's test. Transposing the two counters fails both direction tests,
which is why there are two fixtures and not one covering "something was unpaired":
this PR has already had to fix a transposition defect that a single combined
fixture would have missed.
Assisted-by: ClaudeCode:claude-opus-5
The comparison table interpolated report-provided text straight into
pipe-delimited rows. `fail_reason` is free-form — the suite writes whatever
`classify` produced — and `ai_duel compare` will read any report file it is
handed, so a `|` in one of those fields adds a column to that row and a newline
ends the row early. The table stops being rectangular exactly when a matchup is
already failing, which is the moment the diagnostics are actually read, and the
nightly `cat`s that table into a GitHub issue body.
`md_cell` escapes `|` to `\|` (the documented markdown escape, which renders
correctly and keeps raw CI logs readable) and folds newlines to spaces. It is
applied to every cell that originates in a report rather than in this file:
`matchup_id`, `exercises`, and the reason continuation row. Uniformly, and not
only where a pipe looks reachable today — deciding per field means re-deciding
each time a field is added, and one of those decisions will eventually be wrong.
This one is uncomfortable to write, because the invariant already had a test.
`markdown_rows_are_rectangular` asserted precisely the property that was broken,
and passed — every one of its fixtures was pipe-free, so it verified the renderer
against input that could not trigger the hazard. It was not a weak test of the
right thing; it was a confident test of the wrong thing, and it made the table
look guarded for as long as it existed. `report_supplied_pipes_cannot_add_columns`
is the reachability arm it never had: a matchup id containing a pipe and a
`fail_reason` containing two, asserted to leave the header, separator, data and
reason rows all the same width.
The measurement in that test had to be fixed before it could prove anything. It
first counted `line.split('|')`, which cannot distinguish `\|` from `|` — so it
reported the escaped output as broken, and would equally have reported a broken
encoder as fine had the counts happened to line up. It now counts separators the
way a markdown parser does, skipping any pipe preceded by a backslash. A test that
cannot tell the fix from the defect is not evidence either way.
Evidence. Four mutants, tree restored byte-identical after each, all four killing
`report_supplied_pipes_cannot_add_columns`: making `md_cell` the identity;
reverting the reason call site alone; reverting the matchup-id call site alone;
and keeping newline folding while dropping pipe escaping. The two single-call-site
mutants are the sweep check — a partial revert fails, so the fix is not pinned at
only one of the places it is applied.
Disclosed gap: the `exercises` cell is encoded for uniformity but no test can kill
that call site. It renders `format!("{f:?}")` of a fieldless enum, so no fixture
can put a pipe in it. Reverting that one site alone leaves the suite green. It is
defense against a future `FeatureKind` that carries data, not a defect being
fixed, and it is stated here rather than folded into the mutant count.
Assisted-by: ClaudeCode:claude-opus-5
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
… read A differing `games_per_matchup` was counted and Warned rather than refused, on the reasoning that the samples which do pair are genuinely comparable and that erroring would break a nightly running `--games 100` against a 10-game baseline. Both halves of that are true. The conclusion was still wrong, one layer downstream: a Warn leaves `any_fail` false, so the gate exits 0, so the nightly step SUCCEEDS — and the step that publishes the report is guarded by `if: steps.gate.outcome == 'failure'` (`.github/workflows/ai-gate.yml:90`). The new columns were written to a file nobody reads. A gate whose verdict covers a tenth of its evidence was returning Pass and saying so only where nothing was listening. Producing a diagnostic is not surfacing it. `compare` now returns `WorkloadMismatch` for `games_per_matchup`, and the diagnostics survive the refusal instead of dying with it. `render_error_markdown` puts the field, both values and the remedy on STDOUT, which is what the workflow captures as the issue body (`:87` redirects stdout into `target/ai-gate-report.md`, `:95` aborts when that file is empty). Printing the refusal to stderr alone would have converted a green false-pass into a red job with an empty issue — a different failure, not a fix. `gate_verdict` returns the stdout body and the exit code together because they are one decision: the issue posts only when the exit is non-zero AND the file is non-empty, so a test pinning either alone cannot see the mode this closes. `main` now only prints and exits. `print_markdown` moved onto the same `render_stdout` so there is still exactly one authority for the success body. The unpaired columns stay. Refusing a `games_per_matchup` mismatch does not make two same-workload reports pair perfectly — a crashed matchup or a filter change still leaves a remainder, and it is still counted rather than skipped. Consequence, stated rather than discovered later: the nightly runs `--games 100` against a `games_per_matchup: 10` baseline and will now fail every night until one of the two is aligned. The required PR gate runs `--games 10`, matches the baseline, and is unaffected — verified against the committed baseline header and `ai-gate.yml:50`. Aligning them needs either a baseline refresh or a workflow edit, neither of which belongs in this commit. `md_cell` escaped pipes before backslashes, so a report-supplied `\|` became `\\|` — an escaped backslash followed by a LIVE separator — breaking the row for exactly the input that looked already safe. Backslashes are escaped first now. The test's separator counter had the same bug in mirror image: "the previous character is a backslash" is right for `\|` and wrong for `\\|`, so it would have called the broken encoder green. It is now `md_separator_count`, keyed on run-length parity, shared by both pipe tests. Evidence. Eight mutants, tree restored byte-identical (sha256-checked) after each; kill counts transcribed from the runs, not recalled. Dropping the new guard, emptying the refusal body, forcing every exit to 0, and forcing every exit to 2 each fail a different named test — the last one is why the control arm exists, since a reject-everything implementation satisfies every refusal assertion. Reverting `md_cell` to pipes-first, and reverting the counter to the previous-character rule, each fail the backslash test; the second is the measurement that would otherwise have hidden the first. Assisted-by: ClaudeCode:claude-opus-5
Independent review of the previous commit returned FAIL. The code was right and two of its stated reasons were not, which matters here because the reasons are what the next editor will act on. The claim that a stderr-only refusal would leave `target/ai-gate-report.md` empty is false. `run_suite` prints the suite's own table to stdout before the baseline is ever loaded (`run.rs`, `print_markdown_table`), so on that path the file always has content and the workflow's `[ ! -s ]` abort is unreachable. My own end-to-end log showed it — the refusal appears BELOW a rendered suite table — and I read past it. What a stderr-only refusal actually produces is a red job whose issue body is a table of PASSing matchups and no statement of what failed: worse to read than an empty file, and reached by a different route. Both doc comments now say that. The empty-file failure is real, though — in the other binary. `bin/ai_perf_gate.rs` writes nothing to stdout before `compare`; every diagnostic on the way there is `eprintln!`, and its first stdout write is the success table. So a `PerfCompareError` produced exactly the outcome the previous commit message claimed it had refused to ship: zero-byte report, "Decision-cost perf gate failed without a drift report", no issue. The previous commit cited `PerfCompareError::WorkloadMismatch` as prior art for the guard and did not look at its caller. `perf::render_error_markdown` now exists and both of that binary's bail-outs — compare refusal AND baseline-read failure — print it to stdout. The markdown envelope is shared (`duel_suite::refusal_markdown`) so the two gates cannot drift into describing the same situation differently; the remedy text stays per-error-type, because the knobs differ. The same wiring gap existed on this side. `render_error_markdown`'s I/O arm was unreachable — `compare` takes two reports and does no I/O — while the real baseline-read failure in `bin/ai_gate.rs` was still exiting 2 through stderr alone. That call site is now wired to the renderer, which fixes the live gap and makes the arm reachable rather than deleting it. The remedy text was actively harmful advice and is rewritten. It told the reader to re-record the baseline under the current workload. Both gate jobs read the same default baseline at different workloads — the PR job at `--games 10`, the nightly at `--full-suite --games 100` — so with a symmetric guard no single `games_per_matchup` satisfies both: at 10 the nightly refuses, at 100 the PR job refuses, and that job has no `continue-on-error`. Following remedy 1 would have traded a red nightly for a red PR gate on every pull request. The text now names both directions and states the constraint that makes the choice non-local: a baseline is shared, so whichever workload is chosen, every invocation that compares against it has to use that one. `compare: 0 FAIL` was a degenerate assertion. Four of five counters are zero on that fixture, so the substring survives transposing pass/warn, zeroing every counter, and deleting the tally loop outright. It now pins the whole line, and a second fixture carries two distinct non-zero counts so no pair of counters can be swapped unseen. Also: the refusal heading and the schema remedy are pinned (the existing assertion read `schema_version`, which comes from `Display`, not from either); every refusal variant in both gates is covered by one loop that asserts the body carries the error AND says more than it, since an implementation that forwards the error string loses exactly the remedy; and the doc block an earlier commit stranded above a helper is returned to the test it describes. Evidence. Seven mutants, all three files sha256-restored after each. Dropping the remedy from the shared envelope fails 3 tests; reducing it to the bare error line fails 4; emptying the perf renderer fails its own; deleting remedy 2 from the workload text fails the refusal test; zeroing the tally and transposing pass/new each fail the verdict test that the old substring assertion could not see; reverting `md_cell` to pipes-first now fails on trailing-run parity as well as on the embedded pipe. Not covered, stated rather than implied: no automated test executes either binary's `main`, so the `print!`-then-exit wiring in both gates can be reverted with the whole suite green. The end-to-end runs are logs, not gates. Assisted-by: ClaudeCode:claude-opus-5
Review's remaining point was that the new coverage stopped one layer short of the thing CI actually runs: `gate_verdict` was tested, but the two statements that use it — print the body, exit with the code — lived in binaries no test executed. A `main` that printed the refusal to stderr, or exited 0 on it, reverted the whole change with every unit test green. That is the same shape as the defect this branch started with, one level up. `emit_gate_verdict` now owns both statements, and all three binaries that compare two suite reports end in it: `ai-gate`, `ai-duel compare`, and — through its own renderer — `ai-perf-gate`. `ai-duel compare` had the identical defect and is fixed by being routed through it rather than repaired in place: its refusal spoke only to stderr, so anything redirecting that command's stdout got an empty file and no statement of what failed. `tests/gate_cli.rs` drives that contract through a real process. `ai-duel compare` is the binary under test because it is the only one of the three that needs no card database and plays no games — it reads two report files and prints a verdict — so the test costs milliseconds instead of a suite run, and it does not add a card-data load that `scripts/check-test-card-data-load.sh` exists to keep out of the test suite. Both halves are asserted on the SAME invocation, because the workflow needs both: it posts the drift issue only when the step failed, and aborts when the report file is empty, so a test pinning either alone cannot see the mode this closes. A control arm asserts a comparable pair still exits 0 with a table, since a binary that refused everything would satisfy every refusal assertion. The fixture is built from the real structs and serialised, not hand-written. The first draft hand-wrote the JSON, got `Expected`'s internally-tagged encoding wrong, and both arms failed on a parse error rather than on the contract — a fixture the binary rejects proves nothing about the binary. Constructing `SuiteReport` means a schema change breaks compilation here instead of silently producing invalid input. Evidence. Three mutants at the boundary, files restored byte-identical. Printing to stderr instead of stdout fails BOTH tests, which is what pins stdout specifically rather than "some output happened". Returning 0 unconditionally fails only the refusal test's exit assertion, and reverting `ai-duel` to its stderr-only refusal fails only the refusal test — so the exit half and the body half are each independently load-bearing at the process boundary, not just in the library. Assisted-by: ClaudeCode:claude-opus-5
Review found the last two instances of a defect this PR has now fixed three
times: `ai-duel compare` returned 2 after an `eprintln!` alone when either
`load_report` failed, so a caller redirecting stdout got an empty file and no
statement of what went wrong. That is the only way the command is used in CI —
`.github/workflows/ai-gate.yml` redirects the gate's stdout, posts it as a
drift issue when the step failed, and aborts when the file is empty. An empty
body turns a diagnosable refusal into "failed without a drift report".
Both arms now render through `render_error_markdown`, the same emitter
`ai-gate` and `ai-perf-gate` already reach on this path. The path stays on
stderr: `CompareError` carries the cause but not the file, and a refusal
reading "I/O error" without naming which of two inputs it was reading is not
actionable.
Fixing this made one remedy string reachable from a new place, and it was
wrong there. `CompareError::Io | Parse` said "The baseline could not be read",
which is false half the time now that the current-report arm renders it — so
it says "report" and points at the stderr line for the side. Caught by writing
the test for the current-side arm, not by review.
Scope of the sweep, stated because "the same recipe" has a boundary.
`run_repro_report` in `ai-perf-gate` also exits 2 from a stderr-only load
failure and is deliberately NOT changed: its stdout is `print_repro_margin`,
the margin gate's own report, not the drift-issue body this contract is about.
Changing it would put a comparison refusal into an output nothing parses as
one. The two usage errors in `run_compare` are likewise left alone — a
mistyped argument has no report to publish.
Evidence. Four mutants, both files restored byte-identical after each and
verified with `diff -q`.
| mutant | result |
|---|---|
| revert the baseline-side fix only | killed |
| revert the current-side fix only | killed |
| publish the body to stderr (the original defect's shape) | killed |
| empty the remedy, leaving the envelope | killed |
The first two are why the test covers both inputs rather than one: they are
separate arms in the source, and a fix applied to one and not the other is
precisely the shape of defect this file exists to catch. They fail the same
test function but different sub-cases, and the assertion message names which.
Missing and malformed are both exercised because they carry different
`CompareError` variants (`Io` vs `Parse`) into the same renderer; a remedy
keyed on only one would leave the other with an envelope and no content, which
is the fourth mutant. Four sub-cases in total: {missing, malformed} x
{baseline, current}. The "missing" fixture asserts the file really is absent,
so it cannot silently degrade into a second malformed case.
Measured at this tree: 3 CLI tests green, 2049 lib tests green,
`clippy -p phase-ai --all-targets -D warnings` clean, `cargo fmt --check` clean.
Assisted-by: ClaudeCode:claude-opus-5
375a5a8 to
8eaee5f
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. |
|
🤖 AI text below 🤖 @matthewevans — answering the HIGH blocker as a design question, since a rebase can't resolve it. 1. You already named the resolution, so this is a sketch of your preference, not a menuAll three of your reviews say the same thing, and the first-named option is the same each time:
So: Option A = workload-specific baselines selected by the workflow (your lead option, given as the 2. What the code supports today (measured, at
|
| capability | where | state |
|---|---|---|
ai-gate accepts a baseline path |
crates/phase-ai/src/bin/ai_gate.rs:141 — "--baseline" => baseline = next_path(...) |
already exists |
| default when the flag is absent | ai_gate.rs:19 — DEFAULT_BASELINE = "crates/phase-ai/baselines/suite-baseline.json" |
this is what both callers silently get today |
| what counts as an incompatible workload | compare.rs:198-211 — WorkloadMismatch on difficulty and games_per_matchup |
two fields, not one |
--refresh-baseline target |
ai_gate.rs:76-77 — writes to args.baseline |
honours the same --baseline selection |
| refresh script arg passthrough | scripts/refresh-ai-baseline.sh:7 — exec ai-gate.sh --refresh-baseline "$@", and ai-gate.sh:20 — exec …/ai-gate "$@" |
fully forwarded |
Consequence: a second baseline can be produced today with
scripts/refresh-ai-baseline.sh --full-suite --games 100 \
--baseline crates/phase-ai/baselines/suite-baseline-nightly.json
and the nightly selects it with one added flag. No comparator, CLI, or script change.
The two callers as they stand: .github/workflows/ai-gate.yml:50 — cargo ai-gate --games 10
(required, PR-level); :87 — cargo ai-gate --full-suite --games 100 (nightly). Both resolve to the
one 10-game default, which is exactly the collision.
3. Why Option B is not viable, with numbers rather than taste
Option B means one shared workload. Both directions fail, and the timing half is measured, not
estimated:
- Raise the PR gate to 100 games. The paired-seed job completed in 11.4 min at
--games 10
(run 31050404179). Games scale the
play phase roughly linearly and compile is a stable ~9 min cold / ~2 warm (your own §2 table), so
~10× the play phase lands far outside the 60-minute wall this issue exists about. Non-starter on a
required check. - Drop the nightly to 10 games. Legal, but it discards the nightly's entire reason to exist: the
sign test atcompare.rs:606(sign_test_mid_p_upper_tail) gets its power from sample count, and
a 10-game nightly is just the PR gate on a timer.
So Option B trades away either the wall-clock budget or the statistical power. Option A keeps both.
4. Recommendation — Option A, concretely
- Commit
crates/phase-ai/baselines/suite-baseline-nightly.json, recorded with the nightly's own
workload (--full-suite --games 100) via the command in §2. - Select it in the nightly step (
ai-gate.yml:87):
cargo ai-gate --full-suite --games 100 --baseline crates/phase-ai/baselines/suite-baseline-nightly.json.
The quick gate at:50keeps the default and is untouched.
This line is yours to land, not ours..github/workflows/**is your domain and we don't edit
it uninvited — so of the three pieces, two are ours to deliver (the committed nightly baseline
artifact with full provenance, and the paired coverage) and the one-line workflow selection is
yours, or ours only if you'd rather bless us landing it. - Paired coverage, per your clause. A test per invocation asserting each is comparable against
the baseline it actually selects — i.e. thatcompare()does not returnWorkloadMismatchfor
(quick workload × default baseline) and (nightly workload × nightly baseline). The discriminating
form is to assert on the workload fields the comparator keys on, so the test fails if either
workflow line or either baseline'sdifficulty/games_per_matchupdrifts. A test that merely
ran the comparator on hand-built reports would pass while the workflow said something else — the
coverage has to read the workflow invocations, or it is not covering the thing that broke.
Trade-offs, stated
- Two baselines to refresh, not one. Real cost: a workload-affecting AI change now needs two
refreshes, and forgetting the nightly one produces a nightly refusal — the same failure mode as
today, just less often. The §3-style coverage is what catches it. - Nightly baseline is expensive to regenerate (100 games × full suite), and "can be produced
today" means mechanically unblocked, not cheap — its wall time is unmeasured and I won't imply
otherwise. The only full-suite figures anyone holds are debug-profile watchdog tails from before
fix(ai): gate projection wall-clock cap on measurement mode + unify AI gate build profiles #6998 unified the build profiles (≥4 h per matchup, wrong profile, 0 of 2 games completed), and
there is no completed post-fix(ai): gate projection wall-clock cap on measurement mode + unify AI gate build profiles #6998 nightly to cite — the most recent scheduled run,
31000694717, was cancelled. The
cheap settling instrument is one timedserver-releasefull-suite run at--games 10, scaled by
game count; that number should exist before anyone budgets the real recording. Offer: this box
has ~1 TB free and 16 cores, so we can host the recording under the refresh script's own provenance
contract and hand you the artifact plus its timing — or you produce it yourself if you'd rather own
the provenance end to end. Either way the figure gets measured before it gets promised. - Sanctioned-failure interaction. Per fix(ai): make the duel-suite comparator see the regressions it was blind to #7026's own Scope Expansion note, a committed baseline can
bless a failing matchup and nothing revalidates it on load. A second baseline doubles the number of
places that can happen. fix(ai): refuse to refresh the duel-suite baseline from a failing run #7029's--refresh-baselineguard reduces it on the write path for both.
5. One thing your review does not cover, surfaced rather than folded in
--full-suite changes the matchup set, not just the game count, and compare() does not treat
a differing matchup set as a workload mismatch — the check at compare.rs:198-211 keys only on
difficulty and games_per_matchup. Extra matchups arrive as new-matchup rows instead. Option A
resolves your stated blocker either way, because each baseline is recorded with its own caller's
matchup set. But if your intent is that "incompatible workload" means the whole workload, then the
matchup set arguably belongs in the same check. Flagging as a question for you, not proposing it —
it is a scope expansion on a PR whose scope expansion is already the thing under review.
matthewevans
left a comment
There was a problem hiding this comment.
[MED] Duplicate baseline seeds can suppress a distinct unpaired current seed. Evidence: crates/phase-ai/src/duel_suite/compare.rs:537-561 deduplicates current games into current_by_seed but increments paired once for every baseline row with that key; :587-591 then computes unpaired_current as current_by_seed.len().saturating_sub(paired) without validating duplicate seeds. With baseline seeds [1, 1] and current seeds [1, 2], the loop sets paired = 2, the map length is 2, and the distinct unmatched current seed 2 is reported as zero. Why it matters: the comparator can return a clean fully-paired result while omitting real current-game coverage, recreating a false-green partial comparison. Suggested fix: validate duplicate seeds as malformed reports before comparison, or count pairing as a set intersection of distinct seeds; add the [1,1] versus [1,2] regression with a discriminating unmatched-current assertion.
🤖 AI text below 🤖
Summary
The duel-suite comparison gate returned
PASSon a recorded run that had two independent regressions in it. In that run (.ab/noC-1.json, the A+B+D leg of #6969),enchantress-mirrorhad 8 of 10 games stop having a winner and carriedstatus: "Fail"withfail_reason: "mirror imbalance: p0=0.10, Wilson 95% CI [0.02, 0.40] excludes 0.50". The compare section printed0 FAIL, 0 WARN, 3 PASSand the gate exited 0.Two causes, both in
classify_row's paired branch:paired_seed_shiftclassified onlySome(0)→Some(1)andSome(1)→Some(0); everySome(_) → Nonefell into a_ => unchangedwildcard. A branch that made the AI stall or loop every game — the exact failure this gate exists to catch — producedflips=0,sign_test_p=None, and a green comparison.SuiteStatus, so an existing matchup that newly failed its own suite check did not fail the comparison. Only the new-matchup arm ever read that field.This makes the draw axis and the suite-status axis first-class, severity-ordered peers of the win/loss axis, and removes the wildcard that hid the first one.
Files changed
crates/phase-ai/src/duel_suite/compare.rs— the substance of the change, of which roughly 85% is tests and comments.decisive_to_draw/draw_to_decisive/draw_sign_test_ponPairedSeedShiftandCompareRow; exhaustive classification match; four new verdict arms (draw Fail/Warn, status Fail/Warn);avg_turn_deltaandsuite_status_shiftcarried on the row;render_markdownextracted fromprint_markdownover aCOLUMNSconstant, with seven new columns; twenty-seven new tests (10 → 37).crates/phase-ai/src/bin/ai_gate.rs—mainrouted throughgate_verdict, so the stdout body and the exit code are decided in one place the tests can reach (round-2 review).Track
Developer
LLM
Model: claude-opus-5
Tier: Frontier
Thinking: high
Implementation method (required)
Method: not-applicable — the change is confined to
crates/phase-ai/src/duel_suite/compare.rs, the duel-suite report comparator (CI gate tooling). Nocrates/engine/game logic, parser, effect, resolver, or targeting code is touched, and no AI decision-making behavior changes: this alters how two recorded reports are compared, not how any game is played.Flow used: measured-defect probe → written plan → implementation → mutant discharge → real-data acceptance → five rounds of independent
review-impl. Rounds 1-4 each returned FAIL, as did round 5's first pass; round 5's re-review returned PASS with one LOW, which this head closes rather than discloses. The findings and their fixes are described inline below rather than summarised away, because two of them were false claims in this PR's own documentation and three were defects in fixes for earlier findings.CR references
None. This is CI comparison tooling, not game rules.
Verification
Measured at the current head
edc1c1ea3, after rebase onto562e7b5d2:cargo test -p phase-ai --lib duel_suite::compare— 37 passed, 0 failed (10 before this change)cargo test -p phase-ai --lib— 2049 passed, 0 failed, 8 ignoredcargo test -p phase-ai --test gate_cli— 2 passed, 0 failed (real process, no card data, 0.02s)cargo clippy -p phase-ai --all-targets -- -D warnings— rc=0cargo fmt --all --check— rc=0ai-gate --games 10(CI's exact gate invocation,server-release, against the committed baseline) —compare: 0 FAIL, 0 WARN, 3 PASS, 0 NEW, 0 REMOVED, rc=0, zero refusalsThe live gate stays green, structurally
Every row has equal draw counters and no
Failon either side — which is exactly the precondition of the invariant stated at the site, so every new guard is false and the chain takes the pre-change path. The live run is an instance of the invariant, not merely consistent with it.All three new columns carry real values through the actual binary rather than only in tests, and the table is well-formed after the
render_markdownextraction, so the refactor did not disturb the real output path.Per-seed cross-check of the only draws in the whole run: affinity seeds
10592735and10592736drew in the current run and arenullin the committed baseline, so both classify(None, None) => unchanged— which is whatdec→draw=0 / draw→dec=0reports. The instrument agrees with the hand count.Disclosure: this is not an identity comparison. The committed baseline records
card_data_hash=56b033662b27ea926a2db73a8b83007b6352ff35, and nocard-data.jsonreachable from this checkout hashes to it — the input the baseline was recorded against no longer exists, so an identity re-run is not achievable. This ran against current card data, which makes the result stronger for the question that matters: the new axes stay silent across genuine data drift.The failure this fixes, measured on real recorded data
The acceptance run is the real comparator over two recorded reports — committed
crates/phase-ai/baselines/suite-baseline.jsonversus.ab/noC-1.json, a real gate run (the A+B+D leg of #6969). Not synthetic:any_fail()decisive→draw=80/00/0paired draw regression: decisive→draw=8 draw→decisive=0 sign-test p=0.00200/00/0red-mirrorandaffinity-mirrorare the built-in negative controls on real data: both measure0on the draw axis and neither moves. The BASE and POST artifacts were diffed and differ, so this is not a stale-binary false pass.draw_sign_test_p = 0.001953125is exactly the hand-derived1/512, produced by the already-existingsign_test_mid_p_upper_tail— reused, not reimplemented.Mutant evidence — 30 mutants over the module's 26 tests
The verdict chain, each mutant flipping a distinct set: dropping either draw tier, dropping the status arm, reordering either Fail arm below the W/L Warn arm (1 test each — precisely the edit the "Do not reorder" comment warns against), restoring the literal pre-fix classification, firing on any movement regardless of direction or significance, making
Fail→Failsilent again, and reading the draw statistic in one fixed direction.Review then measured, twice, that the reporting layer was pinned far more weakly than the chain. Sixteen mutations survived a whole green suite across rounds 3 and 4; all now fail a test. The most consequential:
Δ avg turnscell frozen to a constantdec→draw/draw→deccells transposed — the recorded incident would print its counters backwardsPass → Failit names!= Passinstead of== Fail, mislabellingFail → OpenW→L=0 L→W=10suite statuscolumn's shift branch — the one the status axis exists to surface — replaced by a constant—fallbacks that New and Removed rows printExpected::Mirrorguard deleted (pre-existing arm; every fixture was a mirror)Cells are now asserted by column name against a fixture whose four counters are pairwise distinct (2, 3, 4, 1), so no transposition can hide; the separator is checked to be a valid markdown rule; every reason string is pinned to its counters in order.
Two of these fixes were themselves defective, and are disclosed rather than quietly corrected. The natural spelling of the separator check —
.split('|').filter(|s| !s.is_empty())— drops every segment exactly when the fill is empty, so SEP-EMPTY survived a second time. And the first cell-transposition fixture rendered45%in both win-rate columns, so swapping them changed nothing; the fixture now differs them via a current-only seed, which moves the rate without touching a flip counter. A guard is not a guard until its mutant dies.The working tree was restored and verified byte-identical after every mutant run.
What the tier order actually guarantees — swept, not argued
Two earlier drafts of this invariant were false, both caught by review compiling the base and running candidate inputs through both versions. The claim is now stated to its exact edge and verified over 1,327,104 paired inputs against the compiled base, comparing
(status, reason)on each. The domain covers the counters, both statuses ∈ {Pass, Fail, Open}², everyExpectedvariant, seeds present in only one report, duplicate seeds within a matchup, empty game vectors, zero-matchup reports, and NaN/infinite turn deltas:Failinput keeps verdict and reason byte-identical.Failtakes precisely the pre-change path. That is the full precondition: every new guard needs either unequal draw counters or aFailon one side. Measured: 0 violations.Zero violations of either clause, zero
Fail→X, zeroX→Pass. Head severity is monotone ≥ base on every input, and all 287,712 escalations to Fail are attributable to a significant draw regression or anon-Fail→Failstatus shift — nothing this PR does can make a comparison quieter than it was.Each clause names the test that pins it, because all three false claims in this PR's history had the same shape: a claim about an arm that no test exercises.
The two directions on each axis are deliberately asymmetric.
decisive→drawdominating is the regression signal and can reach Fail;draw→decisivedominating is an improvement and can never Fail through that arm — it requiresdecisive_to_draw > draw_to_decisive— but still Warns, because a comparator silent about games that started resolving would be hiding a behavior change. Same shape on the status axis.Reason strings are first-match-wins, so every axis gets a column
Review also measured that the new draw WARN arm shadows the mirror avg-turn WARN arm: same input, pre-change →
Warn "mirror avg-turn drift +6.0 turns", this PR →Warn "paired draw shift: …". Status unchanged, but the drift magnitude then surfaced nowhere, because the table had no avg-turns column and the reason string was its only surface — the same argument used to justify the draw columns, turned against this change.Fixed by carrying
avg_turn_deltaonCompareRowand adding aΔ avg turnscolumn, which generalizes to the invariant now written atprint_markdown: every axis the verdict chain can decide on owns a column, because the chain is first-match-wins and columns are the only surface that survives suppression. Adding a verdict arm means adding a column. Pinned bymirror_drift_magnitude_survives_a_shadowing_reason.No baseline refresh.
baselines/suite-baseline.jsonis untouched.Review response (maintainer CHANGES_REQUESTED, 2026-08-05)
Two further commits. Both findings reproduced independently at the reviewed head before being fixed.
HIGH — partial comparison could report PASS. Confirmed:
comparevalidated onlyschema_version, andpaired_seed_shiftcontinued past baseline seeds missing from current while never traversing extra current games at all. Two reports sharing no seeds scored zero on every counter and passed.The remedy splits, because two defects hid behind one symptom. A different
base_seedordifficultymakes a seed number denote a different game, socomparenow returnsCompareError::WorkloadMismatch { field, baseline, current }— one parameterized variant mirroring thePerfCompareError::WorkloadMismatchyou pointed at, rather than a sibling per field. A differentgames_per_matchupis not that: it changes how many seeds exist, not what one means, so the samples that do pair remain comparable. It is counted rather than gated — see the disclosure below.card_data_hashis likewise not gated: the committed baseline's hash matches no card-data present on a current checkout, andperf.rscarries card-data hashes as informational fields for the same reason.PairedSeedShift/CompareRowgainunpaired_baselineandunpaired_current, each with a column, and a Warn arm fires when either is non-zero. The arm sits last among the Warns so a row with real drift keeps its drift headline and reports the coverage gap through its columns instead; promoting it flips exactly one test.LOW — unescaped cells. Confirmed, and it indicted an existing test of mine:
markdown_rows_are_rectangularasserted precisely this property and passed, because every one of its fixtures was pipe-free.md_cellnow encodes every report-supplied cell, with a pipe-bearing reachability fixture.Disclosure — the nightly.
This change makes the discarded samples visible asThat was false, and round 2 below replaces it. A Warn exits 0, so the nightly step succeeded, so the step that publishes the report never ran — the counters went to a file nobody read. The correction is in the round-2 section.unpaired curand Warn-level.Verification at the new head. 2044 lib tests pass;
clippy -D warningsandcargo check --workspace --all-targetsboth clean; the live gate is green against the committed baseline (0 FAIL, 0 WARN, 3 PASS, rc=0) with both new columns reading0. Twelve mutants across the two commits, tree restored byte-identical after each — including a control arm so a reject-everything mutant cannot pass as a working guard, and a mutant pinning the deliberategames_per_matchupnon-gate against a later reader "fixing" it.Review response (maintainer round 2, 2026-08-05 11:07Z)
HIGH — the workload mismatch was a green, unpublished partial comparison. Confirmed, and it falsified a claim in the section above.
Reproduced at the reviewed head before changing anything, because the disclosure I wrote in round 1 said this change "makes it visible as
unpaired curand Warn-level" and that the nightly "starts saying out loud that it discards 90 of every 100 samples". Both are false where it matters:ai-gate.yml:87—cargo ai-gate --full-suite --games 100 > target/ai-gate-report.md. stdout, and only stdout, becomes the issue body.ai-gate.yml:90—Open or update drift issueis guarded byif: steps.gate.outcome == 'failure'.CompareReport::any_failcounts onlyFail, so a Warn exits 0, the step succeeds, and that guard is false.The columns were therefore written to a file nobody reads. Producing a diagnostic is not surfacing it. I had verified the counters reached the report and stopped one layer short of the consumer.
Remedy: hard incompatibility with preserved diagnostics — the first of the two you offered. The second (align the nightly baseline or workload) needs either a baseline refresh or a workflow edit, and I do not take either unilaterally.
comparenow returnsWorkloadMismatchforgames_per_matchup, andrender_error_markdownwrites the field, both values and the remedy to stdout, which is what the workflow captures. Printing the refusal only to stderr would have traded a green false-pass for a red job with an empty issue body (:95aborts with "AI gate failed without a drift report") — a different failure, not a fix.gate_verdictreturns the stdout body and the exit code together because the issue posts only when the exit is non-zero and the file is non-empty; a test pinning either alone cannot see this mode.mainnow only prints and exits.The stdout byte stream for a successful run is unchanged by that refactor, which matters because the nightly posts it verbatim:
println!()+print!(table)+println!("\ncompare: …")andformat!("\n{table}\ncompare: …\n")emit the same bytes, and the live run's file confirms it — leading\n, table, then| PASS |\n\ncompare: 0 FAIL, 0 WARN, 3 PASS, 0 NEW, 0 REMOVED\n.The unpaired columns stay. A same-workload pair can still fail to pair — a crashed matchup, a filter change — and that remainder is still counted rather than skipped.
Consequence, stated up front: the nightly will fail every night until baseline or workflow is aligned. It runs
--games 100against agames_per_matchup: 10baseline. The required PR gate runs--games 10(ai-gate.yml:50), matches the baseline header exactly, and is unaffected — the live run below is that invocation. I will do whichever alignment you prefer as a follow-up; it is one line either way, but one line in a file I do not edit unasked.MED —
md_cellescape order. Confirmed, and the test that was supposed to catch it had the same bug in mirror image. Escaping|before\turns a report-supplied\|into\\|— an escaped backslash followed by a live separator — so the input that looks already-safe is the one that breaks the row. Backslashes are escaped first now. The in-test separator counter used "the previous character is a backslash", which is right for\|and wrong for\\|; it is nowmd_separator_count, keyed on run-length parity, shared by both pipe tests. The new fixture carries both parities and inlines the previous encoder as a control, asserting it leaks a separator where the fixed one does not — without that arm the assertion could pass for an unrelated reason.LOW — copied doc paragraph. Confirmed; the two paragraphs describing a distinct-value fixture belonged to
markdown_cells_carry_their_own_column_values. Removed, with a one-line note saying where they went so the next reader does not re-copy them.Mutants for this round — 8, all killed, tree sha256-restored after each.
games_per_matchupguardrender_error_markdown→ empty stringgate_verdictalways exits 0gate_verdictalways exits 2md_cell→ pipes-firstmd_cell→ identityThe last four rows are the point. A reject-everything
gate_verdictsatisfies every refusal assertion and is caught only by the control arm. Emptying the body kills the body assertions while the exit-code assertion survives, and forcing exit 0 does the reverse — so the two halves you asked to see asserted end-to-end are pinned separately rather than by one test that could be passing for either reason. And reverting the counter kills the same test as reverting the encoder, which is the measurement that would otherwise have hidden the encoder bug.Answers to the three CodeRabbit nitpicks are in a separate comment, with the measurements behind each.
Review response (round 3, 2026-08-05 12:06Z) — one blocker is not mine to close
HIGH — the nightly workload is permanently incompatible with the committed baseline. Confirmed, and it invalidates the remedy I offered in round 2.
Measured independently before accepting it, and my own reviewer had reached it first:
ai-gate.yml:50— PR gate,cargo ai-gate --games 10, quick filter (3 matchups), nocontinue-on-error.ai-gate.yml:87— nightly,cargo ai-gate --full-suite --games 100(32 matchups).--baseline. It recordsgames_per_matchup: 10and 3 results.With a symmetric guard no single value satisfies both: at
10the nightly refuses, at100the required PR gate refuses on every pull request. So the remedy I offered last round — regenerate the baseline at 100 — would have traded a red nightly for a red required check. That advice was also in the shipped error text; it is rewritten, and the text now carries the constraint that makes the choice non-local: a baseline is shared, so whichever workload is chosen, every invocation comparing against it must use that one.Worth adding to the picture: the mismatch is not only
games. The nightly runs 32 matchups against a 3-matchup baseline, so 29 reportNEWevery night. That baseline was never the nightly's baseline; this PR made a long-standing mismatch fatal instead of quiet.Both of your suggested fixes need something I don't do unasked, so I am offering rather than choosing. Committing a workload-specific 100-game baseline means editing
.github/workflows/ai-gate.ymlto select it and recording 32 × 100 games — measured at ~15–39 s/game on this box, so hundreds of CPU-hours, not something to produce unreviewed. Using one workload consistently is a one-line workflow edit. I don't edit workflow files without being asked to; say which and I will, including the recording run with the paired-seed report attached so the refresh is auditable. My weak preference and its downside are in a separate comment.Until then this PR leaves the nightly failing loudly with an accurate explanation, which is the outcome your previous review asked for, rather than green while comparing a tenth of its sample.
MED — coverage stopped at
gate_verdict, not the process boundary. Fixed, not disclosed.You were right that a regression in
maincould re-split exit from output with every unit test green.emit_gate_verdictnow owns both statements and all three comparing binaries end in it —ai-gate,ai-duel compare, andai-perf-gatethrough its own renderer.ai-duel compareturned out to have the identical defect and is fixed by routing rather than patched in place.crates/phase-ai/tests/gate_cli.rsdrives the contract through a real process. It runsai-duel comparebecause that is the only one of the three needing no card database and playing no games, so it costs 0.02 s and adds no card-data load forscripts/check-test-card-data-load.shto object to. Both halves are asserted on the same invocation — the issue posts only when the step failed and the file is non-empty — with a control arm so a refuse-everything binary cannot pass.Its fixture is built from the real structs and serialised. The first draft hand-wrote the JSON, got
Expected's internally-tagged encoding wrong, and both arms failed on a parse error rather than on the contract; running it is what caught that.Also fixed this round, from an independent review pass that returned FAIL on the round-2 head:
run_suiteprints the suite table to stdout before the baseline is loaded, so[ ! -s ]is unreachable on that path — my own end-to-end log showed the refusal below a rendered table and I read past it. The real consequence is a red job whose issue body is a table of PASSing rows and no statement of what failed. Both doc comments corrected.ai-perf-gatewrites nothing to stdout beforecompare, so aPerfCompareErrorproduced a zero-byte report and "Decision-cost perf gate failed without a drift report" — exactly what round 2 claimed it had refused to ship, in the gate I cited as prior art without checking its caller. Fixed on both its bail-outs.render_error_markdown's I/O arm was unreachable (comparedoes no I/O) while the real baseline-read failure was still stderr-only. That call site is wired, which fixes the gap and makes the arm live.contains("compare: 0 FAIL")was degenerate — four of five counters are zero in that fixture, so it survived transposing counters, zeroing them, and deleting the tally loop. Now pins the whole line, plus a fixture with two distinct non-zero counts.Mutants this round: 10, all killed, files sha256-restored after each. Seven on the library fixes (dropping the shared remedy fails 3 tests; reducing it to the bare error line fails 4; emptying the perf renderer fails its own; deleting remedy 2 fails the refusal test; zeroing the tally and transposing pass/new each fail the verdict test the old substring could not see; reverting
md_cellnow also fails on trailing-run parity) and three at the process boundary (stderr instead of stdout fails both CLI tests; always-return-0 fails only the exit assertion; revertingai-duelto stderr-only fails only the refusal test — so each half is independently load-bearing where CI actually reads them).Gate A
Gate A PASS head=8eaee5fe919a97c4a9ffd024bebf505158ac2f41 base=c44a4512e6f91684068c259a028eb44b4d801340
Re-recorded at the current head after rebasing onto
upstream/main. Base passed explicitly ratherthan left to the script's default, which resolves against
origin/main— a fork ref that lags.Range non-empty (8 commits, 7 files) but contains zero parser paths, so the parser-specific
verdict is vacuous by construction and is labelled rather than presented as a pass. Gate G also PASS.
Anchored on
sign_test_p < 0.05). The new draw FAIL tier is the same shape at the same seam: same predicate form, same reason-string format, sameif/else ifchain inclassify_row.sign_test_mid_p_upper_tail, the existing significance statistic. The draw axis reuses it verbatim withn = decisive_to_draw + draw_to_decisiveandk = max(...), exactly as the win/loss axis calls it. No new statistic was written.Both line numbers were re-derived at this head with
grep -nafter the rebase, not carried over: thepreviously recorded
:259and:449had drifted and now point at unrelated lines.Final review-impl
Final review-impl PENDING head=8eaee5fe919a97c4a9ffd024bebf505158ac2f41
Deliberately not a PASS line, because a PASS here would be false. The most recent certification is
cert-r7 FAIL head=3c9646266, and no independent pass has run against this head. The maintainer'slatest review (2026-08-05T16:13:23Z) also carries an open HIGH blocker — the repository invokes the
same default 10-game baseline from two incompatible workloads (
ai-gate.yml:49-50at--games 10,:78-87at--full-suite --games 100, against asuite-baseline.jsonrecordinggames_per_matchup: 10) — which is unaddressed at this head: no commit on this branch postdates thatreview. The rebase above changes only the base (diff byte-identical across it, md5
a12bb582741c);it does not answer the blocker.
This section is expected to keep the artifact gate red on the review-impl axis, and that is the
correct signal rather than something to paper over.
Claimed parse impact
None.
Scope Expansion
Two items, both deliberate and both reviewed.
1. The suite-status axis. Review found that an existing matchup whose own
SuiteStatusgoes toFaildid not fail the comparison — proven from this PR's own evidence artifact, which carriesstatus: "Fail"on enchantress-mirror while its compare section read0 FAIL, 0 WARN, 3 PASS. The incident this PR is built on had two independent holes; shipping a fix for one and leaving a known-latent sibling in a required gate would make more work later, so it is closed here. New: a Fail arm (b.status != Fail && c.status == Fail, carrying the matchup's ownfail_reason), and one Warn arm parameterized byc.statusrather than two siblings: recovery (Fail → non-Fail) and still-failing (Fail → Fail) are both reported, never failed — the same asymmetry as the draw axis. Plussuite_status_shifton the row, asuite statuscolumn, and tests includingnoc1_enchantress_row_carries_both_holes, which pins the recorded incident whole by asserting both holes so a future edit that closes one and reopens the other cannot pass it.The still-failing case exists because review showed it is reachable, not theoretical: nothing revalidates a committed baseline when it is loaded, so a baseline that already sanctions a failure keeps sanctioning it and that matchup exits 0 forever. It Warns rather than Fails deliberately — the exit code answers "did this change make things worse", and the baseline, however it got that way, already sanctions that state. Whether a baseline may bless a failure at all is a policy question, and its other half is a guard on
--refresh-baselineinbin/ai_gate.rs; that is now #7029 rather than smuggled in here.(Phrased about the baseline rather than about
--refresh-baselinewriting without a verdict check. That was the mechanism, and #7029 adds the missing check — so naming it as the reason would make this paragraph, and the two source comments quoting it, false the day #7029 lands. Reachability does not depend on it: a baseline blessed before that guard, or hand-edited, is unaffected by any guard on the write path.)Keyed on
Failspecifically rather than any status change, and not by preference:Failis the only status this file already acts on — the new-matchup arm matchesSuiteStatus::Failand treatsPass/Openalike. Same authority, same vocabulary, extended from new matchups to existing ones. Inert on current data (all three live matchups hold their own verdict, so thesuite statuscolumn readsPasson every row of the live run).2. Table columns.
print_markdowngaineddec→draw,draw→dec,draw sign p,Δ avg turns, andsuite status;CompareRowcarriesavg_turn_deltaandsuite_status_shift. The verdict chain is first-match-wins, so a row that fires on one axis prints only that axis's reason and hides every other axis's magnitude — review measured exactly that regression in an earlier revision of this PR, where the new draw Warn arm shadowed the mirror avg-turn reason and the drift magnitude then surfaced nowhere.bin/ai_gate.rsandbin/ai_duel.rsare the only callers ofprint_markdown, neither reads the columns, and no CI step parses the table —.github/workflows/ai-gate.ymlcats the whole file into an issue body. Verified through the real binary, not only tests: the 13-column table renders correctly and every new column carries real values.render_markdownwas split out ofprint_markdownso this invariant is enforced by tests rather than asserted in a comment — while the table was inlined inprintln!, the new columns could be deleted with the whole suite green.Disclosed non-change:
(Some(a), Some(b))pairs with seats other than 0/1 still count asunchanged, exactly as before. The duel suite is two-player; reclassifying them is out of scope.Validation Failures
None.
CI Failures
None.
Summary by CodeRabbit