fix(engine): make the CR 603.4 hoist binding classifiers fail closed - #7491
fix(engine): make the CR 603.4 hoist binding classifiers fail closed#7491JacobWoodson wants to merge 3 commits into
Conversation
…hase-rs#7406) `filter_binding_diverges` and `gate_binding_diverges_at_fire_time` both ended in `_ => false`. In this module `false` means "both legs of the CR 603.4 hoist read this identically, so hoisting is safe", so every unclassified variant was silently asserted to be reproducible at fire time. That is fail-open in the destructive direction: a wrong `true` costs a conservative re-check, a wrong `false` gates the ability off the stack and, for a consumed one-shot, deletes it outright (`false_gate_consumes_one_shot`). The same tail on the sibling `QuantityRef` axis was already found wrong in practice. Both are now exhaustive and wildcard-free, matching the three sibling classifiers, so a new variant fails to compile until it is adjudicated. `filter_binding_diverges` newly declines the resolution-published population families the tail swallowed: the `last_*_ids` anaphora, tracked sets, the CR 607.2a linked-exile population and its order, the CR 609.7a chosen damage source, the CR 615.5 post-replacement window, and the CR 608.2k cost-paid referent. `Typed` also gained the CONTROLLER axis, adjudicated by a new `controller_ref_binding_diverges`. `ControllerRef::TargetPlayer` / `TargetOpponent` / `ParentTarget*` / `ChosenPlayer` / `ScopedPlayer` all read `ability.targets` / `chosen_players` / the per-iteration player, which the fire-time `FilterContext` (built with `ability = None`, `targets = &[]`) does not carry — it silently re-scopes the same printed population to the triggering player instead. That axis was reachable through every `Typed` filter while the arm read only `FilterProp::Another`. `gate_binding_diverges_at_fire_time` answers `false` or recurses for every arm `ability_condition_to_static_condition` can bridge today, so that half is pure hardening with no behaviour change; the resolution-scoped arms answer `true` on their own reading so widening the bridge cannot re-open the hole. The `FilterProp` payload axis of `Typed` is deliberately still out of scope and documented as such. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Caution Review failedAn error occurred during the review process. Please try again later. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Generated for head Parse changes introduced by this PR✓ No card-parse changes detected. |
matthewevans
left a comment
There was a problem hiding this comment.
Request changes — the TargetPlayer classifier is not grounded in the fire-time data model.
🔴 Blocker
[HIGH] TargetPlayer is treated as fire-time-safe even though its authority is a player target on the resolved ability. Evidence: crates/engine/src/game/triggers.rs:22065-22070 constructs ResolvedAbility with vec![] targets and claims the fire-time path would count the triggering player's creatures; crates/engine/src/game/filter.rs:1461-1467 instead resolves TargetPlayer (and TargetOpponent) solely from an actual TargetRef::Player in ability.targets. Why it matters: the fixture never exercises target-bound resolution, so it cannot establish that the classifier preserves or correctly declines the target-player population at fire time. Suggested fix: use a TargetRef::Player fixture with creature populations that distinguish trigger controller from target, and demonstrate both that fire-time evaluation has no target and that resolution reads the target-bound population; classify this controller reference conservatively until that behavior is represented correctly.
✅ Clean
The parse-diff artifact is bound to 925dfa959ac2a0d43b6ede45cc894a7af5d65934 and reports no card-parse changes.
Recommendation: request changes with the target-bound runtime proof and conservative classification above before reconsidering this hardening PR.
…board Review feedback on phase-rs#7491: the `ControllerRef::TargetPlayer` half of `resolution_published_population_gate_declines_the_fire_time_hoist` rode the shared `run` fixture, which builds its `ResolvedAbility` with `targets: vec![]`. With no player target on either leg, both the fire-time and resolution-time readings fall through to the same triggering-player population, so the row could show the decline happening but never that declining PRESERVES a correct outcome. Its assertion message claimed a divergence the board did not exhibit. Adds `run_target_bound`, which binds a real `TargetRef::Player` and splits the boards so the two legs genuinely disagree: * the TARGET (P1) controls two creatures -> resolution gate TRUE * the controller / triggering player (P0) has none, and `ZoneChangeRecord::test_minimal` pins the event's controller to P0, so the fire-time `Typed` arm falls back to P0 -> fire-time gate FALSE It then resolves the survivor and asserts `monarch == Some(P0)`, which is the part the old row could not state: a hoist here does not re-check, it DELETES a one-shot whose resolution-time gate was true. Confirmed discriminating by reclassifying `ControllerRef::TargetPlayer` as non-divergent and re-running: `stack` drops 1 -> 0. Also corrects the `TargetPlayer` / `TargetOpponent` comment in `controller_ref_binding_diverges`. It said both arms "fall back to the TRIGGERING player", which holds for `filter_inner_for_object`'s `Typed` arm but not for `filter::controller_ref_player`, which has no such fallback and answers `None`. Both readings diverge from the target-bound one; the comment now says so per site. No classification changed: `TargetPlayer` was already `true` (declines the hoist), which is the conservative answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — the evidence pointed at a real hole in the test, fixed in 36bcd7d. One correction on the framing first, because it changes what the fix needed to be. The classification was already the conservative one
It isn't. The test criticism is correct, and was the actual defectThe fixture genuinely could not establish what its assertion message claimed. Fixed with a dedicated
It then resolves the survivor and asserts Confirmed discriminating, rather than assumed: reclassifying Your
|
|
Held pending current-head CI evidence. The previous Approval and merge-queue enrollment are paused because the available check-artifact downloads for this head are failing externally with HTTP 429, and the parse-diff sticky comment is still bound to prior head |
matthewevans
left a comment
There was a problem hiding this comment.
Request changes — the current head is still not fail-closed across the Typed filter surface.
🔴 Blocker
crates/engine/src/game/triggers.rs:10715-10721 explicitly leaves the FilterProp payload axis unadjudicated, while filter_binding_diverges at :10727-10735 returns false for every property except Another. That includes payloads such as SharesQuality, InTrackedSet, and SameNameAsParentTarget that can depend on a nested/resolution-bound referent. A delayed trigger can therefore still hoist a condition whose fire-time and resolution-time populations differ—the failure mode this PR is meant to eliminate.
Extend the same authority with a recursive property-level divergence classifier (including nested filters and resolution-bound referents), then add a target-bound production-path regression that fails when that classifier is removed. The existing TargetPlayer case is a good shape, but it does not cover the omitted property axis.
✅ Clean
The current TargetPlayer fixture does repair the previous review’s specific target-binding test gap: it reaches the real delayed-trigger path and distinguishes the two player populations.
Recommendation: request changes — complete the Typed property axis before claiming the hoist classifiers fail closed.
Fixes #7406.
The defect
filter_binding_divergesandgate_binding_diverges_at_fire_time(crates/engine/src/game/triggers.rs) both ended in_ => false. In this modulefalsemeans "both legs of the CR 603.4 hoist read this identically, so hoisting is safe", so every unclassified variant was silently asserted to be reproducible at fire time.That is fail-open in the destructive direction. A wrong
truecosts a conservative re-check; a wrongfalsegates the ability off the stack and, for a consumed one-shot, deletes it outright (false_gate_consumes_one_shot). The same tail on the siblingQuantityRefaxis was already found wrong in practice in #7389, which is why that one was made exhaustive there and this one was split out.Both classifiers are now exhaustive and wildcard-free, matching
object_scope_unbound_at_fire_time/player_scope_unbound_at_fire_time/quantity_ref_binding_diverges, so a new variant fails to compile until it is adjudicated.filter_binding_diverges— all 54TargetFiltervariantsNewly declining, i.e. the families the tail swallowed:
LastCreated,LastRevealed,LastZoneChanged,TrackedSet,TrackedSetFilteredQuantityRef::TrackedSetSizealready declines under.ExiledBySource,ExiledCardByIndexQuantityRef::CardsExiledBySource/CardTypeSetSource::ExiledBySourceverdicts.CostPaidObjectResolvedAbility, which isNoneat fire time — the population-level counterpart ofObjectScope::CostPaidObject.ChosenDamageSource,PostReplacementSourceController,PostReplacementDamageSource,PostReplacementDamageTarget,PostReplacementDamageTargetOwnerEverything else is adjudicated non-divergent with its reasoning inline: literals and snapshots, controller-derived players, source-relative reads served by the
TriggerSourceContext, durable per-source/per-player choices, and the matched-event referents (which both legs read through thecurrent_trigger_event-or-DETECTION_TRIGGER_EVENTdual path — the same argumentObjectScope::EventTargetandPlayerScope::DefendingPlayerare already non-divergent under).A second fail-open inside the arm that was already there
TargetFilter::Typedread onlytf.propertiesforFilterProp::Anotherand ignoredtf.controllerentirely.ControllerRef::TargetPlayer/TargetOpponent/ParentTargetController/ParentTargetOwner/ChosenPlayer/ScopedPlayerall readability.targets,ability.chosen_playersor the per-iteration player. The fire-timeFilterContextis built withability = Noneandtargets = &[], soTargetPlayersilently falls back to the triggering player and counts a different population — with no gate rejection to catch it. That axis is reachable through everyTypedfilter in the engine, which makes it the widest door into the hoist decision.New
controller_ref_binding_divergesadjudicates all 14 variants;TypedandStackAbilityboth consult it. This is slightly beyond the issue's literal text, but the issue asks each variant to be justified against the module's one question, andTypedcannot be answered honestly without it.gate_binding_diverges_at_fire_time— all 57AbilityConditionvariantsEach is adjudicated on its own reading rather than on "does the bridge pass it". The old tail was inert only because
ability_condition_to_static_conditionhappens to decline the arms it covered — a claim about a different function, silently re-underwritten every time that bridge grows an arm.Every arm the bridge passes today (
IsYourTurn,CompletedDungeon { specific: None },SourceAttachedToCreature,ControlsCommander,QuantityCheck,Not) answersfalseor recurses, so this half is a pure hardening change with no behaviour difference — pinned by a test.SourceMatchesFilter/ControllerControlsMatching/WasStartingPlayernow recurse into their filter and controller payloads.Deliberately out of scope
The
FilterProppayload axis ofTyped— ~90 variants, several carrying nestedTargetFilters and resolution-scoped referents. It is documented as unadjudicated in the function's doc comment rather than left implicit, and wants its own pass.Behaviour change
Only in the conservative direction: the shapes above stop being hoisted and keep today's resolution-only reading, costing CR 603.4's fire-time half for those gates. No hoist that happens today stops happening for any other reason, and nothing newly hoists.
Verification
cargo fmt --allclean;cargo clippy -p phase-engine --all-targets -- -D warningsclean.docs/MagicCompRules.txtbefore being written.resolution_published_population_gate_declines_the_fire_time_hoistdrives two production minimal pairs (ObjectCount{F} >= 2, differing only inF) plus per-family unit pins, and asserts the four payload-free bridging gates still hoist.divergent_gate_bindings_decline_the_fire_time_hoist,non_battlefield_presence_gate_declines_the_fire_time_hoist,resolution_scoped_quantity_gate_declines_the_fire_time_hoist— pass unchanged and still assert what they intend. They scope their populations withControllerRef::You, which remains non-divergent, so the new controller screen does not silently relax them.Not checked against real cards:
card-data.jsonis gitignored and not generated in this worktree. The issue records that no card is known to reach a misclassified filter, and every change here declines rather than admits a hoist, so the worst case is a delayed trigger losing CR 603.4's fire-time half rather than one deleted off the stack.🤖 Generated with Claude Code