fix(ai): stop proposing strategically vacuous loop-shortcut Shortens - #7101
fix(ai): stop proposing strategically vacuous loop-shortcut Shortens#7101lgray wants to merge 9 commits into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughThe PR adds conservative shortcut-efficacy classification for priority actions. Shortcut responses now use separate possibility and efficacy stages, including grouped sacrifice-for-mana actions and precast-copy responses. Tests cover confined actions, interaction, ownership, routing, and action-set coverage. ChangesShortcut efficacy evaluation
Supporting engine updates
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/engine/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
⛔ Files ignored due to path filters (1)
crates/engine/tests/fixtures/dina_noff_turn5_4p.json.gzis excluded by!**/*.gz
📒 Files selected for processing (5)
crates/engine/src/ai_support/mod.rscrates/engine/src/ai_support/shortcut_efficacy.rscrates/engine/src/game/engine_resolution_choices.rscrates/engine/tests/integration/main.rscrates/engine/tests/integration/shorten_efficacy.rs
| /// ponytail: a fetched permanent that itself enables interference is not | ||
| /// modelled. Using it needs a further priority window, and this design does NOT | ||
| /// claim one is guaranteed — CR 732.1b says only that the shortcut rules *can | ||
| /// be used* on a loop, and CR 732.2a makes proposing | ||
| /// permissive ("may suggest"). | ||
| /// Scope, stated on BOTH axes: | ||
| /// - across windows: a bounded miss — a seat's fetched answer goes unused for | ||
| /// THIS shortcut; | ||
| /// - within the window: the worst case is NOT bounded by "one shortcut". On | ||
| /// an `UntilLethal` offer the accepted sequence runs to lethal, so the | ||
| /// in-window cost of a missed out is elimination. | ||
| /// | ||
| /// Accepted because the miss requires the out to be reachable ONLY through the | ||
| /// fetched permanent; a directly-castable answer is already caught by the | ||
| /// top-level fold. Upgrade path: walk the fetched object's own abilities if a | ||
| /// real game shows a missed out. Owner: this lane, deferral burndown. | ||
| pub(crate) fn any_action_may_interfere(state: &GameState, actions: &[GameAction]) -> bool { | ||
| actions.iter().any(|action| match action { | ||
| GameAction::PassPriority => false, | ||
| GameAction::CastSpell { object_id, .. } => { | ||
| object_window_reach(state, *object_id).may_interfere() | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
A mana-producing spell classifies OwnResourcesOnly and can produce a false Accept.
The documented deferral covers only "a fetched permanent that itself enables interference". A second shape reaches the same false Accept and is not covered.
Effect::Mana returns OwnResourcesOnly at line 183. A ritual cast (for example, "Add {B}{B}{B}") heads that effect, so object_window_reach proves the CastSpell confined and any_action_may_interfere answers false.
Consider a polled seat holding a ritual and a Lightning Bolt, with no lands. The Bolt is not castable yet, so it never enters the flat priority list. The only enumerated non-pass action is the ritual cast, which classifies confined. Stage 2 returns Accept, and the seat loses a window in which it could have ramped into real interaction. On an UntilLethal offer that costs the game, which is the direction the module doc names as unacceptable.
This differs from the fetched-permanent deferral in one way that matters: mana produced during the window is spendable in the same window, so the miss does not need a further priority window to bite.
Two options:
- Classify a
CastSpellwhose reach folds toOwnResourcesOnlysolely throughEffect::ManaasMayInterfere. Mana production changes what the seat can afford, so it is not inert in the way a self-contained fetch is. - Keep the current verdict and widen the deferral note to name this shape and its in-window cost.
Option 1 is the fail-closed choice and matches the module's stated direction.
🛡️ Sketch for option 1
-/// ponytail: a fetched permanent that itself enables interference is not
-/// modelled. Using it needs a further priority window, and this design does NOT
-/// claim one is guaranteed — CR 732.1b says only that the shortcut rules *can
-/// be used* on a loop, and CR 732.2a makes proposing
-/// permissive ("may suggest").
+/// ponytail: a fetched permanent that itself enables interference is not
+/// modelled. Using it needs a further priority window, and this design does NOT
+/// claim one is guaranteed — CR 732.1b says only that the shortcut rules *can
+/// be used* on a loop, and CR 732.2a makes proposing
+/// permissive ("may suggest").
+///
+/// A mana-producing CAST is a distinct shape and is NOT deferred here: the mana
+/// is spendable in the SAME window, so a seat holding a ritual plus an
+/// as-yet-unaffordable answer would Accept by omission. Casting a spell that
+/// only adds mana is therefore treated as interference below.Then special-case the cast arm so a spell whose only confined verdict comes from Effect::Mana does not prove confinement.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/engine/src/ai_support/shortcut_efficacy.rs` around lines 424 - 445,
Update any_action_may_interfere and the CastSpell classification path so a spell
whose object_window_reach result is OwnResourcesOnly solely because of
Effect::Mana is treated as MayInterfere. Preserve confined classification for
non-mana self-contained effects, while ensuring mana-producing casts are
fail-closed and return true.
matthewevans
left a comment
There was a problem hiding this comment.
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.
|
Generated for head Parse changes introduced by this PR✓ No card-parse changes detected. |
e72a0c8 to
63cc3be
Compare
|
🤖 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 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 Note what that costs you, since it is my problem and not yours to discover: The test module is still registered in Your evidence re-checked at the current base, and it holds. The Fail-closed classification. The This also closes @coderabbitai's inline finding at Both regressions are response-level, not classifier-unit. Each drives a real 4p game dump through
Both discriminate on this fix specifically. Restoring the deleted arm flips both rows to No deficiency note stands in for the behaviour. Per-commit on Added and removed are split rather than summed, because the commit that does the most here removes The table names the live chain.
Five behavioural commits, not one — the count has grown twice since this line first said An earlier revision of this section said "every other production-file touch in the chain is zero The CR 701.21a note that remains is on a different axis ( Evidence holds you named
The adjacent case in the same class — closed, not disclosedThe untapped fetch is fixed in this PR — in the local chain; the remote branch is still The same call was made three more times, on the same reasoning. A review found that
The flagship set was re-derived from the final predicate rather than patched, and it moved. What this changes at today's card pool. Two things make this number easy to get wrong, and I got
Re-measured at the enforcement surface — 22,717 top-level abilities. Every figure below is
Read the two right-hand columns together, because they are the whole argument. 2449 of 2600 Object-level, on top of the above. A review round found that A later round measured that "the three collections" was itself too narrow by eight fields: The instrument is a twin of Why "conservative" here is a structural guarantee and not a hope. Each edit either adds a Every gate is revert-probed, and each probe is reported with the assertion it landed on. "The
The last two are the pair worth reading. Cutting the split conjunct reds the split row and nothing Which conjuncts are inert at today's pool, stated rather than left for you to discover:
Four serialization details make a census here easy to get vacuously right, and I name them because 2. This does not manifest at the tip being pushed — the comparator there is 0/0/0/0 with both Already fixed upstream — recording it only so the earlier artifacts in this thread are readable. One process noteAn instruction on my side of this work asked that the existing private helper
Happy to do the relocation as a separate change if the reuse is wanted for future rows. Known and measured — disclosed rather than heldThese are open items I chose to surface instead of holding the branch for another internal round.
|
|
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
I also confirmed both named integration tests exist and assert
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. |
`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
63cc3be to
545c840
Compare
|
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
Both action paths use the updated classification:
Both response-level regressions exist and assert
The original inline finding is addressed. ✏️ Learnings added
You are interacting with an AI system. |
|
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
The two action paths use this classification:
Both response-level regressions exist and assert
The high-priority finding is addressed. 🐇 ✏️ Learnings added
You are interacting with an AI system. |
|
Tip For best results, initiate chat on the files or code changes.
The two response-level regressions cover both required paths:
The updated classifier also keeps unknown or unreadable rules content conservative. This prevents the shortcut logic from treating incomplete classifier visibility as proof of Thank you for the detailed evidence and for distinguishing the measurement SHAs from the current pushed head. 🐇 ✏️ Learnings added
You are interacting with an AI system. |
matthewevans
left a comment
There was a problem hiding this comment.
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.
🤖 AI text below 🤖
Summary
smart_shortcut_responsedocumented "a way to break the loop" but calledhas_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 answeredShortenagainst 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: theWindowReachclassifiercrates/engine/src/ai_support/mod.rs— stage-2 wiring,stage_two_action_set, sharedshortcut_probecrates/engine/src/game/engine_resolution_choices.rs— citation-gate enrolment floor 6 → 8 (test-side)crates/engine/tests/integration/shorten_efficacy.rs— new: acceptance rowscrates/engine/tests/integration/main.rs—mod shorten_efficacy;crates/engine/tests/fixtures/dina_noff_turn5_4p.json.gz— new: the real 4p dump the acceptance rows driveTrack
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.txtbefore 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
All 9 completion checks ran at the committed head in a clean detached worktree, each with
head_before == head_after == e72a0c8fand porcelain 0, recorded in the run receipt (sha25625ca6711dcc09a4dfa849f25cb4ac7275be8a053133642061b33931a74b06ee5, 61/61 artifact digests re-verified from disk):cargo fmt --all -- --check— exit 0cargo clippy -p phase-engine --all-targets -- -D warnings— exit 0, 0 warningscargo 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 refreshedRead the ai-gate result honestly: it is green but non-probative for this seam. The suite provably never executes the changed code —
duel_suitebuilds every state throughGameState::new_two_player→GameState::new, which initialisesloop_detection: LoopDetectionMode::Off, andphase-ainever reaches the one projecting authority (GameState::set_match_config):MatchConfig0 hits inphase-aivs 88 incrates/. All fiveWaitingFor::LoopShortcutassignments inphase-aiare#[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 recordsgit_sha: 2d686880b94b, which does not resolve (git cat-file -t→fatal: 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::comparereads neither provenance field, while its siblingduel_suite/perf.rsalready implements exactly that guard.Parser evidence:
parser_evidence=PROJECTED_PARSE_DIFF(forced bySOURCE_HASH_DIFFERENCE, base9e829d1edfe972b8→ candidate4043f185c0f90c61). Projectedcard-data.jsonis byte-identical on both sides (953a51b2…66784); the projection is not a stale-binary artifact — the twooracle-genbinaries differ (2b920770…vs1586b035…) and so doescoverage-data.json(828cefde…vs2418b06e…).Acceptance rides a real 4-player dump (844846 B, sha256
9843d5165cbbf7dd7bca4171c7888c190b7eba7e52a2ed095b44ff76fadd7886) driven through the production restore andapply(), not a synthetic scenario.Gate A
Gate A PASS head=e72a0c8f6a04e52dce55686cc267760abe312cdc base=674b3a999573e88df8ec120805ff2a7730b6f64e
Anchored on
crates/engine/src/ai_support/mod.rs:1136—flat_actions_have_meaningful_priority, the stage-1 predicate whose action set stage 2 must cover;stage_two_action_setis built from the same predicate so the two cannot drift apartcrates/engine/src/game/ability_scan.rs:4268—ability_definition_axes, the existing classifier-at-a-seam precedent this follows; the new wildcard arm is deliberatelyMayInterfere, the opposite of that default, because a wrong Accept can lose a game while a wrong Shorten costs a beatFinal 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, 0added_cards, 0removed_cards.Scope Expansion
None.
Frozen scope is 6 paths, byte-matching the run's
scope-paths.nul(sha2564b4b68b17216252c0c633d0dad912ef1e62efcd6dbd8f00fb3d9953ed8388634) and equal togit diff --name-only -z BASE CANDIDATE.Validation Failures
None.
Two disclosures that are not failures but which a reviewer should not have to discover:
game::engine::no_living_player_has_meaningful_priority_actionstill inlines the same seven-statement probe recipe rather than routing through the new sharedai_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.any_action_may_interferedocuments one accepted miss with its cost: a fetched permanent that itself enables interference is not modelled, so a confined fetch classifiesOwnResourcesOnlyeven 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 anUntilLethaloffer. Measured witness for the related parser residual:Invoke Justice, whose "distribute four +1/+1 counters … target player controls" clause is dropped with noUnimplementedmarker.CI Failures
None.
This branch is based at
674b3a999, 12 commits behindupstream/main. Rebase deferred deliberately: the only two paths overlapping upstream's drift areengine_resolution_choices.rsandtests/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 keepenrolled >= ENROLLED_FLOOR(8) satisfied after any rebase, andgit merge-treereports no conflicts.Summary by CodeRabbit
New Features
Bug Fixes
Tests