Skip to content

fix(ai): cast-commit whiff for dynamic damage spells - #7091

Merged
matthewevans merged 19 commits into
phase-rs:mainfrom
CodeOptimist:main
Aug 11, 2026
Merged

fix(ai): cast-commit whiff for dynamic damage spells#7091
matthewevans merged 19 commits into
phase-rs:mainfrom
CodeOptimist:main

Conversation

@CodeOptimist

@CodeOptimist CodeOptimist commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Intro (human written)

Absolutely followed the /engine-implementer pipeline even though "not required".
This is the last remaining example in my Issue #6582 with the Slash of Light play.

Personal remarks

When I opened that issue I had no idea every example I provided was a separate bug. This has been an experience, my first with agentic coding, and it feels good to "close the loop" whether this PR is approved or not.
This is a second/third data point of whether deepseek-v4-flash-0731 can produce quality results. I would say /engine-implementer is mandatory, after all DS can very cheaply and rapidly execute it. I would hope that DeepSeek with engine-implementer > Sonnet without engine-implementer???
I am very nervous about polluting the codebase with contributions of insufficient quality, so I'm going to stay strictly in the non-engine lane (but using the pipeline), or just step back for a while... probably both. I do have other projects. 😅 (Though I need to publish my minion tweaks. 🤔)
I have the orchestration log of both this and the previous PR, which might be worth looking at.
I did have Gemini 3.1 Pro Extended compare the results of DeepSeek not using engine-implementer, versus using, for my earlier PR #7082 and it was a stark difference. I'm not suggesting DS be approved for any scope of work, but if so that should be mandatory. Maybe I need to open a "Evaluating DeepSeek" thread on the Discord or something and dump some files to collaborate on its evaluation? If I'm not taking a break entirely... that sounds like work. 😅
Cheers for now! 🍻

PR

Summary

Fixed a bug where the AI would waste dynamic damage spells (like Slash of Light) on creatures it couldn't kill by extending AntiSelfHarmPolicy. A new removal_lethality guard was added to evaluate dynamic damage amounts live at the cast-commit decision step, applying a soft penalty to prevent non-lethal whiffs.

Files changed

  • crates/phase-ai/src/policies/anti_self_harm.rs
  • crates/phase-ai/src/policies/removal_lethality.rs
  • crates/phase-ai/tests/ai_quality.rs

Track

Developer

LLM

Model: deepseek-v4-flash-0731
Tier: Frontier
Thinking: high

Implementation method (required)

Method: /engine-implementer
(But technically not-applicable — Changes are confined entirely to the AI policy layer (crates/phase-ai), with no modifications to engine game logic, parser, or rules behavior.)

CR references

CR 107.3a, CR 120.3, CR 701, CR 704.5f, CR 704.5g, CR 704.5h

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 fmt --all -- --check — clean (exit 0)

  • cargo clippy --all-targets -- -D warnings — clean

  • cargo test -p phase-ai — all suites pass (2033 lib + 23 ai_quality + integration; 0 failures)

  • cargo test -p phase-engine — 18,569 + 4,610 + others pass, 0 failures

  • cargo ai-gate — compare: 0 FAIL, 2 WARN, 1 PASS

Gate A

Gate A PASS head=6b0329fa2e1765838d07149cc669cdcdf5588615 base=61f559e6aa98270de447de7d26ce503ee4cf7561

Anchored on

  • crates/phase-ai/src/policies/anti_self_harm.rs:396 — analogous soft penalty for whiff detection (harmful-creature-only-no-target)
  • crates/phase-ai/src/policies/removal_lethality.rs:373 — analogous target-selection lethality gate primitive (lethality_bonus)

Final review-impl

Final review-impl PASS head=6b0329fa2e1765838d07149cc669cdcdf5588615

Claimed parse impact

None.

Scope Expansion

None.

Validation Failures

None.

CI Failures

None.

Summary by CodeRabbit

Bug Fixes

  • Improved spell decisions to avoid casting creature-targeting removal when no legal opposing creature can be defeated.
  • Better evaluates damage against targets with different toughness values and dynamically determined damage.
  • Preserves casting when at least one valid target can be defeated.
  • Prevents wasteful non-lethal casts from being selected or committed in high-difficulty gameplay.
  • Correctly handles nested target filters and mixed damage-and-destruction or damage-and-control spells when secondary effects remain useful.
  • Improved handling of untargeted mass removal without incorrectly applying targeting-immunity protections.

Problem:
The AI was committing non-lethal direct damage spells (like Slash of Light) against opponent creatures, effectively wasting the card. The existing cast-commit whiff detection (`AntiSelfHarmPolicy::score_pre_cast`) failed open for dynamic damage amounts (like `ObjectCount`) because the underlying `lethal_to_creature` helper returns `None` for non-fixed amounts.

Solution:
Extend `AntiSelfHarmPolicy` with a cast-commit lethality guard that evaluates dynamic damage amounts live.
* Introduced `removal_lethality::can_kill_any_legal_target`, which composes the existing `pending_damage_to_object` and `outcome_is_lethal` primitives to determine if the spell can actually kill any of its legal targets.
* Applied the established soft penalty (`wasted_cast_penalty`, -8.0) if the spell is harmful, targets only creatures, has opponent targets, but is provably non-lethal against all of them.
* Designed the gate to fail open (no veto) for variable-X spells, non-damage removal, or unknown damage sources at cast-commit to prevent over-blocking.

Validation:
* Flipped pinned reproduction tests for Slash of Light to successfully assert `PassPriority` over `CastSpell`.
* Added building-block and pipeline tests to prove the gate only blocks total whiffs (deferring partial whiffs to target selection), and safely ignores non-damage and variable-X spells.
@coderabbitai

coderabbitai Bot commented Aug 8, 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

The PR adds legal-target lethality analysis for harmful creature-targeting spells. The AI penalizes wasteful non-lethal casts, avoids committing total whiffs, and continues to prefer casts when at least one legal target is lethal.

Changes

Creature removal lethality

Layer / File(s) Summary
Legal-target lethality analysis
crates/phase-ai/src/policies/removal_lethality.rs
Adds can_kill_any_legal_target, recursive target-filter analysis, legal-target resolution, fail-open handling, and coverage for damage, variable damage, non-damage, self-only, control, and mixed effects.
Removal and protection classification
crates/phase-ai/src/policies/effect_classify.rs, crates/phase-ai/src/policies/self_protection_classify.rs
Exposes DestroyAll filters and excludes untargeted mass removal from targeting-immunity checks.
Cast-policy wasted-removal handling
crates/phase-ai/src/policies/anti_self_harm.rs
Adds a wasted-cast penalty for harmful creature-only spells that cannot lethally remove any legal opposing target.
End-to-end AI decision validation
crates/phase-ai/tests/ai_quality.rs
Adds Slash of Light tests for ranking, target selection, cast commitment, partial lethality, and mixed effects.

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

Sequence Diagram(s)

sequenceDiagram
  participant AI as anti_self_harm
  participant Lethality as removal_lethality::can_kill_any_legal_target
  participant Resolver as Legal target resolver
  AI->>Lethality: evaluate harmful creature-only spell
  Lethality->>Resolver: resolve legal opposing creatures
  Resolver-->>Lethality: targets and damage outcomes
  Lethality-->>AI: lethality result
  AI-->>AI: apply penalty or allow cast
Loading

Possibly related PRs

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing cast-commit whiffs for dynamic damage spells.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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 (2)
crates/phase-ai/tests/ai_quality.rs (2)

388-395: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Strengthen the reach-guard so the test cannot pass for the wrong reason.

!results.is_empty() does not prove the cast was ever offered. If the mana funding or the priority setup stops the engine from generating a CastSpell candidate for Slash of Light, the AI still produces a pass and unrelated downstream actions, so all three assertions hold and the test goes green without exercising the cast-commit gate.

Assert that the engine offers the cast before running the AI. Capture the spell id from add_spell_to_hand_from_oracle (currently discarded at Line 330) and check the candidate set.

💚 Proposed stronger reach-guard
-    scenario
+    let slash = scenario
         .add_spell_to_hand_from_oracle(
             P0,
             "Slash of Light",
             true,
             "Slash of Light deals damage equal to the number of creatures you control plus the number of Equipment you control to target creature.",
         )
         .id();
+    // Reach-guard: the engine must actually offer the cast, otherwise the
+    // assertions below would pass without reaching the cast-commit gate.
+    assert!(
+        engine::ai_support::candidate_actions(runner.state())
+            .iter()
+            .any(|candidate| matches!(
+                candidate.action,
+                GameAction::CastSpell { object_id, .. } if object_id == slash
+            )),
+        "the affordable Slash of Light cast must be a generated candidate"
+    );
+
     let ai_players = HashSet::from([P0]);
-    // Reach-guard: the engine's full Very Hard action pipeline gave the AI a
-    // chance to act (at least one decision was produced), proving the test did
-    // not short-circuit before the cast-commit gate could be evaluated. A pass
-    // (or any decision) reaching the arms above means the whiff guard fired.
-    assert!(
-        !results.is_empty(),
-        "Very Hard AI must produce at least one decision at the cast-commit step"
-    );
🤖 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/tests/ai_quality.rs` around lines 388 - 395, Strengthen the
reach guard in the test by retaining the spell ID returned from
add_spell_to_hand_from_oracle instead of discarding it, then verify the engine’s
candidate set contains a CastSpell action for that specific spell before running
the AI. Keep the existing results non-empty assertion as a separate downstream
guard.

275-286: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The whiff guard does not change behavior at Medium difficulty.

The comment states that at Medium the cast decision routes through the search path, which scores the whiff cast at approximately WIN_SCORE whether or not the penalty applies. The reported misplay therefore still reproduces for a Medium AI. The soft wasted_cast_penalty cannot outweigh a terminal-eval score of that magnitude.

Confirm this is an accepted limitation for the release. Do you want me to open an issue for the Medium search/terminal-eval artifact so the gap is tracked rather than only recorded in a test comment?

🤖 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/tests/ai_quality.rs` around lines 275 - 286, The
Medium-difficulty search/terminal-evaluation path still permits the whiff
misplay and is currently excluded from the test. Track this limitation
explicitly by opening or linking a follow-up issue for the Medium artifact, and
update the test comment to reference that tracking issue while preserving the
existing assertions.
🤖 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/removal_lethality.rs`:
- Around line 412-456: Update the harmful creature-only effect loop around
pending_damage_to_object to fail open whenever the spell contains any harmful
creature-only effect that is not Effect::DealDamage, preventing non-damage
removal from being evaluated with whole-spell damage. Separately scan every
DealDamage effect in ctx.effects() for amount.contains_x() before applying the
veto, including damage effects whose target filters are not creature-only;
preserve the existing lethal-target evaluation for damage-only spells.

---

Nitpick comments:
In `@crates/phase-ai/tests/ai_quality.rs`:
- Around line 388-395: Strengthen the reach guard in the test by retaining the
spell ID returned from add_spell_to_hand_from_oracle instead of discarding it,
then verify the engine’s candidate set contains a CastSpell action for that
specific spell before running the AI. Keep the existing results non-empty
assertion as a separate downstream guard.
- Around line 275-286: The Medium-difficulty search/terminal-evaluation path
still permits the whiff misplay and is currently excluded from the test. Track
this limitation explicitly by opening or linking a follow-up issue for the
Medium artifact, and update the test comment to reference that tracking issue
while preserving the existing assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cdfef3a2-64ed-4b28-a1b3-6a302abcac20

📥 Commits

Reviewing files that changed from the base of the PR and between 7262d7f and 6b0329f.

📒 Files selected for processing (3)
  • crates/phase-ai/src/policies/anti_self_harm.rs
  • crates/phase-ai/src/policies/removal_lethality.rs
  • crates/phase-ai/tests/ai_quality.rs

Comment thread crates/phase-ai/src/policies/removal_lethality.rs
@matthewevans matthewevans self-assigned this Aug 8, 2026
@matthewevans matthewevans added the bug Bug fix label Aug 8, 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.

🔴 Blocker — mixed removal spells are falsely penalized as damage whiffs

Reviewed 6b0329fa2e1765838d07149cc669cdcdf5588615.

crates/phase-ai/src/policies/removal_lethality.rs:412-456 evaluates each harmful creature-only effect, but pending_damage_to_object at :204-301 aggregates all DealDamage effects on the spell. For a mixed spell such as “deal 1 damage to target creature; destroy target creature,” the Destroy iteration receives the sibling one-damage outcome, sees a surviving target, and returns false. anti_self_harm.rs:412-416 then applies the anti-self-harm whiff penalty despite the Destroy effect being useful.

The existing pure-Destroy test at removal_lethality.rs:650-700 only covers PendingDamage::None, not this mixed interaction.

Please:

  1. Gate the veto to damage-only analyzable spells: fail open when any harmful creature-only effect is non-DealDamage.
  2. Fail open for variable-X damage found anywhere in the spell, including DealDamage effects whose filter is not creature-only.
  3. Add a mixed DealDamage(1) + Destroy regression proving it is not penalized as a whiff, plus an offered CastSpell production reach guard so the AI assertion cannot pass without exercising the cast path.

Do not seek approval until the mixed-effect behavior is covered and fresh CI is green.

@matthewevans matthewevans removed their assignment Aug 8, 2026
@matthewevans

Copy link
Copy Markdown
Member

@CodeOptimist human Matt here - I don't mind at all that you're trying DeepSeek - if you can have it follow the engine-implementer skill that's fantastic. I'm super curious as well! Also the review bot is not going to let crap code in - the question will be how many review iterations does it take. :) but happy to take contributions especially when this level of effort is given! Cheers :)

Problem:
The newly introduced cast-commit lethality guard (`can_kill_any_legal_target`) over-blocked certain viable spells by falsely identifying them as total damage whiffs. Because the underlying evaluation only aggregates `DealDamage` effects, mixed removal spells (e.g., "deal 1 damage to target creature; destroy target creature") were evaluated solely on their non-lethal damage component, incorrectly ignoring the independent `Destroy` line (CR 701.8a). Additionally, variable-X damage spells with non-creature-only target filters could be incorrectly penalized.

Solution:
Introduce explicit fail-open guards to `can_kill_any_legal_target` to ensure it only vetoes provable, pure damage whiffs:
* Fail open if the spell contains *any* harmful creature-only effect that is not `DealDamage`. This prevents false damage-whiff vetoes on mixed damage+destroy spells, recognizing the non-damage half as an independent, useful removal line.
* Fail open if *any* `DealDamage` effect references a variable `X` (CR 107.3a), including those with non-creature-only target filters. Since `X` is chosen at announcement, the damage amount cannot be definitively known at cast-commit.

Validation:
* Added building-block tests to verify the fail-open semantics for mixed damage+destroy spells and non-creature `DealDamage` `X` spells.
* Added a production-path differential test (`mixed_damage_and_destroy_is_not_penalized_as_a_damage_whiff`) proving that mixed spells are successfully evaluated and strictly outrank identical pure-damage whiffs.

@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/removal_lethality.rs`:
- Around line 421-425: Update the guard in the removal-lethality policy to also
accept independently useful contextual effects, including creature-targeting
Effect::GainControl, alongside harmful non-damage effects. Preserve the existing
harmful-effect handling and add a regression test covering nonlethal
Effect::DealDamage combined with Effect::GainControl, expecting the interaction
to fail open.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: be79c311-da00-4394-bf87-7a8054d81882

📥 Commits

Reviewing files that changed from the base of the PR and between 6b0329f and 3169a94.

📒 Files selected for processing (2)
  • crates/phase-ai/src/policies/removal_lethality.rs
  • crates/phase-ai/tests/ai_quality.rs

Comment thread crates/phase-ai/src/policies/removal_lethality.rs
@matthewevans matthewevans self-assigned this Aug 8, 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.

Current-head changes requested

Reviewed 3169a94f47d5d21ef696471a8dc9d67de7fc3300.

[MED] GainControl can still be falsely treated as a total removal whiff. effect_classify.rs:197-203 classifies Effect::GainControl as Contextual, while the new fail-open at removal_lethality.rs:421-425 only recognizes non-DealDamage effects classified Harmful. A mixed spell with nonlethal DealDamage and creature GainControl therefore reaches the damage-only loop at :443-479, returns false for a surviving target, and anti_self_harm.rs:412-416 applies the wasted-cast penalty to a useful control line. Add the narrow GainControl fail-open and a mixed nonlethal-damage + creature-GainControl regression. This confirms the current-head CodeRabbit thread.

[MED] The existing Very Hard end-to-end regression does not prove its cast path is reached. ai_quality.rs:326-398 discards Slash of Light's object id and only asserts that AI results are nonempty. A pass or unrelated action satisfies that assertion even if candidate_actions never offered Slash's CastSpell. Retain the spell id and assert the generated candidates contain that exact GameAction::CastSpell before running the AI; keep the nonempty-results assertion as the downstream guard.

The earlier mixed Destroy and any-DealDamage-X blockers are resolved at this head. Rust test shards are still pending, which is a separate required-check condition rather than the substantive block above. Keep auto-merge and merge-queue entry disabled until these two current-head gaps are fixed and fresh checks complete.

@matthewevans matthewevans removed their assignment Aug 8, 2026
The cast-commit lethality gate (can_kill_any_legal_target) only failed open
on Harmful non-DealDamage effects with creature-only filters, so a mixed
'deal damage to target creature; gain control of target permanent' spell
was modelled purely through its non-lethal damage half and wrongly
penalized as a total damage whiff. GainControl is classified Contextual
(effect_classify.rs:198); the fail-open now covers Harmful or Contextual
non-DealDamage effects whose filter can hit an opponent's battlefield
permanent (creature-typed, any-target, or any permanent-type filter,
CR 613.1b Layer 2), so an independent control-changing half-line keeps the
spell castable.

Also harden the Very Hard Slash-of-Light reach-guard: pin the spell's
object id and assert the scorer offers its exact CastSpell candidate before
the AI run, so a pass or unrelated action cannot satisfy the test vacuously.

Adds discriminating unit tests (creature and permanent GainControl
fail-open) and a production-path differential test proving a mixed
damage+GainControl spell outranks the identical pure-damage whiff.
…-open

The cast-commit guard's fail-open helper (targets_creature_or_permanent)
only recognized creature-typed, any-target, and flat permanent-type
filters. Parser-reachable shapes that also name battlefield permanents
fell through the catch-all and re-introduced the false whiff veto the
GainControl fail-open was built to close: TypeFilter::Non ("nonland
permanent", "noncreature"), nested TypeFilter::AnyOf, Kindred
(CR 308.1), and TargetFilter-level Or/And/Not disjunctions.

Restructure into two recursive predicates that fail open on every filter
shape not provably limited to non-permanents: filter_can_match_permanent
(TargetFilter-level: Any/Typed/Or/And/Not) and
type_filter_can_match_permanent (TypeFilter-level: permanent types,
recursive AnyOf, Non excluding Non(Permanent|Card|Any), Kindred). Drop
the mis-cited CR 608.2b (target-legality rule, unrelated to disjunction
semantics) in favor of referencing the engine's own filter-matching.

Adds a shared mixed damage+GainControl fixture and three discriminating
unit tests (AnyOf, Non(Land), TargetFilter::Or): each fails against the
old catch-all, proving the shapes stay fail-open.
The AnyOf discrimination test used a flat AnyOf([Artifact, Creature])
disjunction, which the pre-commit single-level arm already handled — the
test passed before the recursive helper existed and did not pin the
nested-AnyOf recursion added to close review LOW phase-rs#2. Swap the fixture to a
nested AnyOf(AnyOf(artifact, creature)) and add a sibling
AnyOf(Non(Land), Non(Creature)) case: both shapes fell through the old
flat arm to the catch-all, so each fails against the pre-commit code and
pins the recursion (including descent into negated inners) going forward.
Correct the doc comment to state the pre-commit limitation accurately.
comments cleanup by Gemini 3.1 Pro Extended

@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/removal_lethality.rs`:
- Around line 417-433: Update the non-DealDamage fail-open check in the
removal-lethality logic to determine eligibility from legal game objects rather
than filter shape. Classify non-targeted battlefield-removal effects such as
Effect::DestroyAll explicitly, and for targeted effects use find_legal_targets
to require an actual opposing battlefield permanent, respecting
TypedFilter.controller; add regressions for mixed damage plus DestroyAll and
controller-limited control effects.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1ba8d154-3ef5-4014-ad78-3e55b7b33035

📥 Commits

Reviewing files that changed from the base of the PR and between 3169a94 and 5c63806.

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

Comment thread crates/phase-ai/src/policies/removal_lethality.rs Outdated

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

Blocking for current head 5c638063dd1e82b3861493cc4c99304e8bea9e18.

The mixed-removal utility classifier still has high-severity false positives. removal_lethality.rs:426–433 and :502–532 infer usefulness from the filter shape but ignore TypedFilter.controller; an own-controller-constrained GainControl branch can therefore be credited as opposing-player removal when it has no legal opposing population. In addition, effect_classify.rs does not extract DestroyAll, so a mixed DestroyAll + damage effect is not classified consistently.

Please make the decision depend on the actual legal opposing target/population after applying the complete typed filter (including controller), rather than a filter-shape proxy. Extend the effect extraction to cover DestroyAll, and add regressions for (1) own-controller-constrained GainControl not being credited as removal and (2) a mixed DestroyAll + damage effect receiving the intended classification.

The earlier requested-changes review was on 3169a94…; this requirement applies to the current head.

…filter shape

The cast-commit gate's fail-open credited any Harmful/Contextual
non-DealDamage effect whose FILTER SHAPE could match a permanent,
ignoring TypedFilter.controller: an own-controller-constrained GainControl
branch ('gain control of target creature you control', CR 108.3/115.2b)
was credited as opposing removal with no legal opposing population, and a
mixed DestroyAll + damage spell was classified only through its damage half
because extract_target_filter never surfaced DestroyAll.

Replace the shape proxy (targets_creature_or_permanent and its
filter/type-filter helpers, deleted) with a population check:
effect_has_legal_opposing_line resolves the COMPLETE typed filter —
controller included — via the engine's find_legal_targets and credits the
effect only when a legal object under an opponent's control exists. A
wipe's real population is resolved the same way.

Surface Effect::DestroyAll's population filter in extract_target_filter:
unlike the SetTapState/Suspect All scopes (which keep a Single sibling and
stay hidden), a wipe is inherently mass (CR 701.8) and its target IS the
population it destroys, so removal classification now sees the wipe line of
a mixed spell consistently.

Regressions: own-controller GainControl veto (with the AI's own creature
present, so the veto proves the controller axis, not the empty set) and
mixed damage+DestroyAll fail-open, plus an extract_target_filter unit test
contrasting the Suspect/SetTapState carve-outs.
…-immunity

The population-based gate justified TypedFilter.controller semantics with
'CR 108.3 / CR 115.2b' — but 108.3 is the OWNER rule and 115.2b does not
exist. Use CR 108.4 (an object's controller) and CR 109.5 ('you' refers to
the object's controller) in all five sites.

Surfacing Effect::DestroyAll in extract_target_filter (previous commit)
made harmful_effect_uses_object_targeting classify a wipe as a
single-target effect, so CantBeTargeted/HexproofFrom/Protection grants were
wrongly credited as answering untargeted mass removal (CR 115.10a). Exclude
DestroyAll there — it is the only surfaced inherently-mass effect, exactly
restoring the pre-surfacing guarantee — and document the CR 115.10a /
702.11 / 702.16 / 702.18 rationale.
by Gemini 3.1 Pro Extended

@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/effect_classify.rs`:
- Around line 412-421: Remove Effect::DestroyAll from the targeting-filter
branch used by extract_target_filter, and preserve it through a separate
population-only path for mass effects. Update the helper boundary and its
callers, including find_legal_targets usage, so DestroyAll never supplies a
TargetFilter or receives shroud, hexproof, protection, or source-target legality
checks, while targeted Destroy, DealDamage, and RemoveCounter behavior remains
unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 654a9bac-83be-4273-a5f7-61d3de818980

📥 Commits

Reviewing files that changed from the base of the PR and between 5c63806 and c97eeb6.

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

Comment thread crates/phase-ai/src/policies/effect_classify.rs Outdated

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

Blocking for current head c97eeb6884f433644ec001aa1a18beb43b408ceb.

DestroyAll is still evaluated as if it had a legal target: effect_classify.rs:393–421 feeds it through find_legal_targets, and removal_lethality.rs:499–520 uses that result. The actual resolver is non-targeted and instead matches a population (destroy.rs:259–337). This misclassifies default board wipes and wipes whose prospective population is protected/otherwise excluded.

Please give mass removal a separate population-evaluation path that mirrors resolver semantics rather than target legality. Add regressions for both a default DestroyAll population and a protected/excluded population, then re-check the mixed-removal score against those cases.

The prior formal request was on 5c63806…; this requirement applies to the current head.

DestroyAll was fed through find_legal_targets (target legality) even though
the resolver (destroy.rs resolve_all) is NON-targeted and matches a
battlefield population: hexproof/protected opposing creatures were wrongly
excluded from the wipe's usefulness (CR 115.10a — an affected object is not
a target), and a declared TargetFilter::None read as an empty set instead
of the resolver's default all-creatures population (CR 701.8).

Dispatch Effect::DestroyAll in effect_has_legal_opposing_line to a new
resolver-mirroring path: mass_effect_has_opposing_population iterates the
battlefield, substitutes the default creature population for None, skips
indestructible (CR 702.12b), and matches the population via the engine's
matches_target_filter with a from_source_with_controller FilterContext —
the same primitives resolve_all uses, with no targeting exemptions.

Regressions: default-population wipe (mixed deal-1 + DestroyAll{None}
fails open; pre-fix the empty find_legal_targets set vetoed it) and a
hexproof-only opposing population (helper-level assert pins the
resolver-semantics seam; pre-fix target legality credited nothing), plus an
ai_quality differential proving a mixed damage + default-population wipe
outranks the identical pure-burn whiff through the full cast pipeline.
…test

The sibling test can_kill_fails_open_on_mixed_damage_and_destroy_all still
described the wipe population as resolving 'via find_legal_targets', but
that test's typed-creature DestroyAll now dispatches through the
resolver-mirroring mass path (mass_effect_has_opposing_population —
battlefield population via matches_target_filter, CR 115.10a) introduced in
a30eb9c. Update the test doc comment and assert message to name the mass
path; behavior and assertion are unchanged. Remaining find_legal_targets
mentions in the file are all in genuinely target-legality code or
historical pre-fix descriptions.

@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 (1)
crates/phase-ai/tests/ai_quality.rs (1)

794-801: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tighten the margin so the assertion pins the whiff penalty.

The bug under test applies a full wasted_cast_penalty (magnitude 8) to the mixed spell. The assertion accepts any gap above 1.0. If a later change applies a partial whiff penalty to the mixed spell, the gap shrinks but stays above 1.0, and this test still passes. The test then stops guarding the fix.

Both spells carry the identical DealDamage effect, so assert against the penalty magnitude rather than an arbitrary epsilon.

♻️ Anchor the bound to the penalty magnitude
     assert!(
-        mixed_score > pure_score + 1.0,
+        mixed_score >= pure_score + config.penalties().wasted_cast_penalty.abs(),
         "mixed deal-1 + default-population destroy-all ({mixed_score:.3}) must outrank \
          the identical pure burn whiff ({pure_score:.3}): the wipe's default \
          all-creatures population (CR 701.8 / destroy.rs `resolve_all`) makes the \
          3/3 a wipe target and the wipe is non-targeted (CR 115.10a), so the gate \
          must not penalize the spell as a damage whiff"
     );

Use the accessor that AiConfig exposes for the penalty band; do not hard-code 8.0.

🤖 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/tests/ai_quality.rs` around lines 794 - 801, Update the score
comparison in the mixed-versus-pure assertion to require a margin tied to the
full wasted-cast penalty, using the penalty-band accessor exposed by AiConfig
instead of the hard-coded 1.0 threshold. Preserve the existing comparison and
diagnostic message while ensuring partial whiff penalties cannot satisfy the
test.
🤖 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/phase-ai/tests/ai_quality.rs`:
- Around line 794-801: Update the score comparison in the mixed-versus-pure
assertion to require a margin tied to the full wasted-cast penalty, using the
penalty-band accessor exposed by AiConfig instead of the hard-coded 1.0
threshold. Preserve the existing comparison and diagnostic message while
ensuring partial whiff penalties cannot satisfy the test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 672f3a38-b220-444f-8699-b33d417f815f

📥 Commits

Reviewing files that changed from the base of the PR and between c97eeb6 and 5ce09cb.

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

@matthewevans matthewevans self-assigned this Aug 9, 2026
@matthewevans

Copy link
Copy Markdown
Member

Closed — the contributor model policy is a hard admission gate.

This PR's canonical declaration is Model: deepseek-v4-flash-0731. It opened on 2026-08-08, after the 2026-07-24 cutoff. The policy version live at that time, docs/AI-CONTRIBUTOR.md §0.1.1, accepts Frontier-tier models only; DeepSeek is not an accepted Frontier entry. The same section requires an out-of-policy PR to be closed without an implementation disposition.

This is a policy disposition about this PR's declared model, not a judgment about the author or the implementation. Please re-run the work on a listed Frontier-tier model from current main, with the required current-head review and verification evidence.

…guard

The hexproof 'can't be targeted' claim recurs at nine sites citing CR 702.11a,
which is only 'Hexproof is a static ability.'; the cannot-be-targeted rule is
CR 702.11b (verified text). Correct all nine (CR 115.10a non-targeted kept
alongside where cited); bare CR 702.11 group refs left as-is.

With extract_target_filter target-only again, harmful_effect_uses_object_targeting's
leading !DestroyAll conjunct was dead (the .is_some() check already excludes
wipes). Remove it; document that target-only extraction excludes mass effects.
No behavior change.
…lassify

The any_stack_harmful_answerable_by_grants doc cited CR 702.11a for the
cannot-be-targeted ('targeting immunity') behavior; 702.11a is only 'Hexproof
is a static ability' and 702.11b is the cannot-be-targeted rule. Align with
the same correction applied to the whiff-gate pipeline. The DefensiveGrant
variant comment at line 279 (naming the shroud/he xproof ABILITIES
themselves) keeps 702.11a, which is defensible.
@CodeOptimist

CodeOptimist commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Feeling like I need to loop in a more intelligent model. 😅 Might be time to test GLM. 😂 (In fact I should run both. Do a little time travel. Compare them after.)

I compared both on the task of responding to the latest review. I actually tested GLM-5.2 twice, because omp+GLM-5.2 compacted the orchestrator's session at only 23% of 1M just before it instructed the implementation agent. (Not clear on why but I have some theories.)

A price comparison of that first run:
DeepSeek: $1.19
GLM-5.2: $4.69
According to Gemini's (3.1 Pro Extended) analysis, GLM did really bad (Gemini almost seems too opinionated; compaction seems to have contributed): https://share.gemini.google/FHOLwdzFNx24

So I re-tested ensuring compaction was disabled:
DeepSeek: $1.19
GLM-5.2: $8.83 (actual; I double-checked, it wasn't the two accidentally summed)
Gemini still thinks DeepSeek did much better: https://share.gemini.google/FpCwjHV2MtGv

Shocking results honestly.

@matthewevans

Copy link
Copy Markdown
Member

Feeling like I need to loop in a more intelligent model. 😅 Might be time to test GLM. 😂 (In fact I should run both. Do a little time travel. Compare them after.)

I compared both on the task of responding to the latest review. I actually tested GLM-5.2 twice, because omp+GLM-5.2 compacted the orchestrator's session at only 23% of 1M just before it instructed the implementation agent. (Not clear on why but I have some theories.)

A price comparison of that first run: DeepSeek: $1.19 GLM-5.2: $4.69 According to Gemini's (3.1 Pro Extended) analysis, GLM did really bad (Gemini almost seems too opinionated; compaction seems to have contributed): https://share.gemini.google/FHOLwdzFNx24

So I re-tested ensuring compaction was disabled: DeepSeek: $1.19 GLM-5.2: $8.83 (actual, I double-checked; it wasn't the two accidentally summed) Gemini still thinks DeepSeek did much better: https://share.gemini.google/FpCwjHV2MtGv

Shocking results honestly.

Yikes. Super interesting. I don't know anyone else that's tried DeepSeek on this project before :)

@matthewevans matthewevans self-assigned this Aug 10, 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.

🔴 Blocker — target-player wipes are still treated as empty at cast commit

Reviewed current head b548a65dc34f376ee2e04d4d07d953e1e2e70515.

mass_effect_has_opposing_population constructs FilterContext::from_source_with_controller at crates/phase-ai/src/policies/removal_lethality.rs:596, which deliberately has no ResolvedAbility. But the engine resolves ControllerRef::TargetPlayer / TargetOpponent by reading the first player target from ability.targets and otherwise fails closed (crates/engine/src/game/filter.rs:2705-2734). DestroyAll population filters of that shape have a companion player target slot (crates/engine/src/game/ability_utils.rs:2800-2805, 3365-3470), and that target is not bound yet when the cast-commit policies score a CastSpell candidate.

Consequently, a mixed spell such as “deal 1 damage to target creature; destroy all creatures target opponent controls” sees no mass population in this helper even with an opponent creature present. Its damage half then reaches the nonlethal path and receives the wasted_cast_penalty at anti_self_harm.rs:420-424; tactical_gate can also classify it redundant. That contradicts this helper’s conservative fail-open contract and does not mirror destroy::resolve_all, which uses FilterContext::from_ability after the companion player has been selected.

Please make an unresolved player-relative mass population an explicit unknown/fail-open result, and thread that through both score_pre_cast and is_redundant_creature_only_removal so it cannot apply a whiff penalty or hard-reject the cast. Add a production-pipeline regression with a dynamic nonlethal damage half plus DestroyAll { target: TypedFilter::creature().controller(TargetOpponent) }, a live opponent creature, and the companion player target still unbound at cast commit. It should prove the mixed cast is offered and is not penalized relative to the target-player wipe baseline.

✅ Clean

The current head correctly keeps DestroyAll out of extract_target_filter; the TargetFilter::None/hexproof regression exercises the non-targeted population distinction. The remaining issue is specifically the missing ability-bound population context for the sibling target-player class.

Recommendation: request changes. Fix the unresolved target-player population seam and add the discriminating regression before approval.

@matthewevans matthewevans removed their assignment Aug 10, 2026
mass_effect_has_opposing_population builds a FilterContext with no
ResolvedAbility, but the engine resolves ControllerRef::TargetPlayer /
TargetOpponent by reading the first TargetRef::Player from ability.targets
and FAILS CLOSED without it (filter.rs TargetPlayer arm). A mixed spell
carrying a companion-player wipe ('destroy all creatures target opponent
controls') therefore read NO mass population at cast-commit even with an
opponent creature present, so its non-lethal damage half was vetoed
(wasted_cast_penalty) and is_redundant_creature_only_removal could
hard-reject the whole cast — contradicting the helper's conservative
fail-open contract and destroy::resolve_all (which resolves the population
via FilterContext::from_ability after the companion player is announced).

Make the mass population TRI-STATE: mass_effect_has_opposing_population now
returns Option<bool> — Some(true) opposing population exists, Some(false)
provably empty, None UNKNOWN when the population filter carries an unbound
player-relative controller scope (TargetPlayer/TargetOpponent/Scoped/
ParentTarget/Chosen/Triggering, via the new
filter_has_unbound_player_controller — conservative: any future
ControllerRef variant fails open). effect_has_legal_opposing_line and the
cast-commit seam has_opposing_mass_population treat None as useful
(!= Some(false)), threading the fail-open through BOTH anti_self_harm's
:397 rescue and tactical_gate::is_redundant_creature_only_removal so an
unresolvable-at-commit wipe can never be penalized or hard-rejected
(CR 109.4 / CR 115.1 / CR 601.2c).

Regressions: unit test (unknown seam + no veto) and two production-pipeline
differentials — mixed TargetOpponent wipe vs target-player-wipe baseline on
a live opponent board (anti_self_harm path) and a hexproof-only board
(tactical-gate thread, M-offered reach-guard discriminating).
…ilterProp boundary

filter_has_unbound_player_controller's doc claimed only You/Opponent resolve
from the casting source alone, which is inaccurate: ActivePlayer resolves
from state.active_player, EnchantedPlayer from source.attached_to, and
SourceChosenPlayer from source.chosen_attributes (filter.rs): for a pending
spell object those are genuinely absent, but the stated reason was wrong and
could mislead a future maintainer into 'correcting' a conservative
classification. Reword to the honest criterion: non-You/Opponent scopes are
UNBOUND BY CONSERVATIVE DESIGN (an over-approximation so a scoped wipe is
never provably-empty at cast-commit), naming the three source/global-state
readable variants explicitly. Also document the FilterProp-embedded
ControllerRef boundary (Owned/Attacking/ProtectorMatches/HasAttachment/
HasAnyAttachmentOf/MostPrevalentCreatureTypeIn/CanEnchant) as latent with no
current parser-emittable wipe population. No behavior change.
@matthewevans matthewevans self-assigned this Aug 11, 2026
@matthewevans

Copy link
Copy Markdown
Member

Review hold — current head 7899cd94c0b7f4fbd11bd91efd06a7325d2a5f68

Manual implementation review is clean for the current player-relative wipe fail-open and its cast-commit/tactical-gate regressions. The remaining external condition is the in-progress Rust required suite plus the Paired-seed AI and decision-cost performance gates. No parser diff is expected for this phase-AI-only change.

Once those checks settle green, maintainer review will resume with the current head; no contributor code action is requested by this hold.

@matthewevans matthewevans removed their assignment Aug 11, 2026
@CodeOptimist

Copy link
Copy Markdown
Contributor Author

(Does it need manual resume? 🤷)

@matthewevans

Copy link
Copy Markdown
Member

(Does it need manual resume? 🤷)

Fixing the bot!

@matthewevans matthewevans self-assigned this Aug 11, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer update: I pushed 6de77a7058baafe3311828bd365ded1425298176, which routes the shared legal-opponent-creature predicate through the engine's team-aware players::is_opponent authority and adds a discriminating Two-Headed Giant regression.

The current-head CI run is still pending, including Rust lint (fmt, clippy, parser gate), the four Rust tests shards, Paired-seed AI gate, and Decision-cost perf gate. This is a nonterminal hold only; I will not approve or enqueue until those current-head checks complete successfully.

@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 on current head 6de77a7: the cast-commit lethality path, resolver-population boundary, and team-aware opponent relation are clean; current required checks are green.

@matthewevans
matthewevans added this pull request to the merge queue Aug 11, 2026
@matthewevans matthewevans removed their assignment Aug 11, 2026
Merged via the queue into phase-rs:main with commit 58363f3 Aug 11, 2026
19 checks passed
@CodeOptimist

CodeOptimist commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Something noteworthy. Although omp handled the orchestration/sub-agents of engine-implementer like a champ (with my own .claude/ modifications), I just noticed that it actually injected the contents of AGENTS.md into the system prompt and not CLAUDE.md.
So it only had the text line: "CLAUDE.md".
It did not inject the contents of CLAUDE.md and actually was NOT working with that in its system prompt for this PR run!! omp is supposed to be "claude-aware" so I first just deleted AGENTS.md, but it does not inject CLAUDE.md.
So I replaced it with a symlink: ln -s CLAUDE.md AGENTS.md and then re-ran omp, and it had the contents of CLAUDE.md in its system prompt this time.

So, theoretically omp+DeepSeek was a little bit hamstrung during this round. And should now be leveled up.

Enjoying this for sure!

@matthewevans

Copy link
Copy Markdown
Member

Something noteworthy. Although omp handled the orchestration/sub-agents of engine-implementer like a champ (with my own .claude/ modifications), I just noticed that it actually injected the contents of AGENTS.md as context... which was just the single line: CLAUDE.md. It did not inject the contents of CLAUDE.md and actually was NOT working with that in its system prompt for this PR run!! omp is supposed to be "claude-aware" so I first just deleted AGENTS.md, but it does not inject CLAUDE.md. So I replaced it with a symlink: ln -s CLAUDE.md AGENTS.md and then re-ran omp, and it had the contents of CLAUDE.md in its system prompt this time.

So, theoretically omp+DeepSeek was a bit hamstrung during this round. And should now be leveled up.

Enjoying this for sure!

That's super weird.. because when checked out on my system, it is a symlink:

lrwxr-xr-x@ 1 matt  staff  9 Apr  1 08:03 AGENTS.md -> CLAUDE.md

But I do see what you're saying in the repo: https://github.com/phase-rs/phase/blob/main/AGENTS.md

I did another fresh checkout and it did correctly check it out as a symlink. What platform are you working off of?

@CodeOptimist

CodeOptimist commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

You're 100% correct. I use Fork on Windows. I have phase-rs completely inside of WSL. I somehow had core.symlinks=false in local git settings. It's now set true and that fixed it WSL side and then I installed a little package called wslgit for the Fork side (it even falls back to Windows git for my Windows repos).

All fixed now, thanks for the check. All good!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants