Skip to content

perf(ai): make the AI gates finish and leave evidence - #6969

Merged
matthewevans merged 4 commits into
phase-rs:mainfrom
lgray:perf/ai-gate-fixes
Aug 3, 2026
Merged

perf(ai): make the AI gates finish and leave evidence#6969
matthewevans merged 4 commits into
phase-rs:mainfrom
lgray:perf/ai-gate-fixes

Conversation

@lgray

@lgray lgray commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Summary

Makes the two AI gates finish and leave evidence: per-game/per-scenario progress to
stderr, game-level (not matchup-level) parallelism in the duel suite, mimalloc's C core
compiled at -O2 in the dev profile the gates actually run under, and removal of a
debug_assert!(false, …) that made the debug gate score a seed differently from the
release build its own baseline was cut with.

Context for why this matters now: across the last 200 ai-gate.yml runs
(2026-07-26 … 2026-08-03) 86 PR runs were killed by timeout-minutes: 60 at a 60.4 m
median — 22 of 22 on 2026-08-02 — and all 3 scheduled nightly runs died at their 300 m
ceiling, so the drift issue never opens. A killed run currently produces nothing: the
suite prints only per-matchup lines and writes every artifact after the last matchup
returns. Run 30782543973 shows 37m59s of silence before the kill.

Files changed

  • Cargo.toml[profile.dev.package.libmimalloc-sys] opt-level = 2
  • crates/phase-ai/src/duel_suite/run.rs — per-game progress; game-level parallel runner
  • crates/phase-ai/src/duel_suite/perf.rs — per-scenario progress with counter payload
  • crates/phase-ai/src/search.rs — remove the pending-cast debug_assert!(false, …)
  • crates/phase-ai/tests/scenarios.rs — replace the two vacuous Claws assertions

Track

Developer

LLM

Model: claude-opus-4.8
Tier: Frontier
Thinking: high

Implementation method (required)

Method: not-applicable — no crates/engine/ game-logic change. This is AI-gate harness
(crates/phase-ai/src/duel_suite/), one workspace build-profile override, and one
crates/phase-ai/src/search.rs assertion removal that does not alter any resolution path.
Plan, plan-review and two rounds of implementation-review were still run as independent
agents.

CR references

None new. search.rs's existing CR 601.2 rationale for the CancelCast recovery is
preserved and re-stated in the comment that replaces the assert.

Verification

All numbers below are transcribed from named logs in the branch worktree; nothing is recalled.

Static gates at the exact committed head (04185c70a, rebased onto 1738d5c91)

gate command result
fmt cargo fmt --all --check clean (exit 0)
clippy cargo clippy -p phase-ai --all-targets -- -D warnings clean
lib tests cargo test -p phase-ai --lib duel_suite 63 passed, 0 failed, 8 ignored
scenario tests cargo test -p phase-ai --test scenarios claws 2 passed, 0 failed
Gate G ./scripts/check-parser-combinators.sh PASS (router/grant boundary intact)
Gate A same script PASS head=04185c70a173999aff3ab5e1cd0d3664727057c0
Gate P ./scripts/check-prelowered-ratchet.sh PASS (no producer count increased)

Commit C's propagation, measured both waysreadelf --debug-dump=info <bin> | grep -A1 'GNU C23'
reports -O0 for libmimalloc-sys-0.1.49/c_src/mimalloc/v3/src/static.c on the pre-override
binaries and -O2 on the post-override binary. Verified on both ai-gate and ai-perf-gate, and
re-verified on the three A/B binaries below (PRE -O0, noC -O0, POST -O2) as the
discriminating control for that leg.

Behavioural A/B, --games 10 (the exact CI PR-gate invocation), pinned card-data, one leg at a
time, 16-core host. All three binaries are built from base c9daf66e3, so the pair isolates exactly
these four commits and nothing upstream.

