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.

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