Skip to content

feat(phase-ai): add discard-matters deck-feature axis + DiscardPayoffPolicy - #6786

Merged
matthewevans merged 4 commits into
phase-rs:mainfrom
minion1227:minion_discard_matters_axis
Jul 29, 2026
Merged

feat(phase-ai): add discard-matters deck-feature axis + DiscardPayoffPolicy#6786
matthewevans merged 4 commits into
phase-rs:mainfrom
minion1227:minion_discard_matters_axis

Conversation

@minion1227

@minion1227 minion1227 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a discard_matters deck-feature axis and its companion DiscardPayoffPolicy to phase-ai, so the AI can see that in some decks discarding is the payoff, not the cost.

CR 701.9: with Archfiend of Ifnir, Bone Miser, Waste Not or Containment Construct on the battlefield, every card pitched is a repeatable value trigger. The AI's instinct is the opposite — card_advantage scores a card leaving hand as a loss, and nothing credits the trigger it fires — so it declines its own engine, routing around rummaging outlets and treating a discard cost as pure downside.

Files changed

  • crates/phase-ai/src/features/discard_matters.rs (new)
  • crates/phase-ai/src/features/tests/discard_matters.rs (new)
  • crates/phase-ai/src/policies/discard_payoff.rs (new)
  • crates/phase-ai/src/policies/tests/discard_payoff.rs (new)
  • crates/phase-ai/src/features/mod.rs
  • crates/phase-ai/src/features/tests/mod.rs
  • crates/phase-ai/src/policies/mod.rs
  • crates/phase-ai/src/policies/tests/mod.rs
  • crates/phase-ai/src/policies/registry.rs
  • crates/phase-ai/src/config.rs

Track

Developer

LLM

Model: claude-opus-5
Tier: Frontier
Thinking: high

Implementation method (required)

Method: not-applicable — this is AI heuristic code under crates/phase-ai/. No crates/engine/ file is touched: no parser, effect-resolution, targeting, stack or rules-behavior change. The axis reads already-parsed AST through existing engine authorities.

CR references

  • CR 701.9 — Discard (the keyword action this axis is built on). Verified at docs/MagicCompRules.txt:3327.
  • CR 107.1b — a resolved quantity of zero moves no card, so a zero-count discard emits no event and fires no engine.
  • CR 700.2 — modality: a live candidate is scored before modes are chosen, so only an unconditional discard is credited.
  • CR 603.3d — trigger target legality, via the engine's hypothetical_trigger_fireable preflight.

Every number was grep-verified against docs/MagicCompRules.txt before being written. No CR annotation is attached to ordinary implementation logic.

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.

  • Gate A output below is for the current committed head.

  • Final review-impl below is clean for the current committed head.

  • Both anchors cite existing analogous code at the same seam.

  • cargo test -p phase-ai1801 passed, 0 failed, plus every integration binary green (including every_policy_penalty_is_tuning_registered_or_explicitly_untuned, which enforces that both new PolicyPenalties fields are registered).

  • cargo test -p phase-ai --lib discard — 60 passed, 0 failed.

  • cargo clippy -p phase-ai -p phase-engine --all-targets --features proptest -- -D warnings — exit 0.

  • ./scripts/check-engine-authorities.sh — exit 0.

  • cargo fmt --all — clean.

Mutation evidence that the guards are not vacuous. Four invariants were broken individually and five tests went RED: dropping the Effect::DiscardCard sibling (fails at BOTH the deck-time and the live seam), dropping the opponent-scope guard, admitting CycledOrDiscarded, and dropping the SelfRef madness exclusion.

One note on method, because it nearly produced a false conclusion: with all four mutations applied at once, opponent_discard_is_not_a_source did not fail — the DiscardCard mutation had disabled the very variant that test's fixture uses, masking the scope mutation. Re-run in isolation it fails correctly. Combined mutations can hide a real guard and make it look vacuous.

cargo ai-gate is not attached: no quick-filter deck in the suite clears DISCARD_MATTERS_FLOOR, so the policy is inert there, and the gate has been timing out at its 60-minute job limit on recent heads. Happy to attach a paired-seed control if you want it on this head.

Gate A

Gate A PASS head=275d6812376e64a42508c33b933bb3238050caa0 base=1604a6f3023413a34740d6d268d77872b3150b0f

Anchored on

  • crates/phase-ai/src/features/draw_matters.rs:75 — the established axis shape this mirrors: detect() folding per-DeckEntry CardFace AST into counts, pub(crate) parts-based predicates shared with the policy, and a commitment scalar; :248 is the same two-pillar commitment::geometric_mean with a calibration anchor in its docstring.
  • crates/phase-ai/src/policies/draw_payoff.rs:45 — the same TacticalPolicy shape: activation() as a single floor-plus-scale knob, cheap-to-expensive verdict() ordering where a card-local AST check rejects non-members before any board scan, the hypothetical_trigger_fireable liveness preflight, MAX_REWARDED_ENGINES as a pub(crate) cap the bounded-score test asserts against, and the exhaustive GameAction match so a new variant fails at compile time.

Final review-impl

Final review-impl PASS head=275d6812376e64a42508c33b933bb3238050caa0

Self-review before submission caught and fixed two things:

  1. A wildcard _ => false on the GameAction match. Replaced with the exhaustive 126-variant enumeration the house idiom requires, so a newly added action fails this match at compile time instead of silently bypassing the payoff.
  2. A calibration anchor asserted from intuition. The docstring claimed ≈0.84 for the pitch shell; the formula actually returned exactly 1.0, because the test deck saturated both pillars. Rather than loosen the assertion I recomputed the real curve, moved the anchor to a realistic deck (10 outlets + 4 engines → 0.878), raised the payoff saturation from 5 to 8 per 60, and pinned all three anchors — 0.878 / 0.481 / 0.196 — to the values the code returns.

Claimed parse impact

None. No parser, card-data or Oracle-text code is touched.

Scope Expansion

None. One feature axis and one policy, in new files, plus their registration sites.

Validation Failures

None.

CI Failures

None known at submission.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a “discard matters” deck evaluation to identify self-discard enablers, “whenever you discard” payoff engines, and compute a commitment score.
    • Introduced a dedicated discard-payoff scoring policy with separate verdict routing for controller-discard engines.
    • Added a configurable discard-payoff bonus (reward capped across credited engines).
  • Bug Fixes
    • Tightened discard eligibility to prevent credit for conditional discards, opponent discard, unsupported triggers, self-referential/cycling triggers, and zero-card discards.
  • Tests
    • Expanded unit tests covering discard-as-cost classification and commitment/policy gating (floor, clamping, routing, and reward caps).

@minion1227
minion1227 requested a review from matthewevans as a code owner July 29, 2026 11:05
@superagent-security

Copy link
Copy Markdown

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

@superagent-security superagent-security Bot added the contributor:flagged Contributor flagged for review by trust analysis. label Jul 29, 2026
@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 29, 2026
@coderabbitai

coderabbitai Bot commented Jul 29, 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 detection for self-discard sources and payoff engines, integrates the resulting commitment feature into deck analysis, and introduces a registry-enabled policy that rewards qualifying discard actions using live battlefield triggers.

Changes

Discard payoff evaluation