leg binary commits wall games finished verdicts
PRE .bins/ai-gate-PRE none (base c9daf66e3) killed at 4811s (my 80-min cap; CI's is 60) 20 of 30 — 2 of 3 matchups red-mirror PASS p0=4/10 turns=13.9; enchantress-mirror FAIL p0=1/10 turns=11.8; no report JSON written at all
noC .bins/ai-gate-noC A+B+D (C neutralised via --config) 1883s, rc=0 30 of 30 red-mirror PASS 4/10 13.9; affinity-mirror PASS 4/10 11.4; enchantress-mirror FAIL 1/10 11.8
POST .bins/ai-gate-POST A+B+C+D 1629s, rc=0 30 of 30 red-mirror PASS 4/10 13.9; affinity-mirror PASS 4/10 11.4; enchantress-mirror FAIL 1/10 11.8

Throughput: PRE 20/4811s = 0.0042 games/s → noC 30/1883s = 0.0159 games/s (3.8x) → POST
30/1629s = 0.0184 games/s (4.4x), same box, same workload, one leg at a time. The noC→POST
gap is a single sample of one seed set and is not claimed as commit C's speedup; commit C's
claim is the DW_AT_producer propagation above, nothing more.

Verdict-neutrality, two independent controls.

  • Commits A+B+D vs the committed (release-cut) baseline: seeds match one-for-one with
    differing winners: 0 on red-mirror and affinity-mirror, and noC matches PRE's own partial rows
    digit-for-digit where PRE got that far. Game-level parallelism moved no verdict.

  • Commit C in isolation: noC and POST differ by exactly commit C (same base, same source,
    C neutralised on noC via --config, -O0 vs -O2 confirmed in each binary's DWARF). All
    30 of 30 games agree on both winner and turns:

    matchup POST == noC POST == baseline
    red-mirror 10/10 10/10
    affinity-mirror 10/10 10/10
    enchantress-mirror 10/10 2/10 — the pre-existing regression below, identical on both binaries

Commit D is visible in the report. PRE panic-aborted affinity-mirror seed 10592730
(search.rs:921, "AI fallback reached during pending cast (variant ManaPayment, spell Kappa
Cannoneer)") and scored it (None, 0); noC's affinity-mirror turns are
[15,12,9,12,14,13,8,8,11,12] — no turns: 0. Its disclosed cost is real too: that one seed
consumed roughly 26 of noC's 31 minutes.

Pre-existing, not caused by this PR: enchantress-mirror FAILs identically on the fix-free PRE
binary. And the compare stage reports that FAIL as PASS — see the disclosure below.

Test non-vacuity. Each new assertion was revert-probed or has a natural control:

assertion control
assemble_matchup_result_…_divides_by_games_per_matchup, avg_turns/avg_duration_ms mutating the denominator to games.len() flips 1.625 → 6.5; test FAILS
same test, status/fail_reason mutating only classify's third argument to games.len() flips PASS↔FAIL; test FAILS (independently of the averages)
regroup_games_restores_game_index_order_from_shuffled_completion deleting the sort_by_key yields [2,0,1]; test FAILS
game_tasks_skip_matchups_whose_payload_failed deleting the is_ok() filter adds (1,0)/(1,1); test FAILS
assert_no_fallback_cancel positive half (mana-first) the identical assertion on the witness board FAILS — the AI's action list there is [PassPriority], which is how the witness test's vacuity was discovered
activation_legal_for precondition (witness) it is the only assertion in that test that can fail; the negative assertion cannot fire on a board the AI passes on

Gate A

./scripts/check-parser-combinators.sh at the committed head:
Gate A PASS head=04185c70a173999aff3ab5e1cd0d3664727057c0
(Gate G PASS, Gate P PASS in the same run.)

Anchored on

upstream/main at 1738d5c91 ("fix(parser): keep the card-type gate on "from among them" casts
(#6880) (#6959)"). The four commits were rebased onto it cleanly (4/4, no conflicts); the only
overlap with the three intervening upstream commits was the release: v0.44.0 version bump in the
same Cargo.toml this PR touches, in a different table.

Final review-impl

Two independent review rounds (separate agents, no shared context), both on the committed diff.

Round 1 — 2 MED + 4 LOW. Fixed: doc paragraphs asserting a purity the crate elsewhere denies
(now hedged "modulo #4878"); assemble_matchup_result's test rewritten so the games_per_matchup
denominator is observable; assert_no_fallback_cancel extended to break_reason; the Cargo.toml
comment de-overclaimed. Round 1 also surfaced two clippy errors of my own
(needless_option_as_deref, unused_mut) that would have redded CI.

Round 2 — 1 HIGH + 5 MED/LOW, all addressed, every claim re-verified by me at the source before
being accepted:

  • HIGH: commit C's "no mechanism by which an allocator's optimization level can move the gates'
    payloads" was false. projection.rs's ungated 15 ms TIME_CAP is the mechanism (chain
    re-derived independently: registry.rsvelocity_scorecan_afford_projection
    deadline.rs:33-43 Deadline::none()context.rs:96-98 is_none_or). Both the comment and
    commit C's message now name it instead of denying it.
  • MED: scenario_claws_of_gix_witness_board_does_not_dead_end was vacuous, and strengthening it
    proved it — adding an ActivateAbility assertion made it FAIL, because the AI's entire action
    list on that board is [PassPriority]. Resolution: the discriminating content is the new
    activation_legal_for precondition; the positive assertion moved to the mana-first sibling, which
    does complete ([ActivateAbility, SelectCards, PassPriority]). Disclosed in commit D's message.
  • Also fixed: the module-header and drive_game purity overclaims the round-1 hedge pointed at;
    "reads no wall clock" (it reads it four times — nothing it reads enters the GameResult); a
    citation naming candidate_actions_broad_with_probe where the guard is in
    semantic_candidate_actions_with_probe; two test docs describing the deleted panic, one quoting a
    string with 0 occurrences in search.rs; a sort rationale citing a stability guarantee that is
    not load-bearing; and commit B's undisclosed loss of per-matchup rows under a timeout kill.

Every fix was folded into the commit that owns it — there are no "fix the previous commit" commits
in this branch.

Claimed parse impact

None. No parser file is touched; the pre-commit parser combinator gate (Gate G) and the
PreLowered ratchet (Gate P) both passed on every commit.

Scope Expansion

None.

Validation Failures

None from this branch. Three things found while measuring are pre-existing on main and are
disclosed rather than fixed here — each moves gate verdicts, so each wants its own change and its
own baseline sign-off:

  1. The paired-seed comparator is blind to decisive→draw regressions. compare.rs:269-273
    classifies Some(_) → None as unchanged, so a run in which 8 of 10 enchantress-mirror games
    went from a decisive result to a draw reported flips W→L 0 | flips L→W 0 | PASS while the
    suite section of the same report said FAIL, and the process exited 0.
  2. enchantress-mirror FAILs on unmodified main (FAIL p0=1/10), reproduced on the fix-free
    PRE binary — so it is not introduced by anything here. Scope caveat: that is measured on my
    host. CI's run of this PR finished only 2 of 10 enchantress games before its timeout and both
    matched the baseline, so how much of this reproduces on a hosted runner is not established.
    See the cross-environment note under CI Failures.
  3. The gates are host-speed-dependent. projection.rs:110's 15 ms TIME_CAP is not gated on
    measurement mode (unlike search.rs:2012 / planner/mod.rs:691) and is reachable at the gate's
    default Medium via EvasionRemovalPriorityPolicy::velocity_score; the
    projection_min_budget_ms floor is bypassed because Deadline::none() returns
    remaining() == None and context.rs:96-98 is is_none_or. A bail scores 0.0 where a completed
    projection scores up to +3.0, and that term picks the removal target. This is why commit C's
    comment states a mechanism instead of claiming allocator changes are invisible.

CI Failures

Everything except the two AI-gate jobs is green, including the required
Rust (fmt, clippy, test, coverage-gate) aggregator:

check result
Rust (fmt, clippy, test, coverage-gate) pass
Rust lint (fmt, clippy, parser gate) pass 10m29s
Rust tests (shard 1/2, 2/2) pass 15m22s / 14m44s
Card data (generate, validate, coverage) pass 56s
Frontend (lint, type-check, test) pass 3m7s
WASM / Tauri compile check pass 1m27s / 1m30s
Lobby worker, Superagent, triage labels pass
Paired-seed AI gate cancelled — timeout-minutes: 60
Decision-cost perf gate cancelled — timeout-minutes: 60

This PR does not make the gate fit inside CI's 60-minute budget, and the run above is
the measurement that proves it.
Stating that plainly is the point: run 30829742995 is
the first ai-gate run in this repo that can be diagnosed rather than guessed at.

From the job log (Paired-seed AI gate, job 91740407535):

  • cargo ai-gate --games 10Finished dev profile [unoptimized] in 2m 04s, gate started
    15:58:08Z, ##[error]The operation was canceled at 16:55:31Z57m23s of gate run.
  • 22 of 30 games finished (red-mirror 10/10, affinity-mirror 10/10, enchantress-mirror
    2/10), each with a timestamped start/done winner=… turns=… …ms line. On main the
    same kill produces per-matchup lines only — run 30782543973 shows 37m59s of unbroken
    silence before its kill and no artifact of any kind.
  • Those 22 games sum to 197.1 min of game work against 57.4 min of wall
    3.43x effective parallelism on a 4-core hosted runner, with 8 more games still in
    flight at the kill. Commit B is doing its job; the runner is simply too small.
  • The arithmetic is not close: ~270 core-minutes of work at --games 10 against a
    4 core × 60 min = 240 core-minute budget. And the single slowest game alone is
    30.1 min
    (affinity-mirror seed 10592730) — a floor no amount of parallelism beats.

So the remaining fix is CI-side (runner size, timeout-minutes, or --games), not
source-side. That is written up separately for the maintainer rather than committed here,
since .github/workflows/** is out of scope for this PR.

Cross-environment divergence, disclosed. Of the 22 games CI finished, 21 match my
local POST run seed-for-seed on both winner and turns
(red-mirror 10/10,
affinity-mirror 10/10 — including the 30-minute seed 10592730, which completes rather
than panicking, commit D visible in CI). The one exception is enchantress-mirror seed
10593729: CI reports p1, 10 turns (matching the baseline) where my box reports
draw, 8 turns.

I could not close that out, and I am not going to guess at it. What I did exclude is local
machine load: replaying that seed alone on an idle box takes 125s instead of 277s — a 2.2x
speed swing — and returns the identical draw, 8 turns. Unexcluded candidates remain
the card-data version (CI restores a cardgen cache; my legs pin card_data_hash 0acb81c9…) and the ungated 15 ms TIME_CAP under Validation Failures item 3. Note this
divergence is orthogonal to this PR's commits — it reproduces on noC, which does not
contain commit C.

lgray added 4 commits August 3, 2026 09:18
Both gates were silent for their entire run and wrote every artifact only
after the last unit of work finished, so a CI timeout kill left nothing to
diagnose with. Measured: workflow run 30782543973 shows 37m59s of zero
output before the kill, and across the last 200 `ai-gate.yml` runs
(2026-07-26 .. 2026-08-03) 86 PR runs were killed at a 60.4m median by
`timeout-minutes: 60` — on 2026-08-02, 22 of 22.

- `duel_suite/run.rs`: one `start` line and one `done` line per game,
  carrying `HH:MM:SS` UTC, matchup id, game index, seed, winner, turn count
  and elapsed ms. Each line is self-identifying so worker interleaving is a
  grep problem, not an ordering problem. `utc_hms()` is seconds-of-day
  arithmetic — `phase-ai` depends on neither `chrono` nor `time`, and a bare
  elapsed counter cannot be lined up against a killed job's own timeline.
- `duel_suite/perf.rs`: one `start` and one `done` line per perf scenario,
  the `done` line carrying that scenario's full counter payload as a single
  JSON line, so a killed sample still leaves a machine-readable partial
  result for every scenario that did complete.

All new output is stderr. That is load-bearing in both gates: the perf
parent runs its children with `Stdio::null()` on stdout to keep its markdown
table clean (`bin/ai_perf_gate.rs`), and both nightly jobs pipe the gate's
stdout into a GitHub issue body. The per-scenario lines are emitted after
`run_perf_scenario` returns, i.e. outside the `perf_counters::reset()` /
`snapshot()` window, so no counter can move.

Assisted-by: ClaudeCode:claude-opus-4.8
…atchup

`run_matchups_parallel` capped its worker count at the number of selected
matchups (`n_workers = available_parallelism().min(run_total)`), and the
games inside a matchup ran strictly sequentially. The PR gate selects three
matchups (`ai_gate.rs`'s `DEFAULT_QUICK_FILTER`), so it ran three-wide no
matter how many cores were available, and its wall clock was the longest
single matchup's serial game time — not the total work divided by the core
count.

The cursor now hands out `(matchup, game)` pairs. A game is a pure function
of `(payload, seed, difficulty, action_cap)` and its seed is still
`base_seed + matchup_idx*1000 + game_idx` derived from the matchup's
ORIGINAL index, so nothing about a game's result depends on which worker
picked it up or when.

Supporting extractions, each now the single authority for its concern so the
sequential (attribution / harvest) runner and the parallel runner cannot
drift apart:

- `play_reported_game` — seed derivation, `catch_unwind`, harvest
  side-channel and progress lines for one game.
- `assemble_matchup_result` — the win/draw tally and the `classify` verdict,
  computed from the games vector alone.
- `game_tasks` — the work list; a matchup whose decks failed to resolve
  contributes zero tasks and keeps its `failed_result`.
- `regroup_games` — regroups by matchup and restores `game_idx` order.

Deck payloads are resolved once per matchup on the calling thread before the
fan-out, so a deck-resolution failure is DETECTED before any game starts and
no game re-resolves a deck the sequential runner resolved once. (It is not
*printed* any earlier: `build_payload` is silent, and the failure still reaches
the operator through `failed_result` in the post-join row loop.)

Ordering is not cosmetic: `MatchupResult::games` is a `deterministic_core`
field, so completion order leaking into it would be a baseline diff on every
parallel run. `regroup_games_restores_game_index_order_from_shuffled_completion`
feeds a deliberately interleaved, out-of-order completion list; deleting the
`sort_by_key` makes it fail. `game_tasks_skip_matchups_whose_payload_failed`
fails if the `is_ok()` filter is dropped.

Output contract change, in both directions. Gained: per-game lines stream live
and interleave across matchups, each tagged with its matchup id. Lost: the
per-matchup verdict rows now all print after the join, so a run killed at
`timeout-minutes: 60` emits ZERO verdict rows where the old completion-order
printer emitted one per finished matchup. That is a real regression in
kill-resilience at the matchup level and it is disclosed rather than buried —
it is a net gain only because each per-game `done` line carries id, seed,
winner and turns, so the tally and the `classify` input are reconstructible
from the log. The post-join order is also selection order, which is
deterministic; the old completion-order counter was not.

`play_reported_game` also reads the wall clock (for `elapsed_ms` and the
progress timestamps), but nothing it reads there enters the returned
`GameResult`. The suite is NOT wall-clock-free further down, and the module
docs now say where: `projection.rs`'s 15 ms `TIME_CAP` is not gated on
measurement mode. That is pre-existing and orthogonal to this commit.

The sequential branch is deliberately untouched. Attribution installs a
thread-local `tracing` dispatcher via `with_default`, so events raised on
scoped worker threads would bypass `CaptureLayer` and `drain()` would return
nothing — parallelising it would silently empty the attribution, not merely
interleave it. Harvesting joins it because one `HarvestSink` owns one append
stream. Neither is on the CI path: `ai_gate.rs` sets neither option.

Assisted-by: ClaudeCode:claude-opus-4.8
`bin/ai_gate.rs` and `bin/ai_perf_gate.rs` both install
`#[global_allocator] mimalloc::MiMalloc`, and both are invoked through
`.cargo/config.toml` aliases that carry no profile flag — so CI runs them in
the dev profile. The workspace had no `[profile.dev.package]` overrides, so
mimalloc's entire C core (one unity translation unit,
`libmimalloc-sys-0.1.49/c_src/mimalloc/v3/src/static.c`) inherited
`opt-level = 0`: every allocation in a gate run walked an unoptimized malloc.

Measured on the linked artifact, both ways:

    readelf --debug-dump=info target/debug/ai-gate | grep -A1 'GNU C23'

before: `GNU C23 16.1.1 ... -g -gdwarf-4 -O0 -ffunction-sections ...`
after:  `GNU C23 16.1.1 ... -g -gdwarf-4 -O2 -ffunction-sections ...`
followed in both cases by
`DW_AT_name: .../libmimalloc-sys-0.1.49/c_src/mimalloc/v3/src/static.c`.
Same flip on `ai-perf-gate`. Package-profile propagation into the `cc` build
is therefore confirmed, not assumed.

This is NOT provably payload-neutral, and saying so is worth more than a clean
claim. Implementation review found a live wall-clock branch on the AI's
decision path: `phase-ai/src/projection.rs:110` is `TIME_CAP = 15ms`, `:139-142`
bails on it, and unlike `search.rs:2012` and `planner/mod.rs:691` it is NOT
gated on measurement mode. It is reachable at the gate's default
`AiDifficulty::Medium` — `policies/registry.rs` registers
`EvasionRemovalPriorityPolicy` unconditionally and its `velocity_score` calls
`AiSession::get_or_project` → `project_to`, with the `projection_min_budget_ms`
floor bypassed because a measurement-mode `Deadline::none()` reports
`expired() == false` and `remaining() == None`. A bail scores 0.0 where a
completed projection scores up to +3.0, and that term selects the removal
target. Making allocation faster therefore lets more projections finish, which
can move a target, a board, a winner, and every counter downstream.

That hazard is pre-existing and host-speed-wide: it fires on any faster or
slower machine, and this override neither creates it nor can avoid it. The fix
— gating `TIME_CAP` on measurement mode the way `search.rs:2012` already does —
is a behavior change that needs its own baseline sign-off and does not belong
in a build-profile commit. What IS established here is only what `readelf`
shows. Scoped to the one package so
nothing else loses debug fidelity. `debug` is deliberately left alone —
`libmimalloc-sys`'s build script keys `MI_BUILD_RELEASE`/`NDEBUG` off it, and
it is also what keeps `DW_AT_producer` available as the evidence channel above.

Assisted-by: ClaudeCode:claude-opus-4.8
… in debug

`search::fallback_action`'s pending-cast branch is a HANDLED condition: it
logs and returns `CancelCast` (CR 601.2), which is what every release build
has always done. It also carried `debug_assert!(false, ...)`, which made the
two profiles disagree about the game *result*, not just about diagnostics:

- both AI gates run the dev profile (`.cargo/config.toml`:
  `ai-gate = "run --bin ai-gate --"`, no profile flag), so CI panics here;
- `duel_suite/run.rs` catches that panic per game and scores the seed
  `(None, 0)` — a DRAW release would have played out;
- the committed `suite-baseline.json` it is compared against is
  release-generated (`scripts/refresh-ai-baseline.sh` execs
  `scripts/ai-gate.sh`, which does `cargo build --release --bin ai-gate`).

So CI was comparing a debug run against a release-cut baseline across an
assert that only exists in one of them. Removing it makes the CI run agree
with the baseline's own profile. No baseline is refreshed by this commit.
Verified the committed baseline is unaffected: it contains zero `turns == 0`
games, and its only two `winner: null` rows are affinity-mirror seeds
10592735/10592736 at `turns: 8` — genuine draws, not panic artifacts.

The diagnostic guidance moves verbatim into the `tracing::error!` that was
already there; the gap the assert guarded is still a real bug, and the error
event is still the way to find it.

The two Claws-of-Gix scenarios named that assert as their detector. Their
backing assertion, `assert!(results.len() <= 200)`, is structurally vacuous:
`run_ai_actions` is hard-capped at `MAX_AI_ACTIONS_PER_SEQUENCE = 200`
(`auto_play.rs`), so the predicate holds for every possible run. Replaced
with `assert_no_fallback_cancel`, which is sound because `tactical_gate.rs`
rejects `CancelCast` from the strategic pool — an APPLIED `CancelCast` can
only have come from `fallback_action`. Unlike the assert it replaces, it also
holds in release builds.

Measured while strengthening those two tests, and disclosed rather than
quietly absorbed: on the WITNESS board the AI does not activate the Claws at
all — driving the loop yields exactly `[PassPriority]`. So that test was
vacuous for the scenario its name describes, both before this change (the
removed `debug_assert` could never fire on a board the AI passes on) and after.
Its load-bearing assertion is therefore the new `activation_legal_for`
precondition, which fails the moment a Metalcraft/cost regression stops
`legal_actions` surfacing the activation; its doc now states exactly that
instead of claiming a completion it does not observe. The positive
`ActivateAbility` assertion went to the mana-first sibling, which does complete
the activation (measured: `[ActivateAbility, SelectCards, PassPriority]`). The
same assertion failing on the witness board is that assertion's own
discriminating control. Why the AI declines a legal, witnessed Claws
activation is a separate question and is not in this commit's scope.

Disclosed trade: in dev, a seed that reaches this branch used to abort the game
at `(None, 0)` immediately. It will now churn cast → CancelCast until
`MAX_TOTAL_ACTIONS = 10_000` (`duel_suite/run.rs`) instead. Latent today — the
release baseline's 30 games run 8-20 turns with no capped game, so no quick-gate
seed reaches the branch — but it is a known cost, not a surprise, and it is the
correct trade: a handled path must not be a panic in one profile only.

`assert_no_fallback_cancel` checks both outcomes. `run_ai_actions` only records
an action once `apply_interaction` succeeded, and a dead-end reached through the
bare `allows_cancel_cast` disjunct is not enumerated by
`candidate_actions_broad_with_probe`, so that shape lands in `break_reason`
rather than in `results` and would slip past a results-only assertion.

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray
lgray requested a review from matthewevans as a code owner August 3, 2026 15:55
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR optimizes libmimalloc-sys in debug builds, changes duel-suite parallelism to game-level scheduling with progress reporting, and replaces pending-cast debug panics with logged CancelCast fallback handling. Tests cover scheduling, aggregation, activation legality, and fallback outcomes.

Changes

Duel suite execution and reporting

Layer / File(s) Summary
Native debug build configuration
Cargo.toml
The development profile sets libmimalloc-sys to opt-level = 2.
Game-granular execution
crates/phase-ai/src/duel_suite/run.rs
The parallel runner creates tasks per game, resolves payloads once, executes games through shared helpers, preserves ordering, and aggregates matchup results after completion.
Suite progress and aggregation validation
crates/phase-ai/src/duel_suite/perf.rs, crates/phase-ai/src/duel_suite/run.rs
The suite logs scenario and game progress, serializes counter snapshots, and tests task filtering, result ordering, empty slots, and aggregation values.

Pending-cast fallback behavior

Layer / File(s) Summary
Pending-cast fallback and scenario coverage
crates/phase-ai/src/search.rs, crates/phase-ai/tests/scenarios.rs
Pending casts now log an error and return CancelCast without a debug assertion. Claws of Gix tests validate activation legality and reject fallback cancellation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant run_parallel_suite
  participant play_reported_game
  participant assemble_matchup_result
  run_parallel_suite->>play_reported_game: execute one matchup game
  play_reported_game-->>run_parallel_suite: return game result and duration
  run_parallel_suite->>assemble_matchup_result: aggregate ordered game results
  assemble_matchup_result-->>run_parallel_suite: return matchup summary
Loading

Possibly related PRs

Suggested labels: enhancement, bug

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: improving AI gate completion and adding progress evidence.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Aug 3, 2026
@matthewevans matthewevans self-assigned this Aug 3, 2026
@matthewevans matthewevans added the enhancement New feature or request label Aug 3, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes — repository hard-stop review is required for this build-profile change before this PR can receive implementation review.

[HIGH] This PR changes workspace build configuration. Evidence: Cargo.toml:47-75 adds [profile.dev.package.libmimalloc-sys] opt-level = 2; the current policy packet classifies Cargo.toml as a hard_stop path. The change applies to the native AI binaries and the PR itself documents that it can change live, time-capped projection completion and therefore downstream AI decisions. Why it matters: this is not an isolated harness refactor; it alters dependency compilation and decision behavior across the dev profile, so it needs a dedicated maintainer-approved performance/baseline review before the remaining implementation can be evaluated or merged. Suggested fix: split or otherwise obtain the required maintainer review for the Cargo profile override, with the decision-impact/baseline evidence scoped to that change; then request a fresh review of the resulting current head.

No implementation verdict is implied by this hard-stop disposition.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
crates/phase-ai/src/duel_suite/run.rs (4)

578-613: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tag the abort lines with the timestamp and matchup id.

Under the game-level runner, games from different matchups interleave on stderr. The two abort lines print only the seed, so an operator cannot attribute an aborted game to a matchup without cross-referencing seed arithmetic. Every other progress line in this function carries utc_hms() and spec.id. Make the abort lines match.

Both branches also repeat the same catch_unwind + abort-print shape. A small local closure for the abort report would keep the two in sync.

♻️ Proposed change
-            Err(_) => {
-                eprintln!("       seed {seed} aborted: AI panic during suite game");
-                (None, 0)
-            }
+            Err(_) => {
+                eprintln!(
+                    "{ts} [{id}] game {n}/{total} seed={seed} aborted: AI panic during suite game",
+                    ts = utc_hms(),
+                    id = spec.id,
+                    n = game_idx + 1,
+                    total = options.games_per_matchup,
+                );
+                (None, 0)
+            }
🤖 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/run.rs` around lines 578 - 613, Update both
AI-panic abort reports in the harvest and non-harvest branches of the game-level
runner to include utc_hms() and spec.id alongside the seed, matching the
surrounding progress lines. Extract the shared abort-report formatting into a
small local closure and reuse it from both catch_unwind Err branches.

1109-1231: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add coverage for seed derivation from the original matchup index.

The tests cover task filtering, regrouping, empty slots, and the aggregation denominator. They do not cover the one contract that the restructuring most easily breaks: the worker derives matchup_seed from idx (the matchup's original position in all_matchups()), not from pos (its position in selected). With an id filter active, those two differ. If a later edit uses pos, every seed shifts and only a full baseline comparison detects it.

Extract the derivation into a small helper, then assert it against a filtered selection.

🧪 Suggested helper and test
fn matchup_seed(base_seed: u64, original_idx: usize) -> u64 {
    base_seed.wrapping_add(original_idx as u64 * 1_000)
}

#[test]
fn game_seed_follows_original_matchup_index_not_selection_position() {
    // `selected` position 0 holding original index 3 must seed from 3, not 0.
    assert_eq!(matchup_seed(7, 3).wrapping_add(2), 3_009);
}

As per path instructions: "Strengthen tests around observable legality, fallback cancellation, aggregation denominators, task filtering, regrouping, and empty slots; avoid vacuous assertions."

🤖 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/run.rs` around lines 1109 - 1231, Extract
matchup seed calculation into a helper such as matchup_seed, using the original
matchup index rather than the selected-position index, while preserving wrapping
arithmetic. Add a focused test with a filtered selection where original index 3
is at selected position 0, and assert the derived game seed reflects index 3.

Source: Path instructions


319-335: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a named task type instead of (usize, usize).

The pair (usize, usize) flows through game_tasks, the worker loop, and regroup_games as (matchup position, game index). A small struct GameTask { matchup_pos: usize, game_idx: usize } would make each field self-describing and remove the risk of swapping the two indices in a later edit. The same applies to the 4-tuple (usize, usize, GameResult, u128).

🤖 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/run.rs` around lines 319 - 335, Replace the
positional `(usize, usize)` game-task representation with a named `GameTask`
struct containing `matchup_pos` and `game_idx`, and update `game_tasks`, the
worker loop, and `regroup_games` to use its fields. Also introduce a named type
for the `(usize, usize, GameResult, u128)` value passed through those paths,
updating construction and destructuring without changing behavior.

476-485: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Progress numbering now differs between the two runners.

The sequential branch labels rows with the matchup's original index over matchups.len() (Line 293-298). This loop labels rows with the selection position over the selected count. With an id filter active, the same matchup gets a different label depending on which runner ran it. Reuse selected's original index and total here so log lines stay comparable across branches.

🤖 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/run.rs` around lines 476 - 485, The
selected-results loop should use each matchup’s original index and the overall
matchup total, matching the sequential runner’s progress labels. Update the loop
around result.matchup_id to retain or derive the corresponding original index
from selected and format progress with that index and total instead of the local
enumerate position and results.len().
🤖 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 `@Cargo.toml`:
- Around line 73-74: Validate the opt-level change for libmimalloc-sys under the
authoritative dev profile by generating before/after paired-seed AI evidence and
comparing projection completion, target selection, winners, and counters against
the 15ms cap. Do not rely on --release-generated suite-baseline.json; if the
optimization is intended only for the PR gate, move it into a dedicated profile
instead of [profile.dev.package.libmimalloc-sys].

---

Nitpick comments:
In `@crates/phase-ai/src/duel_suite/run.rs`:
- Around line 578-613: Update both AI-panic abort reports in the harvest and
non-harvest branches of the game-level runner to include utc_hms() and spec.id
alongside the seed, matching the surrounding progress lines. Extract the shared
abort-report formatting into a small local closure and reuse it from both
catch_unwind Err branches.
- Around line 1109-1231: Extract matchup seed calculation into a helper such as
matchup_seed, using the original matchup index rather than the selected-position
index, while preserving wrapping arithmetic. Add a focused test with a filtered
selection where original index 3 is at selected position 0, and assert the
derived game seed reflects index 3.
- Around line 319-335: Replace the positional `(usize, usize)` game-task
representation with a named `GameTask` struct containing `matchup_pos` and
`game_idx`, and update `game_tasks`, the worker loop, and `regroup_games` to use
its fields. Also introduce a named type for the `(usize, usize, GameResult,
u128)` value passed through those paths, updating construction and destructuring
without changing behavior.
- Around line 476-485: The selected-results loop should use each matchup’s
original index and the overall matchup total, matching the sequential runner’s
progress labels. Update the loop around result.matchup_id to retain or derive
the corresponding original index from selected and format progress with that
index and total instead of the local enumerate position and results.len().
🪄 Autofix (Beta)

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: e68cebd0-87f1-4c99-9ade-49f752c1edeb

📥 Commits

Reviewing files that changed from the base of the PR and between 7a1b357 and 04185c7.

📒 Files selected for processing (5)
  • Cargo.toml
  • crates/phase-ai/src/duel_suite/perf.rs
  • crates/phase-ai/src/duel_suite/run.rs
  • crates/phase-ai/src/search.rs
  • crates/phase-ai/tests/scenarios.rs

Comment thread Cargo.toml
@matthewevans matthewevans removed their assignment Aug 3, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer-side review. I had independently profiled this from the CI side (#6967) before this PR landed, so this is a cross-check against a second data set plus four concrete proposals.

Short answer: yes, this helps, and the direction is right — but the arithmetic says it does not reach green on a hosted runner by itself.

What I verified at source (not taken on trust)

All three of your disclosed pre-existing findings are real. I checked each against main rather than accepting the write-up:

  1. The comparator is blind to decisive→draw. compare.rs:268-273 matches (baseline.winner, current.winner) with arms for (Some(0), Some(1)) and (Some(1), Some(0)), then _ => unchanged += 1. So (Some(0), None) — a decisive result becoming a draw — is counted as unchanged. Confirmed.
  2. TIME_CAP is ungated. projection.rs:110 is a bare const TIME_CAP: Duration = Duration::from_millis(15) and :140 tests elapsed >= TIME_CAP with no measurement-mode check, unlike the gating at search.rs:2012 / planner/mod.rs:691. Confirmed.
  3. The debug_assert!(false, …) exists at search.rs:921, immediately above return Some(GameAction::CancelCast). In the debug build the gate actually runs, that path panics; in the release build the committed baseline was cut with, it is compiled out and the cast is cancelled and play continues. The instrument genuinely did not match the run that produced its own reference. Confirmed.

That third one is the most interesting thing in this PR and I don't think the summary gives it enough weight: it means some fraction of the existing baseline was never comparable to what the gate measures.

The per-game telemetry is already earning its keep

I could compute everything below only because of commit A. Your PR's own gate run (30829742995) was killed, and unlike every previous killed run it still tells us exactly what happened. Before: 30782543973 gave 37m59s of silence. That alone justifies the change.

Where I disagree: this is not sufficient on a 4-vCPU runner

From your own run's per-game lines:

matchup games done Σ CPU-min mean max
red-mirror 10/10 50.3 5.0m 10.5m
affinity-mirror 10/10 122.7 12.3m 30.1m
enchantress-mirror 2/10 24.2 12.1m 14.2m
total 22/30 197 9.0m 30.1m

Extrapolating the 8 unfinished games at the observed mean gives ~269 CPU-minutes of work.

269 CPU-min / 3 workers = 90 min wall
269 CPU-min / 4 workers = 67 min wall

ubuntu-latest is 4 vCPU, and ~8 min of the 60 is already spent on Generate card data. Even perfect game-level parallelism overruns the budget. Your measured 4.4× (30 games in 1629 s) implies a host with roughly 10–16 usable cores; that is the gap between your box and CI, and it is why this PR's own Paired-seed AI gate and Decision-cost perf gate were both CANCELLED.

This is not an argument against the PR — game-level parallelism is strictly better than matchup-level, and the skew in that table (30.1 min vs 2.3 min games) is exactly the case matchup-level parallelism handles worst. It is an argument that one more lever is needed in the same change, or the gate stays red and all of this good work reads as "still broken."

Proposals

P1 — Add the missing multiplier: optimize the compute crates, not just the allocator

[profile.dev.package.libmimalloc-sys] opt-level = 2 optimizes the allocator's C core. Your own decomposition puts that at 1.16× (noC 0.0159 → POST 0.0184 games/s), against 3.8× for the parallelism + assert work. Meanwhile phase-engine and phase-ai — where essentially all the time goes — still compile at opt-level = 0.

[profile.dev.package.phase-engine]
opt-level = 2

[profile.dev.package.phase-ai]
opt-level = 2

Two things make this safe, and they are the same two your PR already relies on:

  • opt-level does not disable debug_assertions, so this does not silently re-hide the class of defect commit D removed. Commit D is still needed on its own merits.
  • Verdict-neutrality is already established for this axis: ai-gate.yml:138-140 states "debug profile (authoritative): counter VALUES are profile-independent", and the paired-seed verdict is behavioural (p0%, flips W→L, flips L→W, sign test) with no timing term.

Please measure rather than assume the multiplier — a compute-bound Rust workload usually gains far more from this than from an allocator change, but the honest number is whatever your A/B harness reports, and you already have the harness built. Worth reporting the compile-time cost alongside it, since it lands on every dev build.

P2 — Land the comparator fix with this, not after it

I understand the instinct to keep it separate, and normally I'd agree. Here the sequencing is the problem:

  • Today the gate times out. It is visibly broken, and nobody mistakes it for coverage.
  • After this PR the gate completes and prints PASS — while _ => unchanged still swallows decisive→draw. Your own run had the suite section say FAIL and the compare stage say PASS in the same report, exit code 0.

Shipping throughput first converts a loudly-broken gate into a quietly-wrong one, and the quiet version will be trusted. The fix is small and mechanical: make the match exhaustive over (Option<u8>, Option<u8>) rather than using _, and surface decisive → draw / draw → decisive as their own columns so the sign test isn't computed over a denominator that hides them. A wildcard arm hiding a real case is exactly the failure mode that let this sit.

P3 — Gate TIME_CAP on measurement mode in this PR too

Same sequencing argument. While every run dies at 60 min, host-speed dependence is invisible. The moment runs complete, projection.rs:140 makes verdicts a function of runner speed — and hosted-runner speed varies ~2×, which the workflow's own comment already acknowledges. Copy the gating from search.rs:2012 / planner/mod.rs:691. Without it, P1 and the parallelism change both increase the chance of a verdict flip that has nothing to do with the code under test.

P4 — The 30-minute game is a regression, not a constant

affinity-mirror seed 10592730 at 30.1 min, and enchantress-mirror failing on unmodified main, are consistent with something that landed recently. From the CI side (full data in #6967):

  • Through 07-29T17:04, red-mirror ran 12.7–25.1 min and 3/3 matchups completed.
  • From 07-30T20:21, red-mirror runs 33.8–44.0 min and 0–2 of 3 complete.
  • The last green AI gate run in all recorded history is 07-29T16:59 (ec7601a408).

That is a ~2× per-matchup slowdown inside a 27-commit window (git log --since=2026-07-29T17:00 --until=2026-07-30T21:00). Highest-prior candidates, not proven: bbb176eb75 (adds VehicleDeploymentPolicy to per-candidate scoring, lands 29 min after the last fast run), e6c6bb07c4 (interaction binding adds per-simulated-action work in the search hot path), d05ee575d6 (a perf commit that may have regressed this workload).

I could not run the A/B — this host is at 99% disk. You already have the exact harness for it, so if you have headroom, building at ec7601a408 vs 73fd7f6de9 and running --suite-filter red-mirror with a fixed seed would settle it in one shot. Entirely reasonable as a follow-up rather than in this PR; flagging it so the headroom this PR buys doesn't quietly get re-consumed.

P5 — Make re-degradation visible

SuiteReport already computes total_duration_ms (run.rs:499). Emitting it against a recorded budget — as a warning distinct from the behavioural verdict — would have surfaced this drift in late July instead of at the timeout wall. Cheap, and it is the check that would have made this PR unnecessary.

Summary

Commits A (per-game progress) and B (game-level parallelism) are clearly right and I'd take them as-is. Commit D is a genuine measurement-correctness fix that deserves more prominence than it's getting. Commit C is fine but is the smallest lever of the four.

The blocker is P1: without another multiplier, the 269 CPU-minutes still don't fit in 4 cores × 60 min, and the gate stays red. P2 and P3 I'd want in the same change specifically because this PR is what makes the gate start completing — they are only harmless while it doesn't.

Thank you for the measurement discipline here. The A/B controls, the DWARF -O0/-O2 verification, and the revert-probes on every new assertion are well above the bar, and disclosing three pre-existing defects you could have quietly left alone is the right call.

@matthewevans

Copy link
Copy Markdown
Member

Correction to my P1 — and it changes the ordering, so please read before acting on my previous comment.

I justified adding opt-level = 2 for phase-engine / phase-ai by claiming verdict-neutrality was "already established for this axis," citing ai-gate.yml:138-140. That citation is about the decision-cost gate's counters. It does not cover the paired-seed win-rate gate, and your Cargo.toml comment already explains why — I should have read it more carefully before proposing.

I've now walked the chain you describe and it holds at every step:

  • policies/registry.rs:373 registers EvasionRemovalPriorityPolicy unconditionally.
  • policies/evasion_removal_priority.rs:164 calls session.get_or_project(...), guarded at :162 by can_afford_projection() — which a measurement-mode Deadline::none() bypasses.
  • projection.rs:139-142 then bails purely on wall clock: if elapsed >= TIME_CAP { return Err(BailReason::TimeCapExceeded { elapsed }) }.
  • A bail yields 0.0; a completed projection contributes a real score that selects the removal target.

So speed is an input to the verdict on this path. That is precisely the hazard your commit C comment declines to hide, and you were right to state it rather than claim allocator changes are invisible.

The consequence for my proposal is worse than for yours, not better: if a 1.16× allocator speedup can move a target through TIME_CAP, a 5–20× engine-level opt-level change is far more likely to. P1 as I wrote it is not verdict-neutral, and should not land as-is.

Revised ordering. P3 is not a companion to P1 — it is a prerequisite for it:

  1. Gate TIME_CAP on measurement mode first, mirroring search.rs:2012 / planner/mod.rs:691. This is small and self-contained, but it is verdict-affecting and wants its own baseline sign-off — so it deserves its own change, exactly as you said.
  2. Only then is any further optimization (mine, or a larger version of your commit C) verdict-neutral rather than verdict-shifting.

This also means the honest reading of your commit C is that it carries a real, disclosed risk today — and the fix for that risk is step 1, not more caution about allocators.

What does not change: commits A (per-game progress) and B (game-level parallelism) are unaffected by any of this — B's neutrality rests on paired seeds and you demonstrated it with differing winners: 0. Commit D is a straight correctness fix. And the capacity arithmetic in my previous comment stands: ~269 CPU-min against 4 vCPU is ~67 min wall, so the gate still needs a throughput lever beyond parallelism. It just needs step 1 to come first, so that lever doesn't buy speed by silently changing what the gate measures.

Apologies for the churn — my P1 was reasoned from the wrong half of the workflow's comment, and your Cargo.toml note had already gotten this right.

@matthewevans
matthewevans merged commit 470b0bf into phase-rs:main Aug 3, 2026
17 of 19 checks passed
@lgray

lgray commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Flagging this now rather than after the fact, since you're actively working this area in #6967 and it affects the window you'd build on.

Your P4 fast-reference anchor ec7601a408 is not an ancestor of main.

$ git fetch upstream ec7601a408
fatal: couldn't find remote ref ec7601a408

$ git fetch upstream ec7601a4084ce859f4cbaf1c91a214dbd404f30d
 * branch  ec7601a4084ce859f4cbaf1c91a214dbd404f30d -> FETCH_HEAD

$ git merge-base --is-ancestor ec7601a4084ce859f4cbaf1c91a214dbd404f30d upstream/main
$ echo $?
1

It resolves to test(phase-ai): enforce the routed-and-neutral invariant instead of skipping it (2026-07-29T16:43:18Z) — a squashed-away PR-branch commit. Good news
first: the full SHA still fetches
(the server allows reachable-SHA1-in-want), so the
A/B you proposed remains executable exactly as specified.

The consequence is for the inference, not the experiment. You wrote that the last
green AI gate run in recorded history is 07-29T16:59 at ec7601a408, and derived a
~2× per-matchup slowdown inside a 27-commit window of main. But that fast measurement
was taken on a branch, while the slow measurements are on main — the two arms sit
on different lines of history. So the window may be mis-scoped: the step could sit
outside it, or partly reflect branch-vs-main differences rather than anything that
landed. When we run the A/B we'll label the fast arm branch-not-main and scope the
conclusion accordingly, rather than quietly treating it as a main datapoint.

This is the third off-main reference point in this area, which is what makes it
worth a comment rather than a footnote:

reference stamped git_sha state
crates/phase-ai/baselines/suite-baseline.json 2d686880b94b HTTP 422 — no such commit
crates/phase-ai/baselines/perf-baseline.json 64b65e58e249 a #6777 branch commit, not on main
your P4 fast anchor ec7601a408 orphaned branch commit, not an ancestor of main

Three independent instances turns "re-cut baselines only from a commit that is an
ancestor of main" from hygiene into a measured necessity — the cost is exactly what
you just hit: a regression window anchored to something nobody can reproduce from a
fresh clone. We'll carry that as a named residual with all three instances rather than
as a general suggestion.

Sequencing note: we're running the A/B after the TIME_CAP gating change lands its
own wall-clock measurement. Gating the cap removes a time bound and leaves only
STEP_CAP, so it may itself move per-game wall clock — interpreting your A/B through an
instrument that is about to shift would mislabel whatever it found.

Same courtesy you extended by verifying our disclosed defects at source before building
on them.

lgray added a commit to lgray/phase that referenced this pull request Aug 5, 2026
…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
lgray added a commit to lgray/phase that referenced this pull request Aug 5, 2026
…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
lgray added a commit to lgray/phase that referenced this pull request Aug 5, 2026
…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
lgray added a commit to lgray/phase that referenced this pull request Aug 5, 2026
`--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
lgray added a commit to lgray/phase that referenced this pull request Aug 5, 2026
`--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
lgray added a commit to lgray/phase that referenced this pull request Aug 5, 2026
…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
lgray added a commit to lgray/phase that referenced this pull request Aug 5, 2026
`--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
lgray added a commit to lgray/phase that referenced this pull request Aug 5, 2026
`--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
lgray added a commit to lgray/phase that referenced this pull request Aug 5, 2026
…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
lgray added a commit to lgray/phase that referenced this pull request Aug 8, 2026
…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
lgray added a commit to lgray/phase that referenced this pull request Aug 8, 2026
`--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
lgray added a commit to lgray/phase that referenced this pull request Aug 9, 2026
`--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
lgray added a commit to lgray/phase that referenced this pull request Aug 9, 2026
…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
lgray added a commit to lgray/phase that referenced this pull request Aug 10, 2026
…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
lgray added a commit to lgray/phase that referenced this pull request Aug 10, 2026
`--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
lgray added a commit to lgray/phase that referenced this pull request Aug 14, 2026
`--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
lgray added a commit to lgray/phase that referenced this pull request Aug 14, 2026
…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
lgray added a commit to lgray/phase that referenced this pull request Aug 17, 2026
…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
lgray added a commit to lgray/phase that referenced this pull request Aug 17, 2026
`--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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants