fix(engine): correct the answer-beat frame mechanism and harden its row (follow-up to #7005) - #7023
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe change clarifies loop-detection semantics and updates synchronized-window regression coverage. Tests identify every frame minted during a beat, including frames affected by eviction or ring rebuilding, and validate synchronized priority state. ChangesLoop sampling semantics
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/engine/tests/integration/loop_shortcut.rs`:
- Around line 11274-11306: Replace the length-based mint detection in the beat
loop around dump_drive_one_beat with complete Arc membership tracking: snapshot
every pre-beat loop_detect_ring entry via Arc::as_ptr, then after the beat
identify and validate every remaining frame whose pointer was not present in
that snapshot. Remove the back_after/grew assertion and ensure append, capacity
eviction, and clear-and-rebuild paths all pass every newly minted frame to the
existing validation logic, preserving production failure-path coverage.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 6c380cd0-2eb2-4a3f-a086-7b11e3cf6250
📒 Files selected for processing (3)
crates/engine/src/analysis/resource.rscrates/engine/src/game/engine.rscrates/engine/tests/integration/loop_shortcut.rs
|
Generated for head Parse changes introduced by this PR✓ No card-parse changes detected. |
matthewevans
left a comment
There was a problem hiding this comment.
Review head: 483ce27d3e211ad63771a750b856827144626c66
[MED] The regression test does not validate every frame it claims to validate. Evidence: crates/engine/tests/integration/loop_shortcut.rs:11283-11330 detects only a changed back pointer, derives grew from net ring length, and validates rev().take(grew); crates/engine/src/types/game_state.rs:19664-19672 evicts the front before pushing at capacity. Why it matters: an evicting push changes the back while preserving length, causing the row to fail instead of checking the newly minted frame; a clear-and-rebuild beat can also have positive net growth smaller than its minted-frame count, leaving some new frames unvalidated. Suggested fix: snapshot the complete pre-beat Arc::as_ptr membership, then validate every remaining post-beat sample whose pointer was absent before the beat; do not infer minted frames from length.
This confirms the still-current CodeRabbit finding at #7023 (comment).
…t frame homogeneity
Comment/test-prose correction on top of `f144bb374`. NO production behaviour
change: the `sync_waiting_for(state, &wf)` hoist that commit made is correct and
is untouched. What it shipped WRONG was the justification.
`f144bb374` claimed, in its message and in three in-tree sites, that a
heterogeneous ring "breaks `ring_delta_signature`'s turn-position conjunct,
because `impl PartialEq for GameState` compares `waiting_for`/`priority_player`".
That is false at source. `ring_delta_signature`'s body reads exactly two things:
`ResourceVector::snapshot(&f.normalized)` and
`window_scope_from_cover_frames(..).phase_invariant`, and `phase_invariant` is
`turn_number` + `phase` + `extra_phases.is_empty()` on both frames. Measured:
`grep -cP "waiting_for|priority_player|loop_states_equal"` over the function body
returns 1, and that single hit is a prose comment, not code.
The real sensitivity is BASIS A. The ring scans call
`analysis::resource::loop_states_equal_modulo_resources(prior, state)` with
`prior` a ring frame's `normalized` half and `state` the LIVE board; that chains
to `types::game_state::loop_states_equal` => `impl PartialEq for GameState`,
which DOES compare both fields, and neither `normalize_for_loop` nor
`project_out_resources` neutralizes either. An un-synced answer-beat frame
therefore compares UNEQUAL against a synced live board and basis A misses the
recurrence the frame was minted to certify. Each link verified at source.
Corrected, in order of harm:
* `tests/integration/loop_shortcut.rs` arm-3 failure message — it printed the
false reason ON FAILURE, the worst placement of the four. Now states that
`ring_delta_signature` is INSENSITIVE to the two fields and that arm 3 is the
blast-radius pin, not a restatement of arms (1)/(2).
* the same file's row doc, and `game/engine.rs`'s permanent comment at the
sampler, both re-cited to basis A. The comment quotes and names the false claim
rather than silently replacing it, because a later reader would otherwise
re-derive it.
* `analysis/resource.rs`'s `ring_delta_signature` doc gains a minimal SCOPE note
separating what the function READS from where its frames' homogeneity comes
from. The pre-existing turn-position paragraph is TRUE (via `phase_invariant`)
and is left standing.
Also corrected:
* BLAST RADIUS was justified by "`apply_action_boundary` re-syncs". Three
production routes reach `apply_action` without that boundary:
`inject_pinned_answer`'s three dispatches, `drive_loop_action_iteration`'s
ten, and `apply_interaction_pre_reconciliation_for_life_safety`, which returns
`raw.result` without ever calling `finish_action_boundary` (recorded in
`apply_action_boundary_core`'s own comment). The claim is now an argument about
RE-DERIVATION (`finish_action_boundary` runs the same `sync_waiting_for` over
`result.waiting_for` and copies it back; the reorder does not change
`ActionResult.waiting_for`) plus the measured fact that all three non-boundary
routes drive a CLONE (`drive_one_shortcut_cycle`'s `work`, the drive's `clone`,
`preview_candidate_life_safety`'s `preview`), never the settled board.
* "PRE-pipeline pair" was the wrong label for what the pre-fix snapshot carried:
`run_post_action_pipeline_from` itself writes `state.waiting_for` at five sites.
Relabelled "UN-SYNCED pair".
* the permanent comment asserted, present tense, "0 divergences over 18,486 lib +
4,487 integration rows" — figures from a probe tree that this tree already
exceeds. Restated as a historical measurement attributed to `f144bb374`, with
no transcribed row count that can go stale.
* one sentence added for a consequence `f144bb374` left unmentioned: because the
synchronizer runs `normalize_legacy_attach_waiting_for`, `state.waiting_for` can
now differ from the returned `ActionResult.waiting_for` on this path, where the
old raw clone made them exactly equal. Benign, and why.
ARM 2 ASSERTED THE WRONG COMPARAND — the one code-affecting fix here.
`frame.priority_player == frame.active_player` is not what `sync_waiting_for`
establishes: it sets `priority_player =
turn_control::authorized_submitter_for_player(state, wf.acting_player())`, which
re-routes to a DIFFERENT seat under a turn-decision controller (Mindslaver) or a
latched search-decision controller. A correctly-synced frame on any future
turn-control fixture would false-fail this standing pin. The comparand is now the
authority function applied to the frame's own window.
That change does NOT weaken the arm, measured rather than argued. Neither
`effective_authority_for_player` nor `search_decision_authority` reads
`priority_player`, so the recomputation cannot be tainted by the mutant it must
catch. Re-run at this tree, predicate `cargo test -p phase-engine --test
integration loop_shortcut::answer_beat_frames_carry_the_synced_window_and_the_
offer_certificate_is_exact -- --exact`, one unit = one test row:
* clean: 1 passed.
* MUTANT-A (`state.priority_player = PlayerId(3);` after the sync): FAILED at
`loop_shortcut.rs:11335`, `left: PlayerId(3)` / `right: PlayerId(0)`, beat 5 —
the first answer-beat mint. Arm 2 still fires.
* PURE REVERT of the reorder (drop the hoisted sync, restore the raw clone below
the record): 1 passed — so the row's own "a pure revert leaves all three arms
GREEN" statement remains true under the new comparand, and the arm is still a
standing pin rather than a live catch. `engine.rs` restored byte-identically
afterwards, verified by sha256.
Assisted-by: ClaudeCode:claude-opus-5
CR 603.5 CENSUS PIN, moved because this commit moved it. The line-exact pin in
`the_cr_603_5_prompt_census_is_pinned_so_a_sixth_producer_is_a_counted_event`
goes `engine.rs:11549 ⇒ :11583`, and the drift-log entry carries the evidence the
row's own convention demands: engine.rs's entire delta this round is ONE comment
hunk (`@@ -11177,8 +11177,42 @@ fn apply_action`, numstat +42/-8 ⇒ net +34)
sitting above the producer, 11549 + 34 = 11583 exactly, the producer line is
sha256-identical to `a6d1a0e62:engine.rs:11549` (prefix `8a544e878d3e77fb…`, the
same prefix the log already recorded for `:11549`), it is still inside
`begin_pending_trigger_target_selection` (which moved by the same +34, :11400 ⇒
:11434), the census total (37) and partition (5/7/25) asserts fired GREEN on the
run that caught this, and the other four entries did not move. Not a sixth
producer.
ONE MORE ROTTED CITATION, same class, fixed LINE-NEUTRALLY. `bounded_cycle_offer`'s
basis-A note cited the four sibling ring `find_map` scans as
`:481`/`:668`/`:710`/`:808`. Measured at this tree those lines hold `);`, `block,`,
`}` and `state,` — meaningless fragments; the real scans are the `On` reconcile,
Path B, Path C and `find_live_loop_winner`. That stale citation is what propagated
the bogus coordinates into this round's own brief, through two agents, before
anyone re-derived them — so the fix is to NAME the four and delete the numbers,
which cannot rot the same way. The block grows by one line and the sampler comment
was tightened by one to pay for it, keeping the CR 603.5 producer exactly on
`:11583`: engine.rs's hunks above it are now `@@ -1924,5 +1924,6 @@` (+1) and
`@@ -11177,8 +11178,41 @@` (+33), still 11549 + 34.
COUNT RECONCILED, and my first figure was wrong. `drive_loop_action_iteration`
dispatches `apply_action` TEN times, not nine. Predicate: `awk` the function body
(`fn drive_loop_action_iteration(` at :4062 to the next column-0 `}` at :4319) and
`grep -cP 'apply_action\('` it; one unit = one call-expression opening line. The
ten are :4087 :4118 :4150 :4173 :4200 :4222 :4231 :4256 :4271 :4303. My "nine" was
a transcription miscount off a whole-file grep that had listed :4231 — not a
different predicate. Corrected above and in the comment.
… of measuring it
CodeRabbit (Major) on `answer_beat_frames_carry_the_synced_window_and_the_offer_
certificate_is_exact`. The row is a STANDING pin — it exists to fire the first
time an answer beat diverges — but its mint detector could hand arms (1)/(2) the
wrong frame, in which case the pin goes quietly vacuous instead of firing. Two
independent ways, both closed here. No production change; test-only.
(1) ATTRIBUTION. A single beat can reach BOTH samplers — `apply_action`'s
forced-window answer site, then the settle site in
`pass_priority_once_with_pipeline`. The ring then grows by 2, `answered_forced_
window` is true, and `back()` holds the SETTLE frame; arms (1)/(2) pass while
inspecting a frame the reorder never touched. `assert_eq!(grew, 1, ..)` on the
answer path is the property that makes `back()` the answer-site frame, and
`settle_mints += grew` stops the settle path under-counting a double mint.
(2) EVICTION. `record_loop_detect_sample` pops the front before pushing once the
ring is at `LOOP_DETECT_RING_CAP`, so a full ring mints WITHOUT growing and the
old `len() == before ⇒ continue` read that as "no mint" — `answer_mints` would
then measure a smaller set than the reach-guard claims. Fixed by detecting a mint
through `Arc` identity of the ring's back, which is what
`game::engine::drive_one_shortcut_cycle` already does and for this exact reason
(its own comment: "a length delta reads 0 once it is full"). Reusing the shipped
instrument rather than adding a second one.
DELIBERATE DEVIATION, stated rather than slipped in: the brief asked for a
headroom assertion against the ring's capacity. The capacity is real and fixed —
`LOOP_DETECT_RING_CAP: usize = 16`, `types/game_state.rs:20025` — but it is
PRIVATE to that module, so an integration row can only transcribe the literal.
A transcribed cap fails in the dangerous direction: if the const ever SHRINKS
below the drive's ring length, `len() < 16` still passes while eviction silently
resumes. Identity detection has no such failure mode and makes the capacity
irrelevant to this row, so the residual `assert!(grew >= 1, ..)` needs no
constant: back-changed-but-length-did-not IS "at capacity and evicting", named as
such in the message and failing closed.
MEASURED, one unit = one emitted probe line = one beat on which the ring's back
changed. Temporary `eprintln!` on the line after `grew` is computed, whole drive,
`--nocapture`:
beat=0 answered_forced=false grew=1 len=1
beat=5 answered_forced=true grew=1 len=2
beat=9 answered_forced=false grew=1 len=3
beat=14 answered_forced=true grew=1 len=4
beat=18 answered_forced=false grew=1 len=5
So `assert_eq!(grew, 1)` is REACHED on both answer beats (5 and 14) and true
there — not vacuously skipped — and the 2/3 answer/settle split the row's doc
claims is reproduced by the new counters. The probe was removed afterwards and
the file restored byte-identically (sha256 `45b33588f08cc6cf…`).
HONEST LIMIT on the second assertion: max ring length on this drive is 5 against a
cap of 16, so `assert!(grew >= 1)` never trips here and is NOT discriminating on
this fixture. That is its job — it is a fail-closed guard against a future fixture
whose drive fills the ring, which is exactly the case where the old detector would
have gone silent.
Assisted-by: ClaudeCode:claude-opus-5
ARM SEPARATION, a defect the arm-1 mutant caught in MY OWN prior round. The
`priority_player` comparand added in `e0da32fca` derived its semantic player with
`frame.waiting_for.acting_player().expect(..)` placed BEFORE arm (1). A window
with no acting player is precisely what arm (1) exists to catch, so the
`waiting_for = GameOver { winner: None }` mutant died on the unwrap at
`loop_shortcut.rs:11321:14` — arm (1)'s explanation replaced by an unwrap string,
and the row's own doc claim that the two arms are separately live silently false.
Fixed by making arm (2) an `if let`. The pair is TOTAL, so the skip opens no hole:
either arm (2) runs, or the window has no actor and arm (1) fails on that same
frame.
Both mutants re-run at the final tree, predicate `cargo test -p phase-engine
--test integration loop_shortcut::answer_beat_frames_carry_the_synced_window_and_
the_offer_certificate_is_exact -- --exact`, one unit = one test row:
* `state.priority_player = PlayerId(3);` ⇒ FAILS ARM (2), `:11327`, beat 5,
`left: PlayerId(3)` / `right: PlayerId(0)`, arm (2)'s own message.
* `state.waiting_for = WaitingFor::GameOver { winner: None };` ⇒ FAILS ARM (1),
`:11337`, beat 5, `left: GameOver { winner: None }` /
`right: Priority { player: PlayerId(0) }`, arm (1)'s own message.
* clean: 1 passed. `engine.rs` restored byte-identically after both,
sha256 `2393d6a2597c1e33…`.
A DIFFERENT arm answers each mutant, which is the property "separately live"
names; the doc bullet is corrected to say so and to record why arm (2) is an
`if let`.
CR 603.5 census pin re-derived, not assumed: this commit touches only
`tests/integration/loop_shortcut.rs`, and the producer is still `engine.rs:11583`
with sha256 prefix `8a544e878d3e77fb` unchanged.
…ts, not over `back()`
Follow-up review on `e7e50229b`. That commit fixed a vacuity in this row and
introduced a NEW false claim of the same shape as the one the branch exists to
correct — a locally true statement generalised one step too far. Test-only; no
production behaviour change.
THE FALSE CLAIM, stated before it is fixed. `e7e50229b`'s comment said `grew == 1`
"is the property that makes `back()` the answer-beat frame". It is NECESSARY, not
sufficient. The row's `answered_forced_window` is `is_forced_cascade_window()` read
BEFORE the beat, i.e. ONE conjunct of the production answer-site gate, which also
requires `!in_simulation_probe()`, `loop_detection.samples()`, `!stack.is_empty()`,
`stack.len() >= stack_len_before_action`, and `Priority{player == active_player}`.
One test beat is one `apply()`, and `apply()` reaches the SETTLE sampler after
`apply_action` returns. So on a beat where answering the forced window resolves the
last stack entry — `!stack.is_empty()` false, answer site gated OFF — and the
refill cascade mints one settle frame, `grew == 1` holds, `answer_mints`
increments, and arms (1)/(2) inspect a SETTLE frame while the row claims an answer
frame. That is precisely the class this row targets: a self-refilling drain
cascade. The predecessor paragraph "SITE ATTRIBUTION IS EXACT, not assumed" is
wrong for the same reason and is corrected in place, quoting itself.
FIXED AT THE ROOT rather than by asserting harder. Arms (1) and (2) now run over
`loop_detect_ring.iter().rev().take(grew)` — every frame the beat added, on every
minting beat — so attribution stops mattering instead of getting sharper. Sound
because BOTH samplers gate their record on `Priority{player == active_player}`
(`engine.rs` answer site; `pass_priority_once_with_pipeline` settle site) and both
record after their own `sync_waiting_for`: a frame failing either arm is a real
defect whichever site minted it. `grew == 1` is therefore no longer asserted at
all — what remains of the mint accounting is `Arc`-identity detection and
`grew >= 1`, and the comment now says only what each actually rules out.
MINT DETECTOR: the production `is_some()` guard restored. `drive_one_shortcut_cycle`
uses `ring_back_after.is_some() && ring_back_after != ring_back_before`;
`e7e50229b` kept only the inequality. The settle sampler's `else` arm calls
`loop_detect_ring.clear()`, so `back()` can go to `None` inside a beat — then
`None != Some(..)` fell through to `len() - before`, which underflows and panics
"attempt to subtract with overflow" (dev profile leaves `overflow-checks` at its
`true` default) instead of this row's own explanation. Now: `is_some()` skips a
cleared-and-not-repushed beat as the non-mint it is, `saturating_sub` cannot
underflow, and the `grew >= 1` message names BOTH causes — at-capacity eviction
AND clear-then-repush — because `e7e50229b`'s message asserted "AT CAPACITY",
which is false for the clear case.
`settle_mints` NOW FEEDS AN ASSERTION. `e7e50229b` incremented it and reached only
an interpolated message while its commit message listed it among vacuities "both
closed here" — an overclaim. It is now the reach-guard for the widening
(`settle_mints > 0`), which is the conjunct that proves the settle-frame coverage
was actually exercised. The `answer_mints` guard's message is corrected to say it
is a reach signal over ONE gate conjunct, not proof of which sampler minted.
CITATIONS DE-ROTTED, same class as this branch's `bounded_cycle_offer` fix two
hunks away. Four in-tree references named `f144bb374`, which the user's rebase
orphaned: `git merge-base --is-ancestor f144bb3 HEAD` exits 1. Two of the four
sat in a PERMANENT PRODUCTION COMMENT, and under squash-merge neither that SHA nor
`a6d1a0e62` survives into main. All four now cite `phase-rs#7005`, which is durable and was
already named alongside them.
EVIDENCE. Predicate for every row below: `cargo test -p phase-engine --test
integration loop_shortcut::answer_beat_frames_carry_the_synced_window_and_the_
offer_certificate_is_exact -- --exact`; one unit = one test row.
Reachability probe, temporary `eprintln!` after `grew`, `--nocapture`, one unit =
one minting beat:
beat=0 forced=false grew=1 len=1 inspected=1
beat=5 forced=true grew=1 len=2 inspected=1
beat=9 forced=false grew=1 len=3 inspected=1
beat=14 forced=true grew=1 len=4 inspected=1
beat=18 forced=false grew=1 len=5 inspected=1
HONEST LIMIT: `grew > 1` never occurs on this fixture, so the `take(grew)` slice is
always ONE frame and the widened loop CANNOT be shown here inspecting several
frames from a single beat. What IS measurable is the other half of the widening —
arms now run on the 3 non-forced beats as well as the 2 forced ones, 5 inspected
frames against 2 before — and that half is proved two-sided below.
Mutants at the final tree, `engine.rs` restored byte-identically after each
(file sha256 `ed92a21b6715739f…`; `loop_shortcut.rs` `9684389b859345c0…`, both
re-measured against the COMMITTED content after the `saturating_sub` fix, not a WIP
tree):
* `state.priority_player = PlayerId(3);` after the answer-site sync ⇒ ARM (2),
`loop_shortcut.rs:11352`, beat 5, `left: PlayerId(3)` / `right: PlayerId(0)`.
* `state.waiting_for = WaitingFor::GameOver { winner: None };` same place ⇒ ARM (1),
`:11365`, beat 5, `left: GameOver { winner: None }` /
`right: Priority { player: PlayerId(0) }`. A DIFFERENT arm answers each.
* NEW, the widening's own two-sided control — `state.priority_player = PlayerId(3);`
immediately before the SETTLE sampler's `record_loop_detect_sample()` ⇒ ARM (2),
`:11352`, BEAT 0, a beat the probe measured as `forced=false`. TRIVIALIZE arm:
the same mutant with the arms narrowed back to forced beats only (one-line
`continue`, the pre-widening scope) PASSES. So the widening is measured coverage,
not decoration — it catches a settle-frame defect that the predecessor could not
see. Both files restored byte-identically after every run.
CENSUS PIN re-derived, not assumed: `engine.rs`'s two citation edits are 1:1 line
substitutions, the producer is still `engine.rs:11583`, sha256 prefix
`8a544e878d3e77fb` unchanged, pin literal unchanged.
THREE CORRECTIONS TO `e7e50229b`'s OWN MESSAGE, since under squash-merge that text
lands. They are corrections, not restatements:
1. It cited the mutant failures at `loop_shortcut.rs:11327` and `:11337`. Those were
measured BEFORE a subsequent doc edit inserted 6 lines above them, and the
message was written after. The committed positions were `:11333`/`:11343`; at
THIS tree they are `:11352`/`:11365` as measured above. The lesson is the one
this branch keeps re-learning: a coordinate measured before a later edit is not
a coordinate.
2. It listed `drive_loop_action_iteration`'s ten dispatches as `:4087 :4118 :4150
:4173 :4200 :4222 :4231 :4256 :4271 :4303`. Each is exactly 1 low — the same
commit's `bounded_cycle_offer` fix added one line above the function. Re-derived
at this tree (`fn` spans `:4063`..`:4320`): `:4088 :4119 :4151 :4174 :4201 :4223
:4232 :4257 :4272 :4304`. The COUNT, 10, re-derives exactly; only the
coordinates were wrong.
3. It gave the restored `loop_shortcut.rs` as sha256 `45b33588f08cc6cf…` without
saying what that digest was of. `git cat-file -t` reports it is not a valid
object, and it never could be: it is `sha256sum` of the working-tree FILE, not a
git blob id (git hashes a header plus content). It also described a WIP tree —
the file was edited again before the commit — so it corresponds to no committed
state either. Restoration digests in this message are labelled as file digests
and were taken against the committed content.
CLIPPY CAUGHT ONE OF MINE, recorded because the process point matters more than
the token. The first cut of the underflow guard was
`after_len.checked_sub(before).unwrap_or(0)`, which `cargo test` compiles happily
and `clippy::manual_saturating_arithmetic` rejects under `-D warnings`
(`loop_shortcut.rs:11297`). I had verified the change with targeted `cargo test`
runs before committing and let the battery find it — the battery did its job, but
the cheap gate belonged before the commit, not after. Now `saturating_sub`, which
is what the guard meant: 0 on underflow, and the `grew >= 1` assertion below turns
that 0 into a named failure.
Assisted-by: ClaudeCode:claude-opus-5
…ch both routes a scalar cannot name Maintainer review on phase-rs#7023 (matthewevans, CHANGES_REQUESTED at `483ce27d3`), confirming CodeRabbit's still-open Major. Test-only; no production behaviour change. Both claims were re-verified at source before any edit, and both are true. THE DEFECT, stated before it is fixed. The row inferred a SET of minted frames from a SCALAR: it detected a changed `Arc::as_ptr` on the ring's back, computed `grew = after_len - before_len`, and validated `rev().take(grew)`. Two production routes break that inference. (1) EVICTION AT CAPACITY. `GameState::record_loop_detect_sample` calls `pop_front()` and THEN `push_back()` once `loop_detect_ring.len() == LOOP_DETECT_RING_CAP`. The back changes while the length stays equal, so `grew == 0` and the row's `assert!(grew >= 1, ..)` FAILED THE TEST on a legitimate beat. That guard was labelled "fail closed"; the label was wrong about which side of the line the beat is on, and it has gone with the code. (2) CLEAR-AND-REBUILD. `apply_action` clears the ring at the TOP of the beat for any action that is neither `PassPriority` nor `OrderTriggers` answering a non-forced window, and a sampler then mints LATER in the same `apply()`. Net growth is `minted - cleared`, so `take(grew)` silently skips frames the row claims to validate. The sentence "a cleared ring minted nothing, so the beat is skipped" was untrue for exactly this beat and is also gone. FIXED BY DELETING THE SCALAR, not by re-deriving it more carefully. The row snapshots the COMPLETE pre-beat membership of `loop_detect_ring` and validates every remaining frame whose identity was absent from that snapshot. Append, eviction and clear-and-rebuild are then covered uniformly, the ring's capacity stops mattering to the row, and `grew` appears in no assertion. The per-frame validation logic (arms (1)/(2)) is untouched — only how its frame set is obtained. The snapshot holds `Arc` CLONES, not raw addresses, and that is load-bearing rather than defensive: `pop_front` DROPS the evicted allocation before `Arc::new` claims a new one of identical layout, so an address-keyed snapshot can be aliased by the allocator handing the freed block straight back, and a genuinely new frame would then read as an old one. A retained strong reference makes every snapshotted address un-reusable for the beat, so `ptr_eq` is exact by construction. (Aliasing was not observed on these drives — 0 over the `dellian` drive — which is why the reason is construction and not a measured near-miss.) REACHABILITY SHIPS WITH THE FIX rather than after it. The row's own `dina_conqueror_4p` drive reaches NEITHER route — measured: max ring 5 against a capacity of 16, 0 evicting beats, 0 clearing beats — so correcting the detector alone would have re-opened the evidential hole that sent the PR back. Two new rows put each route on a board where it is live, both driven through production `apply()`: * `an_evicting_beat_mints_without_growing_the_ring` — `dellian_emblem_conqueror_4p`, ordinary pass-the-drain drive. MEASURED: beat 72, ring 16 -> 16, 1 minted, 1 dropped, net growth 0; three more evicting beats inside the first 90. * `a_clearing_beat_rebuilds_the_ring_inside_the_same_beat` — `dina_conqueror_4p`, the same board plus one `GameAction::SetAutoPass { UntilStackEmpty }`, which is the exact payload the client's Arena-style "Resolve All" control dispatches. MEASURED: beat 6, ring 2 -> 1, 2 dropped, 1 minted, net growth 0 against a real mint. `SetAutoPass` has to be dispatched by name because it is absent from `ai_support::legal_actions_for_viewer` (`classify_flat_priority_action` files it with the preference-propagation actions), so the generic dump driver never picks it. A survey of the tracked 4p dumps under the driver's own policy found no natural clear-and-rebuild beat: on `fantastic_four_bounded_loop_4p` every non-`Priority` window met with a non-empty ring is a FORCED one, which is precisely the class `apply_action`'s clear exempts. Both new rows SEARCH ON A STRUCTURAL PREDICATE AND ASSERT THE CONSEQUENCE. The witness is the first beat that lost a pre-beat frame without the ring shrinking (ⓔ) or that lost every pre-beat frame (ⓒ) — statements about membership alone — and what is then asserted is what the beat minted and what its length did. A witness selected on "minted while the length stood still" would have carried its own conclusion into the arm that claims to test it. ALSO ADDED, and it is a reach guard rather than decoration: `frames_validated == ring_at_offer` in the answer-beat row. Every frame the offer's ring holds must have reached arms (1)/(2) as a member of some beat's minted set, so a detector that silently returned the EMPTY set on a minting beat — exactly what a length delta returns at capacity — can no longer pass quietly. DRIVE COST. `drive_one_beat_passing_fast` takes `dump_drive_one_beat`'s `Priority` arm directly: that policy is unconditionally "pass", and routing it through the per-viewer enumerator is the dominant cost of a long drive on the 152-entry `dellian` stack. `apply` performs the real legality check itself, so nothing is skipped but the enumeration. Measured equivalence, not assumed: the eviction row's own mutant panics name beat 72 — the same first evicting beat the enumerator-driven probe measured. The 120-beat dellian drive fell from ~9 min to ~2 min. MUTANTS, per assertion, two-sided, each pair flipping its own named assertion. PRISTINE controls (comment-only rebuild) pass for all three rows; the harness reports SETUP-ERROR unless libtest executed exactly one test, after a first run produced two vacuous PASSes from `--exact` against an unqualified test name. frames_validated DROP `.skip(1)` on the post-beat ring -> FAIL 4 vs 5 TRIV minted filter `true` -> FAIL 49 vs 5 arm (1) prod `waiting_for = GameOver` -> FAIL beat 5, GameOver vs Priority{P0} arm (2) prod `priority_player = PlayerId(3)` -> FAIL beat 5, PlayerId(3) vs PlayerId(0) ⓔ reach prod no pop at capacity -> FAIL reach-guard, max ring 28 prod `LOOP_DETECT_RING_CAP = 4096` -> FAIL reach-guard ⓔ shape prod eviction made 2-for-2 -> FAIL beat 67, (2,16) vs (1,16) TRIV minted filter `false` -> FAIL beat 72, (0,16) vs (1,16) ⓒ reach prod top-of-beat clear neutered -> FAIL reach-guard prod `SetAutoPass` added to exemption -> FAIL reach-guard ⓒ shape prod sampler pushes twice -> FAIL beat 1, (0,0) vs (1,1) TRIV minted filter `false` -> FAIL beat 6, (0,1) vs (1,1) ONE MUTANT MISSED ITS TARGET AND IS RECORDED RATHER THAN DROPPED: making the sampler push twice flips ⓔ's REACH guard, not its shape assertion, because the doubled push steps the length 15 -> 17 so `len == LOOP_DETECT_RING_CAP` never holds and the ring never evicts at all. ⓔ's DROP arm is the 2-for-2 eviction instead. Assisted-by: ClaudeCode:claude-opus-5
483ce27 to
b90042d
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
|
🤖 AI text below 🤖 @matthewevans — both mechanisms confirmed at source and fixed. Pushed as Your [MED] was right, and understated in one respect. At capacity Fix. Both routes are now live on real fixtures through production
Each assertion has a drop arm and a trivialize arm flipping that assertion, including reach witnesses for both new rows. Two things worth your attention rather than buried in the body. The mutant harness first reported two vacuous passes — Two of the three LOW diagnostic findings I disclosed earlier are still open and still disclosed; the third is closed by this commit. The PR body is re-measured and re-bound to this head. |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
crates/engine/tests/integration/loop_shortcut.rs (2)
11514-11527: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReuse
ring_membership_deltain the answer-beat loop.
ring_membership_deltare-implements the sameArc::ptr_eqmembership filter that the answer-beat loop already writes inline at Lines 11312-11316. The two copies can drift, and only one of them is exercised by the two new regressions.Make the helper return the minted frames and derive the counts from it, so all three call sites share one definition.
♻️ Proposed shape
-fn ring_membership_delta<T>( +fn minted_frames<'a, T>( + before: &[std::sync::Arc<T>], + after: &'a std::collections::VecDeque<std::sync::Arc<T>>, +) -> Vec<&'a std::sync::Arc<T>> { + after + .iter() + .filter(|f| !before.iter().any(|b| std::sync::Arc::ptr_eq(b, f))) + .collect() +} + +fn ring_membership_delta<T>( before: &[std::sync::Arc<T>], after: &std::collections::VecDeque<std::sync::Arc<T>>, ) -> (usize, usize) { - let minted = after - .iter() - .filter(|f| !before.iter().any(|b| std::sync::Arc::ptr_eq(b, f))) - .count(); + let minted = minted_frames(before, after).len(); let dropped = before .iter() .filter(|b| !after.iter().any(|f| std::sync::Arc::ptr_eq(b, f))) .count(); (minted, dropped) }As per coding guidelines, "reuse existing helpers instead of writing duplicate string or collection logic".
🤖 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/engine/tests/integration/loop_shortcut.rs` around lines 11514 - 11527, Update ring_membership_delta to return the minted Arc frames rather than only counts, then derive minted and dropped counts from that shared result as needed. Replace the answer-beat loop’s inline Arc::ptr_eq membership filtering around the existing call sites with this helper, ensuring all three call sites use one membership definition.Source: Coding guidelines
11693-11730: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winBind the clear-and-rebuild witness to the dispatched beat.
The loop dispatches
SetAutoPasson one beat, but the witness predicate at Line 11719 accepts any beat that lost every pre-beat frame. A future fixture change or an unrelated clearing beat can satisfy the predicate, and the assertion message at Lines 11743-11750 would then attribute the clear to aSetAutoPasspress that did not happen on that beat.Record whether the current beat is the dispatched one, and require that flag in the witness predicate. The row then proves the route it names instead of relying on the measured "0 clearing beats" property of this dump.
♻️ Proposed fix
- let outcome = if !fired + let dispatched_here = !fired && before.len() >= 2 - && matches!(state.waiting_for, WaitingFor::Priority { .. }) - { + && matches!(state.waiting_for, WaitingFor::Priority { .. }); + let outcome = if dispatched_here { fired = true; @@ let (minted, dropped) = ring_membership_delta(&before, &state.loop_detect_ring); - if before.is_empty() || dropped != before.len() { + if !dispatched_here || before.is_empty() || dropped != before.len() { continue; }As per path instructions, flag constructor or witness shortcuts that can silently mask the very bug a regression test claims to catch.
🤖 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/engine/tests/integration/loop_shortcut.rs` around lines 11693 - 11730, Track whether the current loop beat is the one that dispatches SetAutoPass in the outcome branch, and require that flag alongside the existing ring-clearing conditions before assigning clearing. Update the beat loop around the SetAutoPass dispatch and the clearing witness predicate so the recorded witness can only refer to that dispatched beat, preserving the existing outcome handling and assertions.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@crates/engine/tests/integration/loop_shortcut.rs`:
- Around line 11514-11527: Update ring_membership_delta to return the minted Arc
frames rather than only counts, then derive minted and dropped counts from that
shared result as needed. Replace the answer-beat loop’s inline Arc::ptr_eq
membership filtering around the existing call sites with this helper, ensuring
all three call sites use one membership definition.
- Around line 11693-11730: Track whether the current loop beat is the one that
dispatches SetAutoPass in the outcome branch, and require that flag alongside
the existing ring-clearing conditions before assigning clearing. Update the beat
loop around the SetAutoPass dispatch and the clearing witness predicate so the
recorded witness can only refer to that dispatched beat, preserving the existing
outcome handling and assertions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 044aebe8-8074-4ffc-bc4c-2a9c36dd31bd
📒 Files selected for processing (3)
crates/engine/src/analysis/resource.rscrates/engine/src/game/engine.rscrates/engine/tests/integration/loop_shortcut.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- crates/engine/src/analysis/resource.rs
- crates/engine/src/game/engine.rs
Use the shared membership helper for the answer-beat validator and bind the clear-and-rebuild witness to the SetAutoPass dispatch beat. Co-authored-by: lgray <lgray@users.noreply.github.com>
matthewevans
left a comment
There was a problem hiding this comment.
Review head: f2df77c
The prior membership-delta finding and both current-head test-witness comments are resolved. The answer-beat validator, eviction row, and clear/rebuild row now share the Arc-identity minted-frame definition; the clear/rebuild witness additionally requires the exact beat that dispatched SetAutoPass UntilStackEmpty.
Re-review found no remaining implementation findings.
🤖 AI text below 🤖
Summary
Carries the review-response work for #7005 that missed its merge. #7005 was squash-merged at
90c525eb7from heada6d1a0e62while three follow-on commits were being reviewed; a push of those commits raced the merge and GitHub never adopted them, so they stranded on the fork branch. This PR is exactly that content, rebased onto current main.It corrects false mechanism claims that main now carries, replaces orphaned commit-SHA citations, closes the CodeRabbit Major on the answer-beat row, and restores a guard the row's mint detector had dropped. Comments and one test row only — zero production behaviour change. The entire non-comment
crates/engine/src/delta is a single string literal (a line-anchored census pin).Files changed
crates/engine/src/game/engine.rs— the sampler comment re-cited to the correct basis; a stale ring-scan comment whose coordinates had rotted onto unrelated code, now naming symbols; census-pin literalcrates/engine/src/analysis/resource.rs— a scope note onring_delta_signaturestating which fields it actually readscrates/engine/tests/integration/loop_shortcut.rs— answer-beat row: assertions widened over every frame a beat mints, mint detector guard restored, reach-guard assertedTrack
Developer
LLM
Model: claude-opus-5
Tier: Frontier
Thinking: high
Implementation method (required)
Method: /engine-implementer
CR references
None added, moved, or removed —
git diffover the range matches noCR \dline in either direction. The CR numbers the touched hunks rely on were re-verified against the rules text regardless:CR 732.2a,CR 704.5a,CR 119.3,CR 110.1,CR 104.4b,CR 603.5,CR 122.1,CR 606.3,CR 608.2,CR 703.1/.2/.3,CR 500.8— all resolve and describe the annotated code.What main currently carries, and why this is worth a second PR
A false mechanism, in three in-tree sites. fix(engine): commit every axis a bounded loop-shortcut offer publishes #7005 claims a ring heterogeneous in
waiting_for/priority_playerbreaksring_delta_signature's turn-position conjunct. Measured, that function's body reads neither field — it comparesResourceVector::snapshot(&f.normalized)andwindow_scope_from_cover_frames(..).phase_invariant, andphase_invariantcompares onlyturn_number,phase,extra_phases.is_empty(). The real sensitivity isloop_states_equal_modulo_resourcesapplied to a ring frame'snormalizedsnapshot, which chains toimpl PartialEq for GameState. One of the three sites is an assertion message, so on failure it would print a false reason.Citations of a commit that does not exist in this history. Four in-tree references named a pre-rebase SHA that no longer resolves from main; two sat in a permanent production comment. Under squash-merge no branch SHA survives, so they were pre-rotted. Replaced with the PR number.
An unclosed CodeRabbit Major. The answer-beat row derived site attribution from a ring-length delta, so a beat recording at both sampler sites would leave the row inspecting a settle frame while both arms passed. Fixed structurally — the arms now run over every frame the beat minted, which makes attribution irrelevant rather than more carefully asserted.
A dropped guard. The mint detector omitted the
is_some()check its production counterpart has, so a ring clear would reach an uncheckedusizesubtraction and panic with an overflow message instead of the row's own explanation.Verification
Measured at
b90042d66e5badf3fa9837c3bd6c04488f984ad6, basea998c722413c8ec1364fd19fb8502d7f964fd0af, clean tree:cargo fmt --all— cleancargo clippy --workspace --all-targets -- -D warnings—EXIT=0cargo test -p phase-engine --lib—18522 passed; 0 failed; 6 ignoredcargo test -p phase-engine --test integration—4558 passed; 0 failed; 2 ignoredRow-count movement fully attributed. The +2 integration rows are this branch's two new regression rows —
git diffover the range adds 2 and removes 0#[test]/#[tokio::test]. The last rebase moved the base across three upstream commits (a client change and two chores) which add and remove zero engine tests in both directions, so the count is unchanged by the rebase itself. Zero unexplained movement.Rebase is content-preserving, verified:
git range-diffpairs every commit with=, and the rebase correctly dropped #7005's squash and #7020 as already upstream.Rebase is content-preserving, verified three ways:
git range-diffpairs all three commits with=; all three touched files are byte-identical by sha256 across the rebase; and the only paths differing between the pre- and post-rebase tips belong to #7020, not to this branch.Discrimination of the answer-beat row. Clobbering
priority_playerafter the sync fails one arm (PlayerId(3)vsPlayerId(0)); clobbering the window fails the other (GameOvervsPriority{PlayerId(0)}), with the first arm passing before it, so the arms are separately live. Clobberingpriority_playerat the settle sampler fails at a non-forced beat, and the same mutant with the assertion narrowed back to forced-only passes — a two-sided control showing the widened scope is real added coverage rather than a restatement. Five inspected frames against two before the widening.Gate A
Gate A PASS head=b90042d66e5badf3fa9837c3bd6c04488f984ad6 base=a998c722413c8ec1364fd19fb8502d7f964fd0af
The gate's scope is
crates/engine/src/parser, and this PR changes 0 files there. The PASS is real but uninformative for this diff — it passes because no parser code is touched, not because parser code was checked.Anchored on
crates/engine/src/game/engine.rs:6050— the settle-beatrecord_loop_detect_sample()call inpass_priority_once_with_pipeline, the pre-existing authority for recording a loop-detect frame. The widened assertions rest on both samplers sharing itsPriority{player == active_player}conjunct and its record-after-sync_waiting_forordering.crates/engine/src/game/effects/mod.rs:5844—optional_effect_is_infeasible, the pre-existing single authority for "this optional effect cannot be taken", the seamupfront_optional_gateextends rather than parallels.Final review-impl
Final review-impl PASS head=b90042d66e5badf3fa9837c3bd6c04488f984ad6
Stated precisely, because the honest answer is not one line. Three independent passes ran on the first three commits: a review of the first (BLOCK, on the false mechanism above), a re-review of the remedy, and a verification pass returning NON-BLOCKING with three LOW diagnostic findings. Rebases since have been measured content-preserving, so those passes bind to this head for that content.
The fourth commit — the fix for this PR's review — has not had a separate independent review pass. It was self-verified with per-assertion DROP/TRIVIALIZE mutant pairs and with both broken routes driven live through production
apply(), and the maintainer's re-review is the intended next check. I would rather say that than let one PASS line imply a review that did not happen.Claimed parse impact
None. No file under
crates/engine/src/parseris touched.Scope Expansion
None. This is #7005's own review-response content, unchanged.
Validation Failures
Three LOW diagnostic findings, disclosed rather than fixed. A circuit breaker was set before the last round so further non-blocking findings would be disclosed instead of chased; these are its output. None is a correctness defect and none is reachable on the current fixture.
A comment says "a cleared ring minted nothing, so the beat is skipped".Closed by the review-response commit — that comment and the code it described are gone; the clear-and-rebuild case now has its own row.continueguards. Still open.From the review-response commit, disclosed unprompted. The mutant harness initially reported two vacuous PASSes:
--exactmatches the full libtest path, so a bare function name filtered every test out and libtest still exited 0. It was fixed by qualifying the path and adding aran() == 1positive control that reports a setup error on zero tests, and every figure was re-measured under the corrected harness. Separately, one mutant misses its intended target — the doubled-push mutation steps the ring length past the capacity check, so it flips the eviction row's reach guard rather than its shape assertion; the shape assertion's drop arm is a 2-for-2 eviction instead. Both are recorded rather than quietly dropped. Also: the "no tracked fixture reaches clear-and-rebuild naturally" claim was surveyed over 4 dumps, not all 10, and no case was constructed in which an address-keyed snapshot actually aliases — theArc-clone choice rests on the allocator being free to reuse an address, not on an observed collision.Honest limits. The underlying sync fix that #7005 landed remains causally inert on the current corpus — instrumenting the pre-fix tree measured zero stale samples at either sampler, with an always-true comparison of the same shape on the same line proving the instrument live, and a pure revert of that reorder still passes. This PR hardens the row that guards it; it does not change that.
CI Failures
None.
Summary by CodeRabbit
Documentation
Tests