Skip to content

feat(phase-ai): add draw-matters deck-feature axis + DrawPayoffPolicy - #6688

Merged
matthewevans merged 19 commits into
phase-rs:mainfrom
minion1227:minion_draw_matters_axis
Jul 28, 2026
Merged

feat(phase-ai): add draw-matters deck-feature axis + DrawPayoffPolicy#6688
matthewevans merged 19 commits into
phase-rs:mainfrom
minion1227:minion_draw_matters_axis

Conversation

@minion1227

@minion1227 minion1227 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Tier: Frontier
Model: claude-opus-4-8

Summary

Adds a draw-matters deck-feature axis (DrawMattersFeature) and its companion DrawPayoffPolicy — CR 121.1 "whenever you draw."

With a "whenever you draw a card" engine on the battlefield — The Locust God (make an Insect), Psychosis Crawler / Niv-Mizzet (ping each opponent), Chulane — every extra draw is a repeatable value trigger. card_advantage values having cards but not triggering the engine, so the AI won't lean into an extra-draw spell or ability when it has a payoff out. This axis lets a policy see that engine.

Structural detection, no card-name matching:

Axis AST surface
sources Effect::Draw { target: Controller } (CR 121.1) — the card-draw enablers that feed the engine
payoffs a TriggerMode::Drawn trigger (CR 121.1) that is controller-scoped (valid_target None/Controller) and not self-referential, so a "when this card is drawn" hand trigger is excluded — only repeatable battlefield engines count

Commitment is a geometric mean over (source, payoff) — both mandatory: card draw with no engine is just card advantage (card_advantage governs it), an engine with no way to draw extra only fires on the natural draw for turn.

Policy rewards a CastSpell/ActivateAbility that draws the controller a card — detected via the action's own CastFacts primary/ETB effects (a cast permanent's activated draw ability doesn't fire on cast) or the activated ability — when the AI controls a live draw engine (structural match over trigger_definitions, the runtime trigger authority). Composes with card_advantage the same way CyclingDiscipline (patience) composes with a payoff policy.

Files changed

  • crates/phase-ai/src/features/draw_matters.rs (new) · features/tests/draw_matters.rs (new)
  • crates/phase-ai/src/policies/draw_payoff.rs (new) · policies/tests/draw_payoff.rs (new)
  • crates/phase-ai/src/features/mod.rs, features/tests/mod.rs, policies/mod.rs, policies/registry.rs, policies/tests/mod.rs, config.rs

CR references

  • CR 121.1 — a card is drawn (the TriggerMode::Drawn event and the Effect::Draw enabler)

Every number grep-verified against docs/MagicCompRules.txt.

Implementation method (required)

Method: /add-ai-feature-policy

Track

Developer

LLM

Model: claude-opus-4-8
Thinking: high

Performance

verdict() runs per candidate per search node. The card-local check — does this action draw the controller a card (its own CastFacts primary/ETB effects, or the activated ability) — runs FIRST and rejects every non-draw action; only then does it scan the battlefield for a live engine, and only in a deck whose activation floor is already cleared. No affordability sweep, no find_legal_targets.

Verification

Round-11 head 1a411e1c2:

  • cargo test -p engine --lib17817 passed; 0 failed (covers the inline cfg(test) suites in replacement.rs and draw.rs, the two files this round changes)
  • cargo test -p phase-ai --lib1636 passed; 0 failed (feature-detection axes + policy verdict branches, incl. registry-routed CastSpell and ActivateAbility regressions, and six paired replacement cases: mandatory execute substitution, mandatory runtime_execute substitution and zero-count rescale all withhold; optional substitution, opponent-scoped substitution and positive count rescale all still pay)
  • cargo test -p engine --test integration draw_preflight_matches_live_pipeline8 passed; 0 failed — the preflight-vs-pipeline equivalence suite: each shape asks can_draw_at_least_one for a prediction, then drives the REAL draw and asserts predicted == observed, with expected delivery pinned independently so a regression breaking both sides alike still fails. Covers all five suppression legs (draw restriction, empty library, Prevent, both substitute slots, zero-count) plus unreplaced and count-modified surviving controls.
  • Fail-on-revert evidence: reverting the preflight to the prior prevention-only logic fails exactly the three suppression cases and leaves the three positive controls green — the controls are not vacuous.
  • cargo clippy -p engine --all-targets --features proptest -- -D warnings — clean
  • cargo clippy -p phase-ai --all-targets -- -D warnings — clean
  • cargo fmt --all — clean
  • scripts/draw_replacement_census.py --producers --check — PASS (the file's two Draw-definition producers are consolidated into one parameterized helper; single renamed row, re-frozen with --write)
  • cargo ai-gate — the policy activates only on a draw-engine deck, which none of the three gate matchups (mono-red burn, affinity, Selesnya enchantress) is, so DrawPayoffPolicy::activation returns None and it never fires; CI's paired-seed AI gate is authoritative on this head.

Gate A

./scripts/check-parser-combinators.sh against the committed head:

Gate G PASS (router/grant architecture: strict router vs permissive grant boundary intact)
Gate A PASS head=1a411e1c2e73fed826867ed68c7bff0bdc0dfa15 base=8f2abfe08268785533cc2694b0bd775605c54796

Notes

  • draw_payoff_bonus is registered UNTUNED pending a paired-seed calibration.
  • Payoff detection is structural over live trigger_definitions at decision time (not a name lookup), the pattern adopted for the cycling axis.
  • Boundary with card_advantage / spellslinger_prowess: card_advantage scores the card itself; this axis adds the extra value a draw carries when it fires an engine. spellslinger_prowess counts spell-cast triggers; a draw event is a disjoint trigger. A card can read on both axes — the overlap is intentional and the axes stay independent.

Summary by CodeRabbit

  • New Features
    • Added a new “whenever you draw” deck feature that detects draw sources and draw-payoff engines to produce a commitment score.
    • Introduced a draw-focused AI policy (with new draw_payoff_bonus tuning) that prioritizes drawing when qualifying draw-payoff engines are active.
  • Bug Fixes
    • Improved trigger preflight so engines aren’t rewarded when their required trigger/execute targets can’t be satisfied.
  • Tests
    • Added/expanded unit tests for draw-matters detection, draw-payoff activation gating, modal classification, timing/trigger constraints, and policy routing.

CR 121.1: with a "whenever you draw a card" engine on the battlefield
(The Locust God, Psychosis Crawler, Niv-Mizzet), every extra draw is a
repeatable value trigger. card_advantage values having cards but not
triggering the engine, so the AI won't lean into extra draws when it has
a payoff out.

New DrawMattersFeature axis (structural, no card names):
- source_count: card-draw enablers (Effect::Draw scoped to Controller).
- payoff_count: permanents with a controller-scoped, non-self
  TriggerMode::Drawn engine trigger.
- commitment: geometric mean over (source, payoff) — both mandatory
  (draw with no engine is just card advantage; an engine with no extra
  draw only fires on the natural draw for turn).

New DrawPayoffPolicy: on a CastSpell/ActivateAbility that draws the
controller a card (its own CastFacts primary/ETB effects, or the
activated ability), if the AI controls a live draw engine (structural
match over trigger_definitions), score a positive per-engine bonus.
Composes with card_advantage. draw_payoff_bonus registered UNTUNED.

Tests: 11 feature + 6 policy (incl a registry-routed regression and a
name-only-impostor neutral case) + full 1582-test phase-ai lib suite
pass; clippy -p phase-ai --all-targets -D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@minion1227
minion1227 requested a review from matthewevans as a code owner July 27, 2026 00:14
@superagent-security superagent-security Bot added the contributor:flagged Contributor flagged for review by trust analysis. label Jul 27, 2026
@superagent-security

Copy link
Copy Markdown

🚨 Contributor flagged. Click here for more info: Superagent Dashboard

@coderabbitai

coderabbitai Bot commented Jul 27, 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

Adds structural deck detection for controller-drawing sources and “whenever you draw” engines, aggregates the feature, and introduces a registered policy that rewards qualifying draw actions. Configuration defaults, trigger preflight support, and feature and policy tests are included.

Changes

Draw Matters AI Policy

Layer / File(s) Summary
Draw-matters feature detection
crates/phase-ai/src/features/draw_matters.rs, crates/phase-ai/src/features/mod.rs, crates/phase-ai/src/features/tests/*
Classifies controller draw sources and draw-payoff triggers structurally, computes commitment, exposes it through DeckFeatures, and tests classification and calibration behavior.
Hypothetical trigger fireability
crates/engine/src/game/ability_utils.rs, crates/engine/src/game/effects/draw.rs, crates/engine/src/game/triggers.rs
Adds conservative trigger-constraint, draw-permission, and execute-target preflight used to identify currently fireable draw-payoff engines.
Draw-payoff policy integration
crates/phase-ai/src/config.rs, crates/phase-ai/src/policies/draw_payoff.rs, crates/phase-ai/src/policies/mod.rs, crates/phase-ai/src/policies/registry.rs
Adds the configurable payoff bonus, implements candidate and battlefield trigger checks, and registers DrawPayoffPolicy.
Policy validation and routing
crates/phase-ai/src/policies/tests/*
Tests activation thresholds, draw-action verdicts, fireability limits, target legality, draw restrictions, impostor engines, and registry routing.

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

Possibly related PRs

  • phase-rs/phase#6683: Extends PolicyPenalties and tuning registration with a related payoff-bonus field.

Suggested labels: quality

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 92.31% 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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding the draw-matters feature axis and DrawPayoffPolicy.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

🧹 Nitpick comments (1)
crates/phase-ai/src/policies/draw_payoff.rs (1)

108-121: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer an exhaustive match over the _ => false fallback.

Coding guidelines call for exhaustive match over wildcard defaults so new GameAction variants surface at compile time rather than silently falling through to false here.

As per coding guidelines, "Use idiomatic Rust: prefer enums over stringly typed data, exhaustive match over wildcard defaults" and path instructions for crates/**/*.rs: "wildcard _ match arms where the enum is known and an exhaustive match would let the compiler catch missing variants."

🤖 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/policies/draw_payoff.rs` around lines 108 - 121, The
candidate_draws_controller match currently hides newly added GameAction variants
behind a wildcard false arm. Replace `_ => false` with explicit arms for every
remaining GameAction variant, preserving false behavior for non-CastSpell and
non-ActivateAbility actions while making the match exhaustive.

Sources: Coding guidelines, 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/phase-ai/src/policies/tests/draw_payoff.rs`:
- Around line 162-286: Add verdict tests for DrawPayoffPolicy’s ActivateAbility
path using an ability activation candidate, including a modal ability whose
selected mode draws and another mode does not. Assert the drawing mode receives
the engine-active reward while the non-drawing mode remains neutral, exercising
candidate_draws_controller’s ActivateAbility branch and selected-mode scoping.

---

Nitpick comments:
In `@crates/phase-ai/src/policies/draw_payoff.rs`:
- Around line 108-121: The candidate_draws_controller match currently hides
newly added GameAction variants behind a wildcard false arm. Replace `_ =>
false` with explicit arms for every remaining GameAction variant, preserving
false behavior for non-CastSpell and non-ActivateAbility actions while making
the match exhaustive.
🪄 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: 9d9b5814-7a9a-4738-862f-3addf971a63a

📥 Commits

Reviewing files that changed from the base of the PR and between 6e0701b and 7952325.

📒 Files selected for processing (10)
  • crates/phase-ai/src/config.rs
  • crates/phase-ai/src/features/draw_matters.rs
  • crates/phase-ai/src/features/mod.rs
  • crates/phase-ai/src/features/tests/draw_matters.rs
  • crates/phase-ai/src/features/tests/mod.rs
  • crates/phase-ai/src/policies/draw_payoff.rs
  • crates/phase-ai/src/policies/mod.rs
  • crates/phase-ai/src/policies/registry.rs
  • crates/phase-ai/src/policies/tests/draw_payoff.rs
  • crates/phase-ai/src/policies/tests/mod.rs

Comment thread crates/phase-ai/src/policies/tests/draw_payoff.rs
Parity with the phase-rs#6683 review fix: the draw-payoff scan matched an engine
only by its structural trigger shape, so a rate-limited "whenever you
draw ... only once each turn" engine would still earn a bonus on a second
draw even though its trigger can't fire again.

The battlefield scan now pairs the structural classifier with live firing
eligibility per trigger entry — a OncePerTurn / OncePerGame engine that
has already fired (per the engine's authoritative triggers_fired_this_turn
/ triggers_fired_this_game ledgers, keyed via
GameObject::trigger_definition_ref) no longer counts. trigger_is_draw_payoff
is exposed as is_draw_payoff_trigger so the per-entry check can pair shape
with eligibility.

Regressions: a once-per-turn engine already fired this turn scores
neutral; the same engine unfired still rewards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

Re: the CodeRabbit nitpick on candidate_draws_controller's _ => false arm — keeping it as-is, intentionally.

  • Precedent: this mirrors the merged graveyard_types::candidate_fills_own_graveyard (and hand_disruption, board_development, anti_self_harm, …), which all match the two action-bearing GameAction variants and fall the rest through to a default. GameAction is a ~30-variant enum; an exhaustive false-for-everything-else match would be ~28 lines of noise and diverge from that established pattern.
  • Unreachable in production: DrawPayoffPolicy::decision_kinds() is [CastSpell, ActivateAbility], so PolicyRegistry only ever routes those two GameAction kinds here. The _ arm is a defensive default for an unreachable case, not a place where a future variant would silently need draw-detection — a new GameAction that draws cards would arrive as a new DecisionKind and wouldn't reach this function at all.

Happy to expand it if you'd prefer the fully-exhaustive form despite the precedent — just say the word.

@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/phase-ai/src/policies/tests/draw_payoff.rs`:
- Around line 260-316: Add symmetric OncePerGame coverage near
once_per_turn_engine: create an once_per_game_engine helper using
TriggerConstraint::OncePerGame, then add a test that inserts its trigger key
into triggers_fired_this_game and verifies DrawPayoffPolicy returns
draw_payoff_no_engine with zero delta. Also add an unfired OncePerGame control
test confirming the engine remains rewarding.
🪄 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: 8d5d0e3f-0323-4000-8a65-d69020ad3275

📥 Commits

Reviewing files that changed from the base of the PR and between 7952325 and 09edbb1.

📒 Files selected for processing (3)
  • crates/phase-ai/src/features/draw_matters.rs
  • crates/phase-ai/src/policies/draw_payoff.rs
  • crates/phase-ai/src/policies/tests/draw_payoff.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/phase-ai/src/policies/draw_payoff.rs
  • crates/phase-ai/src/features/draw_matters.rs

Comment thread crates/phase-ai/src/policies/tests/draw_payoff.rs
@matthewevans matthewevans self-assigned this Jul 27, 2026
@matthewevans matthewevans added the enhancement New feature or request label Jul 27, 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.

Changes requested — candidate timing and trigger eligibility are both overstated in the live draw-payoff score.

🟡 Blockers

[MED] A modal/else draw is credited before its draw mode is selected. crates/phase-ai/src/features/draw_matters.rs:98-109 scans with AbilityScope::Potential, and draw_payoff.rs:110-121 uses that classifier directly for the live candidate. Potential is appropriate for deck classification, but not an action before SelectModes: a non-draw mode can receive a draw payoff. Split runtime scanning to unconditional effects plus the definitions selected by SelectModes, and cover paired draw/non-draw modal choices.

[MED] trigger_still_fireable treats every constraint except OncePerTurn/Game as live. draw_payoff.rs:126-140 therefore rewards engines that cannot trigger under constraints such as OnlyDuringYourTurn, main-phase, nth-draw, or max-times. The engine's trigger path evaluates the full constraint authority (crates/engine/src/game/triggers.rs:8005+); use the authoritative occurrence/fireability evaluation rather than a partial policy mirror, with regressions for those unavailable cases.

@matthewevans matthewevans removed their assignment Jul 27, 2026
…omplete trigger eligibility

Round-1 review (phase-rs#6688): two ways the live draw-payoff score was overstated.

[MED] A modal/else draw was credited before its mode was selected.
`is_draw_source_parts` scanned with `AbilityScope::Potential`, so a modal
"choose one — deal damage; OR draw a card" got a draw payoff even when the
non-draw mode would be chosen. The predicate now takes the scope: deck-time
detection keeps `Potential` (a modal draw still marks the card), but the live
candidate scan uses `Unconditional` (CR 700.2) so only a draw that always
happens is credited pre-mode-selection.

[MED] `trigger_still_fireable` treated every constraint except OncePerTurn/Game
as live, rewarding engines that cannot fire. It is now exhaustive over
`TriggerConstraint` (no wildcard): OncePerTurn/Game via the fired-trigger
ledgers; OnlyDuringYourTurn / OnlyDuringOpponentsTurn / OnlyDuringYourMainPhase
via `active_player` + `phase`; and the event/count-dependent constraints
(MaxTimesPerTurn, NthSpell/NthDraw, OncePerOpponentPerTurn, AtClassLevel,
EventSourceControlledBy) conservatively NOT confirmed, so the payoff is never
over-credited.

Tests: modal-draw-not-credited (+ deck-time still counts it), OncePerGame
fired/unfired, OnlyDuringYourTurn off-turn/on-turn; full 1590-test phase-ai lib
suite pass; clippy -p phase-ai --lib --tests -D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

Both blockers fixed at head 8af6c3165.

[MED] Modal/else draw credited before mode selection

is_draw_source_parts now takes an AbilityScope. Deck-time detection keeps Potential (a modal draw mode still marks the card as a draw enabler for the archetype), but the live candidate scan uses Unconditional (CR 700.2), so a modal "choose one — deal 3 damage; OR draw a card" is not credited a draw until the draw mode is actually chosen.

Tests: modal_draw_not_credited_before_mode_selected (runtime → draw_payoff_na) and modal_draw_mode_still_counts_as_a_deck_source (deck-time still counts it).

[MED] trigger_still_fireable incomplete

It's now exhaustive over TriggerConstraint (no wildcard), evaluated against authoritative state:

Constraint Check
OncePerTurn / OncePerGame triggers_fired_this_turn / _game ledger, keyed via trigger_definition_ref
OnlyDuringYourTurn / OnlyDuringOpponentsTurn state.active_player vs the engine's controller
OnlyDuringYourMainPhase your turn and state.phase ∈ {PreCombatMain, PostCombatMain}
MaxTimesPerTurn, NthSpellThisTurn, NthDrawThisTurn, OncePerOpponentPerTurn, AtClassLevel, EventSourceControlledBy conservatively not confirmed at decision time (depends on the triggering event or a per-turn count) → no bonus, so the payoff is never over-credited

Tests: once_per_game_engine_already_fired_is_neutral / _unfired_rewards, only_during_your_turn_engine_is_neutral_off_turn / _rewards_on_your_turn.

The exhaustive match also resolves the earlier CodeRabbit nitpick about wildcard arms here.

Full 1590-test phase-ai suite pass; clippy -p phase-ai --lib --tests -D warnings clean. (The same eligibility-completeness fix is going onto #6683's cycling policy for parity.)

minion1227 added a commit to minion1227/phase that referenced this pull request Jul 27, 2026
…e constraints)

Parity with the phase-rs#6688 review: trigger_still_fireable is now exhaustive over
TriggerConstraint (no wildcard). OncePerTurn/Game via the fired-trigger ledgers;
OnlyDuringYourTurn / OnlyDuringOpponentsTurn / OnlyDuringYourMainPhase via
active_player + phase; and the event/count-dependent constraints
(MaxTimesPerTurn, NthSpell/NthDraw, OncePerOpponentPerTurn, AtClassLevel,
EventSourceControlledBy) conservatively NOT confirmed so the payoff is never
over-credited.

Regressions: once-per-game engine already fired -> neutral; OnlyDuringYourTurn
engine on the opponent's turn -> neutral.

cargo test -p phase-ai --lib cycling_payoff — 13 pass; clippy
-p phase-ai --lib --tests -D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@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/phase-ai/src/policies/tests/draw_payoff.rs`:
- Around line 415-448: Extend the draw payoff timing-constraint tests around
trigger_still_fireable to cover TriggerConstraint::OnlyDuringOpponentsTurn and
TriggerConstraint::OnlyDuringYourMainPhase: assert rewards on each constraint’s
valid turn/phase, zero payoff on invalid turns, and zero payoff for a non-main
phase. Reuse the existing state, engine_with_constraint, draw_spell, and verdict
setup while preserving the current OnlyDuringYourTurn cases.
🪄 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: 647fb847-c745-4c3d-9ed9-a38482fc59fd

📥 Commits

Reviewing files that changed from the base of the PR and between 09edbb1 and 8af6c31.

📒 Files selected for processing (4)
  • crates/phase-ai/src/features/draw_matters.rs
  • crates/phase-ai/src/features/tests/draw_matters.rs
  • crates/phase-ai/src/policies/draw_payoff.rs
  • crates/phase-ai/src/policies/tests/draw_payoff.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/phase-ai/src/policies/draw_payoff.rs
  • crates/phase-ai/src/features/draw_matters.rs
  • crates/phase-ai/src/features/tests/draw_matters.rs

Comment thread crates/phase-ai/src/policies/tests/draw_payoff.rs
@minion1227
minion1227 requested a review from matthewevans July 27, 2026 01:19

@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.

Reviewed at head 8af6c31658cf42b10e06cd03ebefdcd894e1afc4.

[MED] Live draw-payoff detection treats a structurally matching trigger as a usable payoff without evaluating its trigger condition or resolution targets. Evidence: crates/phase-ai/src/policies/draw_payoff.rs:83-86,146-172; the production trigger pipeline evaluates TriggerDefinition::condition with the event and source context and rejects unresolvable target sets (crates/engine/src/game/triggers.rs:435-445,1447-1473,4511-4539). Why it matters: the policy awards a draw bonus for engines that will produce no value, such as Wizard Class at level 3 with no creature you control to target. Suggested fix: use an engine-owned, event/source-aware eligibility preflight for the prospective draw event, including execution target legality, or conservatively reject every engine whose eligibility cannot be established; add Wizard Class zero-target and legal-target runtime cases.

[MED] The cast candidate's immediate-ETB draw path ignores the ETB trigger's condition. Evidence: crates/phase-ai/src/policies/draw_payoff.rs:115-123 classifies every qualifying ETB execute body, while crates/phase-ai/src/cast_facts.rs:330-336 admits it based only on its zone-change shape and execute. Why it matters: Latchkey Faerie is credited as a draw spell even when its prowl cost was not paid, because its condition is never preflighted. Suggested fix: route immediate-ETB draw detection through the same event/source-aware preflight (or reject conditional ETBs until that exists), with runtime no-prowl and prowl-paid Latchkey Faerie cases.

[MED] Deck draw-source detection omits ETB cantrips that the runtime policy intentionally rewards. Evidence: crates/phase-ai/src/features/draw_matters.rs:82-85 scans only face.abilities, whereas crates/phase-ai/src/policies/draw_payoff.rs:115-124 also recognizes immediate ETB trigger bodies. Why it matters: a deck relying on enter-the-battlefield draw sources is undercounted and can miss the activation floor even though those same casts receive the live bonus. Suggested fix: include structurally qualifying ETB cantrip triggers in the deck feature's potential-source scan and add a detector regression that changes source_count/commitment.

[MED] The decision branches introduced by this policy still lack discriminating coverage. Evidence: crates/phase-ai/src/policies/tests/draw_payoff.rs:118-128,186-448 constructs only GameAction::CastSpell candidates and tests only OnlyDuringYourTurn; DrawPayoffPolicy::decision_kinds() also registers ActivateAbility, and trigger_still_fireable separately handles OnlyDuringOpponentsTurn and OnlyDuringYourMainPhase (crates/phase-ai/src/policies/draw_payoff.rs:49-51,125-130,158-164). Why it matters: regressions in activated-draw selection and the remaining timing paths can ship while every current assertion stays green. Suggested fix: add verdict/registry tests that reach ActivateAbility (including draw/non-draw selected branches) and positive/negative runtime cases for opponent-turn and both main-phase timing constraints.

…d 2)

Addresses matthewevans' four [MED] blockers on phase-rs#6688 by moving live
"whenever you draw" payoff eligibility into a single engine authority that
covers the COMPLETE trigger condition, not just selected constraint variants.

Engine (single authority):
- `triggers::hypothetical_trigger_fireable(state, source, entry)` — the one
  place a policy asks "is this on-battlefield payoff live?". It reuses the
  live pipeline's `check_trigger_constraint_with_ref` (now `event: Option<&_>`;
  Some = real eval, None = hypothetical → event-dependent constraints report
  NOT-satisfied rather than guess), rejects an intervening-if `condition`
  (CR 603.4, conservative), and preflights execution target legality via
  `ability_utils::execute_targets_satisfiable` (CR 603.3d — a mandatory-target
  trigger with no legal target produces no effect).
- Deleted the policy-local partial `trigger_still_fireable` reimplementation.

Policy (draw_payoff.rs):
- Live engine scan now `is_draw_payoff_trigger && hypothetical_trigger_fireable`.
- Immediate-ETB draw path skips conditional ETBs (`condition.is_none()`), so a
  Latchkey Faerie prowl cantrip is not credited a draw until it will fire.

Feature (draw_matters.rs):
- `detect()` now also counts self-ETB draw cantrips (Elvish Visionary) as deck
  draw sources via `is_etb_draw_source`, matching what the live policy rewards.

Tests (external, no cfg(test) in source):
- Wizard-Class zero-target neutral / legal-target reward (target legality).
- Latchkey conditional-ETB not credited / unconditional-ETB credited.
- ActivateAbility draw reward / non-draw neutral.
- OnlyDuringOpponentsTurn positive+negative; OnlyDuringYourMainPhase both main
  phases positive + off-phase (upkeep) negative; MaxTimesPerTurn below/at cap.
- Feature: ETB-cantrip source-count regression + opponent-draw control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

Thanks — all four [MED] blockers addressed in db3fe363d. The common root cause was that the policy carried its own partial trigger-eligibility logic; I replaced it with a single engine-owned authority that reuses the live pipeline's constraint checker, so the policy no longer re-implements any part of trigger evaluation.

1. Live payoff detection ignored condition + resolution targets.
New triggers::hypothetical_trigger_fireable(state, source, entry) is the one authority the policy calls. It:

  • reuses the same check_trigger_constraint_with_ref the live pipeline runs — I changed its event param to Option<&GameEvent> (Some = real evaluation, None = hypothetical preflight). In None mode the event-dependent constraints (OncePerOpponentPerTurn, EventSourceControlledBy, NthSpellThisTurn, NthDrawThisTurn) report not satisfied rather than guess — so it covers the complete constraint set, not a hand-picked subset;
  • rejects an intervening-if condition (CR 603.4) conservatively;
  • preflights execution target legality via ability_utils::execute_targets_satisfiable (CR 603.3d — a mandatory-target trigger with no legal target is removed and produces no effect).

Tests: targeted_engine_with_no_legal_target_is_neutral (Wizard-Class enchantment, empty board → no reward) and targeted_engine_with_a_legal_target_rewards (a creature in play → rewarded).

2. Immediate-ETB draw path ignored the ETB condition (Latchkey).
Took the sanctioned conservative path: the ETB draw path now filters condition.is_none(), so a conditional ETB (Latchkey's prowl clause) is not credited a draw. This keeps the semantics uniform with (1) — both the live-engine and ETB paths reject conditional triggers. Because it's a conservative reject, a prowl-paid Latchkey is also (safely) not credited — an under-credit, never an over-credit.
Tests: conditional_etb_draw_is_not_credited (Latchkey CastVariantPaid::Prowl condition → draw_payoff_na) and unconditional_etb_draw_is_credited (Elvish Visionary → rewarded).

3. Deck source detection omitted ETB cantrips.
detect() now counts self-ETB draw cantrips as deck sources via is_etb_draw_source (ChangesZone→Battlefield, SelfRef, Effect::Draw{Controller}), matching what the live policy rewards.
Tests: etb_cantrip_counts_as_a_draw_source (source_count regression) + etb_opponent_draw_is_not_a_source control.

4. Discriminating coverage.
Added: activated_draw_ability_rewards / activated_non_draw_ability_is_neutral (the ActivateAbility seam, draw and non-draw selected branches); only_during_opponents_turn_rewards_off_turn / ..._is_neutral_on_your_turn; only_during_your_main_phase_rewards_in_both_main_phases (PreCombatMain + PostCombatMain) + only_during_your_main_phase_off_phase_is_neutral (upkeep); max_times_per_turn_below_cap_rewards / ..._at_cap_is_neutral.

cargo fmt, clippy -p engine/-p phase-ai (lib+tests) clean; full phase-ai lib suite green (62 draw-specific assertions). The engine change is behind the existing live-trigger tests (it only adds a None mode + a new pub fn; the Some path is byte-for-byte the prior logic).

@matthewevans matthewevans self-assigned this Jul 27, 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.

Reviewed current head db3fe363d886e033f762dd20494a6125c891c7aa.

[MED] The policy still grants a draw-payoff bonus when the candidate cannot actually draw. Evidence: crates/phase-ai/src/policies/draw_payoff.rs:65-68,110-132 classifies any structural Effect::Draw { target: Controller } as a draw, but the authoritative delivery gate returns zero for CantDraw and exhausted PerTurnDrawLimit at crates/engine/src/game/effects/draw.rs:14-48. Why it matters: no CardDrawn event occurs, so a "whenever you draw" engine never triggers, yet this policy adds a positive score to the no-op draw action. Suggested fix: route live candidate qualification through an engine-owned draw preflight that resolves the requested count and applies the same allowed-draw gate; add discriminating CantDraw and exhausted-limit cases that return draw_payoff_na.

[MED] The new generic trigger preflight reports multi/complex mandatory target executions as live even when no legal target assignment exists. Evidence: crates/engine/src/game/ability_utils.rs:1290-1312 returns None unless there is exactly one simple target slot, and execute_targets_satisfiable converts that None to true at :1320-1338; hypothetical_trigger_fireable relies on it at crates/engine/src/game/triggers.rs:8188-8192. Why it matters: a draw-payoff trigger with two mandatory targets (or another unsupported target shape) is credited despite being removed under CR 603.3d when its required choices are unavailable. Suggested fix: make the preflight distinguish confirmed-legal from unknown and have this scoring policy award the bonus only for confirmed legality (or use the existing full legal-assignment authority behind an appropriate cheap guard); cover a multi-target zero-legal-target engine.

@matthewevans matthewevans removed their assignment Jul 27, 2026

@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.

🧹 Nitpick comments (2)
crates/phase-ai/src/policies/tests/draw_payoff.rs (1)

204-277: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

No test exercises the MAX_REWARDED_ENGINES cap.

Every rewarded-verdict test here (single-engine, once-per-turn, targeted, activated-ability, etc.) has exactly one live engine on the battlefield. DrawPayoffPolicy::verdict caps the reward at engines.min(MAX_REWARDED_ENGINES) (crates/phase-ai/src/policies/draw_payoff.rs line 96), but nothing here places more engines than the cap to confirm the score saturates rather than scaling linearly, or that the reported engines fact still reflects the true (uncapped) count.

♻️ Suggested addition
#[test]
fn engine_count_above_cap_saturates_reward() {
    let config = AiConfig::default();
    let mut st = state();
    // Place more engines than MAX_REWARDED_ENGINES.
    for _ in 0..(MAX_REWARDED_ENGINES + 1) {
        engine_on_battlefield(&mut st);
    }
    let (oid, cid) = draw_spell(&mut st);
    let context = context(&config, session(0.9));
    let candidate = cast(oid, cid);
    let decision = priority_decision(&candidate);
    let (delta, reason) =
        score_of(DrawPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config)));
    assert_eq!(reason.kind, "draw_payoff_engine_active");
    let capped = config.policy_penalties.draw_payoff_bonus * MAX_REWARDED_ENGINES as f64;
    assert!((delta - capped).abs() < f64::EPSILON, "reward should saturate at the cap");
}
🤖 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/policies/tests/draw_payoff.rs` around lines 204 - 277,
Add a test near the existing DrawPayoffPolicy tests that places
MAX_REWARDED_ENGINES + 1 live engines using engine_on_battlefield, then casts a
draw spell and evaluates the verdict. Assert the engine-active reason, verify
the reward equals the configured draw-payoff bonus multiplied by
MAX_REWARDED_ENGINES rather than the true engine count, and confirm the reported
engines fact remains uncapped if exposed by the verdict reason.
crates/engine/src/game/triggers.rs (1)

8155-8195: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

hypothetical_trigger_fireable fails closed on AtClassLevel due to missing source_context, not genuine uncertainty.

check_trigger_constraint_with_ref is called with source_context: None (line 8182), but the TriggerConstraint::AtClassLevel arm (line ~8140) reads source_context.and_then(|source| source.source_read(state).class_level()). Since source_context is None here, any Class-level-gated trigger will always evaluate as not-fireable — even though source: &GameObject is directly available to build a real context via trigger_source_context_for_latch. Unlike the event-dependent constraints (OncePerOpponentPerTurn, EventSourceControlledBy, NthSpellThisTurn, NthDrawThisTurn), AtClassLevel has nothing to do with the (unknown) triggering event — the source's level is fully knowable at hypothetical-evaluation time, so failing it closed here is inconsistent with the function's own documented invariant ("event-dependent constraints... conservatively report NOT satisfied").

🐛 Proposed fix
     let definition_ref = source.trigger_definition_ref(entry);
+    let source_context = trigger_source_context_for_latch(state, source);
     // CR 603.2-603.4: the trigger's own constraint, in hypothetical (no-event)
     // mode — the shared authority the live pipeline also uses.
     if !check_trigger_constraint_with_ref(
         state,
         def,
         Some(&definition_ref),
-        None,
+        Some(&source_context),
         source.controller,
         None,
     ) {
         return false;
     }
🤖 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/triggers.rs` around lines 8155 - 8195, Update
hypothetical_trigger_fireable to pass a real source context, created from the
available source via trigger_source_context_for_latch, into
check_trigger_constraint_with_ref instead of None. Preserve conservative failure
for genuinely event-dependent constraints while allowing AtClassLevel to
evaluate the source’s known class level.
🤖 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.

Nitpick comments:
In `@crates/engine/src/game/triggers.rs`:
- Around line 8155-8195: Update hypothetical_trigger_fireable to pass a real
source context, created from the available source via
trigger_source_context_for_latch, into check_trigger_constraint_with_ref instead
of None. Preserve conservative failure for genuinely event-dependent constraints
while allowing AtClassLevel to evaluate the source’s known class level.

In `@crates/phase-ai/src/policies/tests/draw_payoff.rs`:
- Around line 204-277: Add a test near the existing DrawPayoffPolicy tests that
places MAX_REWARDED_ENGINES + 1 live engines using engine_on_battlefield, then
casts a draw spell and evaluates the verdict. Assert the engine-active reason,
verify the reward equals the configured draw-payoff bonus multiplied by
MAX_REWARDED_ENGINES rather than the true engine count, and confirm the reported
engines fact remains uncapped if exposed by the verdict reason.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f04e16bb-8d59-47a8-aeb6-b7e05e8d9468

📥 Commits

Reviewing files that changed from the base of the PR and between 8af6c31 and db3fe36.

📒 Files selected for processing (6)
  • crates/engine/src/game/ability_utils.rs
  • crates/engine/src/game/triggers.rs
  • crates/phase-ai/src/features/draw_matters.rs
  • crates/phase-ai/src/features/tests/draw_matters.rs
  • crates/phase-ai/src/policies/draw_payoff.rs
  • crates/phase-ai/src/policies/tests/draw_payoff.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/phase-ai/src/features/tests/draw_matters.rs
  • crates/phase-ai/src/features/draw_matters.rs

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

…legality (round 3)

Addresses matthewevans' two round-3 [MED] blockers on phase-rs#6688.

1. Draw-delivery gate (CR 120.3 / CR 101.2). A structural `Effect::Draw
   { target: Controller }` produces no `CardDrawn` event — and fires no
   "whenever you draw" engine — when the controller cannot actually draw:
   under a `CantDraw` static, or with a `PerTurnDrawLimit` already exhausted.
   `candidate_draws_controller` now gates on a new engine authority,
   `effects::draw::can_draw_at_least_one`, which reuses the same
   `allowed_draw_count` gate the delivery path runs — so the bonus is never
   added to a no-op draw. New coverage: `CantDraw` → `draw_payoff_na`;
   exhausted per-turn limit → `draw_payoff_na`; limit with headroom → rewarded.

2. Multi-target legality (CR 603.3d). `execute_targets_satisfiable`
   previously treated the cheap single-slot check's `None` (multi-target or
   other complex shape) as satisfiable, crediting a mandatory multi-target
   engine with no legal target assignment. It now falls through to
   `build_target_slots` + `has_legal_target_assignment_for_ability` (the same
   full solver production target selection uses), so a multi-target execute
   with no legal assignment is correctly reported not-live. New coverage: a
   two-target "exchange control of two permanents" engine on an empty board →
   `draw_payoff_no_engine`; with two exchangeable permanents → rewarded.

`cargo fmt`, `clippy -p engine`/`-p phase-ai` (lib+tests) clean; full phase-ai
lib suite green (1609 passed). Engine changes ride behind existing live
targeting/draw tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

Both round-3 [MED] blockers fixed in 36a63732c.

1. Draw-delivery gate (CR 120.3 / CR 101.2). You're right that a structural Effect::Draw { target: Controller } is a no-op — no CardDrawn event, no engine trigger — when the controller can't actually draw. I added an engine-owned preflight effects::draw::can_draw_at_least_one(state, player) that reuses the same allowed_draw_count gate the delivery path runs (it returns 0 under a CantDraw static and caps at the PerTurnDrawLimit remaining). candidate_draws_controller now short-circuits to draw_payoff_na when that gate is closed, so both the CantDraw and exhausted-limit cases withhold the bonus. Covered by cant_draw_static_makes_the_draw_a_no_op, exhausted_per_turn_draw_limit_is_neutral, and a per_turn_draw_limit_with_headroom_rewards control.

2. Multi-target legality (CR 603.3d). Correct — execute_targets_satisfiable was converting the cheap single-slot check's None (multi-target / complex shape) to true. It now falls through to the full authority the same way production target selection does: build_target_slots → if empty, no target needed → otherwise has_legal_target_assignment_for_ability(state, &resolved, &slots, constraints). So a mandatory multi-target execute with no legal assignment is reported not-live. Covered by multi_target_engine_with_no_legal_assignment_is_neutral (a two-target "exchange control of two permanents" engine on an empty board → draw_payoff_no_engine) and multi_target_engine_with_a_legal_assignment_rewards (two exchangeable permanents → rewarded). The cheap single-slot guard still decides the common case first, so the full solver only runs for genuinely multi/complex shapes.

cargo fmt, clippy -p engine/-p phase-ai (lib+tests) clean; full phase-ai lib suite green (1609 passed).

@minion1227
minion1227 requested a review from matthewevans July 27, 2026 07:33
@matthewevans matthewevans self-assigned this Jul 27, 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.

Reviewed current head fd61fd76aec345295e14fe313216218f065dea71.

[MED] The policy still performs draw-delivery/replacement preflight before it knows that the candidate draws. Evidence: crates/phase-ai/src/policies/draw_payoff.rs:110-145 calls can_draw_at_least_one before the GameAction match; that helper scans active draw statics and calls proposed_draw_survives_replacement, which runs find_applicable_replacements (crates/engine/src/game/effects/draw.rs:34-46, crates/engine/src/game/replacement.rs:8790-8811). Why it matters: verdict() runs for every cast and activation candidate at every search node, so non-draw candidates still pay a battlefield/replacement scan despite the policy's stated card-local early-out. Suggested fix: first classify the cast facts/effective activated ability structurally, return neutral for non-draw candidates, and invoke can_draw_at_least_one only after that classifier confirms an unconditional controller draw.

[LOW] The bounded-score contract has no discriminating cap regression. Evidence: MAX_REWARDED_ENGINES limits the reward at crates/phase-ai/src/policies/draw_payoff.rs:38-40,96, while the policy suite has no case with more than one live engine (crates/phase-ai/src/policies/tests/draw_payoff.rs). Why it matters: changing or removing the cap can produce an unbounded hot-path score while every current reward test stays green. Suggested fix: exercise MAX_REWARDED_ENGINES + 1 live engines through the policy or registry, assert the capped delta, and preserve/assert the uncapped engine-count fact if that is intended observability.

[LOW] candidate_draws_controller still uses a wildcard GameAction arm. Evidence: crates/phase-ai/src/policies/draw_payoff.rs:124-145 ends the known enum match with _ => false; this is the unresolved CodeRabbit review finding. Why it matters: a future action variant can silently bypass intentional classification instead of forcing it at compile time. Suggested fix: make this match exhaustive with explicit neutral arms, or delegate to an existing exhaustive action classifier.

The prior draw-delivery/replacement correctness findings are resolved at this head: the shared substitution classifier is now used by the preflight and live replacement pipeline, and the integration test drives both paths. The parse-diff sticky comment is current and reports no card-parse changes.

@matthewevans matthewevans removed their assignment Jul 27, 2026
…ded-score regression (round 11)

Addresses the three round-11 findings.

[MED] hot-path ordering: `candidate_draws_controller` ran the delivery/
replacement preflight BEFORE proving the candidate draws, so every non-draw
CastSpell/ActivateAbility candidate paid a battlefield-static scan and
`find_applicable_replacements` at every search node — the opposite of the
module doc's stated card-local early-out. Split into `candidate_draws_
structurally` (card-local, reads only the candidate AST) and
`draw_is_deliverable` (the expensive, candidate-independent engine authority),
evaluated in that order. Behavior is unchanged; only non-draw candidates get
cheaper, and the module doc is now true.

[LOW] exhaustive `GameAction` match: the wildcard `_ => false` let a future
action silently bypass the policy. Now enumerates all 127 variants, so a new
one fails to compile here and forces an intentional classification. Cast-shaped
siblings (madness, miracle, foretell, ninjutsu, free/copy casts) are grouped
into their own arm documenting WHY they are neutral — `cast_facts` is populated
only for the plain `CastSpell` seam — so that gap is visible rather than silent.

[LOW] bounded-score regression: `MAX_REWARDED_ENGINES` had no test. Adds a
capped case (MAX+1 engines must not out-score MAX) plus a below-cap scaling
control so the pair pins a CAP rather than a constant, and asserts the
`engines` fact still reports the true uncapped count. The constant is
`pub(crate)` so the assertion tracks it instead of a copied literal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

All three addressed at 1a411e1c2.

[MED] Preflight ran before the card-local test

Correct, and worse than a missed optimization: the module doc claimed the card-local check "runs FIRST and rejects every non-draw action" while the code did the opposite. The stated contract and the implementation disagreed.

Split into two gates evaluated cheapest-discriminator-first:

  • candidate_draws_structurally — reads only the candidate's own AST (cast facts / effective activated ability), no board access
  • draw_is_deliverable — the expensive, candidate-independent can_draw_at_least_one (battlefield static scan + find_applicable_replacements)

candidate_draws_controller is now structural && deliverable. Behavior is identical (&& is commutative in result, not in cost); only non-draw candidates get cheaper, and the module doc is now true rather than aspirational.

[LOW] Wildcard _ => false

Now exhaustive over all 127 GameAction variants, so a newly added action fails to compile here instead of silently bypassing the policy.

Enumerating that many arms by hand invites typos, so I derived them from the enum definition programmatically and let the compiler confirm exhaustiveness and non-redundancy. Clippy also caught that { .. } is wrong for the 8 unit variants and the 1 tuple variant — those now use the correct pattern shape.

One thing worth flagging that fell out of doing this: the cast-shaped siblings (CastSpellAsMadness, CastSpellForFree, CastSpellAsMiracle, CastSpellAsSneak, CastSpellAsWebSlinging, Foretell, ActivateNinjutsu, PlayFaceDown, CastPreparedCopy, CastParadigmCopy) all route to DecisionKind::ActivateAbility via the priority-time catch-all, so they do reach this policy and were being dropped by the old wildcard. A madness-cast cantrip currently gets no draw payoff. I've given them their own arm documenting why they're neutral — PolicyContext::cast_facts is populated only for the plain CastSpell announcement seam, so there's no AST here to classify and guessing would be worse. That's now a visible, intentional gap with a named place to fix it, rather than silence. Happy to widen cast_facts in a follow-up if you'd like it in scope.

[LOW] Uncovered MAX_REWARDED_ENGINES

Added a paired regression: MAX_REWARDED_ENGINES + 1 live engines must not out-score MAX_REWARDED_ENGINES, plus a below-cap scaling control (2 engines → 2× bonus) so the pair pins a cap rather than a constant score. The engines fact is asserted to still report the true uncapped count — that observability contract is intentional and is now stated as such. MAX_REWARDED_ENGINES is pub(crate) so the assertion tracks the constant instead of a copied literal.

Verification at 1a411e1c2

  • cargo test -p phase-ai --lib1636 passed; 0 failed
  • cargo clippy -p phase-ai --all-targets -- -D warnings — clean
  • cargo fmt --all — clean
  • Gate A — Gate A PASS head=1a411e1c2e73fed826867ed68c7bff0bdc0dfa15

Engine crate untouched this round; the round-10 engine verification (17817 lib tests, 8/8 preflight-vs-pipeline equivalence, --all-targets clippy with --features proptest) still stands, and both Rust CI shards plus Rust lint went green on fd61fd76a.

@matthewevans matthewevans self-assigned this Jul 27, 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.

Reviewed current head 1a411e1c2e73fed826867ed68c7bff0bdc0dfa15.

[HIGH] The new exhaustive GameAction match is not exhaustive against current main. Evidence: crates/phase-ai/src/policies/draw_payoff.rs:146 has no GameAction::EndContinuousEffect { .. } arm, while current main defines it at crates/engine/src/types/actions.rs:922; the current-head Rust lint job fails with E0004 at draw_payoff.rs:146. Why it matters: phase-ai does not compile, so the required Rust lint/tests, paired-seed AI gate, decision-cost perf gate, and WASM check cannot run. Suggested fix: merge/rebase current main, classify EndContinuousEffect { .. } explicitly as neutral in candidate_draws_structurally, and push a head for which the required checks are green.

@matthewevans matthewevans removed their assignment Jul 27, 2026
…ayoff match

main added a 128th `GameAction` variant while this branch carried a 127-arm
exhaustive match, so the merge CI builds were non-exhaustive and every Rust job
failed to compile. Classify it neutral: ending a continuous effect draws no card.

This is the maintenance cost of an exhaustive action match living inside a
single policy — see the PR discussion for centralizing it in the routing
authority instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

CI went red on 1a411e1c2 across every Rust job — WASM, both test shards, lint, coverage-gate, AI gate, perf gate. Fixed at 49e22f56c. Reporting it plainly because the cause is worth a decision from you, not just a patch.

What happened

Not the round-11 logic. main added a 128th GameAction variant (EndContinuousEffect, #6697-era) while this branch carried the 127-arm exhaustive match from the last round. CI builds the merge with main, so the match was non-exhaustive there: one compile error, every downstream Rust job failed. Everything passed locally against the un-merged branch, which is exactly why it wasn't caught before pushing — my mistake for verifying against the branch rather than the merge.

Fixed by merging upstream/main (clean; this also clears the BEHIND state that was blocking merge anyway) and classifying the new variant neutral — ending a continuous effect draws no card.

The decision I'd like your call on

The exhaustive match was the [LOW] nit from your last review, and it went stale within hours of landing. That isn't bad luck; it's structural. An exhaustive 128-arm GameAction match inside one AI policy means every future action added anywhere in the repo breaks this policy's build, and the breakage surfaces only in merge-CI, not in the contributing PR.

Exhaustiveness inherently means new variants break something — the question is where. Three options as I see them:

  1. Keep it here (current state). You get the forcing function; the cost is recurring merge-CI breakage in an unrelated file, paid by whoever adds an action.
  2. Centralize it. decision_kind::classify is already the routing authority and already exhaustive over WaitingFor for precisely this reason — its doc says "adding a new WaitingFor variant forces a compile error here, ensuring no decision can silently bypass policy routing." Its priority-time GameAction arm is itself a wildcard today. Making that exhaustive gives one break site, serves every policy, and puts the invariant where the routing contract already lives.
  3. Revert to the wildcard and accept the original silent-bypass risk.

I've implemented (1) because that's what was asked and it unblocks you now. I think (2) is the right architecture and I'm happy to do it, but it touches shared routing rather than this axis, so I'm not expanding scope into it unilaterally — say the word and it's a small change.

Related finding, unchanged

Still worth flagging from the last round: the cast-shaped siblings (CastSpellAsMadness, CastSpellForFree, Foretell, ActivateNinjutsu, …) route to DecisionKind::ActivateAbility via the priority catch-all and so reach this policy, but PolicyContext::cast_facts is populated only for the plain CastSpell seam. They're classified neutral with a comment saying why, so a madness-cast cantrip currently gets no draw payoff. Visible gap with a named fix site rather than silence; widening cast_facts is a follow-up if you want it.

Verification at 49e22f56c (post-merge)

  • cargo check --package engine-wasm --target wasm32-unknown-unknown — clean (the job that failed)
  • cargo clippy -p phase-ai --all-targets -- -D warnings — clean
  • cargo test -p phase-ai --lib1636 passed; 0 failed
  • cargo test -p engine --test integration draw_preflight_matches_live_pipeline8 passed; 0 failed, re-run against the merged engine specifically to confirm the shared draw classifier still agrees with the live pipeline after main's changes
  • cargo fmt --all — clean
  • Gate A — Gate A PASS head=49e22f56c7608505921c9fdcd03319f67e46cb43

@matthewevans matthewevans self-assigned this Jul 27, 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.

Reviewed current head 49e22f5.

[MED] Zero-quantity candidate draws are rewarded as deliverable. Evidence: crates/phase-ai/src/features/draw_matters.rs:119-123 recognizes every controller-targeted Effect::Draw without examining its count, and crates/phase-ai/src/policies/draw_payoff.rs:117-133 subsequently asks only whether the player could draw one card in general. The live resolver instead resolves that effect quantity at crates/engine/src/game/effects/draw.rs:157-162; a fixed zero or a resolved X == 0 therefore produces no CardDrawn event. Why it matters: with a nonempty library and a live draw engine, a candidate whose own instruction draws zero receives the positive draw-payoff score even though it cannot trigger that engine. Suggested fix: require a positive candidate-specific resolved draw quantity (and conservatively stay neutral until an unbound dynamic quantity is known positive), then add a registry-routed fixed-zero and bound-X-zero regression paired with the live resolver no-event assertion.

@matthewevans matthewevans removed their assignment Jul 27, 2026
…idates (round 12)

`is_draw_source_parts` matched every controller-targeted `Effect::Draw`
without inspecting its count, while the resolver resolves that quantity
(`resolve_quantity_with_targets(..).max(0)`) and emits `CardDrawn` only per
delivered card. A candidate whose own instruction draws ZERO therefore received
the full draw-payoff bonus despite being unable to fire any engine. The
player-level delivery gate added last round does not catch this: it asks whether
the PLAYER can draw, not whether this CANDIDATE draws.

Parameterizes the one classifier with a typed `DrawQuantity` rather than forking
a second copy: `Any` for deck-time classification (a "draw X" card is still an
enabler for archetype detection, its count unknowable at deck-build time) and
`ResolvesPositive` for a live candidate. The positive check delegates to the
engine's `resolve_quantity` authority instead of re-deriving quantity semantics,
so it agrees with the resolver by construction.

X is genuinely unbound at the candidate seam — `resolve_quantity` supplies no
`chosen_x` (only `resolve_quantity_with_targets` does, from a `ResolvedAbility`
a spell still being announced does not have), so every `Variable{X}` draw
resolves to zero and stays conservatively neutral until known positive.

Tests: registry-routed fixed-zero (withheld) and fixed-positive (rewarded)
pair; an X case asserting neutrality for both unset and set `cost_x_paid`,
documenting that a stale value from an earlier activation must not be mistaken
for this candidate's X; and a live-resolver assertion in the equivalence suite
that a zero-count draw emits no `CardDrawn`, paired with a positive control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

Fixed at 7b8bdd3b4. You're right, and the gap is a real one my last round's fix could not have caught.

The finding

is_draw_source_parts matched every controller-targeted Effect::Draw without inspecting its count, while the resolver resolves that quantity and emits CardDrawn only per delivered card. So a candidate whose own instruction draws zero collected the full payoff bonus with a live engine and a full library. The player-level gate I added last round asks whether the player can draw — a different question from whether this candidate draws.

The fix

Parameterized the single classifier with a typed DrawQuantity rather than forking a second copy of it:

  • Any — deck-time. A "draw X" card is still a draw enabler for archetype classification; its count is unknowable at deck-build time.
  • ResolvesPositive { state, controller, source } — live candidate. Delegates to the engine's resolve_quantity authority instead of re-deriving quantity semantics, so it agrees with the resolver by construction rather than by inspection. Same principle as the replacement classifier last round.

One correction to your suggested regression

You asked for a bound-X-zero case. I wrote one, it failed, and the failure was correct: there is no reachable bound-X state at this seam.

resolve_quantity supplies no chosen_x — only resolve_quantity_with_targets does, reading it off a ResolvedAbility, which a spell still being announced does not have (GameAction::CastSpell carries no X; X is chosen later at WaitingFor::ChooseXValue). So every Variable { "X" } draw resolves to zero here and is conservatively neutral, which is the "stay neutral until known positive" half of your suggestion, reached structurally rather than by a special case.

Rather than force a passing assertion, the test now pins the real behavior: neutral for both an unset and a set cost_x_paid. The Some(2) half matters independently — cost_x_paid is object state that can survive from an earlier activation, and it must not be mistaken for this candidate's X. Pinning the equivalence means a future change that does bind X here has to make a deliberate decision about which value the policy trusts, instead of silently picking up a stale one.

The live-resolver assertion

Added to the equivalence suite: resolving Effect::Draw { count: Fixed(0) } emits no CardDrawn, paired with a Fixed(1) positive control so it cannot pass by the resolver simply breaking. That pins the live fact the new gate models, in the same file that pins the replacement legs.

Verification at 7b8bdd3b4

  • cargo test -p phase-ai --lib1639 passed; 0 failed (registry-routed fixed-zero withheld / fixed-positive rewarded pair, plus the X neutrality case)
  • cargo test -p engine --test integration draw_preflight_matches_live_pipeline9 passed; 0 failed
  • cargo clippy -p engine --all-targets --features proptest -- -D warnings — clean
  • cargo clippy -p phase-ai --all-targets -- -D warnings — clean
  • cargo fmt --all — clean
  • Gate A — Gate A PASS head=7b8bdd3b4b9fe297d1cb16cd4597235d0dd30baa

Prior head 49e22f56c was green on every required check (both Rust shards, lint, coverage-gate, WASM, card-data, frontend, Tauri, perf gate); the paired-seed AI gate was cancelled rather than failed, and re-runs on this head.

@matthewevans matthewevans self-assigned this Jul 27, 2026
@matthewevans

Copy link
Copy Markdown
Member

Review hold for current head 7b8bdd3b4b9fe297d1cb16cd4597235d0dd30baa.

The current source review confirms that live candidate detection now requires a positive engine-resolved draw quantity, while deck classification retains its intentional potential-draw behavior. The zero-count regression is paired with the real draw pipeline and the parser diff confirms no card blast radius. However, the paired-seed AI gate and decision-cost performance gate are still running, so this head is not approvable or queueable yet. Please let those current-head gates complete, then request another review.

@matthewevans matthewevans removed their assignment Jul 27, 2026
@minion1227
minion1227 requested a review from matthewevans July 27, 2026 22:28
@minion1227

Copy link
Copy Markdown
Contributor Author

@matthewevans — ready for re-review at 7b8bdd3b4.

The standing CHANGES_REQUESTED was submitted against 49e22f56c at 18:47Z; the fix for its finding landed at 20:12Z, so the review predates it.

Every required check is green

14 pass / 3 skip. Both Rust shards, Rust lint, coverage-gate, WASM, card-data, frontend, Tauri, lobby worker, decision-cost perf gate, and the paired-seed AI gate — the last of which had never completed a run on an earlier head (cancelled once, blocked by the compile break before that), so this is its first clean pass on this work.

The one red check is Contributor trust (superagent, action_required, 35/100). It is an account-reputation signal, not a repository check: it fails identically on every head including those where all Rust jobs were green, and nothing in the diff can move it. Flagging it so it isn't mistaken for a code failure.

Findings from your last three reviews

Finding Head Status
[MED] Preflight ran before the card-local test 1a411e1c2 candidate_draws_structurally (candidate AST only) now gates draw_is_deliverable. The module doc had been asserting this contract while the code did the opposite; it is now true.
[LOW] Wildcard _ => false 1a411e1c2 Exhaustive over all GameAction variants, derived from the enum definition; correct pattern shape for unit/tuple variants.
[LOW] MAX_REWARDED_ENGINES uncovered 1a411e1c2 Capped case + below-cap scaling control, so the pair pins a cap rather than a constant; constant is pub(crate) so the assertion tracks it.
[HIGH] Match not exhaustive against main 1a411e1c2 Merged main, classified EndContinuousEffect. Also cleared the BEHIND state.
[MED] Zero-quantity draws rewarded 49e22f56c Typed DrawQuantity parameter on the one classifier; live path requires a positive count via the engine's resolve_quantity.

Two things I did not silently absorb

The exhaustive match is in the wrong home. It went stale against main within hours of landing and took every Rust job down with one E0004, because CI builds the merge. That will recur on every future GameAction addition, from any unrelated PR. decision_kind::classify is already the routing authority and already uses exactly this forcing function for WaitingFor; moving it there gives one break site serving every policy. I implemented what you asked and left the architecture question open rather than deciding it myself — happy to do it if you want it.

Your suggested bound-X-zero regression has no reachable state. resolve_quantity supplies no chosen_x; only resolve_quantity_with_targets does, from a ResolvedAbility an announcing spell does not have. Every Variable { "X" } draw resolves to zero here, which is the conservative half of your suggestion reached structurally. The test pins neutrality for both unset and set cost_x_paid — the latter matters because cost_x_paid can survive from an earlier activation and must not be read as this candidate's X.

Local verification at this head

cargo test -p engine --lib 17817 passed · cargo test -p phase-ai --lib 1639 passed · draw_preflight_matches_live_pipeline 9 passed · cargo clippy -p engine --all-targets --features proptest -- -D warnings clean · cargo clippy -p phase-ai --all-targets -- -D warnings clean · Gate A PASS head=7b8bdd3b4b9fe297d1cb16cd4597235d0dd30baa

@matthewevans matthewevans self-assigned this Jul 27, 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 at 7b8bdd3: the current implementation uses the engine-owned quantity, draw-delivery/replacement, and trigger-legality authorities; the zero/positive quantity regression is registry-routed and paired with a real draw-pipeline assertion. Current-head required checks, paired-seed gate, and decision-cost performance gate are green; the parse-diff artifact reports no card-parse changes.

@matthewevans
matthewevans added this pull request to the merge queue Jul 27, 2026
@matthewevans matthewevans removed their assignment Jul 27, 2026
Merged via the queue into phase-rs:main with commit 7d63341 Jul 28, 2026
17 of 18 checks passed
@minion1227
minion1227 deleted the minion_draw_matters_axis branch July 28, 2026 03:39
minion1227 added a commit to minion1227/phase that referenced this pull request Jul 28, 2026
Sibling PR phase-rs#6688 (draw-matters axis) merged as 7d63341, which collided
with this branch in five files. Resolutions:

- phase-ai config.rs / features/mod.rs / policies/registry.rs: keep both
  axes. Ordering follows the files' append-chronological convention, so
  draw-matters (already on main) precedes cycling.
- engine ability_utils.rs: keep this branch's `resolve_legal_modal_choice`
  delegation over main's open-coded
  filter_modes_by_target_legality + modal_choice_with_target_assignment_limit.
  The authority runs those exact steps internally, so the delegation
  subsumes main's behaviour and satisfies the single-authority contract
  this PR was asked for. Same for the Unimplemented placeholder check:
  a modal root with NO modes is still a real gap, because then the
  placeholder itself is what would resolve.
- engine triggers.rs: keep the gated `Effect::unimplemented(..)`
  constructor (CLAUDE.md forbids hand-built `Effect::Unimplemented`
  literals), plus this branch's `announced_modal_choice` field and its
  round-10 regression.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:ai AI opponent area:engine Core rules engine contributor:flagged Contributor flagged for review by trust analysis. enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants