diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index 6ec4dac558..3af92f7c4a 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -1325,6 +1325,15 @@ fn map_delta( /// normalized. It does not consult a board predicate, but it DOES require the frames it /// compares to be homogeneous in turn position, which is what "homogeneous" above now means /// in full. +/// +/// ⚠ SCOPE OF THAT REQUIREMENT — what this function READS, versus where the frames' sameness +/// comes from. It 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()`. The sampler gate that mints the frames +/// also makes them homogeneous in `waiting_for`/`priority_player`, but THIS function never +/// looks at those two — basis A does, via `loop_states_equal_modulo_resources` ⇒ +/// `loop_states_equal` ⇒ `impl PartialEq for GameState`. Do not cite `ring_delta_signature` as +/// the consumer of either field. pub(crate) fn ring_delta_signature(state: &GameState) -> Option<(u32, ResourceVector)> { let frames = state.loop_detect_ring.len(); // 2k + 1 with k >= 1. diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 6911c2d464..bcd4597b7a 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -1921,11 +1921,12 @@ fn bounded_cycle_offer( // (4) CERTIFICATION — two bases, first match wins, NEVER combined. // - // Basis A is a fifth copy of the ring `find_map` scan (`:481` the `On` reconcile, `:668` - // Path B, `:710` Path C, `:808` `find_live_loop_winner`). Recorded, not hidden: the repo - // already made this call at `find_live_loop_winner`'s own doc — "a deliberate, isolated - // copy … the `On` arm stays VERBATIM (byte-identity gate)" — and retargeting the four - // shipped walks would edit byte-identity-gated paths inside a feature commit. Newest + // Basis A is a fifth copy of the ring `find_map` scan. The other four are NAMED, not + // numbered — the `On` reconcile, Path B, Path C, `find_live_loop_winner` — because the + // coordinates that stood here had ROTTED onto unrelated code. Grep the symbols. Recorded, + // not hidden: `find_live_loop_winner`'s own doc already made this call — "a deliberate, + // isolated copy … the `On` arm stays VERBATIM (byte-identity gate)" — and retargeting the + // four shipped walks would edit byte-identity-gated paths inside a feature commit. Newest // prior first: the most recent recurrence is the least extrapolation. // // TWO PARALLEL VECS OVER ONE INDEX SPACE. They are built from the same `VecDeque` in the @@ -11174,14 +11175,47 @@ fn apply_action( // and recomputes `priority_player` through `turn_control`'s authorized-submitter // resolver — and it now runs BEFORE the record, exactly as the settle sampler in // `pass_priority_once_with_pipeline` does. So a frame minted here carries the same - // `waiting_for`/`priority_player` pair a settle frame carries, and that homogeneity - // is load-bearing: `impl PartialEq for GameState` compares both fields and - // `normalize_for_loop` neutralizes neither, so a mixed ring would break - // `ring_delta_signature`'s turn-position conjunct. BLAST RADIUS IS THE RING ONLY — - // `apply_action_boundary` re-syncs the returned `wf` before the result leaves the - // engine, so the settled state is unchanged. MEASURED before the reorder: a - // `debug_assert_eq!` census on both fields reported 0 divergences over 18,486 lib + - // 4,487 integration rows, so this replaces a coincidence with a guarantee. + // `waiting_for`/`priority_player` pair a settle frame carries. + // + // WHICH CONSUMER THAT HOMOGENEITY IS FOR — and it is NOT `ring_delta_signature`. The + // claim #7005's first commit shipped ("a heterogeneous ring breaks `ring_delta_signature`'s + // turn-position conjunct, because `impl PartialEq for GameState` compares both + // fields") is FALSE at source: that function's body reads only + // `ResourceVector::snapshot(&f.normalized)` and + // `window_scope_from_cover_frames(..).phase_invariant`, and `phase_invariant` is + // `turn_number` + `phase` + `extra_phases.is_empty()`. Neither field is in it. + // + // The consumer that DOES read them is BASIS A — the ring scans that 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 + // compares BOTH `waiting_for` and `priority_player`, and neither `normalize_for_loop` + // nor `project_out_resources` neutralizes either. An answer-beat frame carrying an + // un-synced pair therefore compares UNEQUAL against a live board that has been + // synced, and basis A silently misses the recurrence the frame was minted to certify. + // + // BLAST RADIUS. Nothing this leaves in `state` survives to a consumer unrecomputed: + // `finish_action_boundary` runs the SAME `sync_waiting_for` over `result.waiting_for` + // (`:1171`) and copies the outcome back into the result (`:1189`), and the reorder + // never changes `ActionResult.waiting_for` itself. That is an argument about + // RE-DERIVATION, not reachability, because `apply_action_boundary` is not the only + // route: `inject_pinned_answer`'s three dispatches and `drive_loop_action_iteration`'s + // ten reach `apply_action` directly, and + // `apply_interaction_pre_reconciliation_for_life_safety` returns `raw.result` without + // ever calling `finish_action_boundary` (`apply_action_boundary_core`'s own comment at + // `:1119` records it). All three drive a CLONE — `drive_one_shortcut_cycle`'s `work`, + // the drive's `clone`, `preview_candidate_life_safety`'s `preview` — never the settled + // board. MEASURED pre-reorder by an instrumented `debug_assert_eq!` census over the + // full lib + integration corpus (per-site counts in PR #7005's history; one unit = + // one `record_loop_detect_sample` invocation): 0 at EITHER sampler where the sync + // changed either field, so this replaces a coincidence with a guarantee. + // + // ONE CONSEQUENCE OF USING THE SYNCHRONIZER RATHER THAN A RAW CLONE: + // `normalize_legacy_attach_waiting_for` can now edit `state.waiting_for` on this + // path, so it may differ from the returned `ActionResult.waiting_for`, where the raw + // clone made the two exactly equal. Benign — the boundary re-normalizes at `:1171` + // and copies back at `:1189`, and `inject_pinned_answer` fails closed on every prompt + // kind it has no pin producer for. sync_waiting_for(state, &wf); if answering_forced_window && !in_simulation_probe() @@ -15457,7 +15491,27 @@ mod stage2_injector_tests { // sha256 `a6d7f2f9d1e15de5…5cb032`, matching `117baa6a1:engine.rs:11549`, and // still inside `begin_pending_trigger_target_selection` (opens :11400 here, // :11271 at `b654513cb`). - "game/engine.rs:11549".to_string(), + // + // THIS PR (the basis-A prose correction), ON TOP OF `a6d1a0e62`: `:11549 ⇒ + // :11583`, +34. LOCAL, not upstream — the CI-vs-local diagnosis in the header + // does not apply. engine.rs's entire delta this round is THREE COMMENT HUNKS + // and nothing else. Two sit ABOVE this producer — `@@ -1924,5 +1924,6 @@` in + // `bounded_cycle_offer` (+1: the rotted `:481/:668/:710/:808` sibling-scan + // coordinates replaced by symbol names) and `@@ -11177,8 +11178,41 @@` in + // `apply_action` (+33) — summing to +34, and 11549 + 34 = 11583 exactly. The + // third is THIS drift entry, below the producer, which therefore cannot move + // it (its own size is deliberately not arithmetic here — a self-counting + // entry restates itself every edit). A comment round cannot mint a prompt, + // and the census agrees — the two asserts above this one fired GREEN on the + // run that caught this (total still 37, partition still 5/7/25) and the other + // four entries did not move (`effects/` is untouched by this commit; + // `scoped_library_search.rs:452` re-read and sha256-confirmed in place). + // Identity re-established rather than assumed: line :11583 is byte-identical + // by sha256 (`8a544e878d3e77fb…`, the SAME prefix this log recorded for + // `:11549`) to `a6d1a0e62:engine.rs:11549`, and it is still inside + // `begin_pending_trigger_target_selection`, which moved by the same +34 + // (opens :11400 ⇒ :11434). + "game/engine.rs:11583".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index f716c86b29..1a357a5e4b 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -26,7 +26,8 @@ use engine::types::ability::{Effect, TargetRef}; use engine::types::actions::GameAction; use engine::types::events::GameEvent; use engine::types::game_state::{ - CastPaymentMode, GameState, LoopDetectionMode, StackEntryKind, WaitingFor, YieldTarget, + AutoPassRequest, CastPaymentMode, GameState, LoopDetectionMode, StackEntryKind, WaitingFor, + YieldTarget, }; use engine::types::identifiers::ObjectId; use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; @@ -11163,34 +11164,79 @@ fn ai1_the_bounded_declare_candidate_withdraws_when_the_offer_publishes_a_pin() /// `answering_forced_window`) called `record_loop_detect_sample` BEFORE installing the /// pipeline's returned `wf`, while the settle sampler in `pass_priority_once_with_pipeline` /// records AFTER its `sync_waiting_for`. A frame minted at the answer site therefore carried -/// the PRE-pipeline `waiting_for`/`priority_player` pair and a settle frame carried the synced -/// one. That is a detection hazard, not cosmetics: `impl PartialEq for GameState` compares both -/// fields and `normalize_for_loop` neutralizes neither, so a heterogeneous ring breaks -/// `analysis::resource::ring_delta_signature`'s turn-position conjunct. The fix routes `wf` -/// through `game::public_state::sync_waiting_for` — the canonical synchronizer, which also -/// recomputes `priority_player` via `turn_control::authorized_submitter_for_player` — before -/// the record, so both producers mint the same shape. +/// the UN-SYNCED pair — whatever the reducer or `run_post_action_pipeline` last wrote straight +/// into `state.waiting_for`, with `priority_player` never recomputed — while a settle frame +/// carried the synced one. (NOT the "pre-pipeline" pair: the pipeline itself writes +/// `state.waiting_for` at five sites inside `run_post_action_pipeline_from`.) +/// +/// That is a detection hazard, not cosmetics — but the consumer is BASIS A, not +/// `ring_delta_signature`. PR #7005's first commit and the comments it shipped said a +/// heterogeneous ring breaks `ring_delta_signature`'s turn-position conjunct "because +/// `impl PartialEq for GameState` compares both fields"; that is false at source. That +/// function reads only `ResourceVector::snapshot(&f.normalized)` and +/// `window_scope_from_cover_frames(..).phase_invariant` (= `turn_number` + `phase` + +/// `extra_phases.is_empty()`). The real sensitivity is the ring scans that 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 `loop_states_equal` ⇒ +/// `impl PartialEq for GameState`, which DOES compare `waiting_for` and `priority_player`, and +/// neither `normalize_for_loop` nor `project_out_resources` neutralizes either — so an +/// un-synced frame compares UNEQUAL against a synced live board and basis A misses the +/// recurrence. The fix routes `wf` through `game::public_state::sync_waiting_for` — the +/// canonical synchronizer, which also recomputes `priority_player` via +/// `turn_control::authorized_submitter_for_player` — before the record, so both producers mint +/// the same shape. /// /// FIXTURE: the tracked `dina_conqueror_4p` dump, driven through the production `apply()` path, /// so every frame asserted below was minted by `record_loop_detect_sample` itself rather than /// staged by the test. /// -/// SITE ATTRIBUTION IS EXACT, not assumed. The answer site's own gate conjunct is -/// `state.waiting_for.is_forced_cascade_window()` read BEFORE the action; the settle sampler is -/// reachable only from `pass_priority_once_with_pipeline`, i.e. from a `Priority` window, which -/// `is_forced_cascade_window` deliberately excludes. So "the pre-beat window was forced AND the -/// ring grew" names the answer site and nothing else. MEASURED on this drive: 2 answer-beat -/// mints (beats 5 and 14), 3 settle mints, offer at beat 19 over a 5-frame ring. +/// SITE ATTRIBUTION IS DELIBERATELY NOT ATTEMPTED, and the paragraph that stood here claiming +/// it was "exact" was WRONG. It argued that because the settle sampler is reached only from +/// `pass_priority_once_with_pipeline` — i.e. from a `Priority` window, which +/// `is_forced_cascade_window` excludes — "the pre-beat window was forced AND the ring grew" +/// names the answer site and nothing else. That conflates the window BEFORE the beat with the +/// window at the settle sampler's own moment. One beat here is one `apply()`, which reaches the +/// settle sampler AFTER `apply_action` returns, so a beat whose pre-beat window was forced can +/// perfectly well mint at the SETTLE site — e.g. when answering it resolves the last stack +/// entry, gating the answer site off on `!stack.is_empty()` while the refill cascade settles. +/// `answered_forced_window` is ONE conjunct of the production answer-site gate (which also +/// demands `!in_simulation_probe()`, `loop_detection.samples()`, `!stack.is_empty()`, a +/// non-shrinking stack, and `Priority{player == active_player}`), so it is a REACH signal and +/// never an attribution. +/// +/// The row therefore asserts arms (1) and (2) over EVERY frame a beat MINTED, which makes +/// attribution irrelevant rather than sharper: both samplers gate their record on +/// `Priority{player == active_player}` and both record after their own `sync_waiting_for`, so a +/// frame that fails either arm is a real defect whichever site minted it. The minted set is the +/// ring's pre-beat/post-beat `Arc`-identity MEMBERSHIP DIFFERENCE. No scalar is derived from the +/// ring's length, and the length-delta accounting that stood here is deleted rather than +/// sharpened — a scalar cannot name that set on either of the two production paths the loop body +/// documents. MEASURED on this drive: 2 forced-window minting beats (5, 14), 3 non-forced ones +/// (0, 9, 18), 5 frames minted and validated in total, offer at beat 19 over a 5-frame ring. +/// +/// THIS FIXTURE REACHES NEITHER of the two paths that break a length delta, measured rather than +/// assumed: 0 evicting beats and 0 clear-and-rebuild beats over the drive, max ring 5 against a +/// capacity of 16. Both are reached — and the deleted scalar shown wrong on each — by +/// `an_evicting_beat_mints_without_growing_the_ring` and +/// `a_clearing_beat_rebuilds_the_ring_inside_the_same_beat`, on boards driven through the same +/// production `apply()`. /// /// ⚠ WHAT THIS ROW DOES **NOT** CATCH, stated rather than implied. A PURE REVERT of the reorder -/// leaves all three arms GREEN, and that is a measurement rather than an oversight: a -/// `debug_assert_eq!` census on both fields at that position reported 0 divergences over 18,486 -/// lib + 4,487 integration rows, so no fixture in the corpus reaches the divergence. The row is -/// consequently the STANDING pin — it fires the first time a beat does diverge — and its +/// leaves all three arms GREEN, and that is a measurement rather than an oversight: an +/// instrumented `debug_assert_eq!` census on both fields at that position, run on the +/// pre-reorder tree over the full lib + integration corpus, reported 0 divergences (per-site +/// counts in PR #7005's history), so no fixture in the corpus reaches the divergence. The +/// row is consequently the STANDING pin — it fires the first time a beat does diverge — and its /// instrument is proved live by MUTANTS at the sampler instead of by the revert: -/// * `state.priority_player = PlayerId(3);` after the sync ⇒ arm (2) FAILS at the first mint. +/// * `state.priority_player = PlayerId(3);` after the sync ⇒ arm (2) FAILS at the first mint +/// (beat 5), `PlayerId(3)` vs `PlayerId(0)`, with arm (2)'s own message; arm (1) is never +/// reached. /// * `state.waiting_for = WaitingFor::GameOver { winner: None };` after the sync ⇒ arm (1) -/// FAILS at the first mint (arm (2) still passes, so the two arms are separately live). +/// FAILS at the same beat, `GameOver { winner: None }` vs `Priority { player: PlayerId(0) }`, +/// with arm (1)'s own message. Arm (2) is SKIPPED there rather than passed — `GameOver` has +/// no acting player — which is exactly why arm (2) is an `if let` and not an unwrap: an +/// unwrap would panic on the `None` and replace arm (1)'s explanation with its own. Each +/// mutant is answered by a DIFFERENT arm, which is what "separately live" means. /// /// Arm (3) is the BLAST-RADIUS pin: the certificate is byte-exact under the pure revert, which /// is what makes "this reorder does not perturb detection" a measurement. @@ -11219,6 +11265,7 @@ fn answer_beat_frames_carry_the_synced_window_and_the_offer_certificate_is_exact let pin = engine_live_opponents(&state, P0).first().copied(); let mut answer_mints = 0usize; let mut settle_mints = 0usize; + let mut frames_validated = 0usize; let mut offer_beat = None; for beat in 0..400usize { if matches!( @@ -11232,50 +11279,138 @@ fn answer_beat_frames_carry_the_synced_window_and_the_offer_certificate_is_exact break; } let answered_forced_window = state.waiting_for.is_forced_cascade_window(); - let before = state.loop_detect_ring.len(); + // THE BEAT'S MINTED SET IS A MEMBERSHIP DIFFERENCE. Nothing about the ring's LENGTH, + // and nothing about its `back()`, can name it — both are unsound against production: + // + // * `GameState::record_loop_detect_sample` pops the front before pushing once the ring + // is at `LOOP_DETECT_RING_CAP`, so an evicting push mints while the length stays + // EQUAL. A `back()`-changed + `after - before` detector reads that as a mint of size + // zero and fails on a legitimate beat. + // * `apply_action` clears the ring at its top for any non-`PassPriority`, + // non-`OrderTriggers` action answering a non-forced window, and the settle sampler in + // `pass_priority_once_with_pipeline` mints LATER in the same beat. Net growth is then + // `minted - cleared`, which is smaller than `minted` — a `take(net)` silently skips + // frames the row claims to have checked. + // + // The full pre-beat membership covers append, eviction and clear-and-rebuild uniformly, + // and it makes the ring's capacity irrelevant to the row (no literal cap is transcribed + // here; the const is private to `types::game_state`, so a copy could only rot). + // + // The snapshot holds `Arc` CLONES, not raw addresses, and that is load-bearing rather + // than incidental: `pop_front` DROPS the evicted `Arc` before `Arc::new` allocates the + // replacement, 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 whole + // beat, so `Arc::ptr_eq` is exact by construction instead of by luck. + let before: Vec<_> = state.loop_detect_ring.iter().cloned().collect(); if dump_drive_one_beat(&mut state, pin).is_err() { break; } - if state.loop_detect_ring.len() == before { + // Frames minted and then evicted WITHIN one beat are absent here by construction — + // they are gone from the ring. "Every frame this beat added that the ring still holds" + // is exactly the set the arms below claim, and exactly the set any consumer can read. + let (minted, _) = ring_membership_delta(&before, &state.loop_detect_ring); + if minted.is_empty() { continue; } - if !answered_forced_window { - settle_mints += 1; - continue; + frames_validated += minted.len(); + if answered_forced_window { + answer_mints += 1; + } else { + settle_mints += minted.len(); } - answer_mints += 1; - let frame = &state - .loop_detect_ring - .back() - .expect("the ring just grew, so it has a back element") - .live; - // ── (2) ITS PRIORITY PLAYER. Asserted FIRST so a mutation that touches only - // `priority_player` is caught by its own arm instead of being masked by arm (1). - assert_eq!( - frame.priority_player, frame.active_player, - "beat {beat}: `sync_waiting_for` recomputes `priority_player` from the window it \ - installs, so an answer-beat frame must carry the post-sync submitter for the \ - returned `Priority{{active_player}}` window, not whatever the pre-pipeline window \ - left behind" - ); - // ── (1) THE NEWEST SAMPLED STATE: the window the action RETURNS, never the forced one - // it answered. - assert_eq!( - frame.waiting_for, - WaitingFor::Priority { - player: frame.active_player - }, - "beat {beat}: the sampler's own gate requires the RETURNED `wf` to be \ + // BOTH ARMS RUN OVER EVERY FRAME THIS BEAT MINTED, which is what makes site attribution + // stop mattering — and the row deliberately does NOT try to attribute. + // + // `answered_forced_window` is `is_forced_cascade_window()` read BEFORE the beat, i.e. + // strictly ONE conjunct of the production answer-site gate; that gate also demands + // `!in_simulation_probe()`, `loop_detection.samples()`, `!stack.is_empty()`, a + // non-shrinking stack, and `Priority{player == active_player}`. One beat here is one + // `apply()`, which reaches the SETTLE sampler after `apply_action` returns. So a beat + // that answers a forced window resolving the LAST stack entry gates the answer site + // off (`!stack.is_empty()` false) while the refill cascade mints a settle frame, and + // `answer_mints` increments over a frame the SETTLE site produced. + // + // Iterating the minted frames removes the question instead of sharpening it. Both + // samplers gate their record on `Priority{player == active_player}` and both record + // after their `sync_waiting_for`, so both arms hold for EITHER site's frame; a frame + // that fails one is a real defect no matter which sampler produced it. + for sample in minted { + let frame = &sample.live; + // ── (2) ITS PRIORITY PLAYER. Asserted FIRST so a mutation that touches only + // `priority_player` is caught by its own arm instead of being masked by arm (1). + // + // The comparand is the AUTHORITY FUNCTION, not `frame.active_player`. `sync_waiting_for` + // sets `priority_player = turn_control::authorized_submitter_for_player(state, + // waiting_for.acting_player())`, which re-routes to a DIFFERENT seat whenever a + // turn-decision controller (Mindslaver) or a latched search-decision controller is in + // play. Pinning the seat itself would false-fail a correctly synced frame on any future + // turn-control fixture. The recomputation is not circular: neither + // `effective_authority_for_player` nor `search_decision_authority` reads + // `priority_player`, so a mutant that clobbers only that field still fails here. + // + // `if let` rather than `expect`, and the difference is MEASURED: an `expect` here + // pre-empts arm (1). A window with no acting player (`GameOver`) is precisely what + // arm (1) exists to catch, so panicking on the `None` before reaching it replaces + // arm (1)'s explanation with an unwrap message and destroys the arms' separation — + // the `waiting_for = GameOver` mutant died on the unwrap instead of on arm (1). The + // pair stays TOTAL, so this skip opens no hole: either arm (2) runs, or the window + // had no actor and arm (1) below fails on that same frame. + if let Some(semantic_player) = frame.waiting_for.acting_player() { + assert_eq!( + frame.priority_player, + engine::game::turn_control::authorized_submitter_for_player( + frame, + semantic_player + ), + "beat {beat}: `sync_waiting_for` recomputes `priority_player` from the window \ + it installs, so an answer-beat frame must carry that window's AUTHORIZED \ + SUBMITTER, not whatever the un-synced state left behind" + ); + } + // ── (1) THE NEWEST SAMPLED STATE: the window the action RETURNS, never the forced one + // it answered. + assert_eq!( + frame.waiting_for, + WaitingFor::Priority { + player: frame.active_player + }, + "beat {beat}: the sampler's own gate requires the RETURNED `wf` to be \ `Priority{{active_player}}`, so recording before the sync is the only way the \ frame can carry a different window — and `impl PartialEq for GameState` compares it" - ); + ); + } } assert!( answer_mints > 0, - "reach-guard: the drive must mint at least one frame at the FORCED-WINDOW ANSWER site, \ - else arms (1)/(2) never ran and this row passes vacuously; got answer={answer_mints} \ - settle={settle_mints}" + "reach-guard: the drive must reach at least one MINTING beat whose pre-beat window was \ + FORCED, else the answer-site path this row exists for was never exercised and it \ + passes vacuously. Stated exactly: this counts beats whose PRE-beat window satisfied \ + `is_forced_cascade_window()`, which is ONE conjunct of the production answer-site \ + gate, so it is a reach guard and NOT proof that the answer sampler is what minted; \ + got answer={answer_mints} settle={settle_mints}" + ); + assert!( + settle_mints > 0, + "reach-guard for the WIDENING: arms (1)/(2) now run over every frame a beat minted, \ + from either sampler, so the drive must also mint at a non-forced (settle) beat — \ + otherwise the settle-frame coverage this row claims is untested. got \ + answer={answer_mints} settle={settle_mints}" + ); + let ring_at_offer = state.loop_detect_ring.len(); + assert_eq!( + frames_validated, ring_at_offer, + "reach-guard on the MEMBERSHIP DIFFERENCE ITSELF, which is what makes 'every frame the \ + beat minted' a measurement instead of a claim: every frame the offer's ring holds must \ + have reached arms (1)/(2) as a member of some beat's minted set. A detector that \ + silently returned the EMPTY set on a minting beat — exactly what a length delta returns \ + at capacity — leaves frames in the ring that no arm ever read, and that lands here as \ + validated {frames_validated} against ring {ring_at_offer}. Equality rather than `>=` \ + because \ + this drive neither evicts nor clears (measured: max ring 5 against a capacity of 16, 0 \ + evicting and 0 clearing beats), so a frame counted but no longer present is equally a \ + defect on THIS board" ); let offer_beat = offer_beat.expect( "reach-guard: the bounded offer must FIRE on this real 4p drain, else arm (3) asserts \ @@ -11350,8 +11485,265 @@ fn answer_beat_frames_carry_the_synced_window_and_the_offer_certificate_is_exact assert_eq!( *delta, expected_delta, "EXACT per-period signature: +1 to the controller, -1 to each opponent, and every \ - other axis at rest. A ring whose frames disagreed on `waiting_for`/`priority_player` \ - could not produce this signature at all, because `ring_delta_signature` compares the \ - frames with `impl PartialEq for GameState`" + other axis at rest. `ring_delta_signature` is INSENSITIVE to \ + `waiting_for`/`priority_player` — it reads resource snapshots plus \ + `phase_invariant` (turn/phase/extra-phases) — so this arm is the blast-radius pin \ + for the reorder, not a restatement of arms (1)/(2). The frame homogeneity those two \ + arms pin is basis A's concern (`loop_states_equal_modulo_resources` ⇒ \ + `impl PartialEq for GameState`)" + ); +} + +// ───── #7023 maintainer item: a beat's minted frames are a MEMBERSHIP set, not a length delta ───── + +/// `(minted_frames, dropped)` for one beat, by `Arc` IDENTITY: frames the ring gained, and +/// pre-beat frames it lost. Generic over the sample type so the ring's private element type is +/// never named here. +/// +/// `before` must be a slice of `Arc` CLONES held across the beat, not of raw addresses. +/// `GameState::record_loop_detect_sample` calls `pop_front()` — which 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 whole beat, so `ptr_eq` is exact by construction +/// rather than by luck. +fn ring_membership_delta<'a, T>( + before: &[std::sync::Arc], + after: &'a std::collections::VecDeque>, +) -> (Vec<&'a std::sync::Arc>, usize) { + let minted = after + .iter() + .filter(|f| !before.iter().any(|b| std::sync::Arc::ptr_eq(b, f))) + .collect(); + let dropped = before + .iter() + .filter(|b| !after.iter().any(|f| std::sync::Arc::ptr_eq(b, f))) + .count(); + (minted, dropped) +} + +/// `dump_drive_one_beat`'s policy with its `Priority` arm taken directly. That policy is +/// unconditionally "pass" at a `Priority` window, so routing through the enumerator costs a full +/// per-viewer candidate scan — on the 152-entry `dellian` stack, the dominant cost of a long +/// drive — only to find the `PassPriority` the policy already chose. `apply` performs the real +/// legality check itself (`game::priority::pass_priority_legality`), so nothing is skipped but +/// the enumeration. Every other window still goes through the shared driver unchanged. +fn drive_one_beat_passing_fast(state: &mut GameState, pin: Option) -> Result<(), String> { + if let WaitingFor::Priority { player } = state.waiting_for { + return apply(state, player, GameAction::PassPriority) + .map(|_| ()) + .map_err(|e| format!("pass err: {e:?}")); + } + dump_drive_one_beat(state, pin).map(|_| ()) +} + +/// CR 732.2a. ROUTE ⓔ — EVICTION AT `LOOP_DETECT_RING_CAP`: a beat that MINTS WITHOUT GROWING. +/// +/// `answer_beat_frames_carry_the_synced_window_and_the_offer_certificate_is_exact` reads a beat's +/// minted frames as the `loop_detect_ring`'s pre/post `Arc`-identity MEMBERSHIP DIFFERENCE. It +/// previously read them as `rev().take(after_len - before_len)` off a changed `back()`, and this +/// row plus its ⓒ sibling are the reachability half of the #7023 review that replaced that: the +/// scalar is wrong on two production routes, and the `dina_conqueror_4p` drive that row performs +/// reaches NEITHER — measured on it: max ring 5 against a capacity of 16, 0 evicting beats, 0 +/// clearing beats. Correcting a detector against routes no fixture reaches would re-open the +/// evidential hole the correction exists to close, so each route gets a board. +/// +/// THE MECHANISM. `GameState::record_loop_detect_sample` calls `pop_front()` and THEN +/// `push_back()` once the ring is at `LOOP_DETECT_RING_CAP`. One frame leaves, one arrives: the +/// back changes, the LENGTH DOES NOT. Net growth is 0 on a beat that minted 1, so +/// `rev().take(net)` inspects nothing and the deleted `assert!(grew >= 1, ..)` — labelled "fail +/// closed" in-tree — failed the test on an ordinary drain beat rather than on an anomaly. That +/// label was wrong about which side of the line the beat is on, and it went with the code. +/// +/// FIXTURE: the tracked `dellian_emblem_conqueror_4p` dump, driven through production `apply()`, +/// so the ring that reaches capacity is this drive's own accumulation through +/// `record_loop_detect_sample`. MEASURED: first evicting beat at 72 (ring 16 -> 16, minted 1, +/// dropped 1), three more inside the first 90. +/// +/// THE SEARCH PREDICATE IS STRUCTURAL AND THE ASSERTION IS THE CONSEQUENCE, never the reverse: +/// the witness is the first beat that LOST a pre-beat frame without the ring shrinking — pure +/// membership plus an ordering, saying nothing about minting — and the assertion is then 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. +/// +/// This row does NOT re-assert the answer-beat row's frame invariants (the synced +/// `waiting_for`/`priority_player` pair). Its subject is the DETECTOR, not the frames. +#[test] +fn an_evicting_beat_mints_without_growing_the_ring() { + let mut state = restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/dellian_emblem_conqueror_4p.json.gz" + ))); + assert_eq!( + state.loop_detect_ring.len(), + 0, + "reach-guard: the dump ships with an EMPTY ring, so the capacity reached below is THIS \ + drive's accumulation through the production sampler and not a restored ring" + ); + + let pin = engine_live_opponents(&state, P0).first().copied(); + let mut max_ring = 0usize; + let mut beats_run = 0usize; + let mut evicting = None; + for beat in 0..120usize { + if matches!(state.waiting_for, WaitingFor::LoopShortcut { .. }) { + break; + } + let before: Vec<_> = state.loop_detect_ring.iter().cloned().collect(); + if drive_one_beat_passing_fast(&mut state, pin).is_err() { + break; + } + beats_run = beat + 1; + max_ring = max_ring.max(state.loop_detect_ring.len()); + let (minted_frames, dropped) = ring_membership_delta(&before, &state.loop_detect_ring); + let minted = minted_frames.len(); + if dropped == 0 || state.loop_detect_ring.len() < before.len() { + continue; + } + evicting = Some(( + beat, + before.len(), + state.loop_detect_ring.len(), + minted, + dropped, + )); + break; + } + + let (beat, before_len, after_len, minted, dropped) = evicting.unwrap_or_else(|| { + panic!( + "reach-guard: no beat replaced a ring frame without shrinking the ring, so this \ + drive never reached `LOOP_DETECT_RING_CAP` and the eviction route is untested — the \ + state the `dina_conqueror_4p` drive is permanently in, and the reason this row \ + exists on a different board. mode {:?}, max ring {max_ring} over {beats_run} beats", + state.loop_detection + ) + }); + assert_eq!( + (minted, after_len), + (1, before_len), + "beat {beat}: an evicting push is 1-for-1 — `record_loop_detect_sample` pops the front \ + and THEN pushes at capacity — so this beat MINTED while its length stood still at \ + {before_len}, dropping {dropped}. THE DELETED SCALAR ON THIS BEAT: \ + `after_len - before_len` is 0, so `rev().take(grew)` inspects NOTHING while the beat \ + minted a frame, and `assert!(grew >= 1)` fails the whole test on an ordinary drain \ + beat. The membership difference reports the mint; no length delta can" + ); +} + +/// CR 732.2a. ROUTE ⓒ — CLEAR-AND-REBUILD INSIDE ONE BEAT: a beat that MINTS MORE THAN IT GROWS. +/// +/// Sibling of `an_evicting_beat_mints_without_growing_the_ring`; the shared argument for why both +/// routes need a board of their own is stated there. +/// +/// THE MECHANISM. `game::engine::apply_action` clears the ring at its top for any action that is +/// neither `PassPriority` nor `OrderTriggers` answering a non-forced window. +/// `GameAction::SetAutoPass` at a `Priority` window is exactly that, and its own arm then calls +/// `pass_priority_once_with_pipeline`, whose settle sampler mints — after which `apply`'s +/// auto-pass loop can call it again inside the SAME beat. Net growth is therefore +/// `minted - cleared`, strictly below `minted` whenever the ring was non-empty, so `take(net)` +/// skips frames the answer-beat row claims to validate. MEASURED here: ring 2 -> 1, 2 dropped, 1 +/// minted, i.e. net 0 against a real mint — the same blindness route ⓔ produces, reached the +/// other way, and the shallow-clear case (`0 < net < minted`) is the same defect with a smaller +/// margin. +/// +/// `SetAutoPass { UntilStackEmpty }` IS NOT A TEST HOOK. It is the exact payload the client's +/// Arena-style "Resolve All" control dispatches (`client/src/game/dispatch.ts`), so this beat is +/// a player pressing that button mid-cascade. It is absent from +/// `ai_support::legal_actions_for_viewer`'s enumeration — `classify_flat_priority_action` files +/// it with the preference-propagation actions — which is why the generic dump driver never picks +/// it and why the route is dispatched by name here instead of being found by the driver's "first +/// legal action" policy. Dispatching it by name changes nothing about the beat: it is one +/// ordinary `apply()`, validated by the production reducer like any other. +/// +/// FIXTURE: the tracked `dina_conqueror_4p` dump — the SAME board the answer-beat row drives, so +/// the contrast is exact. Left to itself that drive never clears (measured: 0 clearing beats); +/// one "Resolve All" press at the first `Priority` window carrying 2 accumulated frames puts it +/// on this route at beat 6. +/// +/// SEARCH PREDICATE STRUCTURAL, ASSERTION THE CONSEQUENCE: the witness is the dispatched +/// `SetAutoPass` beat that lost EVERY pre-beat frame, and the assertion is what the beat minted +/// and what it left behind. The threshold of 2 accumulated frames is not cosmetic: below it a +/// wiped ring and a merely-evicted one are indistinguishable by membership. +#[test] +fn a_clearing_beat_rebuilds_the_ring_inside_the_same_beat() { + let mut state = restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/dina_conqueror_4p.json.gz" + ))); + assert_eq!( + state.loop_detect_ring.len(), + 0, + "reach-guard: the dump ships with an EMPTY ring, so the frames the clear below discards \ + were accumulated by THIS drive through the production sampler" + ); + + let pin = engine_live_opponents(&state, P0).first().copied(); + let mut fired = false; + let mut max_ring = 0usize; + let mut beats_run = 0usize; + let mut clearing = None; + for beat in 0..120usize { + if matches!(state.waiting_for, WaitingFor::LoopShortcut { .. }) { + break; + } + let before: Vec<_> = state.loop_detect_ring.iter().cloned().collect(); + max_ring = max_ring.max(before.len()); + let dispatched_here = + !fired && before.len() >= 2 && matches!(state.waiting_for, WaitingFor::Priority { .. }); + let outcome = if dispatched_here { + fired = true; + let who = state + .waiting_for + .acting_player() + .expect("a `Priority` window names its actor"); + apply( + &mut state, + who, + GameAction::SetAutoPass { + mode: AutoPassRequest::UntilStackEmpty, + }, + ) + .map(|_| ()) + .map_err(|e| format!("resolve-all err: {e:?}")) + } else { + drive_one_beat_passing_fast(&mut state, pin) + }; + if outcome.is_err() { + break; + } + beats_run = beat + 1; + let (minted_frames, dropped) = ring_membership_delta(&before, &state.loop_detect_ring); + let minted = minted_frames.len(); + if !dispatched_here || before.is_empty() || dropped != before.len() { + continue; + } + clearing = Some(( + beat, + before.len(), + state.loop_detect_ring.len(), + minted, + dropped, + )); + break; + } + + let (beat, before_len, after_len, minted, _dropped) = clearing.unwrap_or_else(|| { + panic!( + "reach-guard: no beat lost EVERY pre-beat ring frame, so `apply_action`'s \ + top-of-beat clear never ran on an accumulated ring and the clear-and-rebuild route \ + is untested. resolve-all dispatched={fired}, max ring {max_ring} over {beats_run} \ + beats" + ) + }); + assert_eq!( + (minted, after_len), + (1, 1), + "beat {beat}: `apply_action` cleared all {before_len} accumulated frames at the top of \ + this beat; a sampler must then have MINTED inside the SAME beat, leaving exactly one \ + frame — a left of `(0, 0)` means the beat only cleared, which is a different route and \ + proves nothing about the detector. THE DELETED SCALAR ON THE REBUILD BEAT: \ + `after_len - before_len` saturates to 0 against a real mint, so `take(grew)` validates \ + none of the frames the answer-beat row claims — and where the clear is shallower than \ + the rebuild the scalar is positive but still short. Net growth is `minted - cleared`; \ + it can never name `minted`" ); }