Layer / File(s) Summary
Discard matters feature
crates/phase-ai/src/features/discard_matters.rs, crates/phase-ai/src/features/mod.rs, crates/phase-ai/src/features/tests/*
Classifies controller-discard sources and payoff triggers, computes normalized commitment, exposes the feature through DeckFeatures, and tests detection, calibration, and discard-cost behavior.
Discard payoff policy
crates/phase-ai/src/config.rs, crates/phase-ai/src/policies/discard_payoff.rs, crates/phase-ai/src/policies/mod.rs, crates/phase-ai/src/policies/registry.rs
Adds the configurable bonus and registered tactical policy, validates positive controller-discard quantities, checks live battlefield engines, and caps engine-based scoring.
Policy behavior validation
crates/phase-ai/src/policies/tests/*
Tests activation thresholds, discard classification, trigger eligibility, score limits, discard costs, and registry routing.

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

Suggested reviewers: matthewevans

Sequence Diagram(s)

sequenceDiagram
  participant DeckFeatures
  participant DiscardMatters
  participant DiscardPayoffPolicy
  participant BattlefieldTriggers
  DeckFeatures->>DiscardMatters: analyze discard sources and payoff engines
  DiscardMatters-->>DeckFeatures: return commitment feature
  DiscardPayoffPolicy->>DiscardPayoffPolicy: classify candidate controller discard
  DiscardPayoffPolicy->>BattlefieldTriggers: check live discard payoff triggers
  BattlefieldTriggers-->>DiscardPayoffPolicy: return fireable engine count
  DiscardPayoffPolicy-->>DeckFeatures: return capped policy verdict
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main changes: a new discard-matters feature axis and DiscardPayoffPolicy in phase-ai.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

…Policy

CR 701.9: a deck built on Archfiend of Ifnir, Bone Miser, Waste Not or
Containment Construct *wants* to discard — every card pitched is a repeatable
value trigger. The AI has the opposite instinct: `card_advantage` scores a card
leaving hand as a loss, and nothing credits the trigger it fires. So the AI
declines its own engine, routing around rummaging outlets and treating a discard
cost as pure downside even when the discard IS the payoff.

Feature (`features/discard_matters.rs`), structural over `CardFace` AST:
- `source_count` — self-discard outlets. BOTH enabler spellings are read:
  `Effect::Discard` (rich form) and `Effect::DiscardCard` (older simple form).
  They are separate `Effect` variants with separate resolver paths and real
  cards use each, so classifying only one would silently drop half the class.
- `payoff_count` — `TriggerMode::Discarded` / `DiscardedAll` engines, keeping
  only controller-scoped ones (a "whenever an opponent discards" punisher is a
  different deck) and excluding `SelfRef` triggers, so a pile of madness cards
  cannot masquerade as an engine base.
- `commitment` — geometric mean over (source, payoff): both pillars mandatory.
  Discard with no engine is pure card disadvantage and the AI is RIGHT to avoid
  it; this axis must not push it to.

Policy (`policies/discard_payoff.rs`, `CastSpell` + `ActivateAbility`) credits a
discard only when a LIVE engine is on the battlefield, preflighted through the
engine's `hypothetical_trigger_fireable` authority so a rate-limited, off-timing
or no-legal-target engine is not credited value it cannot produce. The card-local
check runs first, so every non-discard candidate is rejected after reading one
card's AST — no board sweep, no affordability query in the search inner loop.

Boundaries, stated in the module docs so they are reviewable rather than
inferred:
- `hand_disruption` scores OPPONENT discard (`TargetFilter::Opponent`); this axis
  is the disjoint half (`Controller`). The scope that qualifies here is exactly
  the one that disqualifies there, so the two never read the same card.
- `cycling_discipline` owns when to pay a cycling cost. `TriggerMode::
  CycledOrDiscarded` is deliberately NOT read here — counting it would
  double-score one card across two policies with different intents.

Calibration anchors are computed, not asserted from intuition: 10 outlets +
4 engines over 36 nonland → 0.878; 6 + 2 → 0.481; incidental 2 + 1 → 0.196,
below the 0.35 floor. Each is pinned by a test to the value the formula returns.

Both scoring scalars land in `UNTUNED_POLICY_PENALTY_FIELDS` pending a
paired-seed calibration.

60 tests. Five are proven RED by breaking the specific invariant each guards:
dropping the `Effect::DiscardCard` sibling (fails at BOTH the deck-time and live
seams), dropping the opponent-scope guard, admitting cycling triggers, and
dropping the `SelfRef` madness exclusion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@minion1227
minion1227 force-pushed the minion_discard_matters_axis branch from 50d2a46 to 137e0bc Compare July 29, 2026 11:41
@matthewevans matthewevans self-assigned this Jul 29, 2026
@minion1227

Copy link
Copy Markdown
Contributor Author

Rebased onto main at 137e0bc3eaf0062aa3d8353fdba03f8f14ec9a4b — the branch was CONFLICTING and is now MERGEABLE.

The conflict was self-inflicted and worth naming: this branch was cut before #6743 (the cost-reduction axis) merged as 3920a0f81. Both PRs add a deck-feature axis, so they register at the same six sites — pub mod, pub use, the DeckFeatures field, the analyze() call, PolicyId + Box::new(), and PolicyPenalties — landing in the same alphabetical neighbourhoods.

Five files auto-merged. config.rs conflicted in four hunks, all "both sides added adjacent lines". Two of them needed care rather than a mechanical keep-both, because the trailing } / ), sat outside the conflict markers and closed only one side's block:

<<<<<<< HEAD
fn default_cost_reduction_defer_penalty() -> f64 {
    -0.25
=======
fn default_discard_payoff_bonus() -> f64 {
    0.6
>>>>>>>
}                     <- shared, closes only ONE of them

A naive concatenation there yields an unclosed function and a nested one. Both hunks were resolved by hand so each side keeps its own closing token.

I then explicitly verified that the merged cost-reduction registrations survived at every site, since silently dropping them would revert landed work rather than just break the build:

  • features/mod.rs — both pub mod, both pub use, both DeckFeatures fields, both detect() calls
  • policies/mod.rs — both module declarations
  • policies/registry.rs — both PolicyId variants, both Box::new registrations
  • config.rs — all three penalty fields present in the struct, the Default impl, the default_* fns, and UNTUNED_POLICY_PENALTY_FIELDS
  • both tests/mod.rs

Verification on the rebased head:

  • cargo test -p phase-ai — full suite green, including every_policy_penalty_is_tuning_registered_or_explicitly_untuned, which is the mechanical check that no penalty field was lost or orphaned in the resolution.
  • cargo test -p phase-ai --lib cost_reduction44 passed (the already-merged axis is intact).
  • cargo test -p phase-ai --lib discard — 60 passed.
  • cargo clippy -p phase-ai -p phase-engine --all-targets --features proptest -- -D warnings — exit 0.
  • ./scripts/check-engine-authorities.sh — exit 0.
  • Gate A PASS head=137e0bc3eaf0062aa3d8353fdba03f8f14ec9a4b base=1604a6f3023413a34740d6d268d77872b3150b0f

The ## Gate A line in the body has been updated to this head.

@matthewevans
matthewevans force-pushed the minion_discard_matters_axis branch from d0cbea3 to 137e0bc Compare July 29, 2026 11:44

@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 — the stated activated-discard use case is not reached.

🔴 Blocker

discard_matters.rs:104-108 and 187-204 only classify Effect::Discard / Effect::DiscardCard; discard_payoff.rs:148-155 applies that same effect-only predicate to an activated ability. Real rummaging outlets pay AbilityCost::Discard (ability.rs:8309-8320), which this path never reads, so activating the Wild Mongrel/Anje class remains neutral even with a live discard engine.

The claimed regression does not witness that path: policies/tests/discard_payoff.rs:62-70 constructs an activated ability whose effect is discard, while activated_rummaging_outlet_is_credited calls that helper at 304-319. It never supplies AbilityDefinition.cost = Some(AbilityCost::Discard { .. }).

Extend the shared discard-source classification to account for positive, controller-owned discard costs (including the appropriate Composite / OneOf semantics), use it for deck detection and activation scoring, and add a cost-backed runtime policy test that would fail without the change. The accidental fork-only release merge has been removed; this review is against current head 137e0bc3.

Recommendation: repair the cost path and resubmit for review.

@matthewevans matthewevans added the enhancement New feature or request label Jul 29, 2026
@matthewevans matthewevans removed their assignment Jul 29, 2026
…an effect

Addresses the blocker on phase-rs#6786.

The axis was built for the rummaging class — Wild Mongrel, Anje Falkenrath,
flashback-discard — and then never reached it. Those cards spell the discard as
`AbilityCost::Discard` (ability.rs:8309), while the source predicate read only
`Effect::Discard` / `Effect::DiscardCard`. So activating a real outlet stayed
neutral even with a live engine on the battlefield: exactly the case the policy
exists to fix.

Worse, the regression that claimed to cover it did not. `activated_rummaging_
outlet_is_credited` built an ability whose *effect* was a discard — a shape no
printed card has — so it exercised the effect path a second time and witnessed
nothing about costs. A test that constructs its own subject can confirm a
behavior that never occurs.

The fix extends the SHARED `is_discard_source_parts`, so deck detection and
activation scoring pick it up from one place rather than growing a second
cost-only path that could drift from the effect one:

- `ability_cost_discards` gates on the engine's own
  `AbilityDefinition::cost_categories()` authority, which already flattens
  `Composite`/`OneOf` nesting, before walking anything.
- `cost_discards` then walks for a discard the payer actually makes, reusing the
  existing `AbilityScope` parameter for the branch question. CR 601.2h: every
  component of a `Composite` is paid, so a discard inside it is guaranteed.
  CR 118.12a: a `OneOf` resolves to one branch, so its discard is something the
  DECK can plan around (`Potential`) but not something a live candidate is
  committed to (`Unconditional`) — crediting it there would score a discard the
  player may never make.
- Count positivity flows through the existing `DiscardQuantity` parameter, so a
  zero-count cost is not credited at the live seam.
- Non-recursive cost forms defer to `categories()` rather than a hand-written
  match arm: a newly added discarding cost variant is then picked up
  automatically, and the count check that path skips can only UNDER-credit.

A discard cost needs no controller filter — it is always paid by the activating
player, so there is no opponent-scoped case to exclude the way there is for
`Effect::Discard`.

Tests 60 → 68. Six of the eight new ones fail without the cost axis (three
deck-time, three live), including the real Wild Mongrel shape. The two negatives
— `one_of_cost_is_not_credited_at_the_live_seam` and
`zero_count_discard_cost_is_not_credited` — would pass vacuously against the old
code, so they were separately proven by mutating the `OneOf` branch gate and the
count check; both fail under that mutation.

Verified: `cargo test -p phase-ai` 1853 passed, `cargo clippy -p phase-ai
-p phase-engine --all-targets --features proptest -- -D warnings` exit 0,
`check-engine-authorities.sh` exit 0.

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

Copy link
Copy Markdown
Contributor Author

Fixed at 4db334089363fca94b7825164d38f094e2c4da83. You're right on both counts, and the second one is the part I want to own.

The blocker

The axis was built for the rummaging class and then never reached it. Wild Mongrel, Anje and flashback-discard all spell the discard as AbilityCost::Discard; the source predicate read only Effect::Discard / Effect::DiscardCard. Activating a real outlet stayed neutral even with a live engine out — precisely the case the policy exists to fix.

The test that didn't witness it

activated_rummaging_outlet_is_credited built an ability whose effect was a discard — a shape no printed card has. It exercised the effect path a second time and proved nothing about costs, while its name and my PR body both claimed the Wild Mongrel class was covered. I'd even read AbilityCost::Discard at ability.rs:8309 while verifying the AST, noted it was "the cost variant", and then built only the effect path. A test that constructs its own subject can confirm a behavior that never occurs, and this one did.

The fix

Extended the shared is_discard_source_parts rather than adding a cost-only path, so deck detection and activation scoring pick it up from one place and cannot drift:

  • ability_cost_discards gates on the engine's AbilityDefinition::cost_categories() authority — which already flattens Composite/OneOf nesting — before walking anything.
  • cost_discards then walks for a discard the payer actually makes, reusing the existing AbilityScope parameter for the branch question rather than inventing a second axis:
    • CR 601.2h — every component of a Composite is paid, so a discard inside it is guaranteed.
    • CR 118.12a — a OneOf resolves to one branch, so its discard is something the DECK can plan around (Potential) but not something a live candidate is committed to (Unconditional). Crediting it at the live seam would score a discard the player may never make.
  • Count positivity flows through the existing DiscardQuantity parameter, so a zero-count cost is not credited live.
  • Non-recursive cost forms defer to categories() instead of a hand-written match arm, so a newly added discarding cost variant is picked up automatically; the count check that path skips can only UNDER-credit, never over-credit.

A discard cost needs no controller filter — it is always paid by the activating player, so there is no opponent-scoped case to exclude the way there is for Effect::Discard.

Tests: 60 → 68

Six of the eight fail without the cost axis — three deck-time, three live — including the real Wild Mongrel shape (AbilityDefinition.cost = Some(AbilityCost::Discard { .. }), effect unrelated):

features::tests::discard_matters::detects_discard_cost_outlet                    FAILED
features::tests::discard_matters::deck_time_counts_a_one_of_discard_cost         FAILED
features::tests::discard_matters::zero_count_discard_cost_is_still_a_deck_time_outlet  FAILED
policies::tests::discard_payoff::activated_discard_cost_outlet_is_credited       FAILED
policies::tests::discard_payoff::composite_cost_containing_a_discard_is_credited FAILED
policies::tests::discard_payoff::discard_cost_without_an_engine_is_neutral       FAILED

The other two are negatives — one_of_cost_is_not_credited_at_the_live_seam and zero_count_discard_cost_is_not_credited — which would pass vacuously against the old effect-only code, since it credited nothing there either. Flagging that rather than folding them into the count: they were proven separately by mutating the OneOf branch gate to fire at Unconditional and dropping the count check, and both fail under that mutation.

Verification

  • cargo test -p phase-ai1853 passed, 0 failed
  • cargo clippy -p phase-ai -p phase-engine --all-targets --features proptest -- -D warnings — exit 0
  • ./scripts/check-engine-authorities.sh — exit 0
  • Gate A PASS head=4db334089363fca94b7825164d38f094e2c4da83 base=1604a6f3023413a34740d6d268d77872b3150b0f

The body's ## Gate A line is updated to this head. Noted also that the fork-only release merge was removed — thanks.

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

Copy link
Copy Markdown
Member

Maintainer fixup — current-head hold

The prior cost-axis blocker is resolved on this head. I also corrected the OneOf edge case: a live candidate now receives discard credit when every legal payment branch discards, while mixed discard-or-nondiscard costs remain neutral. The added regression covers that distinction.

Required CI, the paired-seed AI gate, decision-cost performance gate, and CodeRabbit restarted on 8e38e8b4d9; holding approval until those current-head signals settle.

@matthewevans matthewevans removed their assignment Jul 29, 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.

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/phase-ai/src/policies/tests/discard_payoff.rs`:
- Around line 503-506: Update the discard-payoff tests around
DiscardPayoffPolicy.verdict, including the cases near the referenced assertions,
to capture the returned score delta alongside reason. Assert a positive delta
for the guaranteed composite path and assert a zero delta for the zero-count and
no-engine neutral paths, while retaining the existing reason.kind checks.
- Around line 494-496: Add a test case in the discard payoff tests covering a
Composite cost containing Tap and a nested OneOf with Discard and PayLife, and
assert it returns discard_payoff_na with a zero delta. Ensure the test exercises
recursive classification so selecting the non-discard branch does not receive
credit.
🪄 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: c4670fe8-f8b0-432a-8372-a46d5230827c

📥 Commits

Reviewing files that changed from the base of the PR and between 137e0bc and 4db3340.

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

Comment thread crates/phase-ai/src/policies/tests/discard_payoff.rs
Comment thread crates/phase-ai/src/policies/tests/discard_payoff.rs Outdated
…ore deltas

Addresses the two CodeRabbit findings on phase-rs#6786, on top of the maintainer's
`OneOf` fixup.

1. Nested `OneOf` inside a `Composite` was unproven. A top-level `OneOf` and a
   flat `Composite` do not show that recursion carries the "not guaranteed"
   answer UP through the composite, so
   `Composite([Tap, OneOf([Discard, PayLife])])` could have been credited by
   treating any nested discard as certain. Added that case plus its positive
   twin, `Composite([Tap, OneOf([Discard, Discard])])`, so the boundary is
   pinned from both sides rather than only the rejecting one.

2. Several cost-path tests asserted only `reason.kind`, which would stay green
   if the guaranteed-composite path returned 0.0 or a neutral path returned a
   nonzero score. They now capture and assert `delta` too — including the
   maintainer's `one_of_cost_with_only_discard_branches_is_credited_at_the_live_seam`,
   which promised positive credit without checking it.

`empty_one_of_cost_is_not_credited` is deliberately NOT claimed as a guard on
the `!costs.is_empty()` precondition. Mutation testing showed it stays green
with that check deleted: an empty `OneOf` never reaches the walk, because the
engine's `cost_categories()` gate reports no `Discards` category for a branch
list with nothing in it. The test is kept because the outcome is worth pinning
at whichever layer enforces it, and its comment now says exactly that instead of
implying coverage it does not provide.

Tests 69 → 72. `composite_wrapping_a_mixed_one_of_is_not_credited` is proven RED
by flipping the `OneOf` live-seam quantifier from `all` to `any`.

Verified: `cargo test -p phase-ai` 1857 passed, `cargo clippy -p phase-ai
-p phase-engine --all-targets --features proptest -- -D warnings` exit 0.

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

Copy link
Copy Markdown
Contributor Author

Both CodeRabbit findings addressed at 275d6812376e64a42508c33b933bb3238050caa0, on top of your OneOf fixup.

Your correction is better than what I wrote — I blanket-rejected every OneOf at the live seam, which under-credits an ability that discards on every branch. Gating on "all branches discard" is the right rule, and I've left it as you wrote it.

1. Nested optional discard. Valid gap. A top-level OneOf and a flat Composite don't show that recursion carries the "not guaranteed" answer up through a composite, so Composite([Tap, OneOf([Discard, PayLife])]) could have been credited by treating any nested discard as certain. Added, plus its positive twin Composite([Tap, OneOf([Discard, Discard])]), so the boundary is pinned from both sides rather than only the rejecting one. The mixed case is proven RED by flipping the live-seam quantifier from all to any.

2. Assert deltas, not only reason IDs. Also valid — several cost-path tests would have stayed green if the guaranteed-composite path returned 0.0, or a neutral path returned nonzero. They now capture and assert delta. That includes your one_of_cost_with_only_discard_branches_is_credited_at_the_live_seam, which promised positive credit without checking it; I added the assertion rather than leaving the gap in place.

One thing I need to correct in my own work. I wrote empty_one_of_cost_is_not_credited claiming it guarded the !costs.is_empty() precondition you added. It does not. Mutation testing showed it stays green with that check deleted — an empty OneOf never reaches the walk, because cost_categories() reports no Discards category for a branch list with nothing in it, so the gate rejects it first.

I kept the test, because the outcome is worth pinning at whichever layer enforces it, but its comment now says exactly that instead of implying coverage it doesn't provide. Reporting it rather than letting "72 tests" carry an implication one of them doesn't earn. The !costs.is_empty() check is still correct as defence-in-depth if that gate is ever bypassed — it just isn't reachable from this path today.

Verification at this head:

  • cargo test -p phase-ai1857 passed, 0 failed
  • cargo clippy -p phase-ai -p phase-engine --all-targets --features proptest -- -D warnings — exit 0
  • Gate A PASS head=275d6812376e64a42508c33b933bb3238050caa0 base=1604a6f3023413a34740d6d268d77872b3150b0f

Tests 69 → 72. Body's ## Gate A line updated to this head.

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

Copy link
Copy Markdown
Member

Maintainer review — current-head hold

Reviewed current head 275d681. The two CodeRabbit findings are resolved: nested Composite/OneOf costs preserve the live all-branches-discard rule, and the affected tests now assert both reason and score delta. The standard CI, CodeRabbit, and security checks are successful. Holding only for the still-running paired-seed AI and decision-cost performance gates; no further code change is requested.

@matthewevans

Copy link
Copy Markdown
Member

Maintainer hold — paired-seed gate pending

Current head 275d681 remains manually reviewed with resolved CodeRabbit feedback. Standard CI and the decision-cost performance gate have passed. The sole remaining condition is the still-running paired-seed AI gate; once it completes successfully, approval and merge-queue enrollment can resume without another implementation review unless the head changes.

@matthewevans matthewevans removed their assignment Jul 29, 2026
@minion1227
minion1227 requested a review from matthewevans July 29, 2026 14:11
@minion1227

Copy link
Copy Markdown
Contributor Author

Re-review requested — the held condition is now met

The paired-seed AI gate has passed (gate pass), which was the sole remaining condition in your hold on 275d681.

Per your note, approval and merge-queue enrollment could resume once it completed successfully "without another implementation review unless the head changes" — the head has not changed. It is still 275d6812376e64a42508c33b933bb3238050caa0, the same commit you reviewed manually and confirmed had the CodeRabbit feedback resolved. I have deliberately pushed nothing since, precisely so that clause would still hold.

Current signals on that exact head:

Check Result
Manual implementation review ✅ (yours, on this head)
CodeRabbit ✅ pass — both findings resolved
Rust lint (fmt, clippy, parser gate)
Rust tests (shards 1/2, 2/2)
Card data (generate, validate, coverage)
Frontend / WASM / Tauri / Lobby worker
Decision-cost perf gate
Paired-seed AI gate pass
Superagent Security Scan
Contributor trust ❌ account-reputation scan (action_required), not a code signal

Contributor trust is the only red, and it is the same account-level flag that was present throughout #6743 without blocking it.

The CHANGES_REQUESTED state showing on the PR is the stale round-1 decision from before the cost-axis fix; your later comments on 4db3340 and 275d681 supersede it, but it cannot clear itself without a fresh review event — which is the main reason I am asking rather than simply waiting.

For completeness, I also ran the paired-seed control locally against this head rather than relying solely on CI, since that job had timed out at its 60-minute limit on three earlier runs across both PRs:

compare: 0 FAIL, 0 WARN, 3 PASS, 0 NEW, 0 REMOVED
red-mirror         40% -> 40%   flips W->L 0  L->W 0
affinity-mirror    40% -> 40%   flips W->L 0  L->W 0
enchantress-mirror 40% -> 40%   flips W->L 0  L->W 0

Zero flips on every deck, which is consistent with the policy being inert across the quick-filter suite — no gate deck clears DISCARD_MATTERS_FLOOR.

Happy to rebase, re-run anything, or take further findings if you would rather another pass. Thanks for the two fixups you pushed on this one — the OneOf all-branches rule is a better rule than what I wrote.

@matthewevans
matthewevans added this pull request to the merge queue Jul 29, 2026
@matthewevans matthewevans self-assigned this Jul 29, 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 — spell-cast discard costs are still outside the policy’s source authority.

🔴 Blocker

crates/phase-ai/src/policies/discard_payoff.rs:141-146 classifies every GameAction::CastSpell only through CastFacts::primary_effects. That collection is spell-resolution text only (crates/phase-ai/src/cast_facts.rs:224-229), so it cannot see the casting-cost authority on the object. The engine represents ordinary additional casting costs at GameObject::additional_cost / AdditionalCost::{Required, Optional, Kicker, Choice} (crates/engine/src/types/card.rs:196-200, crates/engine/src/types/ability.rs:8994-9030); it separately makes Retrace and Jump-start discard requirements part of cast legality (crates/engine/src/game/casting.rs:13068-13078). Consequently, a cast whose mandatory additional cost discards (the Abandon Hope / Big Score / Cathartic Reunion class) remains neutral even with a live payoff engine.

The added cost tests do not witness that production path: crates/phase-ai/src/policies/tests/discard_payoff.rs:466-597 constructs AbilityDefinition.cost on an ActivateAbility candidate. They validate activated costs only, not a CastSpell with additional_cost or a graveyard casting variant.

Extend the cast-side classification at the existing casting-cost authority. sacrifice_cost_mana_gate.rs:195-245 is the relevant pattern: it exhaustively distinguishes AdditionalCost::Required from optional/kicker/choice costs before inspecting the cost structure. Preserve that distinction here—mandatory discard may be credited, while an optional or selected-choice cost must be credited only when the candidate/selection proves it will be paid. Include retrace and jump-start in the same cast-cost seam, and add production-path policy tests for a mandatory spell additional cost, an unselected optional/choice cost, and the applicable graveyard-cast cases.

Recommendation: rework the CastSpell branch around casting-cost provenance and resubmit for review.

@matthewevans matthewevans removed their assignment Jul 29, 2026
Merged via the queue into phase-rs:main with commit 9f5ed5c Jul 29, 2026
19 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:flagged Contributor flagged for review by trust analysis. enhancement New feature or request needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants