fix(engine): exhaustive SCOPE-position PlayerFilter decisions + stamp a real zero instead of leaking the chain step - #6999
Conversation
…exhaustive (#6957) `is_player_scope_local_continuation` ended on `matches!(scope, PlayerFilter::All)`. That is a hand-maintained allowlist, not an exhaustive match, so every other `PlayerFilter` in SCOPE position silently answered "detach" with no compile error — and that answer is structural, not inert: `detach_after_player_scope_local_chain` uses it to pull the continuation out of the loop and run it ONCE as an unscoped tail, where `TargetFilter::ScopedPlayer` has no iteration player to bind to. Replace the allowlist with `scope_keeps_scoped_whole_hand_shuffle_local`, an exhaustive `match` over `PlayerFilter` (no `_` arm), mirroring `player_filter_references_tracked_set`. The answer is the same for every filter, which is the finding: the discriminator is the `ScopedPlayer` recipient on BOTH halves of the whole-hand move/shuffle pair (CR 115.10 + CR 701.24a), never the identity of the player set. The `All` conjunct was a conservative narrowing from #6730, not a rules condition. Behaviour on the current card pool is unchanged: a census of `data/card-data.json` finds exactly four cards with this effect pair (Molten Psyche, Once More with Feeling, Whirlpool Warrior, Winds of Change) and all four already carry `player_scope: All`. Regression runs TWO scoped opponents on purpose — with one iteration the aggregate "a shuffle happened" cannot distinguish "kept in scope" from "detached and run once". Under the reverted gate it fails with 1 shuffle instruction instead of 2. Also re-pins the CR 603.5 prompt census (+62, uniform, producers verified sha256-identical at their new coordinates).
… chain step (#6956) `previous_effect_amount_from_events` ended on `(amount > 0).then_some(amount)`, collapsing "this instruction produced ZERO" into "this effect has no result on this channel". Both call sites assign only on `Some`, so that collapse did not leave the slot empty — it left the PREVIOUS chain step's amount standing, and the next "that many" / "that much" clause read that instead. Stamping `Some(sum)` unconditionally is the mirror bug and fails more quietly. It is also live, not hypothetical: `Effect::PayCost` carries an arbitrary `AbilityCost`, and 19 shipping cards (the Extort cycle) chain a `PreviousEffectAmount` consumer behind a MANA `PayCost` that emits no `LifeChanged` event at all. So the zero case is decided per effect by two independent conditions: 1. the effect OWNS the channel that arm sums, and 2. the instruction COMPLETED — `EffectResolved { kind, source_id }`, pushed by every resolver as its last act and skipped when it suspends for a player choice. This is the same anchor the already-correct sibling `previous_effect_counts_by_player_from_events` uses. Nonzero behaviour is unchanged. Arms closed, each with its own CR determination and its own zero-case test: DealDamage/DamageAll/DamageEachPlayer CR 120.8 + CR 614.7a + CR 615.1 LoseLife CR 119.3 GainLife CR 119.3 + CR 119.9 RemoveCounter CR 122.1 Arms NOT closed, left on predecessor behaviour with the reasoning inline: PayCost CR 118.1 + CR 119.4b say paying 0 life is a real payment whose result is zero, but `pay::resolve` pushes no events at all, so it has no terminal marker and completion cannot be established. Both directions are pinned by tests instead. Fight sums the EXCESS channel into the TOTAL slot and scopes it to a fought creature that may not resolve — a genuine third state. Unobservable today: The Last Agni Kai, the only consumer, gates on the Excess channel, which is written unconditionally. Note: #6956 cites CR 118.2 for "losing 0 life is not a life-loss event". CR 118.2 is the mana-payment rule, and the rules text has no life-loss analogue of CR 119.9 at all, so that premise does not hold and LoseLife is treated under CR 119.3 like the others. Also re-pins the CR 603.5 prompt census (+130 on one entry; producer verified sha256-identical at its new coordinate, other four unmoved).
…istry (#6956 review round 1) Addresses the HIGH plus items 2-6 from review. #6957 untouched except item 6. [HIGH] Thorna and Twigtooth regressed. Its trigger lowers to `RemoveCounter -> LoseLife{PreviousEffectAmount} -> GainLife{PreviousEffectAmount}` for "…each opponent loses X life, you gain X life, … where X is the number of counters removed this way". With an opponent under CR 119.8 "can't lose life" the middle clause totals zero; #6956's first pass claimed that zero and the gain clause read 0 instead of 2. Reproduced exactly as reported (gained=0 vs 2). The generalisation, not the card: CR 608.2c — "…X…, …X…, and …X…, where X is <definition>" names ONE value every clause reads. The engine approximates that with a single mutable slot plus chain-relative reads, so a clause whose OWN quantity IS that shared X is a RELAY, not a fresh anchor; letting its outcome redefine the slot destroys the X its later siblings need. `zero_is_a_result` now requires `!effect_relays_the_shared_amount(..)`. Scoped to the ZERO case on purpose, so it is exactly a restoration of predecessor behaviour for relays and provably no wider than #6956 itself. It does NOT fix the nonzero chain-relative divergence (a two-opponent Thorna still stamps the doubled loss) — that is the pre-existing anchored-quantity gap, left for separate routing. The card-pool census #6956 never got, by nearest stamping producer (the only one a consumer can read): 114 cards read a changed-kind producer directly -> helped 2 had a stamper above it, i.e. real leak content -> Thorna, Groaaaaag 0 harm remaining after the relay guard 4 relay-shaped cards with nothing above them revert to predecessor behaviour (comeuppance, lulu, magma pummeler, new way forward) Last Stand drops out: its consumer's nearest producer is `Draw`, which this diff never changed. [MED] The PayCost justification was factually inverted and is corrected. The 19 Extort cards do NOT have `PayCost` as the direct parent of a `PreviousEffectAmount` consumer — `LoseLife` sits between and overwrites first. ZERO cards do. The guard now rests on CR 118.1 alone ("owns no channel"), and the test docstring says so instead of claiming to pin an Extort direction it never protected. [LOW-MED] Twin registry collapsed. The `_ => 0` summing arm and the separate `_ => false` zero-policy arm could drift, silently restoring #6956 with no compile error — the exact mechanism #6957 removes. Both are now derived from one `amount_channel` classifier returning a typed descriptor carrying the event channel and the zero semantics. Verified: adding a channel is E0004. [LOW] Dangling "see FINDINGS" pointer removed. [LOW] `effect_instruction_completed` no longer overstates parity with the sibling; the comment now names the difference (marker presence vs window-bounded tally). [LOW] `AllExcept` recurses into its anchor in #6957's helper, matching the cited precedent. Behaviour-identical while every arm is `true`. Also adds the shared `quantity_expr_any_ref` walker so the back-reference predicates do not each carry a copy of the composition-form recursion.
…6956) CR 107.3i — "normally, all instances of X on an object have the same value at any given time." The engine lowers every X in a co-anchored clause group to `QuantityRef::PreviousEffectAmount`, which reads the immediately preceding chain step. When the anchor is not the direct predecessor, a later clause reads the intervening relay's result instead of X. Thorna and Twigtooth is the live case: `RemoveCounter -> LoseLife{Prev} -> GainLife{Prev}` with two opponents makes the third clause read the SUMMED life loss (2 x X) rather than X. Two tests, both watched go red against this tree: - `second_consumer_of_a_shared_x_reads_the_relay_not_the_anchor` pins the gap at its current (wrong) value of 4, following the convention already used by `zero_life_pay_cost_is_not_yet_distinguished_from_an_absent_producer` — the open arm becomes an asserted fact rather than an untested assumption. Flipping the expectation to 2 is the acceptance test. - `a_relay_that_re_anchors_x_must_keep_publishing_its_own_result` pins the counter-shape (Magma Pummeler: "remove that many counters ... deals that much damage") where the intervening relay IS the correct anchor. It is byte-identical in the AST to the Thorna shape but demands the opposite answer. Together they prove a stamp-time fix is undecidable: generalizing `effect_relays_the_shared_amount` from the zero case to every case was measured to flip the first test 4 -> 2 (correct) while breaking the second 7 -> 2 (regression). The binding must be recorded where the parser lowers X, not recovered at resolution. No production code changed.
|
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 (4)
📝 WalkthroughWalkthroughThe effects engine now applies scoped shuffle continuation logic to every player filter. It centralizes amount-channel classification, preserves authoritative and measured zero results, shares quantity-reference traversal, and adds regression coverage for shared-X relay behavior. ChangesEffects propagation
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Thorna
participant EffectsEngine
participant PlatinumEmperion
participant GameState
Thorna->>EffectsEngine: Resolve shared-X trigger
EffectsEngine->>PlatinumEmperion: Apply life-loss replacement
PlatinumEmperion->>EffectsEngine: Produce zero life loss
EffectsEngine->>GameState: Reuse removed-counter amount for life gain
Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 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 |
|
Generated for head Parse changes introduced by this PR✓ No card-parse changes detected. |
…ated The rebase onto upstream `b654513cb` (phase-rs#6996, phase-rs#6999, phase-rs#6998, phase-rs#7001, phase-rs#6997, phase-rs#6946) resolved the census row's conflict to upstream's three `effects/mod.rs` literals, which are correct for the upstream tree but not for this branch replayed on top of it. Re-derived from the row's own failure output — never predicted by arithmetic — and re-pinned: `:6065/:6142/:9324 ⇒ :6175/:6252/:9456` The shift is NOT uniform (`+110/+110/+132`), and the asymmetry is the measurement: C1's `upfront_optional_gate` authority is one 110-line hunk above all three producers, and `resolve_chain_body` takes a further `+22` from two hunks inside itself and above its own gate (the `optional_for` coupling note, and adoption A replacing the inline conjunct chain with the authority call plus its `debug_assert!`). The file's whole-file delta is also `+132`, so nothing lands below the third producer, and `6065+110`, `6142+110`, `9324+132` equal the observed coordinates exactly. Identity re-established rather than assumed. Each producer at its new coordinate is sha256-identical to `b654513cb:effects/mod.rs` at its old one and to the pre-rebase tip `117baa6a1` at `:6109/:6186/:9183`, and each is still inside the enclosing function this row NAMES — `drive_sequential_repeated_optional_payment`, `resolve_repeated_optional_payment_choice`, `resolve_chain_body` — which is stronger evidence than the coordinate. The diff instrument discriminates: in the new tree the three old coordinates hold a `may_trigger_auto_choice` lookup, a blank line, and a bare `//`. `engine.rs:11549` was re-derived too, not carried over, and is UNMOVED: upstream's six commits net ZERO above it (`:11420` in both the old base `dcb8f3808` and the new base `b654513cb`), so this branch's own `+95`/`+34` still land it on `:11549`, byte-identical and still inside `begin_pending_trigger_target_selection`. `scoped_library_search.rs:452` is unmoved as well. Two entries holding still while three move is the set-preservation evidence: the row's first two asserts fired GREEN on the run that caught this, so the total stays 37 and the partition stays 5/7/25 — no producer was gained or lost. Assisted-by: ClaudeCode:claude-opus-5
phase-rs#7005) * fix(engine): announce the entries a forced-window answer places on the stack CR 732.2a's ring sampler had exactly one site: `pass_priority_once_with_pipeline`, which fires only at an active-player `Priority` settle. Any stack entry that resolves ACROSS a forced pre-priority window — a CR 608.2b `TriggerTargetSelection`, a CR 603.5 `OptionalEffectChoice`, a CR 603.3b `OrderTriggers` — was therefore never present in two consecutive retained frames, so `certified_period_touch`'s announced set ("entries in a frame's stack absent from the previous frame's") could not see it and `bounded_cycle_pin_slots_for_window` could not publish its choice. The shortcut then described a sequence with unpublished per-iteration choices in it. This adds the SECOND sampling site, in `apply_action`, keyed on the forced-window flag captured BEFORE the reducer consumed it. Its conjuncts are the settle sampler's, plus a non-shrinking-stack guard measured against the pre-action depth. Consequences carried in this commit rather than left to be discovered: * the structural pin `arc_as_ptr_beat_identity_is_the_sample_not_one_of_its_halves` moves 2 -> 3 `as *const` reads (the shared per-beat `before` plus an `after` read in each arm that can advance the ring), so a re-basing onto a field address still flips it; * two doc comments claiming `victim_slot` is "empty on every trajectory that offers today" are FALSIFIED by the widening and are replaced, not softened — a `Targets` declaration is announced now, so `worst_seat_life_loss` reaches `elimination_bounds` in production; * B5f rows that consequence two-sided on the user's own MODE1 capture (tracked here as `f4_user_mode1_no_offer_4p.json.gz`, 860,451 B, derived `jq -c '{gameState}' | gzip -9 -n` from the 20.5 MB envelope): with P1 seeded at 7 and 6 the offer FIRES with `max_iterations == 1`; at 5 and 4 the drive reaches the same beat, raises nothing, and the typed verdict is `NoNarrowedLegalCount`. The arms are ONE life point apart, which is what makes the row about the divisor rather than about the board. Landing FIRST of the five commits is load-bearing: relief without the widening turns a silent no-offer into a treadmill that offers and commits nothing. Assisted-by: ClaudeCode:claude-opus-5 * refactor(engine): one authority for a loop-shortcut period boundary `drive_one_shortcut_cycle` delimits a committed repetition two ways: board recurrence, and — for the certification basis that consults no board predicate at all — the published `frames_per_period` count. The frame-count arm existed at exactly one beat kind, the active-player settle, because that was the ring's only sampling site. With the answer-beat sampler in place that premise is gone: a period whose extra frames are recorded while a player answers a forced pre-priority window would never reach `k`, so `frames_per_period` becomes unreachable on precisely the boards the widening was for, and such a drive can only end at its runaway beat cap having committed nothing. The injector arm therefore advances the same counter, under the same `Arc`-identity frame detector the settle arm uses. Both arms now ask ONE function. `published_period_elapsed(frames_this_cycle, frames_per_period)` carries the two properties neither call site can state: `None` NEVER elapses (an offer that published no signature must not have one invented for it, because ending a cycle early commits a fraction of the published delta — the conditional action CR 732.2a forbids), and the comparison is `>=` rather than `==` (one beat may retain more than one frame, and an `==` would drive past its own boundary). Two regression rows, one on each surface: * `published_period_elapsed_is_total_over_the_axes_that_delimit_a_cycle` asserts the whole truth table, including the `k - 1` and `k + 1` arms that discriminate the off-by-one and the `>=`/`==` choice — the anti-vacuity control the structural row cannot supply; * `the_period_delimiter_has_one_authority_and_both_frame_recording_arms_ask_it` censuses `drive_one_shortcut_cycle`'s extent with the tree's own comment-excluding extractor: exactly two delimiter calls, exactly two counter advances, and ZERO inlined raw comparisons, with a proven-live instrument on both sides of the zero census. Also restores `drive_one_shortcut_cycle`'s doc block, which the delimiter extraction had silently re-attached to the new function, and corrects its "the single `record_loop_detect_sample` call site" sentence — there are two sampling sites now. Assisted-by: ClaudeCode:claude-opus-5 * fix(engine): discharge a loop's replacement obligations against the live board Conjunct (6) classifies each announced stack entry on its CARRYING FRAME — a retained ring sample, and therefore a board from the past. It then discharged the resulting `FreeUnlessReplacements` obligation against that same frame, which answers the wrong question: a shortcut is a claim about the FUTURE, and every remaining repetition resolves under the board that exists NOW. A replacement definition that entered the battlefield after the sample was taken is invisible to the frame-side check, so the described sequence could contain exactly the CR 616.1 resolution-time choice CR 732.2a forbids. The gate now discharges a second time against `state`, guarded by `!ptr::eq(*frame, state)` — a de-duplication, not an exemption: when the pair is carried by `current` itself the first call already ran on that very board. Two rows, each with its own paired positive: * `n3_a_replacement_installed_after_the_frame_was_captured_refuses_certification` builds a ring whose frames are all cloned BEFORE the definition is installed, so the def exists on the live board and nowhere else, and runs four arms — no def (certifies), live-only OPTIONAL (refused, the arm this change exists for), live-only MANDATORY (certifies, which keys the previous arm to optionality rather than to "a definition exists"), and present-everywhere OPTIONAL (refused, proving the frame-side discharge still does its own job so this is an ADDED refusal, not a relocated one). `announced_from_retained_sample` runs on every arm as the reach-guard that the pair is carried by a frame that is not `current`. * `n3_b_a_live_carried_pair_is_still_discharged_by_the_first_call` exhibits the short-circuited shape and shows the optional definition is still refused there. CR anchors corrected in the same change, because they are about this seam. CR 614.1a is "effects that use the word instead" — a sub-rule cited for its parent's job. The prompt-cause authority in `replacement.rs` classifies EVERY applicable replacement, including skips, enters-with, turned-face-up and virtual candidates that carry no `ReplacementDefinition` at all, so its anchor is the definitional head CR 614.1; and what makes an optional replacement disqualify a shortcut is CR 732.2a's ban on conditional actions, not CR 614.1a. Both `replacement.rs` sites and four r9 sites now read `CR 732.2a + CR 614.1`, in that order. CR 616.1 stays on the two-or-more ORDERING branch, where it belongs. Assisted-by: ClaudeCode:claude-opus-5 * fix(engine): one authority for whether a "may" is already answered, and answer the shortcut's gate with it Three places answered "does this ability open ONE up-front optional gate, and to whom, under which key?" — production's own branch in `resolve_chain_body`, the loop-shortcut mint's guard (b), and `analysis::resource::auto_may_answer_for`. The latter two asked the same four predicates and OMITTED two conjuncts the first has: `optional_for` and the CR 608.2d feasibility probe. Measured, that made them return a different answer on two ability shapes, and each was defended only incidentally — the fan-out case by a disjunct in `resolution_prompt.rs` answering a different question, and the infeasible case by membership of a fail-closed list from which three variants have already been promoted out. `effects::upfront_optional_gate` is now the assembler, and production's own branch IS that function rather than a fourth copy of it. `stored_may_answer` is its consumer half. `OptionalFeasibility::{Known, Probe}` exists because a naive adoption would have made production probe TWICE: `resolve_chain_body` has already run `optional_effect_is_infeasible` for the `CastFromZone` decline early-return that precedes the gate, and that arm clones the whole `GameState` per bound object to run a dry-run cast. Adoption A hands its answer over as `Known`; every other caller passes `Probe`, which the authority evaluates LAST so the clone-bearing arm is reached only for `optional AND NOT optional_for AND NOT repeat` entries. It is deliberately not charged against `PROBE_BUDGET`: that counter bounds CR 732.2a certification asks at the verdict door, and mixing wall-clock cost into it would re-base every metered row's pinned spend. Guard (b) adopting the authority is a BEHAVIOUR CHANGE and it is rules-correct in the fail-closed direction. It now withholds the `MayChoice` slot for an `optional_for` ability — CR 608.2d + CR 101.4 make that an APNAP cascade of up to one window per living player, and one published slot standing for N prompts is the cardinality defect group (c) already argues against — and for an infeasible optional, which opens no window at all, so a slot for it is a pin the gate can never spend. Direction: strictly FEWER offers, never more. N0 rides on the same authority: gate (6) now takes relief from a stored auto-choice as well as from a published pin, and the two bases are disjoint by construction because guard (b) publishes a slot only for a may with NO stored answer. Reading an auto-answered may's slotless mint as "unspecified" was the defect. Only `Accept` is relieved — a stored `Decline` is equally prompt-free but produces the OPPOSITE board, so its optional-cleared residual would describe events the shortcut never proposes. Rows, each with its own paired positive: * `a5_a_stored_accept_relieves_gate_six_and_a_stored_decline_does_not` — one board, one key, one value different; the arm the user's MODE1 capture rides on. * `f2a_the_upfront_gate_authority_answers_the_two_shapes_the_third_copy_omitted` — every arm seeds a stored `Accept` under exactly the key the old copy built, so an omitted conjunct is a WRONG answer rather than an absent one. Includes the feasibility control (the same `RemoveCounter` ability on a board ONE counter different) and the pair that proves `Known` overrides the probe instead of re-running it. * `f2b_guard_b_withholds_a_pin_the_cr_603_5_gate_can_never_spend` — deliberately UNSEEDED, so guard (b)'s store conjunct is vacuously true on every arm and the only thing that can move `may` is the axis under test. A seeded variant is rejected: it cannot fail for the reason the row exists. * `f2c_the_cr_603_5_conjunct_set_has_one_production_assembler` — MEASURED per-predicate production call-site counts 2/2/2/1/2, plus the stronger statement that ZERO production consumers live outside `game/effects/`, with a proven-live instrument on the zero census. The three surviving non-authority sites select a DRIVER rather than opening an up-front window, so folding them in would be wrong, not cleaner — and the guarantee is stated honestly: an inline re-derivation from `ability.repeat_for` is NOT caught, and no census over these five tokens can catch it. `cargo clippy -p phase-engine --all-targets -- -D warnings` is clean under the shipped enum, with zero `is never constructed` (both variants are constructed on production paths, and F2a exercises `Probe` to opposite outcomes). Assisted-by: ClaudeCode:claude-opus-5 * test(engine): re-derive every row whose premise was the sampler's blind spot, and pin both user captures The answer-beat sampling site (C4) widened what a CR 732.2a offer can publish, and several rows had encoded the OLD blind spot as if it were a property of the board. Each is re-derived from measurement rather than relaxed, and both of the user's own captures are tracked and driven end to end. THE FIX BAR. `a1_the_users_accept_committed_nothing_board_now_commits_on_every_axis` drives the user's MODE2 capture — the board where the offer fired, the declaration was accepted, and the drive then committed nothing and re-offered. It now publishes all three per-iteration choices and the accepted `Fixed(n)` grant commits EXACTLY n repetitions of the offer's own published per-cycle signature on life and library, with counters and tokens non-zero at n=1 and exactly 3x at n=3. Revert-probe run: with the answer-beat site ablated every axis collapses to 0, reproducing the captured symptom. `m1_...` is its one-field-apart sibling on MODE1 (a STORED CR 603.5 answer, so guard (b) withholds Sue's slot and the auto-answer relief discharges gate (6) instead). `--lib` rows re-keyed to production's own walk. `newest_item4_window` consumes `game::engine::candidate_windows`, so R21(b-placement-B)'s window IS production's rather than a hard-coded `len - 2` that silently asserted `span == 1`; its reach-guard now states what it needs (no denied answer; a gate that ASKED must have completed) with the load-bearing exemption equality byte-identical. R16(ii-b) searches its real construction requirement (`meter.spent > 0`, never `denied`, which would assert itself) and ships its own revert-probe as a `RaisedTwiceLinks` positive control. The CR 603.5 prompt census is re-pinned from the failure's own left side, every producer sha256-identical at its new coordinate and still inside the function the row names, and its authority count is made comment-insensitive so prose cannot trip a call-site pin. Attribution repairs: `r2` is renamed `r2a` to state what its body now asserts; r1 keeps its over-charge follow-up pointer instead of claiming discharge; the `r5_declare_is_accepted` citation is replaced with the per-caller measurement that actually exists; the r28 empty-schema arm DISCLOSES that its path is now reached by staging rather than naturally; a pre-existing `CR 614.1a` comment that described no rule is corrected to CR 614.1. `ai1_the_bounded_declare_candidate_withdraws_when_the_offer_publishes_a_pin` pins the generator's `Fixed` candidate to the published pin set in both directions on one board, and is deliberately not `#[ignore]`d. Assisted-by: ClaudeCode:claude-opus-5 * docs(engine): replace the four notes C4 falsified, and recover the counter assertion a false premise cost Five ACCEPT-WITH-FIXES findings, plus one sibling swept by the same defect mechanism. `crates/engine/src/` is COMMENT-ONLY this round — proved by `git diff -U0 crates/engine/src/` having no non-comment +/- line — so the only executable change is in the F4 test file. F1. Four docs still asserted the pre-C4 premise that `record_loop_detect_sample` has ONE call site, and this branch's own policy is to REPLACE a falsified note rather than soften it. The measured truth is TWO production sites, both after `run_post_action_pipeline` (CR 603.3): the settle sampler in `pass_priority_once_with_pipeline` and the forced-window answer site in `apply_action`. Rewritten at the fn doc and the `loop_detect_ring` field doc (`game_state.rs`), at `frames_per_period` (`resource.rs` — the justification C2 had already repaired in code), and at `ring_delta_signature`, whose homogeneity argument now rests on the `Priority{active_player}` conjunct the two sites SHARE rather than on there being one site. F1e (swept sibling). `drive_one_shortcut_cycle`'s "the frame counter is advanced here and nowhere else" was falsified on this same branch by the forced-window ANSWER arm's own advance. Both arms key the advance on the ring's back allocation changing, which is what keeps drive and mint one-to-one. F2. The second site's comment claimed "the same conjuncts as the settle sampler, PLUS the window flag". Measured, the sets are the same size one member apart: `answering_forced_window` REPLACES `resolved_this_beat`, and the settle site's `else { ring.clear() }` has no counterpart here. The comment now says that, names the consequence (an answer that resolves nothing but leaves the stack non-shrinking records a duplicate frame), and says why it is acceptable — `ring_delta_signature` refuses a zero smallest-period delta, and mint/drive are symmetric because `inject_pinned_answer`'s arms all dispatch `apply_action`. It also documents the latent ordering asymmetry: this site records BEFORE `state.waiting_for = wf` while the settle sampler records after `sync_waiting_for`, and `GameState::eq` compares both `waiting_for` and `priority_player` while `normalize_for_loop` neutralizes neither. Latent, not live: a `debug_assert_eq!` census reported 0 failures across 18,486 lib and 4,487 integration rows, with a `debug_assert!(false)` positive control that fired on the A1 board. Deliberately NOT reordered — that would be a behavioural change for a non-live defect. F3. The fix bar's life and library equalities had no anti-vacuity guard, so an all-zero certificate would satisfy `moved == rate * n` on a board that never moved. Added, in the existing `assert_axis_scales` idiom. F4. The counter assertion had been weakened on a false premise. Measured, the published vector is `counters {(Plus1Plus1, Creature): 2}` — non-zero and state-readable; only `tokens_created: 0` is event-fed. The real obstacle was the accessor: `commit_axes` reads ONE object's counters against an AGGREGATE key. Re-cut against `ResourceVector::snapshot`/`delta`, the accessor the certificate is minted from, plus a "nothing unpublished may move" arm. The aggregate moves 2 at n=1 and 6 at n=3, i.e. exactly 2n. The token axis keeps the scaling arm alone, now for its real measured reason. F5. `has_frozen_window`'s residual was declared as "four authored-ring rows". Measured: two call sites, and NEITHER is an authored ring — both drive the real tracked dumps. Overstated in count, understated in kind. The disclosure now names both rows and the loud floor that lets them keep a hard-coded `span == 1`. The CR 603.5 prompt census went red on F2's line drift and was re-pinned by its own protocol, not by matching the tree: `engine.rs:11515 => :11549`, +34 which is engine.rs's entire (comment-only) delta above the producer, the line sha256-identical at the new coordinate and still inside `begin_pending_trigger_target_selection`; total 37 and partition 5/7/25 unchanged. Every new assertion and guard is proven to flip. Ablating the answer-beat sampling site fails the library equality at `left: 0 / right: -1` (the prior probe's signature) and, with the seat loop bypassed so control reaches it, the recovered counter equality at `left: 0 / right: 2`. Zeroing each published rate in turn fires each guard alone. All five are typed assertion failures, not harness crashes. lib 18487 passed / 0 failed, integration 4487 passed / 0 failed / 2 ignored, clippy --workspace --all-targets -D warnings exit 0. Assisted-by: ClaudeCode:claude-opus-5 * test(engine): re-derive the CR 603.5 producer pins the rebase invalidated The rebase onto upstream `b654513cb` (phase-rs#6996, phase-rs#6999, phase-rs#6998, phase-rs#7001, phase-rs#6997, phase-rs#6946) resolved the census row's conflict to upstream's three `effects/mod.rs` literals, which are correct for the upstream tree but not for this branch replayed on top of it. Re-derived from the row's own failure output — never predicted by arithmetic — and re-pinned: `:6065/:6142/:9324 ⇒ :6175/:6252/:9456` The shift is NOT uniform (`+110/+110/+132`), and the asymmetry is the measurement: C1's `upfront_optional_gate` authority is one 110-line hunk above all three producers, and `resolve_chain_body` takes a further `+22` from two hunks inside itself and above its own gate (the `optional_for` coupling note, and adoption A replacing the inline conjunct chain with the authority call plus its `debug_assert!`). The file's whole-file delta is also `+132`, so nothing lands below the third producer, and `6065+110`, `6142+110`, `9324+132` equal the observed coordinates exactly. Identity re-established rather than assumed. Each producer at its new coordinate is sha256-identical to `b654513cb:effects/mod.rs` at its old one and to the pre-rebase tip `117baa6a1` at `:6109/:6186/:9183`, and each is still inside the enclosing function this row NAMES — `drive_sequential_repeated_optional_payment`, `resolve_repeated_optional_payment_choice`, `resolve_chain_body` — which is stronger evidence than the coordinate. The diff instrument discriminates: in the new tree the three old coordinates hold a `may_trigger_auto_choice` lookup, a blank line, and a bare `//`. `engine.rs:11549` was re-derived too, not carried over, and is UNMOVED: upstream's six commits net ZERO above it (`:11420` in both the old base `dcb8f3808` and the new base `b654513cb`), so this branch's own `+95`/`+34` still land it on `:11549`, byte-identical and still inside `begin_pending_trigger_target_selection`. `scoped_library_search.rs:452` is unmoved as well. Two entries holding still while three move is the set-preservation evidence: the row's first two asserts fired GREEN on the run that caught this, so the total stays 37 and the partition stays 5/7/25 — no producer was gained or lost. Assisted-by: ClaudeCode:claude-opus-5 * docs(engine): replace every doc claim this branch's own rows falsified, and re-measure the span==1 residual The final review at 7841e1e found three survivors of the F1 falsified-doc class plus one unproven mechanism. A mechanical sweep of the same class found two more the review did not name, both in the test file the previous round never searched. Comment-only; no behaviour. r1's doc said the offer "publishes ONE point and commits ZERO cycles (see r1b and r2)". All three clauses are dead: r1b's own assert_eq! pins THREE points [Sue MayChoice, Reed MayChoice, Torch Targets]; r2a commits exactly n at n=1 and n=3; and `r2` names a row this branch renamed (fn-name diff b654513..HEAD: exactly one name disappeared, r2_an_accepted_declaration_commits_zero_cycles_because_reeds_may_is_unannounced, and zero references to it survive). r1's second paragraph was falsified too and went unreported: the in-tree form is the ADDITIVE one (resource.rs observed_life_loss.max(0) + declared_life_magnitude), not the MAX form, and victim_slot is NON-EMPTY on this board, so the two forms do not coincide. r1b's OWN doc block was the sharpest instance and neither round had caught it — it said "403 and 401 are never announced ... publishes exactly ONE point" while the same function's body asserts three and its message says all four sources are announced. The U6 header's "F4 publishes ONE point, not three" and the Fixed-gate bullet's "F4 publishes one point" are corrected with the reason preserved: the AI still declines, but on the emptiness gate, never on the count. resource.rs:10713 is brought into line with the two siblings this branch already replaced at resource.rs:1062-70 and engine.rs:2257-64, reusing their wording. has_frozen_window's span==1 residual was justified by an unproven mechanism ("both fail LOUD on a half period"). A half period is non-degenerate, so those guards do not fire, and both rows' assertions are span-independent — they would PASS. Re-measured here rather than transcribed: at the beat drive_dump_until(gz, 80, has_frozen_window) selects, dina beat=6 ring=2 and dellian beat=5 ring=2, and candidate_windows yields exactly one candidate (idx=0, span=1, len=2) on each, so &live[len-2..] is the whole ring and span==1 is exact. The residual is restated as "exact today, silent if the sampling rate grows this ring past two". inject_pinned_answer's "arms all dispatch apply_action" (two sites) is corrected for precision: four arms, three dispatch, the fourth Err()s before any frame advance. The mint/drive symmetry conclusion survives and is now stated in the stronger form the code supports. The engine.rs edit is deliberately line-neutral (3 for 3) so the CR 603.5 census pin at engine.rs:11549 does not move; verified still on the OptionalEffectChoice producer and the census row green. --lib: 18501 passed; 0 failed; 6 ignored. --test integration: 4513 passed; 0 failed; 2 ignored. clippy --workspace --all-targets -D warnings: exit 0. Assisted-by: ClaudeCode:claude-opus-5 * fix(engine): correct the CR 608.2d announcer citation, derive r2a's rates from the certificate, and make an unresolvable point source fatal CodeRabbit left four inline findings on phase-rs#7005. Three hold; the fourth's premise does not, and the difference is a measurement rather than an argument. F-A - `UpfrontOptionalGate::prompt_player` cited CR 117.3a ("The active player receives priority at the beginning of most steps and phases..."), which is priority timing. The field names the player who ANNOUNCES the choice, which is CR 608.2d ("...the player announces these while applying the effect."). Both quotes verified against the rules text before the edit. `optional_prompt_player`'s own doc carried the identical wrong citation and is fixed with it. Both edits are line-for-line so the CR 603.5 prompt census keeps its exact pins. F-B - CodeRabbit asked for memoization "if the fixtures produce optional, non-repeat, non-`optional_for` `CastFromZone` entries". They do not. Instrumenting the clone-bearing arm and both `state.clone()` sites, with a thread-local marking the `Probe` path, over a full `--test integration` run (reproduced bit-for-bit across two runs): 16402 raw mint calls => 4573 `Probe`-mode feasibility calls => 0 arm entries and 0 clones on that path. The 59 arm entries and 50 clones that do occur are production's own `Known` path, which pays them once per resolution. The zero's positive control is in-band: `P` and `K` are two labels from the same statement, and `K` returned 59. The memo itself already exists for the other caller - `PeriodVerdicts` is a `(FrameIx, ObjectId)`-keyed compute-on-miss memo whose `published` field IS `entry_publishes_pin_slots`. Recorded the measured number at the seam; added no memoization for a cost of zero. F-C - `(libs_before[0] - libs_after[0]) as i64` subtracted two `usize` before the cast, so the zero-commit regression the row exists to catch aborted on an arithmetic overflow instead of printing the row's own diagnostic. Demonstrated both ways: the old form under the underflow condition panics `attempt to subtract with overflow` with the diagnostic suppressed; the new form fails as `assertion left == right` and prints it. The row also asserted `(i64::from(n), i64::from(n))` - two literals - while its message claimed the published per-cycle delta. Both rates now come from `certificate.per_cycle.delta`, negated because the axes are measured as losses, with an anti-vacuity guard so the equality cannot degenerate to `0 == 0 * n`, and seat ids read positionally so a rate belongs to the seat whose movement is measured. F-D - `published_point_names` synthesised `obj<id>` when a point's source was absent from `state.objects`. Every caller compares that string to the SUE/REED/TORCH constants, so an unresolvable source read as "not that card" and silently satisfied m1's negative owner-firewall assertion. Now a panic, matching the treatment the adjacent `other =>` arm already gave the same class of failure. The new guard is proven able to fire: `published_point_names_panics_when_a_points_source_is_absent` deletes the first published point's source and requires the panic, and reports `should panic ... FAILED` when the synthetic fallback is restored. Gates at this tip: lib 18501 passed / 0 failed / 6 ignored; integration 4514 passed / 0 failed / 2 ignored (4513 + the new row); clippy --workspace --all-targets -D warnings exit 0. The CR 603.5 prompt census and `a1_the_users_accept_committed_nothing_board_now_commits_on_every_axis` are both green. Assisted-by: ClaudeCode:claude-opus-5 * fix(engine): synchronize the forced-window state before recording the loop sample `apply_action`'s answer-beat sampler 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 snapshotted the PRE-pipeline `waiting_for`/`priority_player` pair and a settle frame 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 `ring_delta_signature`'s turn-position conjunct. Route `wf` through `game::public_state::sync_waiting_for` — the canonical synchronizer, which also recomputes `priority_player` — before the record, and drop the raw assignment below it. Blast radius is the ring only: `apply_action_boundary` already re-syncs the returned `wf` before the result leaves the engine, so the settled state is unchanged. The edit is line-neutral so the CR 603.5 prompt census keeps its line-exact `engine.rs:11549` pin (verified byte-identical by sha256, still inside `begin_pending_trigger_target_selection`). Mechanism verified at source: `is_forced_cascade_window` is a `matches!` over 13 non-`Priority` variants with a fail-closed fall-through, and the sampler's gate reads the returned `wf` while the snapshot preserved `state.waiting_for`. Evidence — instrumented probe on the pre-fix tree, `--test-threads=1`, full lib + integration; one unit = one emitted probe line = one `record_loop_detect_sample` invocation at that site. Settle site: 996 samples, 0 that the sync changed on either field. Answer site: 285 samples, 0 stale on either field. An always-true comparison of the same shape emitted on the same line is true on 996/996 and 285/285, so the zeros are a verdict rather than a dead instrument. The defect is consequently structural and latent, and this replaces a coincidence with a guarantee. New production-fixture row on the tracked `dina_conqueror_4p` dump, driven through production `apply()`: the newest answer-beat frame is `Priority{active_player}`, its `priority_player` is that seat, and the published `LoopCertificate` is exact under an exhaustive destructure. Revert-probes, measured: clobbering `priority_player` after the sync fails arm 2 (`PlayerId(3)` vs `PlayerId(0)`); clobbering the window fails arm 1 (`GameOver` vs `Priority`), with arm 2 passing first, so the arms are separately live. A pure revert of the reorder PASSES, which is the honest statement that no current fixture reaches the divergence. Gate at this tree: fmt 0, clippy --workspace --all-targets -D warnings 0, lib 18501 passed / 0 failed / 6 ignored, integration 4515 passed / 0 failed / 2 ignored (4514 -> 4515 is exactly the one new row). Assisted-by: ClaudeCode:claude-opus-5
Two related effect-resolution fixes plus test-only pins for a gap that was measured to be undecidable at this layer.
#6957 — SCOPE-position
PlayerFilterdecisions become exhaustiveis_player_scope_local_continuationdispatched through amatches!allowlist. The compiler structurally cannot enumerate an allowlist, so adding aPlayerFiltervariant silently de-registers rather than failing to build. Now exhaustive; a new variant is anE0004.Census reproduced independently: 4 affected cards, no fifth (Teferi's Puzzle Box correctly excluded — bottom-of-library, not a SCOPE-position continuation).
AllExceptnow recurses into its anchor, matchingplayer_filter_references_tracked_set; behaviour-identical while every arm istrue.#6956 — stamp a real zero rather than leaking the previous chain step
When an effect completes with a zero result, the chain slot retained the previous step's amount, so a later consumer read a stale value. Now the completing effect stamps its own
0.Census: 114 cards helped, 0 harmed, 4 restored to predecessor behaviour. (By kind: LoseLife 42, RemoveCounter 40, DealDamage 29, DamageEachPlayer 2, GainLife 1, DamageAll 1.) The 4 restored have no stamper above them, so the guard returns them to exactly their already-shipped behaviour — it can only ever decline to overwrite, never introduce a new wrong value.
The CR 608.2c relay guard
Closing the leak initially broke Thorna and Twigtooth, which the leak had been holding up by accident. CR 608.2c — "…X…, …X…, and …X…, where X is definition" names one value every clause reads. The engine approximates that with a single mutable slot plus chain-relative reads, so a clause whose own quantity is that shared X is a relay, not a fresh anchor; letting its outcome redefine the slot destroys the X its siblings still need.
zero_is_a_resultnow additionally requires!effect_relays_the_shared_amount(...), scoped to the zero case only — provably no wider than the #6956 change itself, since it is a conjunct on a predicate whose only action is.then_some(0).A prior review claim that 19 Extort cards would be zeroed was inverted and has been retracted in the test docstring: Extort parses
PayCost -> LoseLife -> GainLife, so the consumer's direct parent isLoseLife. Zero cards havePayCostas a direct parent. The guard rests on CR 118.1 alone.Test-only: the co-anchored-X gap is undecidable at this layer
Two pins, both watched go red, production code byte-identical:
second_consumer_of_a_shared_x_reads_the_relay_not_the_anchor— pins the gap at its current wrong value (4), following the existingzero_life_pay_cost_is_not_yet_distinguished_from_an_absent_producerconvention. Flipping the expectation to 2 is the acceptance test.a_relay_that_re_anchors_x_must_keep_publishing_its_own_result— the counter-shape (Magma Pummeler) where the intervening relay is the correct anchor.Together they demonstrate that no stamp-time policy can fix this: generalizing the relay guard beyond the zero case was measured to flip the first 4 -> 2 (correct) while breaking the second 2 -> 7 (regression). The two shapes are byte-identical in the AST and demand opposite answers. The binding must be recorded where the parser lowers X.
Scoping for whoever picks that up: only 1 of 7 affected cards carries an explicit
where X isbinder; the other 6 use implicit shared antecedents across coordinated clauses, which is a substantially larger parser-grammar problem. Population 35,657 faces / 52,294 ability containers.Also surfaced, untouched, worth its own issue: Neheb, Dreadhorde Champion has the inverse bug — its
DamageDonetrigger amount outranks the chain slot, so "draw that many" reads combat damage instead of the discard count.Verification
cargo fmt --allclean;cargo clippy -p phase-engine --all-targets -- -D warningsclean;cargo test -p phase-engine18,488 + 12 + 9 + 4,486 pass, 0 fail. CR gate over the full diff: zeroUNVERIFIED:lines.Reverts confirmed red: relay guard disabled -> Thorna
left: 0, right: 2(ordinary-path sibling stays green); #6956 zero policy neutralised -> all 4 closed-arm tests redleft: 7, right: 0; #6957 gate re-narrowed tomatches!(All)-> both tests redleft: 1, right: 2.Context: #6957, #6956
Summary by CodeRabbit
Bug Fixes
Tests