Skip to content

fix(ai): stop proposing strategically vacuous loop-shortcut Shortens - #7101

Open
lgray wants to merge 9 commits into
phase-rs:mainfrom
lgray:fix/shorten-efficacy-window-reach
Open

fix(ai): stop proposing strategically vacuous loop-shortcut Shortens#7101
lgray wants to merge 9 commits into
phase-rs:mainfrom
lgray:fix/shorten-efficacy-window-reach

Conversation

@lgray

@lgray lgray commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Summary

smart_shortcut_response documented "a way to break the loop" but called has_meaningful_priority_action — the auto-pass gate, a strictly weaker predicate. On a real 4-player board a seat holding only a basic-land fetch answered Shorten against a mandatory trigger cascade it could not touch: rules-legal under CR 732.2b, strategically vacuous, and paid for with a real priority window. This adds a stage-2 efficacy classifier (WindowReach) on a fourth orthogonal axis — controller-relative confinement — so a seat whose only actions write inside its own resources accepts, while a seat that can reach past them still shortens.

Files changed

  • crates/engine/src/ai_support/shortcut_efficacy.rs — new: the WindowReach classifier
  • crates/engine/src/ai_support/mod.rs — stage-2 wiring, stage_two_action_set, shared shortcut_probe
  • crates/engine/src/game/engine_resolution_choices.rs — citation-gate enrolment floor 6 → 8 (test-side)
  • crates/engine/tests/integration/shorten_efficacy.rs — new: acceptance rows
  • crates/engine/tests/integration/main.rsmod shorten_efficacy;
  • crates/engine/tests/fixtures/dina_noff_turn5_4p.json.gz — new: the real 4p dump the acceptance rows drive

Track

Developer

LLM

Model: claude-opus-4.8
Tier: Frontier
Thinking: high

Implementation method (required)

Method: /engine-implementer

CR references

13 distinct CR numbers added, each verified against docs/MagicCompRules.txt before writing (not from memory):

CR 104.2a, CR 104.4b, CR 106.4, CR 108.3, CR 302.6, CR 400.1, CR 400.3, CR 701.21a, CR 732.1b, CR 732.2a, CR 732.2b, CR 732.2c, CR 732.5

No CR licence is claimed for stage 2 itself: CR 732.2b is unconditioned and CR 732.2c requires only a different choice. This is AI policy, and the module doc says so.

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.
  • Gate A output below is for the current committed head.
  • Final review-impl below is clean for the current committed head.
  • Both anchors cite existing analogous code at the same seam.

All 9 completion checks ran at the committed head in a clean detached worktree, each with head_before == head_after == e72a0c8f and porcelain 0, recorded in the run receipt (sha256 25ca6711dcc09a4dfa849f25cb4ac7275be8a053133642061b33931a74b06ee5, 61/61 artifact digests re-verified from disk):

  • cargo fmt --all -- --check — exit 0
  • cargo clippy -p phase-engine --all-targets -- -D warnings — exit 0, 0 warnings
  • cargo test (workspace) — 23219 passed / 0 failed (18578 + 12 + 9 + 4620 + 0)
  • cargo ai-gate — exit 0; 0 FAIL, 2 WARN, 1 PASS, 0 NEW, 0 REMOVED; no baseline refreshed

Read the ai-gate result honestly: it is green but non-probative for this seam. The suite provably never executes the changed code — duel_suite builds every state through GameState::new_two_playerGameState::new, which initialises loop_detection: LoopDetectionMode::Off, and phase-ai never reaches the one projecting authority (GameState::set_match_config): MatchConfig 0 hits in phase-ai vs 88 in crates/. All five WaitingFor::LoopShortcut assignments in phase-ai are #[cfg(test)], so no shortcut prompt can be synthesised around the flag either. Corroborated behaviourally: a build with stage 2 deleted produces 30/30 games identical in winner and turn count. Both WARNs are baseline-side confounds — the stored baseline records git_sha: 2d686880b94b, which does not resolve (git cat-file -tfatal: Not a valid object name), and different card data (56b03366…) than this run (863f5422…); the same shifts reproduce in the stage-2-deleted build, so they cannot be attributed to this change. Filed separately as a lane-external gap: duel_suite::compare reads neither provenance field, while its sibling duel_suite/perf.rs already implements exactly that guard.

Parser evidence: parser_evidence=PROJECTED_PARSE_DIFF (forced by SOURCE_HASH_DIFFERENCE, base 9e829d1edfe972b8 → candidate 4043f185c0f90c61). Projected card-data.json is byte-identical on both sides (953a51b2…66784); the projection is not a stale-binary artifact — the two oracle-gen binaries differ (2b920770… vs 1586b035…) and so does coverage-data.json (828cefde… vs 2418b06e…).

Acceptance rides a real 4-player dump (844846 B, sha256 9843d5165cbbf7dd7bca4171c7888c190b7eba7e52a2ed095b44ff76fadd7886) driven through the production restore and apply(), not a synthetic scenario.

Gate A

Gate A PASS head=e72a0c8f6a04e52dce55686cc267760abe312cdc base=674b3a999573e88df8ec120805ff2a7730b6f64e

Anchored on

  • crates/engine/src/ai_support/mod.rs:1136flat_actions_have_meaningful_priority, the stage-1 predicate whose action set stage 2 must cover; stage_two_action_set is built from the same predicate so the two cannot drift apart
  • crates/engine/src/game/ability_scan.rs:4268ability_definition_axes, the existing classifier-at-a-seam precedent this follows; the new wildcard arm is deliberately MayInterfere, the opposite of that default, because a wrong Accept can lose a game while a wrong Shorten costs a beat

Final review-impl

Final review-impl PASS head=e72a0c8f6a04e52dce55686cc267760abe312cdc

Five review rounds (r1–r5), reports persisted on disk. r5: 0 MAJOR. Its 4 MINOR + 3 NIT were all count/label imprecision in non-repo supporting artifacts, not in the committed head, and were corrected after the round without a sixth review — each fix re-measured with a positive control. Disclosed rather than omitted.

Claimed parse impact

None.

Projected parse-diff over the pinned AtomicCards.json: 0 clusters, 0 added_cards, 0 removed_cards.

Scope Expansion

None.

Frozen scope is 6 paths, byte-matching the run's scope-paths.nul (sha256 4b4b68b17216252c0c633d0dad912ef1e62efcd6dbd8f00fb3d9953ed8388634) and equal to git diff --name-only -z BASE CANDIDATE.

Validation Failures

None.

Two disclosures that are not failures but which a reviewer should not have to discover:

  1. A deliberate non-unification. game::engine::no_living_player_has_meaningful_priority_action still inlines the same seven-statement probe recipe rather than routing through the new shared ai_support::shortcut_probe. Routing it would touch the CR 732.5 mandatory-loop firewall — both its production callers are mandatory-loop gates — which is outside this change's scope. Left as a follow-up rather than claimed as done.
  2. A disclosed coverage ceiling. any_action_may_interfere documents one accepted miss with its cost: a fetched permanent that itself enables interference is not modelled, so a confined fetch classifies OwnResourcesOnly even if the fetched land could later enable a real answer. The across-window cost is bounded (one shortcut); the within-window cost is not bounded on an UntilLethal offer. Measured witness for the related parser residual: Invoke Justice, whose "distribute four +1/+1 counters … target player controls" clause is dropped with no Unimplemented marker.

CI Failures

None.

This branch is based at 674b3a999, 12 commits behind upstream/main. Rebase deferred deliberately: the only two paths overlapping upstream's drift are engine_resolution_choices.rs and tests/integration/main.rs, and both are drift-free on measurement — upstream still carries 6 citation-gate-enrolled files with none removed, so this commit's 2 additions keep enrolled >= ENROLLED_FLOOR (8) satisfied after any rebase, and git merge-tree reports no conflicts.

Summary by CodeRabbit

  • New Features

    • Improved shortcut responses by distinguishing harmless actions from actions that may meaningfully affect the game.
    • Better handles grouped sacrifice-for-mana actions and pre-cast copy responses when evaluating shortcuts.
    • Shortcut decisions now account for interactions involving other players’ resources and game zones.
  • Bug Fixes

    • Prevents shortcuts from being shortened when available actions could interfere with the proposed outcome.
  • Tests

    • Added comprehensive coverage for shortcut efficacy, confined actions, cross-player interactions, and edge cases.

@lgray
lgray requested a review from matthewevans as a code owner August 8, 2026 14:53
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 347492f4-ea80-4baa-ab75-0bb8cf9c0d57

📥 Commits

Reviewing files that changed from the base of the PR and between 63cc3be and 545c840.

📒 Files selected for processing (2)
  • crates/engine/src/game/engine_resolution_choices.rs
  • crates/engine/tests/integration/main.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/engine/tests/integration/main.rs
  • crates/engine/src/game/engine_resolution_choices.rs

📝 Walkthrough

Walkthrough

The PR adds conservative shortcut-efficacy classification for priority actions. Shortcut responses now use separate possibility and efficacy stages, including grouped sacrifice-for-mana actions and precast-copy responses. Tests cover confined actions, interaction, ownership, routing, and action-set coverage.

Changes

Shortcut efficacy evaluation

Layer / File(s) Summary
Controller-relative reach classification
crates/engine/src/ai_support/mod.rs, crates/engine/src/ai_support/shortcut_efficacy.rs
Adds WindowReach analysis for effects, costs, nested abilities, ownership filters, objects, and action types. Unknown or unresolved shapes remain interfering. Parser-backed tests cover confined actions and fail-closed cases.
Two-stage shortcut response flow
crates/engine/src/ai_support/mod.rs, crates/engine/tests/integration/main.rs, crates/engine/tests/integration/shorten_efficacy.rs
Adds shortcut_probe and stage_two_action_set. smart_shortcut_response evaluates expanded actions for efficacy and handles precast-copy responses. Integration tests cover fetchlands, interaction, crowned seats, quiet boards, and sacrifice-for-mana actions.

Supporting engine updates

Layer / File(s) Summary
Engine support updates
crates/engine/src/game/engine_resolution_choices.rs, crates/engine/src/game/specialize.rs
Updates citation-gate documentation and raises the enrollment floor from six to eight files. Makes empty_back_face crate-visible and documents it as the canonical empty constructor.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • phase-rs/phase#6838: Both changes address loop-shortcut analysis and observer reachability.
  • phase-rs/phase#6886: This PR extends the shortcut infrastructure and bounded-offer evaluation from that work.
  • phase-rs/phase#7007: Both changes modify AI shortcut probing and priority-cast offer validation.

Suggested labels: bug

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main AI change: preventing strategically vacuous loop-shortcut Shorten proposals.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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/src/ai_support/shortcut_efficacy.rs`:
- Around line 424-445: Update any_action_may_interfere and the CastSpell
classification path so a spell whose object_window_reach result is
OwnResourcesOnly solely because of Effect::Mana is treated as MayInterfere.
Preserve confined classification for non-mana self-contained effects, while
ensuring mana-producing casts are fail-closed and return true.
🪄 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: 0c1bd4a8-7262-4e2a-b5ca-e3dcd0e47ce9

📥 Commits

Reviewing files that changed from the base of the PR and between 117b430 and e72a0c8.

⛔ Files ignored due to path filters (1)
  • crates/engine/tests/fixtures/dina_noff_turn5_4p.json.gz is excluded by !**/*.gz
📒 Files selected for processing (5)
  • crates/engine/src/ai_support/mod.rs
  • crates/engine/src/ai_support/shortcut_efficacy.rs
  • crates/engine/src/game/engine_resolution_choices.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/shorten_efficacy.rs

Comment on lines +424 to +445
/// ponytail: a fetched permanent that itself enables interference is not
/// modelled. Using it needs a further priority window, and this design does NOT
/// claim one is guaranteed — CR 732.1b says only that the shortcut rules *can
/// be used* on a loop, and CR 732.2a makes proposing
/// permissive ("may suggest").
/// Scope, stated on BOTH axes:
/// - across windows: a bounded miss — a seat's fetched answer goes unused for
/// THIS shortcut;
/// - within the window: the worst case is NOT bounded by "one shortcut". On
/// an `UntilLethal` offer the accepted sequence runs to lethal, so the
/// in-window cost of a missed out is elimination.
///
/// Accepted because the miss requires the out to be reachable ONLY through the
/// fetched permanent; a directly-castable answer is already caught by the
/// top-level fold. Upgrade path: walk the fetched object's own abilities if a
/// real game shows a missed out. Owner: this lane, deferral burndown.
pub(crate) fn any_action_may_interfere(state: &GameState, actions: &[GameAction]) -> bool {
actions.iter().any(|action| match action {
GameAction::PassPriority => false,
GameAction::CastSpell { object_id, .. } => {
object_window_reach(state, *object_id).may_interfere()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

A mana-producing spell classifies OwnResourcesOnly and can produce a false Accept.

The documented deferral covers only "a fetched permanent that itself enables interference". A second shape reaches the same false Accept and is not covered.

Effect::Mana returns OwnResourcesOnly at line 183. A ritual cast (for example, "Add {B}{B}{B}") heads that effect, so object_window_reach proves the CastSpell confined and any_action_may_interfere answers false.

Consider a polled seat holding a ritual and a Lightning Bolt, with no lands. The Bolt is not castable yet, so it never enters the flat priority list. The only enumerated non-pass action is the ritual cast, which classifies confined. Stage 2 returns Accept, and the seat loses a window in which it could have ramped into real interaction. On an UntilLethal offer that costs the game, which is the direction the module doc names as unacceptable.

This differs from the fetched-permanent deferral in one way that matters: mana produced during the window is spendable in the same window, so the miss does not need a further priority window to bite.

Two options:

  1. Classify a CastSpell whose reach folds to OwnResourcesOnly solely through Effect::Mana as MayInterfere. Mana production changes what the seat can afford, so it is not inert in the way a self-contained fetch is.
  2. Keep the current verdict and widen the deferral note to name this shape and its in-window cost.

Option 1 is the fail-closed choice and matches the module's stated direction.

🛡️ Sketch for option 1
-/// ponytail: a fetched permanent that itself enables interference is not
-/// modelled. Using it needs a further priority window, and this design does NOT
-/// claim one is guaranteed — CR 732.1b says only that the shortcut rules *can
-/// be used* on a loop, and CR 732.2a makes proposing
-/// permissive ("may suggest").
+/// ponytail: a fetched permanent that itself enables interference is not
+/// modelled. Using it needs a further priority window, and this design does NOT
+/// claim one is guaranteed — CR 732.1b says only that the shortcut rules *can
+/// be used* on a loop, and CR 732.2a makes proposing
+/// permissive ("may suggest").
+///
+/// A mana-producing CAST is a distinct shape and is NOT deferred here: the mana
+/// is spendable in the SAME window, so a seat holding a ritual plus an
+/// as-yet-unaffordable answer would Accept by omission. Casting a spell that
+/// only adds mana is therefore treated as interference below.

Then special-case the cast arm so a spell whose only confined verdict comes from Effect::Mana does not prove confinement.

🤖 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/src/ai_support/shortcut_efficacy.rs` around lines 424 - 445,
Update any_action_may_interfere and the CastSpell classification path so a spell
whose object_window_reach result is OwnResourcesOnly solely because of
Effect::Mana is treated as MayInterfere. Preserve confined classification for
non-mana self-contained effects, while ensuring mana-producing casts are
fail-closed and return true.

@matthewevans matthewevans self-assigned this Aug 8, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current-head changes requested

Reviewed e72a0c8f6a04e52dce55686cc267760abe312cdc.

[HIGH] Mana-producing responses are incorrectly classified as non-interfering in a real Shorten priority window. shortcut_efficacy.rs:171-183 classifies Effect::Mana as OwnResourcesOnly; :443-445 then lets a CastSpell with that reach produce a non-interference result. But engine.rs:5513-5522 gives the Shorten responder an actual Priority window. A ritual or mana spell can fund an otherwise-unaffordable response inside that window, so accepting the shortcut would wrongly surrender a live out.

The same unsound class includes actor-owned sacrifice-for-mana actions: stage two considers them, while Effect::Mana combined with an actor-owned cost collapses to OwnResourcesOnly. Handle these paths conservatively and fail closed. Add response-level regressions for (1) a cast-mana spell and (2) an owned sacrifice-for-mana action, each enabling an otherwise-unaffordable interaction during the Shorten window. Do not accept a residual-deficiency note in place of this behavior.

The current head also lacks a current-head parse-diff receipt and CI is still pending. Those are evidence holds, but the shortcut false-negative above independently blocks approval.

@matthewevans matthewevans removed their assignment Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Generated for head 545c8402ef59601668867f5c475d88d3f4838858.

Parse changes introduced by this PR

✓ No card-parse changes detected.

@lgray
lgray force-pushed the fix/shorten-efficacy-window-reach branch from e72a0c8 to 63cc3be Compare August 9, 2026 20:29
@lgray

lgray commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Response to @matthewevans' [HIGH]

The change you requested is implemented and both required regressions are written, and this is now pushed — current head 545c8402e on base cbdfa042a. The branch was rebased onto current main after this reply was first posted; the pre-existing head you reviewed is preserved at backup/shorten-efficacy-pre-63cc3be4.

Every SHA below is re-stamped to the post-rebase chain, with one deliberate exception. The rebase renamed all nine commits, so the navigational SHAs — per-commit table, head, base — now name objects you can actually git show. The measurement records are left at the SHAs they were measured at (cb1484b5f -> 63cc3be41) and labelled as such, because re-stamping a receipt onto SHAs it never ran against would convert a real measurement into a claim about a run that never happened.

Note what that costs you, since it is my problem and not yours to discover: 63cc3be41 is local to my checkout — the rebase orphaned it, so it is on no remote and you cannot git show it. The base cb1484b5f is ordinary upstream history and does fetch. So do not take the receipt on trust; check the thing that actually carries it across the rebase, which is content identity and is verifiable entirely at the current head. Both files this PR's final commit touches are byte-identical pre- and post-rebase, and you can confirm the post-rebase side directly:

git rev-parse 545c8402e:crates/engine/src/ai_support/shortcut_efficacy.rs   # ba96b586b06f
git rev-parse 545c8402e:crates/engine/src/game/specialize.rs                # 0e8c019933a4

The test module is still registered in tests/integration/main.rs so its tests actually run, and the census floor it raised is unchanged.

Your evidence re-checked at the current base, and it holds. The
ShortcutResponse::Shorten { .. } arm in crates/engine/src/game/engine.rs — cited at :5513-5522
in your review — is byte-for-byte identical at the tip you reviewed (e72a0c8f6) and at the base
this now sits on; upstream churn has only relocated it (it has moved +16 lines since, so I am naming
the arm rather than a line range that keeps going stale). It runs reset_priority, sets
priority_player, and sets waiting_for = WaitingFor::Priority { player }. The responder does get a
real priority window — the deficiency note sitting on those very lines says so in as many words — and
that is exactly why mana produced inside the window is spendable inside it (CR 106.1: "Players spend
mana to pay costs"). The fix is built on your citation, not around it.

Fail-closed classification. The Effect::Mana => WindowReach::OwnResourcesOnly arm is deleted,
so mana falls through to the fail-closed _ => WindowReach::MayInterfere default. This is broader
than special-casing the CastSpell arm: the leaf classification itself is now conservative, so both
the CastSpell path (via object_window_reach) and the actor-owned action path inherit it. The
Composite fold uses .or(...), which is absorbing toward MayInterfere, so nothing re-admits the
old verdict.

This also closes @coderabbitai's inline finding at shortcut_efficacy.rs:445 — once the push below lands; origin/fix/shorten-efficacy-window-reach still points at e72a0c8f6 as this is written, so nothing here is resolved on the remote yet — that anchor is
in the tip it reviewed (e72a0c8f6), where the line sits in any_action_may_interfere's CastSpell
arm; the branch has been rebased since, so I am naming the tip rather than letting the number go
stale. It independently
identified the same false Accept and offered two options. The change takes its option 1 (fail-closed),
and deliberately goes further than the sketch attached to it: the sketch special-cases the CastSpell
arm, which would leave the actor-owned sacrifice-for-mana path — indexed_ability_window_reach
cost_window_reach — still allowlisting mana. Deleting the leaf arm instead makes both paths
inherit the conservative verdict, which is why v10b exists as a separate row from v10a.

Both regressions are response-level, not classifier-unit. Each drives a real 4p game dump through
apply beats to a real DeclareShortcut / RespondToShortcut, parks at
WaitingFor::RespondToShortcut { P2 }, and asserts against smart_shortcut_response — the production
authority, not a test-local reimplementation.

row shape path exercised
v10a_a_cast_mana_spell_that_funds_an_unaffordable_answer_keeps_its_window cast-mana spell (Dark Ritual, verbatim) CastSpell classification
v10b_an_actor_owned_sacrifice_for_mana_seat_keeps_its_window actor-owned sacrifice-for-mana (Lotus Petal, verbatim) stage-two action set

Both discriminate on this fix specifically. Restoring the deleted arm flips both rows to Accept.
They are not over-determined: from the candidate's own SHA-bound projection, Dark Ritual parses to
Mana with cost: null and no conservative field, and Lotus Petal's cost folds OwnResourcesOnly
with no conservative-when-present field — so the Petal's verdict is carried by the mana head
alone. Cross-checked against two rows that correctly cannot flip: v9b (Ironworks, whose
Sacrifice(Typed{Artifact}) is not provably actor-owned) and the Dark-Ritual-as-sub-ability case.

No deficiency note stands in for the behaviour. Per-commit on shortcut_efficacy.rs, comments
stripped, re-derived at the shipped chain. (SHAs below are post-rebase; the branch has been rebased
five times, so any SHA you may have noted from an earlier push of this branch is dead.)

Added and removed are split rather than summed, because the commit that does the most here removes
lines and a summed figure hides that:

The table names the live chain. 70bff0103 and 33a0d0c55 are dead SHAs — pre-rebase
objects that no longer sit on any branch, so diffing against them diffs against nothing. They are
named here only so that a SHA you might have noted from an earlier push of this branch can be
recognized as dead rather than searched for. git log cbdfa042a..HEAD is exactly:

commit shortcut_efficacy.rs, non-comment its #[cfg(test)] mod, non-comment other production files
4fd3a816e +221 / −0 +331 / −0 70 introduces the module — and this is the one commit where "other production files" is not zero
aca9de927 +0 / −7 +52 / −0 0 the deleted Effect::Mana arm — a pure deletion — plus mana_production_is_reach_not_a_confined_own_resource
a99aa92b2 +0 / −0 +4 / −3 0 corrects three justifications a review measured as false
6c9dd8094 0 does not touch the file
29a66524c 0 does not touch the file
2e6e63b9e +24 / −11 +190 / −0 0 the untapped-fetch gate (this replaced the dead 70bff0103)
b4c760fe8 +42 / −14 +182 / −2 0 the unreadable-collection gate, the Hand destination gate, and the shared landing_zone_is_confined authority that closes the split's hand door (this review round)
0ae3aadb0 +23 / −10 +147 / −1 0 makes landing_zone_is_confined exhaustive over Zone (the _ => true wildcard was silently absorbing Zone::Stack), routes both entry points through one carries_unreadable_rules_content covering every unreadable field rather than three, and adds the CardFace staleness guard
545c8402e +1 / −0 +126 / −5 2 adds the room_unlocks disjunct — one line — plus the 12-case test that drives every gated CardFace field through the real printed_cards, the CardMetadata destructure, and one pub(crate) on game::specialize::empty_back_face so the test reuses it instead of duplicating a 22-field literal. The 2 is that visibility change: the only non-shortcut_efficacy.rs production line in this commit

Five behavioural commits, not one — the count has grown twice since this line first said
"three", and it is written as a measured figure rather than a remembered one for that reason.

An earlier revision of this section said "every other production-file touch in the chain is zero
non-comment lines"; that is measured-false for 4fd3a816e, whose 70 lines are ai_support/mod.rs
(+204 / −27 raw — it adds meaningful_sacrifice_mana_actions and stage_two_action_set whole,
extracts shortcut_probe, and re-expresses smart_shortcut_response) and
engine_resolution_choices.rs (+3 / −3, the ENROLLED_FLOOR census bump 6 → 8). The replacement
claim — "it holds for every later commit" — was true when written and is now false too: the
final commit touches game/specialize.rs for 2 non-comment lines, promoting empty_back_face
from private to pub(crate) so a test can reuse it instead of duplicating a 22-field struct literal.
No behaviour changes; visibility does. Recording it here rather than letting the table's 0 column
carry an exception nobody stated is the whole point of the table. Likewise the earlier "0 non-comment lines" for the comment/citation round was really 2
one of the two is an assertion-label string literal, not a comment.

The CR 701.21a note that remains is on a different axis (SelfRef proves control, never ownership
— CR 701.21a has the controller move the permanent to its owner's graveyard, so a
controlled-but-not-owned source puts a card in another player's graveyard) and is genuinely unfixable
at this seam: the AST carries no ownership. It moves no row.

Evidence holds you named

  • CI — now complete and fully green at the post-rebase head 545c8402e: 11 pass, 0 fail
    (Rust lint (fmt, clippy, parser gate); Rust (fmt, clippy, test, coverage-gate); Rust tests shard
    1/2 and 2/2; Card data; Frontend; Lobby worker; Tauri compile; WASM compile; CodeRabbit; security
    scan), with Draft pools skipping. mergeStateStatus = CLEAN. This supersedes the earlier
    e72a0c8f green, which was five rebases stale and certified nothing you are looking at.
  • Local gate at the candidate — nine completion checks, each run in a detached clean worktree
    pinned to the candidate SHA with its HEAD, detached-state and porcelain proven before and after:
    cargo fmt --check; clippy -p phase-engine --all-targets -D warnings;
    clippy --workspace --all-targets -D warnings; cargo test -p phase-engine;
    cargo test -p phase-ai; the targeted
    rows; the parser-path gate; a card-data gate; and cargo ai-gate (no baseline refreshed). The
    last two were added because a review round filed their absence as a gap — the card-data gate is a
    NUL-safe path diff rather than a gen-card-data.sh run, because that script promotes tracked files
    and would dirty the worktree the clean-state proof depends on.
  • Parse-diff, independently confirmed at the live head — this supersedes my receipt as the thing
    to trust.
    CI's own coverage-parse-diff bot ran against 545c8402e and reports "✓ No
    card-parse changes detected."
    That is the same verdict my local receipt reached, reproduced by an
    instrument I do not control, at the post-rebase head you can actually fetch. So the unfetchable-SHA
    caveat above costs you nothing on this point: prefer the bot's comment over my receipt.
  • Parse-diff receipt (mine, for detail only) — measured pre-rebase, at base cb1484b5f -> head 63cc3be41; those
    are the bytes the run executed against and they are deliberately not re-stamped. The rebase
    preserved this commit's content exactly (shortcut_efficacy.rs blob ba96b586b06f and
    specialize.rs blob 0e8c019933a4 are byte-identical on both sides of it), which is what carries
    the result forward to 545c8402e. Comparator oracle_changed 0, added 0, removed 0, clusters 0 ("No card-parse changes
    detected"), and the two projected card-data.json are byte-identical at 5cdaf7fd0d979bee…
    (card-names.json likewise at 342efb394eaecefc…). Both sides are built from the same read-only
    pinned AtomicCards.json (01b46792f375bf0f…, mode 0400) in separate detached worktrees, and the
    comparator run is the base-built binary. Source hashes differ (56cf7ecc0e69c1b3 base vs
    87285ed51276150b candidate) and the four-path projection-authority diff is empty, so
    projection_forced_reason = SOURCE_HASH_DIFFERENCE — the projection was forced, not skipped.
    Guarding the one way this check can pass falsely — two runs of a single stale binary would also
    produce identical output — the two oracle-gen binaries were confirmed distinct
    (c5ba2c14522b6976… vs 2c8476afcb962db8…). Accuracy note, because this paragraph exists to rule out a stale binary and must not contain one: the CANDIDATE binary was rebuilt in this run; the BASE binary was not, and its mtime predates the run root. That is correct rather than stale — BASE_SHA has not moved, so cargo had nothing to rebuild — and the load-bearing fact is that the two binaries are DISTINCT, not that both were compiled today. The identical card data is a measurement,
    not an artifact of comparing a binary with itself.
    One thing a reader should not be surprised by: the two coverage-data.json files are not
    byte-identical (798be86034737134… vs f3445ce918de02c0…). That file's array order is not
    deterministic across runs, so its digest carries no signal in either direction; the discriminating
    facts are the comparator verdict and the card-data.json byte-identity, both above.

The adjacent case in the same class — closed, not disclosed

The untapped fetch is fixed in this PR — in the local chain; the remote branch is still e72a0c8f6
until the push below lands.
My own working notes had it down as a knowingly-retained residual, on an
unmeasured assumption that closing it would vacate the feature's flagship fetchland Accept. That
assumption is false — measured under the final predicate, 102 of the 1002 abilities that reach a
battlefield-destination ChangeZone are still confined (node-level: 210 of 1036). An earlier
draft said "103 battlefield arrivals" with no unit and no predicate; re-measured it matches neither
count, and it collided numerically with the unrelated 103-node descent gap discussed above, so it is
withdrawn rather than nudged. The residual is closed
rather than disclosed. (That deliberation never reached this PR; I mention it only because "why is
this bigger than what I asked for" is the obvious question about the diff.)

The same call was made three more times, on the same reasoning. A review found that
object_window_reach read only obj.abilities while a card's rules content can live in three other
collections, and that destination: Hand was still counted confined. A later round found that the
hand gate had gone in on the ChangeZone arm only, leaving SearchLibrary's destination split
routing cards to hand unchecked — the same-recipe sibling of the fix that had just landed. All three
were doors beside the one you already told me not to leave open, so all three are closed here rather
than disclosed — commit 545c8402e.

effect_window_reach allowlisted a library-to-battlefield move as OwnResourcesOnly whenever the target was actor-owned or TargetFilter::Any — an opponent-typed target was already MayInterfere, so the hole was narrower than "any move", and still wide enough to matter. An
untapped fetch is not confined: the land it puts onto the battlefield taps for mana inside the very
window the Shorten buys, funding an otherwise-unaffordable answer by the identical argument this PR
makes against Effect::Mana. Measured on the production classifier before the fix,
ability_window_reach returned OwnResourcesOnly for both Crop Rotation and Nature's Lore.

enter_tapped was already destructured and ignored in that arm. The gate now requires the AST to
prove a confined entry rather than merely suggest one — EtbTapState::Tapped, with no conditional
enters_modified_if rider that could change the arrival (CR 614.12 checks the permanent's
characteristics as it would exist on the battlefield and CR 614.12a makes any choice it requires
first, so a rider is resolved after this classifier has already answered), not
enters_attacking (CR 508.4), and
enters_under absent or the actor. Unspecified and Untapped both fall out to MayInterfere
(CR 110.5b: permanents enter untapped unless something says otherwise). SearchLibrary's split is
the second door onto the battlefield — it moves its own found cards with no ChangeZone node — and
is gated the same way. Both edits are conjunctive, so they can only move verdicts toward
MayInterfere; neither can manufacture a false Accept.

The flagship set was re-derived from the final predicate rather than patched, and it moved.
Confined: Terramorphic Expanse, Evolving Wilds, Rampant Growth — all parse enter_tapped=Tapped, so
v1/v1b stay green. Reach: Nature's Lore, Crop Rotation, Lotus Petal, Sol Ring, Krark-Clan
Ironworks, Dark Ritual, Surveyor's Scope, Deathrite Shaman, Windswept Heath — and Cultivate,
which this PR listed as confined until the split gate closed its hand door this round. v10c is the
new response-level row — real 4p dump, real RespondToShortcut, asserted on
smart_shortcut_response — with a tapped control on the same board so the tap axis is what the row
measures.

What this changes at today's card pool. Two things make this number easy to get wrong, and I got
it wrong both ways before arriving at the one below, so they are worth naming rather than presenting a
clean figure:

  • Population. game/printed_cards.rs copies only card_face.abilities into obj.abilities,
    and the fold runs over object.abilities alone. Nodes in triggers, static_abilities and
    replacements are never classified, so counting them inflates the figure with rows that cannot
    move. (Those collections are no longer ignored — their presence, along with every
    other rules-bearing field this fold cannot read, now forces MayInterfere at object level, which is
    the fix above. That is a presence check, not a classification, so the census population is unchanged
    and the object-level effect is reported separately.)
  • Unit. A verdict is a fold over a whole ability — cost, sub_ability, else_ability,
    mode_abilities, player_scope, and sixteen conservative-when-present fields. An ability already
    MayInterfere for an unrelated reason cannot flip whatever its ChangeZone node says, so a node
    count is not a verdict count.
  • Descent. effect_window_reach has four arms and none of them recurse into an effect's payload;
    the only recursion is at the ability level. A ChangeZone buried under a CreateDelayedTrigger or
    Token head is never reached — the outer head hits the fail-closed default and stops. Counting
    those inflates the supporting figures by 103 nodes. A review round caught exactly this in my
    numbers and was right.

Re-measured at the enforcement surface — 22,717 top-level abilities. Every figure below is
stamped with what produced it, because main moves faster than a branch can be re-based and an
unstamped corpus figure silently becomes a claim about a corpus nobody measured: base
cb1484b5f155b41181b7abc3e7b0c64afc02f25a, candidate 63cc3be41449e463168f01748c40b118b8be400c,
projected card-data.json sha256 5cdaf7fd0d979bee…, byte-identical on both sides
(from the pinned
AtomicCards.json 01b46792f375bf0f…). Those two SHAs are pre-rebase and left that way on
purpose
— they name the bytes this projection actually ran against; the rebase preserved the
commit's content, so the figures carry forward to 545c8402e unchanged. A later upstream drop can
move these numbers; it cannot make them wrong about the projection named here. These figures were re-read from this candidate's own
projection
, not carried across the amend that produced it — the classifier change cannot touch card
data, but "cannot have moved" is the reasoning behind every figure this reply has had to withdraw, so
the census was re-run and matched line for line. The two behavioural axes are separated so neither absorbs
the other's flips:

axis OwnResourcesOnly before after Own → May May → Own
combined (this PR's diff) 2600 151 2449 (94.2%) 0
Effect::Mana arm deleted (entry gate held off) 2600 522 2078 0
entry + Hand + split gate (mana arm held off) 522 151 371 (71.1%) 0

Read the two right-hand columns together, because they are the whole argument. 2449 of 2600
previously-confined abilities (94.2%) stop being offered a shortcut, and May → Own is 0 on every
axis
— no ability becomes newly offerable. A change that only ever subtracts from the allowlisted
set cannot mint an Accept it did not already mint, so the size of the flip measures how much false
confidence was there before, not churn risk. The denominator is the before-column, 2600. Of the
total, the entry gate accounts for 371 of the 522 that survived the mana arm's deletion.

Object-level, on top of the above. A review round found that object_window_reach folded only
obj.abilities and was blind to the object's replacement_definitions / trigger_definitions /
static_definitions — so Stunning Reversal (whose entire function is a GameLoss replacement,
and whose one ability is a confined self-exile) read OwnResourcesOnly and the seat Accepted the
one shortcut that card exists to survive. Both entry points now return MayInterfere through ONE
authority, carries_unreadable_rules_content, rather than two inline checks that could drift.

A later round measured that "the three collections" was itself too narrow by eight fields: printed_cards
writes more rules-bearing state than the gate read, and obj.keywords carries printed Cascade, which
never reaches trigger_definitions — so a Cascade spell whose printed abilities all read confined was
provably OwnResourcesOnly while resolving it casts a free spell of arbitrary reach inside the window the
seat had just declined to keep. The gate is now the whole unreadable set rather than a curated subset,
because a subset is an allowlist someone has to remember to extend — which is how the first version got
here. Derived from the shipped source rather than transcribed: 17: trigger_definitions, replacement_definitions, static_definitions, keywords, modal, additional_cost, strive_cost, cleave_variant, casting_restrictions, casting_options, spellbook, back_face, case_state, class_level, intensity, attraction_lights, room_unlocks.
Same projection: 72 cards have a fully-confined abilities list and 29 flip — 19 on the three
definition collections (7 static-only) and 10 added by the widening (8 keywords, 1 modal,
1 additional_cost). The other 43 stay confined, so the gate is not simply "everything". The sibling Hand doors are
closed in the same commit — on ChangeZone, accounting for 166 of the ability-level flips above
across 154 distinct cards, and on SearchLibrary's destination split, which a review round found
still open: all 12 split carriers in the corpus route something to hand, so both doors now go
through one shared landing_zone_is_confined authority rather than two call sites that drifted.

The instrument is a twin of ability_window_reach, which is exactly the same-instrument-agreement
trap I refused to walk into in an earlier round — so it is grounded: it must first reproduce
eight (card, verdict) pairs the Rust suite asserts against this same projection (five under the new
predicate, three from the documented revert-probes). It reproduces 8/8. Its own revert-probe:
gate held off on both sides → 0 flips; gate on versus off → 371. It has since been wrong a sixth time, found by self-audit: its player_scope clause tested a bare string against a #[serde(tag = "type")] enum and had the polarity backwards, so it never fired. Corrected, it now agrees exactly with the previous round's independently-written census where it had been off by 1 and 2 — a near-miss against an independent instrument is a defect report, not rounding slack.

Why "conservative" here is a structural guarantee and not a hope. Each edit either adds a
conjunct
to a predicate that was already there — object_is_confined && entry_is_confined where the
code previously read object_is_confined, and the same shape for split — or is an early return to
the absorbing value
(the object-level collection gate). A conjunction can only remove members from
the allowlisted set and an early return to MayInterfere can only remove them, so a verdict can move OwnResourcesOnlyMayInterfere and never the
reverse, and no input can make this code mint an Accept it did not already mint before the
change
. That is a property of the expression's shape, so it holds for every card in the corpus and
every card not yet printed — it does not depend on the census above being complete. The cost of the
change is therefore bounded on one side: shortcut offers not made. The failure mode of an over-tight
gate is a lost offer, which is visible; the failure mode of a loose one is a surrendered out, which is
not.

Every gate is revert-probed, and each probe is reported with the assertion it landed on. "The
test failed" is not attribution: a whole-gate cut reds on the earliest assertion in the test and
proves nothing about the later ones, which is why the clause-level cuts exist. Each cut is made from
pristine source by a driver that asserts its cut text occurs exactly once first — an earlier version
of this probe was a silent no-op that reported everything green, and that assert is what stops the
repeat. Baseline with no mutation is 14/14, and the probe LIST is now derived from the cut table rather than hand-listed in the driver — a review round measured seven gate disjuncts with no probe at all, which a hand-maintained list beside a hand-maintained cut table makes easy to not notice. The baseline probe also asserts the cut table covers every disjunct the gate actually has, so "every cut red" can no longer be reported over a table that silently omits fields.

cut result reds on
object_window_reach gate 11/1 "a GameLoss replacement this module cannot classify is not proof of confinement"
indexed_ability_window_reach gate 11/1 "the activation path takes the same gate … (CR 603.2)"
both gates 11/1 the first of those
trigger disjunct only 11/1 "a trigger this module cannot classify is not proof of confinement either"
static disjunct only 11/1 "nor is a static ability this module cannot classify proof of confinement"
Zone::Hand => false in the shared authority 10/2 the destination row and the Cultivate split row
the split's rest_destination conjunct alone 11/1 the Cultivate split row only

The last two are the pair worth reading. Cutting the split conjunct reds the split row and nothing
else, so it discriminates on its own. Cutting the single Hand arm reds both rows — one deletion,
both doors — which is the shared-authority claim measured rather than asserted. If the two arms were
still answering separately, that cut would have reded one row.

Which conjuncts are inert at today's pool, stated rather than left for you to discover:

  • SearchLibrary's split battlefield conjunct — 12 carriers on this surface, 9 with a
    battlefield primary, and all 9 already print tapped; 0 route rest_destination to the
    battlefield. That conjunct therefore moves nothing today. It is in because it is the second door
    onto the battlefield — a
    split moves its own found cards with no ChangeZone node — and a door left open until a card walks
    through it is the failure mode this whole review is about. Its Hand conjunct is the opposite of
    inert and an earlier draft of this paragraph said "the split gate moves nothing today", which was
    measured-FALSE and is withdrawn:
    all 12 carriers route something to hand, and routing both split
    destinations through the shared authority flips 10 abilities on 10 cards —
    cultivate, kodama's reach, flare of cultivation, claim territory, fork in the road,
    final parting, jarad's orders, troop of ponies and both navigation orb printings.
    (nissa's pilgrimage and peregrination are the other two carriers; they were already
    MayInterfere for unrelated reasons, so they do not flip.) Cultivate leaving the confined set is
    a headline change, not a footnote
    — it was in this PR's own flagship list until this round.
  • enters_modified_if0 carriers on this surface. Its one carrier corpus-wide is Summoner's
    Grimoire, and that node lives in static_abilities, which this classifier never folds over — its
    object is now MayInterfere by the presence gate above, which is a different mechanism and not
    coverage of this conjunct. (It is
    Tapped and attacking and rider-bearing at once — the nominally-tapped-but-not-really shape
    the guard exists for — but it is not evidence of live coverage, and I am not going to present it as
    such.) enters_attacking and a non-You enters_under do each have live carriers: 3 apiece.

Four serialization details make a census here easy to get vacuously right, and I name them because
four successive versions of mine were wrong in exactly these ways: enter_tapped serializes through
a bool compat adapter rather than as "Tapped"; primary_enter_tapped is
skip_serializing_if = "is_unspecified", so the key is absent exactly when the split is untapped;
Effect and TargetFilter are internally tagged ({"type":"SelfRef"}), not bare strings; and
keying on field presence rather than on the tag folds in Dig and ChangeZoneAll, which carry the
same field names and have no arm in effect_window_reach. Each of the four reads as a clean zero
rather than as an error, so the census now carries a schema probe that fails loudly when a key it
reads is never observed, and distinguishes "renamed" from "inert" by counting carriers
document-wide.

2. coverage-parse-diff can synthesize a phantom oracle_changed (in the tool, not in this
change; worth a separate PR). It keys its comparison maps on card_name.to_ascii_lowercase(), and
that key is not unique: measured on this candidate's own projection, 35657 rows → 35627
distinct keys, 30 collisions
, last-wins insert, so 30 rows are silently discarded. All 30 arise
from exact duplicate names — 0 are case variants, so the lowercasing is incidental —
and the colliding rows carry different content (e.g. the two Ancestral Recall rows are not
equal), so last-wins genuinely discards a distinct row.

This does not manifest at the tip being pushed — the comparator there is 0/0/0/0 with both
projected card-data.json byte-identical. It manifested on an earlier candidate of this branch,
where the two sides resolved the colliding key "Fast" differently and that alone was the entire
"oracle_changed": 1.

Already fixed upstream — recording it only so the earlier artifacts in this thread are readable.
#7118 ("stop keying the parse-diff comparison on a non-unique card name") replaces the last-wins
insert with group_by_name returning every row under a name and matching within the group on a
row_key, so nothing is dropped. Nothing is asked for here.

One process note

An instruction on my side of this work asked that the existing private helper
add_sac_for_mana_source_producing be made public and reused rather than duplicated. It was not
made public and is not used here. Recording the divergence with the measurements behind it rather
than leaving it silent:

  • There is no duplication to remove. tests/integration/shorten_efficacy.rs contains zero AbilityCost::Sacrifice and no
    sacrifice-for-mana constructor; the sacrifice-for-mana row stages verbatim Lotus Petal text through
    the single give_parsed_card path. The only hand-built object in the file is give_bolt_with_cost,
    a DealDamage Bolt serving a different purpose.
  • It is the wrong shape for the tests that exist here. The helper (
    crates/engine/src/ai_support/mod.rs:4369) hand-builds a battlefield GameObject carrying a
    hand-built AbilityDefinition. mana_production_is_reach_not_a_confined_own_resource never
    constructs a GameState at all — it classifies an AbilityDefinition parsed from Oracle text — so
    there is nothing for the helper to supply. One later test does build a GameState
    (an_object_whose_rules_content_this_module_cannot_read_is_never_confined), and the reuse point
    applies there and was taken: it calls the shared game::zones::create_object primitive rather than
    hand-rolling GameObject::new + objects.insert, which is what it did in an earlier revision — and
    that hand-rolled version was also incomplete, since it skipped add_to_zone and left the object
    out of its own zone list. What it needs is a hand object carrying a real parsed ability and a
    real parsed replacement, not a battlefield artifact with a hand-built mana ability, so
    add_sac_for_mana_source_producing still supplies nothing it wants.
  • The integration row needs the opposite: a real 4p dump driven through apply beats, with
    the seat's card staged from verbatim Oracle text, because the row's whole discriminating claim is
    that a parsed AST reaches the classifier. A hand-built AbilityDefinition would remove exactly
    the property being measured.
  • Reachability, for completeness. pub(crate) would in fact expose it to
    shortcut_efficacy.rs's own #[cfg(test)] module (same crate, both under cfg(test)). It would
    not reach crates/engine/tests/, since #[cfg(test)] items are not compiled into the lib that
    integration tests link — that would need the helper relocated out of the test module or put behind a
    test-support feature. Neither move is worth making for a helper no test here has a use for.

Happy to do the relocation as a separate change if the reuse is wanted for future rows.


Known and measured — disclosed rather than held

These are open items I chose to surface instead of holding the branch for another internal round.
None is a defect I found and left; each is a limit I can state precisely.

  1. The revert-probe battery was cut short: 20 of 24 rows ran. Baseline none 14/14 green, and
    19 cuts, all red, on 17 distinct assertion messages. Every one of the seven gate disjuncts
    that previously had no coverage now has a cut and a red, as does the new room_unlocks one.
    Not run: hand-dest, stack-dest, split-rest, trigger-clause. The binding for the first
    three is measured, not asserted: landing_zone_is_confined and effect_window_reach are
    byte-identical to the previously-reviewed candidate (function-body digests 72bdf93abef7 and
    1b98331aff50, 7→7 and 54→54 executable lines), and all three were red there. trigger-clause
    cuts the one changed function, whose change is verifiably a single appended line, and its
    immediate neighbour replacement-clause re-ran red at these exact bytes. So of the gate's 17
    disjuncts, 16 have a red at this candidate; trigger_definitions has a cut but its red is from
    the prior candidate's identical text.
  2. A probe cut proves its own case, not the whole loop. The 12 CardFace cases share one test
    function, so a cut aborts at its case and later cases do not execute. The baseline row is what
    carries "all twelve pass together."
  3. The staleness guard covers CardFace, not GameObject. A rules-bearing field written to an
    object by a path other than printed_cards is still ungated. game::stickers is the live
    example and writes only the three definition collections, which are gated. GameObject was
    rejected as the guard seam on measurement: 149 fields, mostly runtime state, would be blanket-..'d
    back within a round.
  4. back_face is gated with 0 carriers among the surviving set — fail-closed, free today,
    and tested rather than assumed.
  5. The corpus census twin approximates for three derived fields. class_level, intensity and
    attraction_lights are not in card-data.json (they are derived at printed_cards time), so the
    twin reaches them through the subtypes that produce them. This is the one place it is not a mirror
    of the Rust, and it is stated in the twin itself.
  6. A superseded destination census was withdrawn as unreproducible. An earlier revision of my
    own notes published "6245 nodes; Battlefield 2151, Command 1, Stack 0" attributed to a named
    instrument. Asked to reproduce it, no scope produced those numbers. It was replaced rather than
    patched, by a census that prints three named scopes every run, each with a positive control that
    fails rather than prints if the walk cannot see destinations at all. Stack is 0 at all three
    scopes; classifier-reachable Command is 0. Cross-check the old figures never had: the
    classifier-reachable Battlefield count decomposes as 1036 ChangeZone + 9 SearchLibrary, and
    1036 is the figure an independent instrument reached for the ChangeZone population.
  7. This head skipped my usual internal review round by explicit decision, to get it in front of
    you sooner. The four findings from the previous internal round were all reproduced and fixed; the
    fixes themselves have had the gates above but not an independent reviewer.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== checked-out commit =='
git rev-parse HEAD
git status --short

printf '%s\n' '== PR head =='
gh pr view 7101 --repo phase-rs/phase --json headRefOid --jq '.headRefOid'

efficacy_file="$(fd -a '^shortcut_efficacy\.rs$' crates/engine/src)"
integration_file="$(fd -a '^shorten_efficacy\.rs$' crates/engine/tests)"

printf '%s\n' '== classifier declarations and Mana handling =='
ast-grep outline "$efficacy_file" --items all --type function,enum --match 'WindowReach|effect_window_reach|any_action_may_interfere'
rg -n -C 5 'Effect::Mana|fn effect_window_reach|fn any_action_may_interfere|MayInterfere' "$efficacy_file"

printf '%s\n' '== named response-level regressions =='
rg -n -C 4 'v10a_a_cast_mana_spell_that_funds_an_unaffordable_answer_keeps_its_window|v10b_an_actor_owned_sacrifice_for_mana_seat_keeps_its_window|smart_shortcut_response|RespondToShortcut' "$integration_file"

Length of output: 54345


@lgray Confirmed at 63cc3be41449e463168f01748c40b118b8be400c.

Effect::Mana no longer has an OwnResourcesOnly arm. It now reaches the conservative _ => WindowReach::MayInterfere branch. This applies to both CastSpell classification and actor-owned sacrifice-for-mana activations.

I also confirmed both named integration tests exist and assert Shorten through smart_shortcut_response:

  • v10a_a_cast_mana_spell_that_funds_an_unaffordable_answer_keeps_its_window
  • v10b_an_actor_owned_sacrifice_for_mana_seat_keeps_its_window

The original finding is addressed. Thank you for the precise evidence and the separate regression paths.

🐇


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

lgray added 9 commits August 9, 2026 15:43
`smart_shortcut_response` documented "a way to break the loop" but called
`has_meaningful_priority_action` — the auto-pass gate, a strictly weaker
predicate it was never meant to reuse. On a real 4p board a seat holding only a
basic-land fetch answered Shorten against a mandatory trigger cascade it could
not touch: rules-legal under CR 732.2b, strategically vacuous, and paid for with
a real priority window.

Adds a stage-2 efficacy classifier (`WindowReach`) as a fourth orthogonal axis:
controller-relative confinement. A seat whose only actions write inside its own
resources Accepts; a seat that can reach past them still Shortens. The wildcard
arm is deliberately `MayInterfere` — the opposite of `ability_scan`'s default —
because a wrong Accept can lose a game while a wrong Shorten costs a beat.

Stage 1 is behaviorally unchanged. No CR licence is claimed for stage 2:
CR 732.2b is unconditioned and CR 732.2c requires only a different choice. This
is AI policy, and the module doc says so.

Stage 2 must classify every action stage 1 counted as meaningful, and at first it
did not. Stage 1's second disjunct, `has_activatable_sacrifice_for_mana`, reads
the state rather than the action list, and sacrifice-for-mana activations never
reach that list — so a seat whose only meaningful action was one of them cleared
stage 1 and then had stage 2 fold over a list that did not contain it. A confined
remainder meant Accept where the shipped predicate returned Shorten. That is not
a wrong classification but an absent one, and a fail-closed wildcard cannot cover
a shape it never receives. `stage_two_action_set` is that union, built from the
same predicate stage 1 uses so the two cannot drift apart, and a row asserts the
membership independently of any verdict so a later reclassification cannot
silently retire the invariant.

`smart_shortcut_response` serves two prompt shapes, so stage 2 applies at the
precast-copy window as well as the ordinary one — the same question deserves the
same answer — and a row drives that window through production
`ai_support::candidate_actions` rather than leaving it inferred. The
`predicted_winner` read is structurally absent there: that variant carries no
`proposal` field.

The two shortcut windows and the tests' reach-guards share one probe recipe
(`ai_support::shortcut_probe`) rather than parallel copies. At base the recipe was
inlined at two production sites and no test-side guard existed; early revisions of
the new guards drifted, evaluating `has_meaningful_priority_action` against the
caller's prompt state, whose `has_activatable_sacrifice_for_mana` rung is gated on
`waiting_for == Priority`, so a guard could read false where production reads
true. Sharing the recipe removes the class. The second copy is NOT unified:
`game::engine::no_living_player_has_meaningful_priority_action` still inlines the
same seven statements. Only the shared probe documents the mirroring; that second
copy carries no back-reference, so a reader standing there has no pointer to it.
Routing it through the shared probe would touch the CR 732.5 mandatory-loop
firewall — both its production callers are mandatory-loop gates: the
`LoopDetectionMode::On` auto-win block, whose own comment calls it the "entire
soundness firewall", and `interactive_loop_bridge`'s `mandatory` binding. That is
outside this change's scope, so it stays a follow-up rather than being claimed as
done.

The confinement guarantee is scoped to what it can actually promise — never a
false `Accept` from a shape it does not recognize — and the doc now names the
residual it cannot cover. A parser-swallowed clause makes an ability look
strictly MORE confined, which is the direction that loses games. Measured on
`Invoke Justice`, whose "distribute four +1/+1 counters … target player
controls" clause is dropped with no `Unimplemented` marker, leaving a lone
graveyard-recursion effect that classifies as confined.

`SearchLibrary`'s arm gains a real fold-absorption witness (`Haunting Echoes`,
whose `SearchLibrary { target_player: None }` sub-ability classifies confined on
its own and is absorbed by its sibling graveyard-exile head). The two library
rows already present are both caught by `target_player` directly and never
exercised the fold they were cited for.

Acceptance rides the real dump (844846 B, sha256
9843d5165cbbf7dd7bca4171c7888c190b7eba7e52a2ed095b44ff76fadd7886) driven
through the production restore and `apply()`, not a synthetic scenario.

Assisted-by: ClaudeCode:claude-opus-4.8
…window

Stage two classified `Effect::Mana` as `WindowReach::OwnResourcesOnly`, so a
seat whose only answer was a ritual -- or an actor-owned sacrifice-for-mana
source -- was judged unable to interfere, and the AI accepted a loop shortcut
that surrendered a live out. `ShortcutResponse::Shorten` hands the responder a
real `Priority` window; inside it, mana is fungible reach.

The defect was a CR 106.4 quotation truncated before the clause that refutes it.
The arm quoted "that mana goes into a player's mana pool" and concluded
board-neutrality implies non-interference. The rule's next sentence is "From
there, it can be used to pay costs immediately". Board-neutrality and
non-interference are different propositions; a correct citation was attached to
the wrong one.

`Effect::Mana` now falls through to the fail-closed `_ => MayInterfere` wildcard.
The allowlist drops from four shapes to three. The *spending*-side
`AbilityCost::Mana` arm deliberately stays -- paying a cost consumes reach, it
does not create it -- and the arm now says so, so a future reader does not read
its survival as an oversight.

Regressions on the real 4p dump, both required by the maintainer:

  v10a  a cast mana spell (Dark Ritual) funding an otherwise-unaffordable Bolt
  v10b  an actor-owned sacrifice-for-mana seat (Lotus Petal, Composite[Tap,
        Sacrifice{SelfRef}]) re-admitted through `stage_two_action_set`

Revert probe executed: restoring the deleted arm flips exactly 3 of 20 rows
(the unit row, v10a, v10b) and leaves 17 green -- including both ACCEPT arms,
the flagship, v9b, and the positive control -- then the files were restored and
verified byte-identical.

Two fixture facts are load-bearing and documented at their sizing sites: Lotus
Petal's capacity is exactly 1 (`AnyOneColor` yields `vec![type; amount]`),
leaving a 1-mana margin over P2's cheapest alternative; and any staged P2 source
contributing 2 or more unlocks Angel of the Ruins' plainscycling, which would
silently destroy v10b's attribution. Both assertions fail loudly instead.

`give_bolt_with_cost` seeds `base_mana_cost` as well as `mana_cost`:
`seed_live_characteristics_from_base` reseeds `mana_cost` from the base on every
layer pass, and every consumer reaches the object through `shortcut_probe`,
which flushes layers -- so setting only `mana_cost` would have made the
"otherwise-unaffordable" premise measure nothing.

Assisted-by: ClaudeCode:claude-opus-4.8
…as false

Review-impl returned Semantic-Impact PASS and Maintainer-Simulation PASS on the
parent commit; these are its three [LOW] and two [NIT] items. No classifier
logic changes -- with comments stripped, the `shortcut_efficacy.rs` delta is a
single assertion-message string.

- `give_bolt_with_cost`'s rationale for setting `base_mana_cost` was false for
  the objects it stages. `reset_recipient_to_base` is reached only over
  `battlefield_phased_in_ids()`, and the incremental arm's recipient set is
  battlefield entrants plus attachment hosts -- neither reaches a hand object.
  `sync_missing_base_characteristics` back-fills base FROM live, the opposite
  direction. The code is kept because it is defensively correct if the staging
  zone ever changes; the comment now says that instead of asserting a mechanism
  that does not fire here. A false justification is worse than none: it gets
  trusted.

- The CR 701.21a quotation began at the rule's second sentence -- the same
  half-quote shape this branch exists to repair. It now starts at sentence one,
  "its controller moves it from the battlefield directly to its OWNER's
  graveyard", which is what bounds the conclusion: `SelfRef` proves control,
  never ownership, so a controlled-but-not-owned source puts a card in another
  player's graveyard while the leg still answers `OwnResourcesOnly`. Not
  repairable at this seam -- the AST carries no ownership -- so the limit is
  named rather than papered over.

- `give_ironworks` had become a byte-equivalent duplicate of `give_parsed_card`;
  field-by-field equivalence was verified, then it was collapsed to a one-line
  delegation. The divergence hazard had already fired in the parent commit,
  where `base_mana_cost` reached one construction path and not the other.

- The `v1` NON-VACUITY note credited the wrong assertion: `stage_two_action_set`
  only appends, so `stage_two == flat` cannot catch a non-sacrifice hand or
  graveyard mana activation. The `non_pass.len() == 1` reach-guard above does.

- `summoning_sick = false` was a proven no-op (`create_object` documents that it
  does not set the flag; `GameObject::new` defaults it false). Removed, with a
  comment recording why it is absent so it is not re-added.

Assisted-by: ClaudeCode:claude-opus-4.8
…sions

Review-impl r2 passed all five of the maintainer's acceptance criteria and
returned no BLOCK; these are its three test-quality [LOW] items. Test-only --
no production seam is touched, and each new assertion has a mutation that was
RUN and observed to redden it, not merely derived.

- v10b's ordinary-mana-source control rested on two negatives (the Sol Ring
  activation is absent from the flat list; stage two equals the flat list) whose
  outcome was dominated by an upstream conjunct. `stage_two_action_set` chains
  `meaningful_sacrifice_mana_actions` over `activatable_object_mana_actions`,
  which for a probe at `Priority` is literally
  `mana_sources::activatable_mana_actions_for_player` -- so a Sol Ring that was
  never swept is filtered out for a reason the row could not distinguish from
  the penalty filter it means to measure. MEASURED: with the new guard removed
  and the Sol Ring tapped, BOTH pre-existing negatives still pass. They were
  green for the wrong reason.

  Two guards close it at two different levels. The sweep function is `pub`, so
  the row now asserts definitionally that the Sol Ring IS in the list stage two
  filters; and it asserts the consequence the row's own doc already stated as
  fact but never checked -- that the Sol Ring's capacity of 2 unlocks Angel of
  the Ruins' {2} plainscycling in the flat list. Swapping in a capacity-1 source
  reddens only the second, so they are independently breakable rather than
  redundant.

- v10a bounded only its ACCEPT arm. Its SHORTEN arm's discrimination rested on
  a derived-but-unasserted fact, so a future fixture or capacity change that
  added one MayInterfere action would over-determine the row silently instead
  of reddening. It now carries the threshold sentinel its sibling already had.
  MEASURED at 2 (the Ritual cast plus Terramorphic Expanse); staging one extra
  two-capacity source makes it 3 and reddens the sentinel.

- v10a asserted only that the {B}{B}{B} Bolt is NOT castable, leaving the
  funding half -- the half its own name claims -- as prose. The row now measures
  it on the production instrument the way v10b's lemma does: a quarantined clone
  casts the Ritual through `apply`, drives the stack, and re-probes. A zone
  reach-guard pins that the Ritual actually resolved (CR 608.2n) before the
  funding assertion runs. Printing the Bolt at {B}{B}{B}{B} reddens the funding
  assertion alone; collapsing the drive bound reddens the zone guard alone.

  The row's doc previously said "The row does NOT assert the post-resolution
  board". That became false with this change, so it is corrected rather than
  left to mislead.

CR 117.1d, CR 601.2g and CR 608.2n were each grepped from the rules text before
being written. The first draft of the last one said 608.2m from memory, which is
a different rule ("if it leaves the stack once it starts to resolve, it will
continue to resolve fully"); the verification step caught it.

Assisted-by: ClaudeCode:claude-opus-4.8
Two review findings, both cases of an assertion claiming more than it
measured.

The SHORTEN-arm sentinel bounded the action set's COUNT at 2 while its own
prose claimed its MEMBERSHIP ("the ACCEPT arm's single fetchland PLUS the
Ritual cast, and nothing else"). A fixture or capacity change that dropped
the fetchland and added some unrelated MayInterfere action satisfies both
`len() == 2` and the ritual reach-guard, so the row would have gone on
measuring the wrong pair silently. Partitioning on the Ritual and asserting
the remainder equals the ACCEPT arm's set closes that gap and pins the
Ritual leg in the same equality: an empty or doubled partition reddens.

The Angel guard matched on card name alone, but the fact it exists to
establish is specific — that the Sol Ring's two mana unlock the {2}
PLAINSCYCLING activation from HAND, which is what makes withholding a
verdict assertion below it correct rather than evasive. A bare name match
is also satisfied by some other Angel ability or an Angel in another zone.
Ability index 0 is measured off the fixture, not assumed: object 210
carries exactly one parsed ability, tagged Cycling with activation_zone
Hand.

Both tightenings are additive; no existing assertion was weakened. Both
were revert-probed, and the SHORTEN probe was chosen to be invisible to the
two pre-existing guards so the new assertion is provably the one catching
it.

Assisted-by: ClaudeCode:claude-opus-5
The mana-reach fix deleted the `Effect::Mana` allowlist arm because mana
produced inside the priority window a Shorten hands the responder can fund
an otherwise-unaffordable answer. The same mechanism was still live one arm
over: `effect_window_reach` allowlisted ANY library-to-battlefield move as
`OwnResourcesOnly`, so an untapped fetch — whose land taps for mana in that
same window — read as confined. Measured on the production classifier,
Crop Rotation and Nature's Lore both classified `OwnResourcesOnly`.

`enter_tapped` was already destructured and ignored in that arm. The gate
now requires the AST to PROVE a confined entry rather than merely suggest
one (CR 110.5b: permanents enter untapped unless something says otherwise):
`EtbTapState::Tapped`, with no conditional `enters_modified_if` rider that
could change the arrival (CR 614.12 + CR 614.12a), not `enters_attacking`
(CR 508.4), and `enters_under` absent or the actor. `Unspecified` and
`Untapped` both fall out to `MayInterfere`. `SearchLibrary`'s `split` is the
second door onto the battlefield — it moves its own found cards with no
`ChangeZone` node — and is gated the same way.

Both edits are conjunctive, so they can only move verdicts toward
`MayInterfere`; neither can manufacture a false Accept. The gate is
destination-scoped rather than tied to the anaphoric disjunct, because
ownership does not stop an untapped land from producing mana.

This DOES reclassify a live class, and an earlier revision of this message
claimed otherwise. That claim came from a census that counted AST nodes over
the whole card document; both halves were wrong. `printed_cards.rs` copies
only `card_face.abilities` into `obj.abilities` and the classifier folds over
`object.abilities` alone, so nodes in `triggers`/`static_abilities`/
`replacements` never reach it; and a verdict is a fold over a whole ability,
so an ability already `MayInterfere` for an unrelated reason cannot flip
whatever its `ChangeZone` node says. Re-measured at the enforcement surface —
22,717 top-level abilities in this candidate's own projection — this commit's
two conjuncts move 195 of the 522 abilities that were still `OwnResourcesOnly`
after the mana arm's deletion (37.4%), all in the `OwnResourcesOnly` ->
`MayInterfere` direction, with 0 moving the other way. The `split` conjunct is
inert at today's pool (9 of 9 battlefield-primary carriers already print
tapped, none routes `rest_destination` to the battlefield); it is in because
that door would otherwise stay open for the first card that walks through it.

Adds `v10c`, a response-level regression driving the real 4p dump to a real
`RespondToShortcut` and asserting on `smart_shortcut_response`, with a
tapped control on the same board so the tap axis is what the row measures.

Assisted-by: ClaudeCode:claude-opus-5
…adable

`object_window_reach` folded only `obj.abilities`. `printed_cards.rs` splits one
card face into four collections — `abilities`, `replacement_definitions`,
`static_definitions` and `base_trigger_definitions` — so a card whose entire
function lives in one of the other three was classified from the empty half.

Measured witness: Stunning Reversal projects `abilities[0] = ChangeZone{origin:
None, destination: Exile, target: SelfRef}`, which is confined on every conjunct
this module reads, while its function is `replacements[0] = {event: GameLoss,
mode: Mandatory}`. A seat holding it read `OwnResourcesOnly`, so the shortcut
window was Accepted for the one card that exists to survive it.

Both entry points now return `MayInterfere` on the presence of any of the three
collections. The activation path gets the gate for its own reason rather than by
symmetry: activating an ability is itself a game event, so a trigger on the same
object can fire off the activation (CR 603.2) or off the cost being paid. The
gate is presence, not content — this module has no classifier for those
definition types, and the named upgrade path is to classify them the way
`ability_window_reach` classifies `AbilityDefinition`.

Same review round, one door over: `destination: Zone::Hand` leaves the confined
set. A card put into hand is a castable card; after the spell that put it there
resolves the active player receives priority (CR 117.3b) and priority then passes
in turn order (CR 117.3d), so the responding seat gets it back still inside this
window and a hand arrival is not provably confined.

That hand gate went in on the `ChangeZone` arm only, and a review round found the
`SearchLibrary` split still routing cards to hand unchecked. Both doors now go
through one `landing_zone_is_confined` authority rather than two call sites
answering the same question — two answers is how they drifted apart. The split is
destructured `..`-free in the closure pattern for the same reason the `ChangeZone`
arm is: a future `rest_enter_tapped` must be a compile error here, not a silently
ignored arrival modifier.

All changes are strictly narrowing — early returns to the absorbing value and one
more conjunct each — so `May -> Own` is 0 by construction, not by census.

Corpus, on this candidate's own SHA-bound projection of 22,717 top-level
abilities: combined 2600 -> 151 confined (2449 flips, 94.2%, 0 widen); the entry
gate alone 522 -> 151 (371, 71.1%). The hand authority accounts for 176 of those,
split by door: 166 on `ChangeZone` across 154 cards, 10 on the split across 10
cards. All 12 split carriers route something to hand (9 via `rest_destination`,
3 via `primary_destination`), so Cultivate and its class leave the confined set.

At object level, 72 printed cards have a fully-confined ability list and 19 carry
an unreadable collection (7 static-only); 53 stay confined. That is a printed-card
census and a lower bound on runtime objects — `game/stickers.rs` pushes into all
three collections at runtime.

The gate's price, stated rather than buried: Diligent Farmhand fetches a basic
land TAPPED and flips anyway, on one static scoped `active_zones: ["Graveyard"]`
that cannot touch this window. It is the only one of the 19 provably inert by its
own zone scope; presence-gating cannot see that, and classifying the collections
is what buys it back.

Tests: `an_object_whose_rules_content_this_module_cannot_read_is_never_confined`
covers all three disjuncts — replacement, trigger (through the real
`materialize_base_trigger_definitions()` wiring, with a premise that the field the
gate reads is populated) and static — each with the other two cleared first, so
every verdict is attributable to one collection.
`a_destination_is_confined_only_when_the_seat_cannot_act_on_what_lands_there`
walks the destination axis on a real parsed node whose target is `SelfRef`, so
destination is the only free variable. The split row asserts Cultivate's real
`rest_destination == Hand` premise and mutates that one field as its control:
since all 12 carriers touch hand, no real card can serve as the tap-axis positive
control, and the test says so instead of implying live coverage.

Revert-probed: each gate and each conjunct cut alone from pristine source reds its
own row and leaves its neighbours green.

Assisted-by: ClaudeCode:claude-opus-4.8
…ontent gate

Review findings against a15f074, all reproduced before being fixed.

`landing_zone_is_confined` matched `Battlefield`/`Hand` and closed with `_ => true`.
`Zone` is a closed seven-variant enum (CR 400.1) and that wildcard was this module's
only fail-OPEN default: it silently absorbed `Zone::Stack`, a live `ChangeZone`
destination, so a node landing a card on the stack read as confined. CR 405.1 puts a
cast spell's card on the stack and CR 608.1 resolves it once all players pass, which is
strictly stronger reach than the `Hand` case this same function closed last round. The
match is now exhaustive, so adding a `Zone` variant breaks the build. `Zone::Command`
joins hand and stack per CR 903.8 (a commander may be cast from the command zone) and
CR 114.1 (emblems carry abilities there).

The object gate's own doc claimed `printed_cards` splits a card face across four
collections. Measured, it writes eight more rules-bearing fields, and `obj.keywords`
carries printed Cascade — which never reaches `trigger_definitions` — so a Cascade
spell whose printed abilities all read confined was provably `OwnResourcesOnly` while
resolving it casts a free spell of arbitrary reach inside the window. Both entry points
now route through one `carries_unreadable_rules_content` authority covering every
unreadable rules-bearing field rather than a curated subset, since a subset is an
allowlist someone has to remember to extend.

That argument applies to the list itself, so the list is now compiler-enforced. A new
test destructures `CardFace` `..`-free and sorts every field into folded / gated /
not-rules-bearing with a reason on each. Writing it immediately found four more
unreadable fields no re-reading had surfaced — `case_state`, `class_level`, `intensity`
and `attraction_lights` — all now gated. `CardFace` rather than `GameObject` because
`GameObject` has 149 fields, mostly runtime state, and destructuring it would be a churn
magnet blanket-`..`'d back within a round; `CardFace` has 33 and is the source
`printed_cards` actually reads. Scope limit stated in the test: it does not cover
`GameObject` fields written outside `printed_cards` (`game::stickers` writes only the
three definition collections, which are gated).

Measured on this candidate's own projection: ability-level figures are unchanged
(2600 -> 151), confirming the zone fix is latent — 0 `Stack` destinations, 1 `Command`
(Hellkite Courser, in `triggers`, never folded). Object level moves 72/19/53 to
72/29/43: the widening adds 10 flips (8 keywords, 1 modal, 1 additional_cost). The four
fields found by the guard are 0 among survivors and real document-wide (solve conditions
15, Class 38, Case 15, Attraction 35) — latent holes, closed for the same reason as the
zone arm. The protected class is untouched — Terramorphic Expanse, Evolving Wilds and
Rampant Growth carry none of these fields, asserted in the census as a reach-guard that
fails rather than prints.

Tests: the destination table now covers all seven zones with a compile-time
exhaustiveness guard that is deliberately not a mirror of the production match; the
object-gate test gains keyword and spellbook disjuncts with a cleared-field control
between each, so no verdict can be a constant.

Assisted-by: ClaudeCode:claude-opus-4.8
…able-content gate

A review round measured three holes in the object-level gate this PR added, all of
the same shape: a set that was curated by hand and therefore incomplete.

Seven of the gate's disjuncts had neither a test nor a revert-probe -- `modal`,
`additional_cost`, `strive_cost`, `cleave_variant`, `casting_restrictions`,
`casting_options`, `back_face`. Each occurred exactly once in the file, in the gate
itself, so deleting any of them left the whole suite green. Witness coverage was
anti-correlated with liveness: `modal` and `additional_cost` are 2 of the 10 cards
the widening flips, while all four disjuncts that did have witnesses are inert at
today's pool -- witnesses had been written for the newest additions rather than
derived from the gate.

`every_gated_card_face_field_reaches_the_gate_through_printed_cards` closes that with
12 cases, each mutating a `CardFace` and running the real `apply_card_face_to_object`,
which also turns the staleness guard's comment-only CardFace->GameObject mapping into
a runnable assertion. CONTROL and witness are separate freshly-created objects because
`printed_cards` seeds `class_level` only while `base_characteristics_initialized` is
false (CR 716.2b); re-applying to one object would have skipped the field the Class
case tests. `back_face` reuses `game::specialize::empty_back_face`, promoted to
`pub(crate)`, rather than duplicating a 22-field literal that would go stale the
moment `BackFaceData` grew.

The staleness guard bound `metadata: _` -- the one field its own comment called
"mixed", and the exact path `spellbook` had already used to get into this gate. It now
destructures all 8 `CardMetadata` fields with a reason each.

The guard's `card_type` bucket reason was measurably wrong: `printed_cards` derives
four object-level rules fields from `subtypes` alone (`Class`, `Case`, `Room`,
`Attraction`), and `room_unlocks` was the one with no disjunct -- so the bucket whose
job is certifying "no gate needed" was certifying a real hole. CR 709.5 makes a shared
type line two static abilities that remove each locked half's rules text, and CR 709.5e
lets any player unlock a half at any priority, so which halves are unlocked decides
what rules text the permanent has and `obj.abilities` cannot express it. Presence gate,
strictly narrowing: 0 flips at today's pool (72/29/43 unchanged; of 60 Room faces only
two clear every other gate and neither is confined).

Assisted-by: ClaudeCode:claude-opus-5
@lgray
lgray force-pushed the fix/shorten-efficacy-window-reach branch from 63cc3be to 545c840 Compare August 9, 2026 20:43
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== checked-out commit =='
git rev-parse HEAD

echo '== current PR head =='
gh pr view 7101 --repo phase-rs/phase --json headRefOid --jq '.headRefOid'

efficacy_file="$(fd -a '^shortcut_efficacy\.rs$' crates/engine/src | head -n1)"
integration_file="$(fd -a '^shorten_efficacy\.rs$' crates/engine/tests | head -n1)"
main_file="$(fd -a '^main\.rs$' crates/engine/tests/integration | head -n1)"

echo '== relevant classifier code =='
rg -n -C 6 'fn effect_window_reach|Effect::Mana|fn any_action_may_interfere|CastSpell|ActivateAbility|indexed_ability_window_reach|cost_window_reach' "$efficacy_file"

echo '== named integration tests =='
rg -n -C 5 'v10a_a_cast_mana_spell_that_funds_an_unaffordable_answer_keeps_its_window|v10b_an_actor_owned_sacrifice_for_mana_seat_keeps_its_window|smart_shortcut_response|RespondToShortcut' "$integration_file"

echo '== integration module registration =='
rg -n -C 2 'shorten_efficacy' "$main_file"

Length of output: 38893


@lgray Confirmed at current head 545c8402ef59601668867f5c475d88d3f4838858.

Effect::Mana no longer has an OwnResourcesOnly arm. effect_window_reach now classifies mana production through the fail-closed MayInterfere fallback.

Both action paths use the updated classification:

  • GameAction::CastSpell uses object_window_reach.
  • GameAction::ActivateAbility uses indexed_ability_window_reach.

Both response-level regressions exist and assert ShortcutResponse::Shorten through smart_shortcut_response:

  • v10a_a_cast_mana_spell_that_funds_an_unaffordable_answer_keeps_its_window
  • v10b_an_actor_owned_sacrifice_for_mana_seat_keeps_its_window

crates/engine/tests/integration/main.rs registers shorten_efficacy. I verified the source and test registration. I did not run the test suite in this review environment.

The original inline finding is addressed.


✏️ Learnings added
Learnt from: lgray
URL: https://github.com/phase-rs/phase/pull/7101

Timestamp: 2026-08-09T20:48:04.743Z
Learning: In `crates/engine/src/ai_support/shortcut_efficacy.rs`, mana production must classify as `WindowReach::MayInterfere`. Mana produced in the priority window created by `ShortcutResponse::Shorten` can pay for a subsequent spell or ability. This applies to both `GameAction::CastSpell` and actor-owned sacrifice-for-mana activation paths.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== checked-out commit =='
git rev-parse HEAD

echo '== PR head =='
gh pr view 7101 --repo phase-rs/phase --json headRefOid --jq '.headRefOid'

efficacy_file="$(fd -a '^shortcut_efficacy\.rs$' crates/engine/src | head -n1)"
integration_file="$(fd -a '^shorten_efficacy\.rs$' crates/engine/tests | head -n1)"
main_file="$(fd -a '^main\.rs$' crates/engine/tests/integration | head -n1)"

echo '== Effect::Mana matches (must have no explicit OwnResourcesOnly arm) =='
rg -n -C 3 'Effect::Mana|fn effect_window_reach' "$efficacy_file" || true

echo '== action classification paths =='
rg -n -C 4 'GameAction::CastSpell|GameAction::ActivateAbility|object_window_reach|indexed_ability_window_reach' "$efficacy_file"

echo '== named response-level regressions =='
rg -n -C 4 \
  'v10a_a_cast_mana_spell_that_funds_an_unaffordable_answer_keeps_its_window|v10b_an_actor_owned_sacrifice_for_mana_seat_keeps_its_window|smart_shortcut_response|ShortcutResponse::Shorten' \
  "$integration_file"

echo '== integration module registration =='
rg -n -C 2 'shorten_efficacy' "$main_file"

Length of output: 30944


@lgray Confirmed at 545c8402ef59601668867f5c475d88d3f4838858.

Effect::Mana has no explicit OwnResourcesOnly arm. It reaches the fail-closed MayInterfere fallback.

The two action paths use this classification:

  • GameAction::CastSpell uses object_window_reach.
  • GameAction::ActivateAbility uses indexed_ability_window_reach.

Both response-level regressions exist and assert ShortcutResponse::Shorten through smart_shortcut_response:

  • v10a_a_cast_mana_spell_that_funds_an_unaffordable_answer_keeps_its_window
  • v10b_an_actor_owned_sacrifice_for_mana_seat_keeps_its_window

crates/engine/tests/integration/main.rs registers shorten_efficacy. I inspected the current source. I did not build or run the test suite in this environment.

The high-priority finding is addressed.

🐇


✏️ Learnings added
Learnt from: lgray
URL: https://github.com/phase-rs/phase/pull/7101

Timestamp: 2026-08-09T20:55:39.708Z
Learning: In `crates/engine/src/ai_support/shortcut_efficacy.rs`, shortcut efficacy classification must fail closed for `GameAction::CastSpell` and `GameAction::ActivateAbility`. `Effect::Mana` must reach `WindowReach::MayInterfere`, because mana produced in the priority window created by `ShortcutResponse::Shorten` can fund a subsequent response.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@lgray Confirmed. The fail-closed Effect::Mana change addresses the reported false Accept.

The two response-level regressions cover both required paths:

  • CastSpell with Dark Ritual.
  • Stage-two actor-owned activation with Lotus Petal.

The updated classifier also keeps unknown or unreadable rules content conservative. This prevents the shortcut logic from treating incomplete classifier visibility as proof of OwnResourcesOnly.

Thank you for the detailed evidence and for distinguishing the measurement SHAs from the current pushed head.

🐇


✏️ Learnings added
Learnt from: lgray
URL: https://github.com/phase-rs/phase/pull/7101

Timestamp: 2026-08-09T21:06:01.526Z
Learning: In `crates/engine/src/ai_support/shortcut_efficacy.rs`, `object_window_reach` and `indexed_ability_window_reach` must fail closed when a `GameObject` contains rules-bearing content that shortcut efficacy classification does not interpret. Presence of unreadable fields, including definition collections and printed metadata such as `keywords`, `modal`, `additional_cost`, `back_face`, and `room_unlocks`, must produce `WindowReach::MayInterfere` rather than prove `WindowReach::OwnResourcesOnly`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@matthewevans matthewevans self-assigned this Aug 9, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current head 545c8402ef59601668867f5c475d88d3f4838858 is blocked on two remaining false-Accept paths. The Mana finding from the prior review is resolved; these findings are new and are independently verified against the current head.

🔴 Blocker

[HIGH] A locally confined fetch can still trigger a live, cross-player response elsewhere on the board. Evidence: shortcut_efficacy.rs:766-779 classifies only the chosen action's source/effect; :396-420 therefore proves a self-targeted, tapped fetch OwnResourcesOnly. But the actual trigger authority collects matching battlefield triggers for the event, including other permanents, at triggers.rs:3530-3640. A controller's Hedron Crab makes a tapped Terramorphic-style landfall action non-vacuous: it creates a target-player mill trigger after the fetch resolves. The classifier never inspects that trigger source and can emit Accept, surrendering the only window that reaches it. The local card corpus records Hedron Crab's landfall as target-player milling at data/mtgish-cards.json:12623.

Suggested fix: make the efficacy decision use the event/trigger authority for the proposed action (or conservatively return MayInterfere whenever resulting triggers have not been proven inert); add a response-level landfall/other-observer regression that would flip back to Shorten if this guard is removed.

[MED] SelfRef and Controller are treated as ownership proofs even though they only prove control. Evidence: shortcut_efficacy.rs:149-161 returns true for both; :411-420 and :473-475 use that proof to classify zone moves and sacrifices as confined. The PR itself accurately notes the counterexample at :1963-1977: sacrificing a controlled-but-opponent-owned permanent moves it to its owner's graveyard. That can change an opponent's resource / break the loop, but an all-confined action set still becomes Accept at ai_support/mod.rs:1592-1600.

Suggested fix: pass object ownership from the probe/action boundary into the confinement check, or default SelfRef/Controller to MayInterfere unless ownership is proven; add a controlled-not-owned response-level regression.

✅ Clean

The previous mana false-Accept is fixed on this head: Effect::Mana now reaches the conservative fallback at shortcut_efficacy.rs:423-454, with current-head response-level coverage for cast mana and sacrifice-for-mana. The Room presence gate and its printed-card pipeline test are also conservative.

Recommendation: request changes. The design must make the two ownership/event paths fail closed before this policy can accept a shortcut.

@matthewevans matthewevans removed their assignment Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants