Skip to content

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

Merged
matthewevans merged 14 commits into
phase-rs:mainfrom
lgray:fix/shorten-efficacy-window-reach
Aug 10, 2026
Merged

fix(ai): stop proposing strategically vacuous loop-shortcut Shortens#7101
matthewevans merged 14 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.
    • Shortcut decisions now account for interactions involving other players’ resources and game zones.
    • Parser diagnostics are retained for visible card data while being removed from hidden cards.
  • Bug Fixes

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

    • Added comprehensive coverage for shortcut efficacy 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

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds conservative shortcut-efficacy analysis for priority actions. Shortcut responses now use separate possibility and efficacy stages, including grouped sacrifice-for-mana actions and precast-copy responses. Game objects retain parser diagnostics with visibility-aware redaction.

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 conservative WindowReach analysis for effects, costs, abilities, ownership filters, objects, and action types. Unknown or unresolved shapes remain interfering.
Two-stage shortcut response flow
crates/engine/tests/integration/shorten_efficacy.rs, crates/engine/tests/integration/main.rs
Adds probing and expanded stage-two action evaluation. Tests cover confined actions, interaction, crowned seats, quiet boards, precast-copy responses, and sacrifice-for-mana actions.

Parser diagnostic visibility

Layer / File(s) Summary
Diagnostic storage and redaction
crates/engine/src/game/game_object.rs, crates/engine/src/game/printed_cards.rs, crates/engine/src/game/visibility.rs, crates/engine/src/game/...
Stores parser diagnostics on faces and objects, propagates them through face changes, initializes fixtures, and removes them from unauthorized projections.

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 initializes parser diagnostics.

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

Possibly related PRs

  • phase-rs/phase#6933: Adds priority-preflight and action-announcement infrastructure used by shortcut probing.
  • phase-rs/phase#6977: Also changes priority-window shortcut and precast_copy_shortcut handling.
  • phase-rs/phase#7007: Also changes AI shortcut and casting evaluation in ai_support.

Suggested labels: enhancement

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 change: preventing strategically vacuous loop-shortcut Shorten proposals.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 thread crates/engine/src/ai_support/shortcut_efficacy.rs Outdated
@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 0db0d6bc96d2822aeb60670145201b7c15d22ee9.

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
lgray added 2 commits August 9, 2026 19:15
`WindowReach::OwnResourcesOnly` was emitted from an AST-only proof whose
premises are live-state facts the AST structurally cannot carry. The module
was fail-closed on unrecognized *variants* but fail-open on known-incomplete
*inputs*. Three gates close three premises, all at `any_action_may_interfere`
— the one seam that already holds `&GameState`.

Ownership (CR 110.2, CR 109.5, CR 701.21a). `SelfRef`/`Controller` prove
CONTROL, never ownership, and a sacrificed permanent goes to its OWNER's
graveyard — so a controlled-but-not-owned permanent changes another player's
resources while every AST predicate still reads confined. Proven set-level:
`TargetFilter::Controller` is a bare unit variant and `Typed` is a set
predicate, so per-object threading is structurally impossible.

Board observers (CR 603.2, CR 603.6a, CR 113.6). Trigger matching is a
board-wide scan; the fold read one object. A tapped, self-targeted fetch was
provably confined while an opponent's Hedron Crab ("Landfall — Whenever a land
you control enters, target player mills three cards") reached every player.
Relief is a disjunction of independently sufficient reliefs, gated
zone-of-function first via the existing CR 113.6 authority. The subject
carve-out is `SelfRef`-EXACT: reusing `filter_is_actor_owned` returns true for
`Typed{controller: You}` — exactly the crab's filter — and provably fails to
fix this, which is pinned as a negative control.

Parse completeness. A parse cannot testify about its own omissions, so
`parse_warnings` now gates confinement and moves from the staleness guard's
NOT-RULES-BEARING bucket to GATED. That one line — "parser diagnostics, never
consulted at runtime" — was the root cause stated as a certification.

The relief predicate lives in `ai_support`, not `game/triggers.rs`: relief is
not intrinsic to a trigger, it holds only relative to the confined-action
allowlists this module defines. `LifeLost` relief is sound only because
`AbilityCost::PayLife` falls to `MayInterfere`; its doc names both allowlists
and that witness so widening either forces a re-audit.

Measured, not assumed: `Milled*`, `EntersOrAttacks` and
`EntersOrHauntedCreatureDies` are NOT relieved — their matchers read a generic
`ZoneChanged` that an allowlisted `ChangeZone` emits (CR 701.17a), so relieving
them would have been a fresh false-Accept. The flagship acceptance seat keeps
`Accept` (171 defs -> 168 zone-gated -> 3 survivors, all relieved); P1 is safe
via the stage-1 short-circuit, not because no board fires.

All 14 revert-probes executed PASS->FAIL, none non-discriminating. 30 CR
numbers verified against the rules text; one self-caught miscite corrected
(701.12a is Exchange, not Fight -> 701.14a).

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

Three defects found by independent review of the previous commit.

Hidden-information leak, introduced by that commit (CR 400.2: a hand and a
library are hidden zones). `GameObject::parse_warnings` was not cleared by
`hide_card`, so an opponent received a card whose `name` read "Hidden Card"
while its diagnostics carried the printed rules text verbatim — and
`SwallowedClause.description` IS that text. Because the field is skipped when
empty, it appeared on exactly the 891 cards that carry one, making it a
per-card fingerprint for reading hands and libraries.

The sibling redactor `redact_face_down_identity_from_observer` had the same
hole — measured, not assumed: a manifested card's diagnostics survived the
face-down transform and reached the observer's payload (CR 708.2, CR 708.5).
Both are cleared, each with a matched-pair test: empty for the non-viewer,
non-empty for the owner, so the negative arm cannot pass vacuously.

The root cause is structural and larger than this field: `hide_card` is an
allowlist-shaped problem implemented as a denylist, so any new `GameObject`
field defaults to LEAKED. Passing the serialization partition guard proves
nothing about visibility. An audit run against the wire found `base_name`
(every hidden card's real name), `spellbook`, `unimplemented_mechanics`,
`token_rules_text` and the `base_*` characteristics all leaking. Those are
PRE-EXISTING and are deliberately NOT fixed here — the correct repair is to
project a minimal object rather than extend a hand-maintained clear-list, and
that carries its own test matrix and client-consumer audit.

A battlefield entry is now confined only from `Zone::Library`. The previous
commit's doc claimed the arriving card "is still in the library, so neither
input contains it" — false for 46 abilities returning from graveyard, hand or
exile, whose objects ARE in `state.objects`. Witness: Helping Hand returning a
Fleshbag Marauder is classified confined while making every player sacrifice.
The precise fix needs an entering `ObjectId` that does not exist at decision
time (`Typed{controller: You}` is an unchosen target), so this narrows the
allowlist instead — moving verdicts only toward `MayInterfere` and making the
paragraph true rather than retreating from it. Measured cost: of 493
document-wide tapped entries, Library's 247 keep classification; 245 lose it.
The flagship fetch rows are untouched — that disjunct already required a
library origin.

`t2_10`'s decoder pin is now order-insensitive, so reordering `TriggerMode`
no longer reds an `ai_support` test.

All four revert-probes executed, each the sole failure of its suite. Full
engine suite 23,638 passed / 0 failed; workspace check and clippy clean.

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray

lgray commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Both findings fixed — head 3f6dfbc13

Both reproduced by execution before anything was written, and both flip under the fix.

[HIGH] cross-player observer. The classifier now proves confinement against the live board. Relief is a disjunction of independently sufficient reliefs, evaluated cheapest-first: CR 113.6 zone-of-function (reusing the existing trigger_definition_functions_in_zone authority, already consumed by analysis::resource) → a mode-relief predicate with a fail-closed _ => false → a subject carve-out.

The carve-out is valid_card == Some(TargetFilter::SelfRef) exactly, and that exactness is the whole fix. Hedron Crab parses to valid_card = Typed{type_filters:[Land], controller: You}, and filter_is_actor_owned — the obvious helper to reuse — returns true on that shape. Reusing it would have passed review while leaving your finding live. That is pinned as a negative control (t2_3): swapping the exact comparison for the helper flips five tests.

[MED] control ≠ ownership. Proven set-level via actor_owns_everything_they_control. Per-object threading is structurally impossible, and that is measured rather than asserted: TargetFilter::Controller is a bare unit variant and Typed is a set predicate, so there is no id to thread; only SpecificObject{id} carries one and it already returned false. Your suggested approach would have fixed SelfRef and left Controller and Typed{controller:You} still proving control-as-ownership.

Feature is not vacated, measured on the flagship 4p dump: 171 trigger definitions → 168 removed by the zone gate → 3 survivors → all relieved → still Accept. Deleting the Life-family relief arm flips that row, so the relief is what carries it.


Two things you should know that you did not ask about

1. This PR introduced a hidden-information leak, and independent review caught it before you did. The new GameObject::parse_warnings field was not cleared by visibility.rs::hide_card, so an opponent received a card whose name read "Hidden Card" while its diagnostics carried the printed rules text verbatim — SwallowedClause.description is that text. Because the field is skipped when empty it appeared on exactly the 891 cards that carry one: a per-card fingerprint for reading hands and libraries. Fixed in both redactors (redact_face_down_identity_from_observer had the same hole — measured, not assumed), each with a matched-pair test so the empty-for-opponent arm cannot pass vacuously.

2. That audit surfaced PRE-EXISTING leaks that are worse, and I did not fix them here. Measured against the wire (filter_state_for_viewerserde_json::to_string):

field status
base_name leaks every hidden card's real name verbatimGameObject::new sets base_name: name.clone()
spellbook, unimplemented_mechanics, token_rules_text leak
base_power/base_toughness and the other base_* characteristics uncleared

The root cause is structural: hide_card is an allowlist-shaped problem implemented as a denylist, so any new GameObject field defaults to leaked. Passing the serialization partition guard proves nothing about visibility — that is exactly how this PR's own regression got in. The correct repair is to project a minimal object rather than extend a hand-maintained clear-list, which carries its own test matrix and client-consumer audit. Blast radius for the cheap ones is measured: base_name and spellbook have zero frontend consumers, unimplemented_mechanics has three. Say the word and I will open that PR — it is a bigger correctness issue than the one you filed, and it is not mine to bundle into this review silently.


A correction to my own previous reply, and a cost you should weigh

My last reply's residual paragraph claimed the arriving card "is still in the library, so neither input contains it." That was false for 46 abilities returning from graveyard, hand or exile — those objects are in state.objects. Witness: Helping Hand returning a Fleshbag Marauder was classified confined while making every player sacrifice. Pre-existing, not a regression, but the prose read as closed when it was not, which is worse than the deferral it replaced.

A battlefield entry is now confined only from Zone::Library. The precise fix needs an entering ObjectId that does not exist at decision time (Typed{controller:You} is an unchosen target), so this narrows the allowlist instead — moving verdicts only toward MayInterfere.

Measured cost, so you can push back on it: of 493 document-wide tapped battlefield entries, Library's 247 keep classification and 245 lose it. The flagship fetch rows are untouched — that disjunct already required a library origin. This is the judgement call in the change most worth challenging; the v10d Accept control is what proves it is a distinction rather than a blanket flip.


Evidence

  • 18 revert-probes executed (14 from the first commit, 4 from the fix), each observed PASS → FAIL, each the sole failure of its suite. None asserted without running.
  • Full engine suite 23,638 passed / 0 failed; cargo check --workspace and clippy -D warnings clean.
  • 42 CR numbers verified against the rules text, 0 unverified — including two self-caught miscites corrected during the work (701.12a is Exchange, not Fight → 701.14a; and the hidden-zone rule is 400.2, not 400.1/108.3).
  • One measured deviation from the approved plan: Milled*, EntersOrAttacks and EntersOrHauntedCreatureDies are not relieved — their matchers read a generic ZoneChanged that an allowlisted ChangeZone emits (CR 701.17a), so relieving them would have shipped a fresh false-Accept of exactly the class you filed.

CI has not yet bound to this head; treat it as unreported until it does.

@matthewevans matthewevans self-assigned this Aug 10, 2026
@matthewevans

Copy link
Copy Markdown
Member

Current head a700239ef2d0d0f2e4ca06c3adc61f33377879e3 has cleared the two previously requested behavior fixes.

  • The shortcut classifier now consults the live board before accepting: board_observer_may_react checks active trigger definitions and the confined-action event set at crates/engine/src/ai_support/shortcut_efficacy.rs:1027-1092, then vetoes Accept at :1161-1185. The new response-level landfall case exercises the original observer scenario.
  • The controlled-versus-owned path is now conservative: actor_owns_everything_they_control at :184-215 gates the action fold before control-relative AST filters are relied upon; the controlled-not-owned fetch regression exercises the prior false-Accept case.

This is a non-terminal CI/evidence hold, not an approval: Rust test shards are still pending, and the existing <!-- coverage-parse-diff --> receipt is bound to parent 3f6dfbc132cd2958025ee1f478dd0578e60b1195, not this merge head. Please let the current workflow settle and publish/refresh the receipt; the next review pass can approve and enqueue if those checks are green.

@matthewevans matthewevans removed their assignment Aug 10, 2026
@lgray

lgray commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Both evidence holds are cleared at a700239ef — no action left on my side

1. Rust shards are bound to the merge head, not the parent. Queried per-SHA (repos/phase-rs/phase/commits/a700239ef2d0d0f2e4ca06c3adc61f33377879e3/check-runs), so this is binding by construction rather than by name-matching a run:

success  Rust tests (shard 1/2)
success  Rust tests (shard 2/2)
success  Rust lint (fmt, clippy, parser gate)
success  Rust (fmt, clippy, test, coverage-gate)
success  Card data (generate, validate, coverage)
success  Frontend / Lobby worker / Tauri / WASM / Security scan
skipped  Draft pools (smoke)

11 pass, 0 fail, 1 skipped. mergeStateStatus = CLEAN.

2. The parse-diff receipt is now bound to the merge head — it refreshed six minutes after you looked. The sticky (comment 5226706836) reads Generated for head a700239ef2d0d0f2e4ca06c3adc61f33377879e3, "✓ No card-parse changes detected", updated_at = 2026-08-10T02:12:03Z. Your comment is stamped 02:06:03Z, so you were reading the receipt bound to parent 3f6dfbc13 — correctly, at the time. Nothing was republished by hand; the Card-data job at the merge head regenerated it.

That result is expected rather than lucky: this PR touches no file under crates/engine/src/parser/, so there is no mechanism by which card parsing could move.


One thing still open that is not mine to close

The base_name leak from my previous comment is pre-existing, unfixed, and larger than either finding you filed: every hidden card ships its real name to opponents, because GameObject::new sets base_name: name.clone() and hide_card never clears it. Same root cause as the regression this PR introduced and fixed — hide_card is an allowlist-shaped problem implemented as a denylist, so a new field defaults to leaked.

I deliberately did not bundle it here. Measured blast radius for triage: base_name and spellbook have zero frontend consumers, unimplemented_mechanics has three. Say the word and I will open it as its own PR.

@matthewevans matthewevans self-assigned this Aug 10, 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 — the prior two fixes are resolved, but this head still has a false-Accept path and an incomplete diagnostic-face contract.

🔴 Blocker

[HIGH] A tapped library search can still select an interfering permanent, while the classifier treats the search as confined. Evidence: crates/engine/src/ai_support/shortcut_efficacy.rs:500-510 admits Library -> Battlefield with TargetFilter::Any whenever entry is tapped; :1080-1091 scans only presently functioning triggers, which correctly excludes the unchosen library card. That is insufficient proof: Elvish Reclaimer searches for any land and puts it onto the battlefield tapped (data/mtgish-cards.json:8334), and the selectable Bojuka Bog has an ETB that can exile any target player's graveyard (data/mtgish-cards.json:3217). This is a live cross-player choice after Shorten, yet any_action_may_interfere can return false. The current regression encodes the same unsupported premise by expecting Accept for Rampant Growth at crates/engine/tests/integration/shorten_efficacy.rs:2808-2888; its vanilla-Swamp fixture does not cover a legal non-inert selection.

Suggested fix: keep this class fail-closed until stage two can prove every legal library selection inert (or conservatively classify library-to-battlefield searches as MayInterfere). Add a response-level Elvish-Reclaimer-to-Bojuka-Bog regression that flips back to Accept if that guard is removed.

[MED] parse_warnings is declared as data for the displayed face but is not transferred when a double-faced card transforms. Evidence: the new field is on GameObject at crates/engine/src/game/game_object.rs:552-578, but BackFaceData has no equivalent at :215-245; consequently face apply/swap only copies the prior fields (crates/engine/src/game/printed_cards.rs:267-348) and transform snapshots omit diagnostics (:722-751; game/transform.rs:121-130). A front-clean/back-warning card can therefore look clean to carries_unreadable_rules_content, and the inverse keeps a stale warning after transforming back.

Suggested fix: thread a serde-defaulted diagnostic vector through BackFaceData, both snapshot helpers, and both face-application directions, with a matched front/back transform regression.

Recommendation: request changes. The library-selection authority needs a sound boundary before this policy can accept a shortcut; complete the face-data threading in the same change or remove the new serialized diagnostic field from this PR.

@lgray

lgray commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Filed the pre-existing leak separately as #7201Hidden-zone card names leak to opponents: hide_card never clears GameObject::base_name.

Short version: GameObject::new sets base_name: name.clone() (game_object.rs:2190), hide_card redacts name but never base_name, and the field is #[serde(default)] with no skip — so every card in an opponent's hand and library ships its real printed name over the wire. CR 400.2 makes library and hand hidden zones.

It is filed rather than fixed here for the reason given in the issue: the one-line clear is a stopgap, and the structural repair is to project a minimal object so a new field defaults to hidden instead of leaked. That wants its own test matrix and a client-consumer audit, and bundling it into this review would make this diff unreviewable against its own gate. #7101 still fixes the regression it introduced (parse_warnings, in both redactors).

Cross-referenced to #6732, which is the same redaction seam failing in the opposite direction — face-down cards unreadable to the player who should be able to identify them, versus hidden cards readable by players who should not.

I could not apply labels (no permission on this repo); it will need triage.

… is inert

The classifier admitted `Library -> Battlefield` arrivals as confined whenever
the permanent entered tapped, on the argument that the card is an unchosen
member of a hidden zone and therefore unreadable. That argument is
measurably false. CR 701.23a: "To search for a card in a zone, look at all
cards in that zone (even if it's a hidden zone)". Probed on the enrolled 4p
dump, 91 of 91 library objects resolve in `state.objects` with full parsed
rules content — and the hazard is already on that board: the seat's own
library holds Bojuka Bog, whose ETB exiles TARGET PLAYER's graveyard. An
unrestricted "search for a land card" could select it, so the seat was
accepting away the only window that reaches a live cross-player choice.

`library_arrivals_are_inert` reads the actor's pooled hidden zones and asks
whether every card matching the union of the object's search filters is
inert, reusing `carries_unreadable_rules_content` as the single inertness
authority rather than growing a second one. It is applied at both entry
points beside that gate, not threaded into the AST classifier: two players
holding the same printed card get opposite verdicts, so this is a board fact
and presenting it as an AST fact would mislabel it. A non-empty pool with an
empty match set fails closed — 24 cards carry a search filter that reads the
ability's own targets, which this context cannot evaluate, and a false
`Accept` is the one outcome not worth risking.

No corpus assumption is made. "Basic lands are vanilla" would have been the
cheap boundary and it is not proven — a future set can print a Basic land
with a trigger. The flagship row survives on measurement instead: Terramorphic
searches Basic-only and all 22 basics in that deck are vanilla.

Elvish Reclaimer, the card the report named, cannot carry the regression: its
`Sacrifice(Typed[Land])` cost is unqualified, so `cost_window_reach` already
answers `MayInterfere` and the row reads `Shorten` with and without the new
gate. Measured, then replaced with its structural siblings Reshape the Earth
and Urza's Cave, which exercise the spell and activated paths respectively.

`parse_warnings` now rides `BackFaceData` and all four face-copy paths, so a
transform swaps diagnostics with the face they describe instead of leaving a
front-clean/back-warning card reading clean. The base-face snapshot line is
load-bearing: `apply_face_down_creature_characteristics` blanks name, types,
abilities and definitions but not diagnostics, so without it turning face up
would have cleared what turning face down preserved — a regression this
change would otherwise have introduced.

Adding a `BackFaceData` field is compile-forced across 34 struct literals;
each is the same single mechanical initializer.

Seven revert-probes executed, each observed pass->fail. Engine suite 18,827
lib + 4,790 integration passed, 0 failed; workspace check and clippy clean.
One self-caught miscite removed (CR 701.23c is undefined qualities in hidden
zones, not a search limit).

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray

lgray commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Both findings fixed — head f505f519e

[HIGH] library search — the premise it rested on was false, and that is the fix

You were right, and the reason is stronger than the one in the report. The old code admitted Library -> Battlefield as confined on the argument that the card is an unchosen member of a hidden zone and therefore unreadable. That argument is measurably false. CR 701.23a: "To search for a card in a zone, look at all cards in that zone (even if it's a hidden zone) and find a card that matches the given description."

Probed on the enrolled 4p dump: 91 of 91 library objects resolve in state.objects with full parsed rules content — and the hazard is already sitting on that fixture. The seat's own library holds Bojuka Bog. So this was not a hypothetical.

library_arrivals_are_inert now reads the actor's pooled hidden zones and asks whether every card matching the union of the object's search filters is inert, reusing carries_unreadable_rules_content as the single inertness authority rather than growing a second one. Applied at both entry points beside that gate — deliberately not threaded into the AST classifier, since two players holding the same printed card get opposite verdicts, and presenting a board fact as an AST fact would mislabel it.

No corpus assumption. "Basic lands are vanilla" was the cheap boundary and I did not take it — it is unproven and a future set can print a Basic land with a trigger. The flagship survives on measurement instead: Terramorphic searches Basic-only and all 22 basics in that deck are vanilla. v1_live_path_fetchland_seat_accepts_on_the_real_4p_board still returns Accept.

Fails closed where it cannot answer: 24 cards carry a SearchLibrary filter that reads the ability's own targets (SameNameAsParentTarget, CanEnchant{ParentTarget}), which this context cannot evaluate. A non-empty pool with an empty match set therefore returns MayInterfere, pinned by t3_4.

One correction to your report: Elvish Reclaimer cannot carry this regression. Its cost parses to AbilityCost::Sacrifice(Typed[Land], controller: null); filter_is_actor_owned rejects an unqualified Typed, so cost_window_reach already answers MayInterfere and the row reads Shorten with and without the new gate — over-determined and non-discriminating. Measured, then replaced with its structural siblings Reshape the Earth (spell path) and Urza's Cave (activated path). Bojuka Bog remains the hazard in every row.

You also flagged the Rampant Growth row's vanilla-Swamp fixture. It now carries two controls in opposite directions: filter-varies/deck-held (Rampant Growth ⇒ Accept) and deck-varies/card-held (strip the non-inert lands ⇒ Accept). The second is exactly what a "Basic == safe" heuristic would fail.

[MED] transform threading — done, not removed

I did not take the "remove the field" option; it would drop real coverage. parse_warnings rides BackFaceData and all four face-copy paths.

The base-face snapshot line turned out load-bearing: apply_face_down_creature_characteristics blanks name, types, abilities and definitions but not diagnostics, so without it, turning face up would have cleared what turning face down preserved — a regression this change would otherwise have introduced. Covered in both directions plus the face-down stash.

Adding the field is compile-forced across 34 struct literals, each the same one-line initializer — that is why the diff is wide.

Adjacent gaps found, reported not fixed: BackFaceData also does not carry cleave_variant, spellbook, case_state, room_unlocks, attraction_lights, intensity (all rules-bearing and all gated), or class_level (arguably correct not to swap — CR 716.2b says a Class retains its level). Separately, printed_cards.rs's intensity comment claims a DFC picks up back-face intensity on transform; measured false — transform_permanent routes through apply_back_face_to_object. All pre-existing.

Evidence

7 revert-probes executed, each observed pass → FAIL with the transition recorded. Engine suite 18,827 lib + 4,790 integration passed, 0 failed; cargo check --workspace and clippy -D warnings clean. CR gate 0 unverified, including one self-caught miscite removed (CR 701.23c is undefined qualities in hidden zones, not a search limit).

Behavior change worth weighing: this gate only moves verdicts toward Shorten, so seats holding unrestricted tutors will now buy priority windows they previously declined. That is the module's stated direction — a false Shorten costs beats, a false Accept costs games — but it is real and beyond the flagship.

Separately filed #7201 for the pre-existing base_name leak, cross-linked to #6732.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/engine/src/game/transform.rs (1)

330-330: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Seed a diagnostic and assert its preservation.

parse_warnings: vec![] cannot detect diagnostic loss during front-to-back or back-to-front transforms. Initialize this fixture with a non-empty warning and assert that transform_permanent preserves it in both directions. apply_card_face_to_back_face already defines this propagation contract.

🤖 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/game/transform.rs` at line 330, Update the fixture
containing parse_warnings to initialize it with a non-empty diagnostic, then add
assertions around transform_permanent for both front-to-back and back-to-front
transformations to verify the warning is preserved. Use
apply_card_face_to_back_face as the propagation contract and keep the existing
transformation behavior unchanged.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/game/game_object.rs`:
- Around line 242-260: Change GameObject::parse_warnings and
BackFaceData::parse_warnings from plain vectors to an explicit
unknown-versus-known state so missing persisted diagnostics remain Unknown
rather than becoming clean. Update serde handling, face-application copies, and
carries_unreadable_rules_content/shortcut_efficacy consumers to treat Unknown as
fail-closed while preserving Known diagnostics behavior.

---

Nitpick comments:
In `@crates/engine/src/game/transform.rs`:
- Line 330: Update the fixture containing parse_warnings to initialize it with a
non-empty diagnostic, then add assertions around transform_permanent for both
front-to-back and back-to-front transformations to verify the warning is
preserved. Use apply_card_face_to_back_face as the propagation contract and keep
the existing transformation behavior unchanged.
🪄 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: 7b47ef9a-8fc3-4b0d-a09c-52e2b880f141

📥 Commits

Reviewing files that changed from the base of the PR and between 3f6dfbc and f505f51.

📒 Files selected for processing (43)
  • crates/engine/src/ai_support/candidates.rs
  • crates/engine/src/ai_support/shortcut_efficacy.rs
  • crates/engine/src/game/casting_tests.rs
  • crates/engine/src/game/day_night.rs
  • crates/engine/src/game/effects/become_copy.rs
  • crates/engine/src/game/effects/change_zone.rs
  • crates/engine/src/game/effects/flip_coin.rs
  • crates/engine/src/game/effects/flip_permanent.rs
  • crates/engine/src/game/effects/prepare.rs
  • crates/engine/src/game/effects/set_room_door_lock.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/effects/transform_effect.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/game/engine_debug.rs
  • crates/engine/src/game/engine_mdfc_land_tests.rs
  • crates/engine/src/game/engine_tests.rs
  • crates/engine/src/game/flip.rs
  • crates/engine/src/game/game_object.rs
  • crates/engine/src/game/printed_cards.rs
  • crates/engine/src/game/specialize.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/src/game/transform.rs
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/game/zones.rs
  • crates/engine/tests/integration/azors_gateway_transform_condition.rs
  • crates/engine/tests/integration/copied_ability_transform_generation.rs
  • crates/engine/tests/integration/craft_tithing_blade_transform.rs
  • crates/engine/tests/integration/deterministic_game_state_serde.rs
  • crates/engine/tests/integration/integration_adventure.rs
  • crates/engine/tests/integration/issue_2425_fable_chapter_iii_transform.rs
  • crates/engine/tests/integration/issue_4001_frolicking_familiar_adventure_instant.rs
  • crates/engine/tests/integration/issue_5326_avatar_aang_transform.rs
  • crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs
  • crates/engine/tests/integration/issue_691_sheoldred_saga_lore.rs
  • crates/engine/tests/integration/kamigawa_flip_cards.rs
  • crates/engine/tests/integration/room_door_lock_unlock.rs
  • crates/engine/tests/integration/rules/battle.rs
  • crates/engine/tests/integration/shorten_efficacy.rs
  • crates/engine/tests/integration/specialize_runtime.rs
  • crates/engine/tests/integration/std_s07_batch5b.rs
  • crates/engine/tests/integration/stolen_goodies_zero_targets.rs
  • crates/engine/tests/integration/tamiyo_inquisitive_student_flip.rs
  • crates/engine/tests/integration/wedding_announcement_transform.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/engine/src/game/specialize.rs
  • crates/engine/src/game/printed_cards.rs

Comment on lines +242 to +260
/// Parser diagnostics for THIS face — the `BackFaceData` half of
/// [`GameObject::parse_warnings`], and per-face for the same reason `abilities`
/// is: the two faces are parsed independently, so a card whose front reads
/// cleanly and whose back does not is the normal case rather than a corner one.
///
/// Without this field the diagnostic was not a per-face fact at all. Face
/// application copies field by field, so a transform kept the FRONT face's
/// diagnostics on the object while displaying the back face's rules text: a
/// front-clean card looked clean after transforming into a back face the parser
/// could not fully read, and a front-dirty one kept a diagnostic that no longer
/// described anything. `ai_support::shortcut_efficacy` reads the object field as
/// evidence that the printed rules text is fully modelled, so the stale answer
/// was load-bearing in both directions.
///
/// `serde(default)` keeps every persisted dump loadable — an older
/// `BackFaceData` simply carries no diagnostics — and `skip_serializing_if`
/// keeps a clean parse byte-identical on the way out.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub parse_warnings: Vec<crate::parser::oracle_ir::diagnostic::OracleDiagnostic>,

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 12 \
  'parse_warnings|CopiableValues|CopyValues|is_copy|object_window_reach|deserialize|from_slice' \
  crates/engine/src crates/engine/tests

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- game_object outline ---\n'
ast-grep outline crates/engine/src/game/game_object.rs --view compact || true

printf '\n--- game_object parse_warnings/copy fields ---\n'
rg -n -C 8 'parse_warnings|BackFaceData|CopiableValues|CopyValues|is_copy|object_window_reach|impl .+Copiable|struct BackFaceData|struct GameObject' crates/engine/src/game/game_object.rs

printf '\n--- shortcut_efficacy relevant block ---\n'
sed -n '3480,3565p' crates/engine/src/ai_support/shortcut_efficacy.rs

printf '\n--- object_window_reach definitions ---\n'
rg -n -C 10 'enum ObjectWindowReach|fn object_window_reach|object_window_reach|object_window_reach' crates/engine/src crates/engine/tests

Repository: phase-rs/phase

Length of output: 243


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- game_object outline ---'
ast-grep outline crates/engine/src/game/game_object.rs --view compact || true

printf '%s\n' ''
printf '%s\n' '--- game_object parse_warnings/copy fields ---'
rg -n -C 8 'parse_warnings|BackFaceData|CopiableValues|CopyValues|is_copy|object_window_reach|impl .+Copiable|struct BackFaceData|struct GameObject' crates/engine/src/game/game_object.rs

printf '%s\n' ''
printf '%s\n' '--- shortcut_efficacy relevant block ---'
sed -n '3480,3565p' crates/engine/src/ai_support/shortcut_efficacy.rs

printf '%s\n' ''
printf '%s\n' '--- object_window_reach definitions ---'
rg -n -C 12 'enum ObjectWindowReach|fn object_window_reach|object_window_reach|object_window_reach|pub enum ObjectWindowReach' crates/engine/src crates/engine/tests

Repository: phase-rs/phase

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- carries_unreadable_rules_content implementation/usages ---'
rg -n -C 10 'fn carries_unreadable_rules_content|carries_unreadable_rules_content|parse_warnings\.is_empty|!object\.parse_warnings\.is_empty' crates/engine/src/ai_support/shortcut_efficacy.rs

printf '%s\n' ''
printf '%s\n' '--- CopyValues / copiable / copy object sources ---'
rg -n -C 12 'struct CopyValues|enum CopyValues|CopyValues|CopiableValues|is_copy|copy_of|clone.*object|new_object|parse_warnings' crates/engine/src crates/engine/tests -g '*.rs' --glob '*copy*' --glob 'game_object.rs' --glob 'game.rs' | head -n 360

Repository: phase-rs/phase

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- exact copy construction sites and parse_warnings/default object creation ---'
rg -n -C 9 '\.parse_warnings|parse_warnings:\s*(vec!\[\]|Vec::new\(\)|self\.[A-Za-z_0-9]+\.parse_warnings|#[A-Za-z_0-9]+\.parse_warnings|\{\})|new_object\(|create_object\(' crates/engine/src -g '*.rs' --glob '*copy*' --glob 'game_object.rs' --glob 'game.rs' --glob 'effects.rs' --glob '**copy*.rs'

printf '%s\n' ''
printf '%s\n' '--- CopyValues implementation ---'
file="$(rg -l 'struct CopyValues|impl .+CopyValues|enum CopyValues' crates/engine/src -g '*.rs' | head -n 5)"
for f in $file; do
  printf '%s\n' "FILE $f"
  rg -n -C 12 'struct CopyValues|impl .+CopyValues|enum CopyValues|copy_warnings|parse_warnings' "$f"
done

printf '%s\n' ''
printf '%s\n' '--- parser diagnostics copy into object ---'
rg -n -C 8 'parse_warnings' crates/engine/src/game crates/engine/src/types --glob '*.rs'

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- CopyValues implementation/definition ---'
rg -l 'struct CopyValues|impl .+CopyValues|enum CopyValues' crates/engine/src -g '*.rs'
while IFS= read -r f; do
  rg -n -C 20 'struct CopyValues|impl .+CopyValues|enum CopyValues|parse_warnings|copy_warnings|warnings' "$f"
done < <(rg -l 'struct CopyValues|impl .+CopyValues|enum CopyValues' crates/engine/src -g '*.rs')

printf '%s\n' ''
printf '%s\n' '--- object copy construction and parse_warnings only ---'
rg -n -C 12 'parse_warnings' crates/engine/src types crates/engine/tests --glob '*copy*.rs' --glob 'game_object.rs' --glob 'game.rs' --glob 'effects.rs' --glob '*clone*.rs' --glob '*copy*.rs'

Repository: phase-rs/phase

Length of output: 197


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '409,610p' crates/engine/src/game/game_object.rs
sed -n '960,1010p' crates/engine/src/game/game_object.rs
sed -n '1320,1410p' crates/engine/src/game/game_object.rs
sed -n '3503,3542p' crates/engine/src/ai_support/shortcut_efficacy.rs

printf '%s\n' '--- all visible CopyValues mentions tracked by git ---'
git ls-files 'crates/engine/src/**/*.rs' 'crates/engine/tests/**/*.rs' | xargs rg -n 'CopyValues|copiable|parse_warnings|BackFaceData::parse_warnings|apply_back_face_to_object|transform_permanent|create_object|new_object' | head -n 300

printf '%s\n' ''
printf '%s\n' '--- focused search for object construction fields around base/new object ---'
rg -n -C 10 'parse_warnings:\s*vec!\[\]|parse_warnings:\s*Vec::new\(\)|parse_warnings:' crates/engine/src/game crates/engine/src/types -g '*.rs'

Repository: phase-rs/phase

Length of output: 50371


Preserve unknown parse status instead of collapsing it to clean.

carries_unreadable_rules_content evaluates non-empty parse_warnings as MayInterfere, but serde makes missing fields and clean parses both deserialize as Vec::new(). Use an explicit Unknown versus Known(Vec<OracleDiagnostic>) state for GameObject::parse_warnings and BackFaceData::parse_warnings, or recompute diagnostics before shortcut_efficacy reads them; copies should remain fail-closed when provenance is unavailable.

🤖 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/game/game_object.rs` around lines 242 - 260, Change
GameObject::parse_warnings and BackFaceData::parse_warnings from plain vectors
to an explicit unknown-versus-known state so missing persisted diagnostics
remain Unknown rather than becoming clean. Update serde handling,
face-application copies, and carries_unreadable_rules_content/shortcut_efficacy
consumers to treat Unknown as fail-closed while preserving Known diagnostics
behavior.

Source: Path instructions

@lgray

lgray commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Evidence bound to f505f519e, so the two holds from your last pass are pre-satisfied:

  • All checks green at this SHA — queried per-commit (/commits/f505f519e.../check-runs), not by run name: both Rust shards, Rust lint (fmt, clippy, parser gate), Rust (fmt, clippy, test, coverage-gate), Card data, Frontend, Lobby worker, Tauri, WASM, security scan all success; Draft pools skipped. 11 pass / 0 fail. mergeStateStatus = CLEAN.
  • Parse-diff receipt regenerated at this headGenerated for head f505f519edc7483912cfc121e05bb625fe8dc0c5, "✓ No card-parse changes detected". Worth noting because this round did add a serialized field to BackFaceData; the Card-data job covers that axis and is green.

@matthewevans matthewevans self-assigned this Aug 10, 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.

Approved current head 0db0d6b.

The library-arrival gate now evaluates the actor-owned selectable pool using the resolver’s owner-zone filter authority, so the prior Bojuka Bog false-Accept is closed; the exact-head parse-diff reports no card parse changes. The transform fixture now seeds distinct front/back diagnostics and asserts both swaps, resolving the current CodeRabbit test-strengthening finding.

I also checked the open unknown-diagnostics suggestion: restored server sessions rehydrate printed faces from the card database before AI decisions, so an omitted compatibility field is not the live shortcut-efficacy provenance path. It does not block this focused change.

@matthewevans
matthewevans enabled auto-merge August 10, 2026 06:07
@matthewevans matthewevans removed their assignment Aug 10, 2026
@matthewevans
matthewevans added this pull request to the merge queue Aug 10, 2026
Merged via the queue into phase-rs:main with commit 55eb20b Aug 10, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants