Skip to content

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

Open
lgray wants to merge 4 commits into
phase-rs:mainfrom
lgray:perf/ai-gate-fixes
Open

perf(ai): make the AI gates finish and leave evidence#6969
lgray wants to merge 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
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