Skip to content

fix(ai): answer selection prompts from the engine's issued domain (#6942) - #6964

Merged
matthewevans merged 1 commit into
mainfrom
fix/6942-ai-empty-selection
Aug 3, 2026
Merged

fix(ai): answer selection prompts from the engine's issued domain (#6942)#6964
matthewevans merged 1 commit into
mainfrom
fix/6942-ai-empty-selection

Conversation

@matthewevans

@matthewevans matthewevans commented Aug 3, 2026

Copy link
Copy Markdown
Member

Fixes #6942.

The bug

fallback_action returned SelectCards { cards: Vec::new() } for a group of WaitingFor variants, under the comment "empty selection is a valid 'choose nothing'". For six of them the engine rejects an empty selection unconditionally, so the AI proposed an action that could never be accepted and the game stalled. CR 608.2d: "The player can't choose an option that's illegal or impossible."

The issue reported five variants. Opening every handler found six, four of which the report never named.

Why the obvious fix would have been wrong

The arm made a blanket cardinality assumption across variants whose contracts differ — and for several the answer is data-dependent at runtime (up_to, optional) rather than a property of the variant. Statically reclassifying them would have traded a softlock for a wrong-decision bug: the legitimate up_to == true cases genuinely should answer empty.

So this does not classify at all. It delegates to the engine's own issued candidate set via build_decision_context_for_semantic_owner — already the authority the gating contract uses. The AI stops restating a cardinality rule it was getting wrong and answers from the domain the engine published.

That removes the second hand-maintained list whose disagreement with the first caused the bug. Two prior drafts mirrored a pub(crate) enumerator into phase-ai instead; one carried an implementer note reading "must mirror candidates.rs exactly", which is the defect recreating itself.

The seat

The contract is issued for a specific seat, so the answer must be built for contract.semantic_owner, threaded through fallback_action. An earlier draft derived it from acting_player(), which diverges from the contract's seat — the fix would have failed precisely in the multi-seat case it exists to serve. The MulliganDecision arm's dispatch is moved off pending.first() for the same reason, mirroring the already-correct form elsewhere in the file.

Engine-side: an enumerator/handler disagreement

The candidate enumerator emits keep_count.min(selectable.len()) for DigChoice while the handler demanded exactly keep_count — so a dig with fewer legal cards than its keep count was unanswerable. Clamped in the handler, which is the defective side (CR 609.3, CR 101.3). This changes engine behaviour, not just AI behaviour, so the four DigChoice integration tests were run by name: issue_4271_birthing_ritual_cmc_filter, gonti_lord_of_luxury_exiles_dug_card, fertile_thicket_reveal_to_top_2349, explore_all_doubler_ordering — all pass.

Deliberately not converted

MultiTargetSelection is a confirmed member of this defect class and is excluded anyway. The enumerator destructures min_targets and discards max_targets, issuing all legal targets at once, so at a min:1/max:1 site with more than one legal target its only non-empty candidate is itself illegal. Delegating would swap a contract-refusing action for a contract-passing, engine-rejected one. At exactly one legal target delegation would be correct, but the arm cannot distinguish, and a singleton-only fix is not a class fix. Filed as follow-up rather than half-fixed.

Scoped claim, stated precisely: this converts every arm answering with an unconditional empty SelectCards, except MultiTargetSelection — 4 of the 12 SelectCards-answering arms in fallback_action. The other seven synthesize a cardinality-plausible answer and are outside the empty-selection half of this issue.

Verification — per-row baselines, because bare base is not one

Threading the contract changes fallback_action's arity, and phase-ai is a single compile unit, so most rows cannot be observed at bare base at all — a compile error is not an observed red. Each row states its own baseline.

  • Window A (base + Steps 1 & 2): 9 rows red, every failure the exact mechanism — left: 0 against the expected count, plus DigChoice: the escape emitted SelectCards { cards: [] }, which the gating contract refuses.
  • Window B (full fix minus only the one-line seat source): 2070 passed, 1 failed — T11 alone, reporting "the arm dispatched on the first pending entry's phase instead of the contract's seat". T10 passes on that same tree, independently confirming T10 is blind to this axis rather than us asserting it. This is the only tree that isolates the seat defect; at base + 1 + 2 T11 would go red merely because the mulligan step is absent.
  • Full suites on the shipped bytes: test-ai 2071/2071, test-engine 22806/22806, clippy clean.

Deviation: the plan mispredicted its own test matrix

The plan's T8 row says "fails at base — NO". That is wrong. Observed at base + Steps 1 + 2, fallback_action returned Some(SelectCards { cards: [] }), not None, so T8 failed with left: Some(SelectCards { cards: [] }), right: None and does discriminate. The consequent correction: T3 is the only genuine both-directions reach-guard, not one of two. The surprise is in the safe direction — more discrimination than promised — but a plan that mispredicts which of its own tests discriminate is worth recording either way.

T2 was also upgraded to collect all offending rows rather than abort on the first, so its failure reports a census rather than a sample: 14 of 16 rows, where the plan predicted 11.

CR annotations — two substitutions

Two of the plan's citations named real rules that do not describe the code. A wrong-but-real citation is worse than none: it creates false confidence that the code was validated, and it survives review because reviewers check code against the stated premise rather than fact-checking the premise.

  • Step 3: dropped CR 601.2 (the casting procedure — nothing about resolution-time choice cardinality); kept CR 608.2d, which states the rule verbatim.
  • Step 4: the plan's CR 605.1a + CR 605.3b for "a mana ability's cost is not optional" is a false attribution — 605.1a defines what a mana ability is, 605.3b says it doesn't use the stack. Replaced with CR 118.1 + CR 118.3, quoted inline; 605.1a is retained only to name the ability class, and the comment now says so explicitly.

All 13 numbers grep-verified, zero UNVERIFIED.

Notes

  • The delegation match is exhaustive with no wildcard — verified by extracting the arm range and grepping for 8-space-indented _ arms (none); the tail arm is preserved verbatim. A future WaitingFor variant cannot silently inherit the wrong default, which is the defect class itself.
  • cargo ai-gate was not run locally. Step 5 changes a live AI decision path, so CLAUDE.md requires the paired-seed gate. It is not run here because crates/phase-ai/** is a trigger path for .github/workflows/ai-gate.yml, so the paired-seed run fires automatically on this PR and is the authoritative result.
  • Four review rounds. Round 4's blocker was a doc-only census undercount that had been miscounted in three consecutive rounds; the corrected 12-arm table is in the plan.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed card-selection prompts that could become impossible when fewer cards matched the required filter. Players can now select all eligible cards and complete the action correctly.
    • Improved automated decision-making so fallback choices remain valid and comply with available options, including mulligans, discard decisions, and payment selections.
    • Preserved filter, ownership, and selection rules when handling incomplete or constrained choices.

)

The AI fallback returned `SelectCards { cards: Vec::new() }` for a group of
`WaitingFor` variants under the comment "empty selection is a valid 'choose
nothing'". For six of them the engine rejects an empty selection
unconditionally, so the AI proposed an action that could never be accepted
and the game stalled. CR 608.2d: a player can't choose an option that's
illegal or impossible.

The arm made a blanket cardinality assumption across variants whose contracts
differ, and several are data-dependent at runtime (`up_to`, `optional`)
rather than a property of the variant. A static reclassification would have
traded a softlock for a wrong-decision bug.

So do not classify at all. Delegate to the engine's own issued candidate set
via `build_decision_context_for_semantic_owner`, which is already the
authority the gating contract uses. The AI stops restating a cardinality rule
it was getting wrong and answers from the domain the engine published --
removing the second hand-maintained list whose disagreement with the first
caused this. Two prior drafts mirrored a `pub(crate)` enumerator into
phase-ai instead; that would have recreated the defect.

The seat is `contract.semantic_owner`, threaded through `fallback_action`.
Deriving it from `acting_player()` -- as an earlier draft did -- diverges
from the seat the gating contract was issued for, so the fix would have
failed precisely in the multi-seat case it exists to serve. The
`MulliganDecision` arm's dispatch is moved off `pending.first()` for the same
reason, mirroring the existing correct form.

Also fixes the engine side of an enumerator/handler disagreement: the
candidate enumerator emits `keep_count.min(selectable.len())` for
`DigChoice` while the handler demanded exactly `keep_count`, so a dig with
fewer legal cards than its keep count was unanswerable. Clamped in the
handler, which is the defective side (CR 609.3, CR 101.3).

`MultiTargetSelection` is a confirmed member of this defect class and is
deliberately NOT converted: the enumerator discards `max_targets` and issues
all legal targets at once, so at a min:1/max:1 site with more than one legal
target its only non-empty candidate is itself illegal. Delegating there would
swap a contract-refusing action for a contract-passing, engine-rejected one.
Filed as follow-up rather than half-fixed.

Every test's revert baseline is stated per row, because most cannot be
observed at bare base at all -- threading the contract changes
`fallback_action`'s arity and phase-ai is one compile unit. T11 in
particular was observed red on the full fix MINUS ONLY the one-line seat
source, which is the only tree that isolates the seat defect; at base+1+2 it
would go red merely because the mulligan step is absent.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The engine now accepts all filter-eligible cards for impossible non-up_to Dig counts. AI fallback actions now use issued decision-contract candidates for selection cardinality, ownership, discard ordering, mana payments, and mulligans. Integration and regression tests cover these behaviors.

Changes

Selection validation

Layer / File(s) Summary
Dig card selection validation
crates/engine/src/game/engine_resolution_choices.rs, crates/engine/tests/integration/dig_impossible_keep_count.rs, crates/engine/tests/integration/main.rs
Non-up_to Dig choices clamp the required count to eligible cards. Tests cover accepted eligible selections, rejected filtered cards, destinations, and prompt resolution.
Contract-driven AI fallback
crates/phase-ai/src/search.rs
fallback_action uses AiDecisionContract candidates for selection, mulligan, mana-payment, and discard actions.
Fallback regression coverage
crates/phase-ai/src/search.rs
Tests cover contract membership, exact cardinality, empty domains, up_to selections, mulligan ownership, and engine application.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GameState
  participant AiDecisionContract
  participant fallback_action
  participant Engine
  GameState->>AiDecisionContract: issue selection candidates
  AiDecisionContract->>fallback_action: provide contract and candidates
  fallback_action->>AiDecisionContract: choose an issued selection
  fallback_action->>Engine: return fallback GameAction
  Engine->>GameState: validate and apply selection
Loading

Possibly related PRs

  • phase-rs/phase#6741: Both changes update fallback_action to produce valid, contract-aware selections.
  • phase-rs/phase#6829: This PR extends decision-contract enforcement for AI fallback actions.
  • phase-rs/phase#6785: Both changes modify AI selection fallback validation in crates/phase-ai/src/search.rs.

Suggested labels: bug

Suggested reviewers: lgray, andriypolanski, claytonlin1110

🚥 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 describes the primary change: AI selection prompts now use the engine's issued domain.
Linked Issues check ✅ Passed The changes address issue #6942 by using issued candidate domains, avoiding invalid empty selections, and fixing the DigChoice cardinality mismatch.
Out of Scope Changes check ✅ Passed The implementation and regression tests remain within the linked issue scope of AI selection fallback and DigChoice validation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/6942-ai-empty-selection

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: 2

🤖 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/engine_resolution_choices.rs`:
- Around line 2991-3002: Correct the CR citation in the DigChoice validation
comment so it references CR 609.3 only (or another rule that directly supports
the behavior), removing the inaccurate CR 101.3 reference. Apply the same
citation correction in
crates/engine/tests/integration/dig_impossible_keep_count.rs lines 5-7; both
sites document the “do as much as possible” handling.

In `@crates/phase-ai/src/search.rs`:
- Around line 1830-1844: Update the WaitingFor::PayCost arm for
CostResume::ManaAbility to select the smallest issued card selection whose
cards.len() is at least the required count, preserving cmp_stable tie-breaking.
Add a sibling helper to issued_selection for this filtered ordering, with
fallback to issued_selection when no qualifying candidate exists, and use it
with the in-scope PayCost count.
🪄 Autofix (Beta)

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: 508ca032-5d1b-49e0-8d0c-e43e8fe5a364

📥 Commits

Reviewing files that changed from the base of the PR and between 1738d5c and d332f48.

📒 Files selected for processing (4)
  • crates/engine/src/game/engine_resolution_choices.rs
  • crates/engine/tests/integration/dig_impossible_keep_count.rs
  • crates/engine/tests/integration/main.rs
  • crates/phase-ai/src/search.rs

Comment on lines +2991 to +3002
} else {
// CR 609.3 + CR 101.3: a dig whose filter (or a short library)
// leaves fewer selectable cards than `keep_count` must keep as
// many as possible, not reject every selection. Without the
// clamp no legal action exists in that state —
// `validate_dig_selection` below requires every kept id to be in
// `selectable_cards` while this gate demands more ids than it
// holds — softlocking every controller. Matches the clamp the
// candidate enumerator (`ai_support/candidates.rs:1185`) and
// `cheap_reject_candidate` (`ai_support/mod.rs:702`) already
// apply.
let required = keep_count.min(selectable_cards.len());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Correct the duplicated CR 101.3 citation.

CR 101.3 defines “card.” It does not support impossible-instruction handling. CR 609.3 supports the “do as much as possible” behavior. (media.wizards.com)

  • crates/engine/src/game/engine_resolution_choices.rs#L2991-L3002: remove or replace the CR 101.3 citation in the DigChoice validation comment.
  • crates/engine/tests/integration/dig_impossible_keep_count.rs#L5-L7: remove or replace the matching CR 101.3 citation in the test documentation.

As per path instructions, flag a CR citation whose rule body does not describe the code.

📍 Affects 2 files
  • crates/engine/src/game/engine_resolution_choices.rs#L2991-L3002 (this comment)
  • crates/engine/tests/integration/dig_impossible_keep_count.rs#L5-L7
🤖 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/engine_resolution_choices.rs` around lines 2991 -
3002, Correct the CR citation in the DigChoice validation comment so it
references CR 609.3 only (or another rule that directly supports the behavior),
removing the inaccurate CR 101.3 reference. Apply the same citation correction
in crates/engine/tests/integration/dig_impossible_keep_count.rs lines 5-7; both
sites document the “do as much as possible” handling.

Source: Path instructions

Comment on lines +1830 to +1844
// carry PendingManaAbility, so CancelCast is not valid here.
//
// CR 605.1a names the CLASS this arm matches (what makes an activated
// ability a mana ability); it is NOT the reason the cost must be paid.
// CR 118.1 + CR 118.3 are: "to pay a cost, a player carries out the
// instructions specified", and "a player can't pay a cost without
// having the necessary resources to pay it FULLY". So a mana ability's
// cost is not optional, and every mana-ability cost handler demands
// exactly `count` (`mana_abilities.rs:1130` tap / `:1161` exile /
// `:1215` sacrifice / `:1267` discard) — an empty selection is rejected
// in all four (#6942). Answer out of the contract instead.
WaitingFor::PayCost {
resume: CostResume::ManaAbility { .. },
..
} => Some(GameAction::SelectCards { cards: Vec::new() }),
} => issued_selection(contract),

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The sacrifice-cost mana ability still softlocks under this arm.

issued_selection prefers the smallest issued SelectCards. The doc comment at Lines 871-875 records that the enumerator for PayCost { kind: Sacrifice, resume: ManaAbility } issues sizes min_count..=count while the handler demands exactly count. For that shape the smallest issued candidate has min_count cards, so the handler rejects it and the AI keeps re-proposing — the exact #6942 failure mode this arm is meant to remove.

This arm has the PayCost fields in scope. Select the smallest issued selection whose length is at least count instead of the global minimum, so the sacrifice shape is answered too. Alternatively, correct the engine enumerator to emit only size count for the sacrifice cost kind.

Do you want me to open a follow-up issue that tracks the sacrifice-cost enumerator/handler mismatch?

🐛 Sketch: honor the prompt's required count for mana-ability costs
         WaitingFor::PayCost {
             resume: CostResume::ManaAbility { .. },
+            count,
             ..
-        } => issued_selection(contract),
+        } => issued_selection_with_min_len(contract, *count),

Add a sibling helper next to issued_selection that applies the same
smallest-then-cmp_stable ordering over candidates whose cards.len() >= min_len,
and falls back to issued_selection when no such candidate is issued.

🤖 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/phase-ai/src/search.rs` around lines 1830 - 1844, Update the
WaitingFor::PayCost arm for CostResume::ManaAbility to select the smallest
issued card selection whose cards.len() is at least the required count,
preserving cmp_stable tie-breaking. Add a sibling helper to issued_selection for
this filtered ordering, with fallback to issued_selection when no qualifying
candidate exists, and use it with the in-scope PayCost count.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Generated for head d332f482a5ce2bbf839c63194756f05220892fc1.

Parse changes introduced by this PR

✓ No card-parse changes detected.

@matthewevans

Copy link
Copy Markdown
Member Author

AI gate produced no verdict — disclosing rather than treating this as green

Both Paired-seed AI gate and Decision-cost perf gate on this PR were CANCELLED at the timeout-minutes: 60 wall, not passed. I am not counting them as evidence.

This is not specific to this PR. Across the 58 most recent completed AI gate runs (oldest 2026-07-31) there are 0 successes — 49 cancelled, 9 failed — spanning 14 unrelated branches including main. Filed as #6967 with the step timings. This branch cannot fix that, and waiting on it is unbounded.

CLAUDE.md requires an ai-gate report for AI behavior changes. That requirement is currently unsatisfiable in CI, so I am stating what I can evidence instead of implying coverage I do not have.

Why the strength risk is bounded by construction

The production delta is ~180 lines; 816 of the 973 added lines in search.rs are tests, plus a 113-line integration test. The live change is confined to fallback_action and deterministic_choice.

In deterministic_choice, the new code ranks the engine's issued SelectCards actions by the same cmp_keep order the old code used to synthesize one. The consequence matters:

when the previously-synthesized ideal pick is in the issued set, its rank vector is [0, 1, …, count-1] — minimal — so the new code selects the identical action.

Divergence is therefore only possible in states where the synthesized pick was not issued. Those are exactly the states this PR fixes, where the old behavior was an unusable action that skipped the fallback_action escape and degraded the whole decision to "no action". There is no previously-working state in which this changes the chosen play.

Confidence

  • Confirmed: the gate outcomes and repo-wide statistics (GitHub Actions API); the test/production line split; the ranking argument above, read from the diff.
  • Not measured: I have not run cargo ai-gate locally, so this is an argument from the code, not a paired-seed measurement. If the gate is restored and shows a regression here, this reasoning is what should be checked first.
  • What would falsify it: an issued-set ordering where a cmp_keep-optimal pick is present but does not receive the minimal rank vector.

Required checks (Rust (fmt, clippy, test, coverage-gate), Frontend (lint, type-check, test)) are both green; the AI gate is not a required check and the merge-queue ruleset adds none. Proceeding on that basis, with the gap recorded here rather than left implicit.

@matthewevans
matthewevans added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit 7a1b357 Aug 3, 2026
17 of 19 checks passed
@matthewevans
matthewevans deleted the fix/6942-ai-empty-selection branch August 3, 2026 15:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AI softlock: fallback emits empty SelectCards for exact-count windows the engine always rejects (DiscardToHandSize + 4 siblings)

1 participant