Skip to content

feat(phase-ai): add poison-clock deck-feature axis + PoisonClockPolicy#6543

Merged
matthewevans merged 6 commits into
phase-rs:mainfrom
minion1227:minion_ai_poison_axis
Jul 24, 2026
Merged

feat(phase-ai): add poison-clock deck-feature axis + PoisonClockPolicy#6543
matthewevans merged 6 commits into
phase-rs:mainfrom
minion1227:minion_ai_poison_axis

Conversation

@minion1227

@minion1227 minion1227 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a poison-clock deck-feature axis (PoisonFeature) and its companion PoisonClockPolicy to phase-ai.

CR 104.3d makes ten poison counters a loss condition entirely independent of life total, tracked in a dedicated Player.poison_counters field. Nothing in the AI scored progress along it, so an infect/toxic deck's whole plan read as doing nothing — a proliferate taking an opponent from 9 to 10 poison scored exactly the same as one taking them from 0 to 1.

Three structural detection axes, no card-name matching:

Axis AST surface CR
sources Keyword::Toxic(N) / Infect / Poisonous(N) on a creature face 702.164 / 702.90 / 702.70
direct Effect::GivePlayerCounter { counter_kind: Poison, target } whose target can resolve to an opponent 122.1f
proliferate Effect::Proliferate | ProliferateTarget 701.34a

All three keywords are combat-damage abilities, so a noncreature face carrying one is rejected. A Controller/SelfRef target scope is rejected so a self-poison drawback is not read as a payoff.

Commitment is a weighted sum, not a geometric mean — a dedicated Infect deck runs zero direct-poison spells and often zero proliferate, and must still read as fully committed. Calibrated to Modern Infect (12 infect creatures + 2 proliferate over 37 nonland) > 0.85, with a superfriends anti-calibration (2 proliferate, no source) staying below the policy floor.

One rules detail drives the policy's branch structure. CR 701.34a defines proliferate over permanents and players that already have a counter. Proliferating with no poisoned opponent advances nothing, so that branch scores zero rather than a nudge — the inverse would push the AI to durdle before the clock has started.

Files changed

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

CR references

  • CR 104.3d — ten or more poison counters loses the game (SBA)
  • CR 122.1f — "poisoned" is one or more poison counters
  • CR 701.34a — proliferate operates only on permanents/players that already have a counter
  • CR 702.164 Toxic N · CR 702.90 Infect · CR 702.70 Poisonous
  • CR 109.3 — conjunction rule for the And arm of the target-filter walk

Every number grep-verified against docs/MagicCompRules.txt before it was written.

Performance

verdict() runs per candidate per search node. Every predicate here is card-local (obj.abilities) or a plain u32 field read on Player.poison_counters. No board-wide sweep, no affordability call, no find_legal_targets — nothing on the documented inner-loop landmine list. activation() opts the whole policy out below the commitment floor.

Measurement — cargo ai-gate with a paired control

| matchup             | baseline p0% | current p0% | flips W→L | flips L→W | sign p | status |
| affinity-mirror     | 20%          | 40%         | 0         | 0         | —      | WARN   |
| enchantress-mirror  | 50%          | 40%         | 3         | 2         | 0.3438 | WARN   |
| red-mirror          | 70%          | 30%         | 5         | 1         | 0.0625 | WARN   |
compare: 0 FAIL, 3 WARN, 0 PASS, 0 NEW, 0 REMOVED

Those WARNs are pre-existing and not caused by this PR. I ran the identical gate on clean upstream/main (ebd8844) as a control and it produces a byte-identical report — same three WARNs, same flip counts, same p-values. crates/phase-ai/baselines/suite-baseline.json was last refreshed on 2026-06-17 (#3492), roughly 200 upstream commits ago.

Consistent with that, none of the three gate matchups contains a poison source, so PoisonClockPolicy::activation returns None and the policy never fires on them — the control simply proves it empirically rather than by argument.

I deliberately did not refresh the baseline. Absorbing a pre-existing divergence into a baseline as part of an unrelated feature PR would hide upstream drift. Flagging it instead: the suite baseline looks due for a refresh on its own commit.

Verification

  • cargo test -p phase-ai --lib1379 passed; 0 failed (13 feature tests + 4 policy tests new)
  • cargo clippy -p phase-ai --all-targets -- -D warnings — clean
  • cargo fmt --all — clean
  • cargo ai-gate — 0 FAIL, with the paired control above

Notes

  • poison_clock_pressure is registered in UNTUNED_POLICY_PENALTY_FIELDS: it is a CR 104.3d win-detector weight whose magnitude is load-bearing for correctness rather than taste, so CMA-ES should not tune it without a paired-seed calibration.
  • Tests live in features/tests/ and policies/tests/ per the house convention (no #[cfg(test)] in source files).
  • Boundary with plus_one_counters: that axis already counts Effect::Proliferate as a +1/+1 enabler. The overlap is intentional and the axes stay independent — one proliferate card genuinely serves both decks, and a Hardened Scales deck scores high there while scoring ~0 here.
  • Known limitation: cards that grant infect to another creature (Tainted Strike, Vector Asp) are not counted as sources. Commitment is creature-count-driven, so an Infect deck still calibrates correctly; a grant axis would be a follow-up.

Tier: Frontier

Summary by CodeRabbit

  • New Features
    • Added per-deck poison strategy analysis (poison sources, direct opponent poison-counter effects, proliferation presence) with a normalized poison-clock commitment score.
    • Introduced a poison-clock tactical policy that evaluates actions toward the CR 104.3d poison-counter win condition, including combat and proliferate contributions.
    • Improved effect-scoping so poison in modal/conditional branches is recognized correctly during evaluation.
  • Tests
    • Expanded coverage for structural poison detection, commitment calibration/clamping, opponent-vs-self targeting rules, combat poison advancement, and modal/conditional behavior.
    • Added backward-compatible deserialization testing for older poison-related tuning artifacts.

CR 104.3d makes ten poison counters a loss condition entirely independent of
life total, tracked in a dedicated `Player.poison_counters` field. Nothing in
the AI scored progress along it, so an infect/toxic deck's whole plan read as
doing nothing: a proliferate taking an opponent from 9 to 10 poison scored the
same as one taking them from 0 to 1.

Feature (`features/poison.rs`) — three structural axes, no card names:
  * sources: Keyword::Toxic(N) / Infect / Poisonous(N) on a creature face
    (CR 702.164 / 702.90 / 702.70 — all three are combat-damage abilities, so
    a noncreature face carrying the keyword is rejected)
  * direct: Effect::GivePlayerCounter { counter_kind: Poison, target } whose
    target can resolve to an opponent (CR 122.1f). A Controller/SelfRef scope
    is rejected so a self-poison drawback is not read as a payoff
  * proliferate: Effect::Proliferate | ProliferateTarget (CR 701.34a)

Commitment is a weighted sum, not a geometric mean: a dedicated Infect deck
runs zero direct-poison spells and often zero proliferate and must still read
as fully committed. Calibrated to Modern Infect (12 infect creatures + 2
proliferate over 37 nonland) > 0.85, with a superfriends anti-calibration
(2 proliferate, no source) staying below the policy floor.

Policy (`policies/poison.rs`) scores CastSpell + ActivateAbility by clock
progress, critical band when the action reaches ten. The branch structure is
driven by a rules detail: CR 701.34a defines proliferate over permanents and
players that ALREADY have a counter, so proliferating with no poisoned
opponent advances nothing and scores zero rather than a nudge — the inverse
would push the AI to durdle before the clock has started.

Every predicate is card-local or a plain u32 field read; no board-wide sweep,
no affordability call, nothing on the documented inner-loop landmine list.

`poison_clock_pressure` is registered UNTUNED: it is a CR 104.3d win-detector
weight whose magnitude is load-bearing for correctness, not taste.

Boundary with plus_one_counters: that axis already counts Proliferate as a
+1/+1 enabler. The overlap is intentional and the axes stay independent — one
proliferate card genuinely serves both decks, and a Hardened Scales deck
scores high there while scoring ~0 here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@minion1227
minion1227 requested a review from matthewevans as a code owner July 23, 2026 11:13
@coderabbitai

coderabbitai Bot commented Jul 23, 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 poison deck analysis, commitment calibration, and a registered poison-clock tactical policy. The policy evaluates direct poison, proliferate, and combat poison actions, detects lethal progress, applies configurable pressure, and includes feature and policy tests.

Changes

Poison clock AI support

Layer / File(s) Summary
Ability-tree traversal support
crates/engine/src/game/ability_utils.rs, crates/phase-ai/src/ability_chain.rs
Adds scoped traversal for unconditional and potential effects and a borrowed modal-ability iterator used by poison classification.
Poison feature detection and calibration
crates/phase-ai/src/features/poison.rs, crates/phase-ai/src/features/mod.rs, crates/phase-ai/src/features/tests/*
Detects poison sources, opponent-directed poison effects, proliferate effects, and normalized deck commitment; integrates and tests the new feature.
Poison-clock tactical scoring
crates/phase-ai/src/config.rs, crates/phase-ai/src/policies/poison.rs, crates/phase-ai/src/policies/tests/poison.rs
Adds configurable pressure and scores direct poison, proliferate, and combat contributions as neutral, critical, or progress-scaled preferences.
Policy registry integration
crates/phase-ai/src/policies/mod.rs, crates/phase-ai/src/policies/registry.rs, crates/phase-ai/src/policies/tests/*
Declares, identifies, registers, and tests PoisonClockPolicy routing through the tactical policy registry.

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

Sequence Diagram(s)

sequenceDiagram
  participant DeckFeatures
  participant PoisonDetection
  participant PolicyRegistry
  participant PoisonClockPolicy
  participant GameState
  DeckFeatures->>PoisonDetection: analyze deck poison commitment
  PoisonDetection->>DeckFeatures: provide PoisonFeature
  PolicyRegistry->>PoisonClockPolicy: route candidate action
  PoisonClockPolicy->>GameState: inspect poison counters and targets
  PoisonClockPolicy-->>PolicyRegistry: return tactical verdict
Loading

Possibly related PRs

  • phase-rs/phase#6544: Extends PolicyPenalties with a serde-defaulted untuned field in the same configuration file.

Suggested labels: enhancement, quality

🚥 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 Clearly names the new poison-clock deck-feature axis and policy, matching the main changeset.
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.
✨ 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: 5

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

1-54: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing direct test coverage for PoisonClockPolicy::verdict().

Existing tests cover reaches_lethal, activation, and most_poisoned_opponent, but none exercise verdict() itself — the lethal-critical branch, the "no counters to proliferate" neutral branch, and the progress-scaled preference branch are all untested here.

As per CLAUDE.md, "ensure new behaviors are backed by unit tests colocated with the feature/policy module."

🤖 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/poison.rs` around lines 1 - 54, Add direct
unit tests for PoisonClockPolicy::verdict covering the lethal-critical branch,
the neutral result when no poison counters can be proliferated, and the
progress-scaled preference branch. Reuse the existing DeckFeatures, GameState,
and PlayerId setup patterns, and assert each verdict’s expected outcome through
the public verdict method.

Source: 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/config.rs`:
- Around line 466-469: Update the PolicyPenalties field poison_clock_pressure
with serde(default = "default_poison_clock_pressure"), define or reuse the
default_poison_clock_pressure helper, and have the PolicyPenalties Default
implementation call that same helper so older serialized values deserialize
consistently.

In `@crates/phase-ai/src/features/poison.rs`:
- Around line 198-207: Update filter_can_hit_opponent to handle
TargetFilter::Not explicitly instead of relying on the wildcard fallback.
Evaluate the negated inner filter so cases such as Not { SelfRef } and Not {
Typed(controller: You) } correctly report whether they can hit an opponent,
while preserving the existing Or and And behavior.

In `@crates/phase-ai/src/policies/poison.rs`:
- Around line 95-104: Update most_poisoned_opponent to exclude eliminated
opponents by filtering on !player.is_eliminated before mapping poison_counters.
Preserve the existing ai_player exclusion and unwrap_or(0) fallback.
- Around line 117-119: Update PoisonClockPolicy::decision_kinds to include
DecisionKind::DeclareAttackers so poison-source attack decisions receive the
policy’s activation scoring; if attacker scoring is intentionally owned by
another policy, route poison-source attackers there instead while preserving
existing spell and ability decision handling.
- Around line 60-65: Update the GameAction::CastSpell handling in the abilities
lookup to resolve the selected modal branch before poison scoring. Use the
chosen mode’s abilities rather than the full printed obj.abilities list, so
DirectPoison is scored only when that mode adds poison; preserve existing
behavior for non-modal spells.

---

Nitpick comments:
In `@crates/phase-ai/src/policies/tests/poison.rs`:
- Around line 1-54: Add direct unit tests for PoisonClockPolicy::verdict
covering the lethal-critical branch, the neutral result when no poison counters
can be proliferated, and the progress-scaled preference branch. Reuse the
existing DeckFeatures, GameState, and PlayerId setup patterns, and assert each
verdict’s expected outcome through the public verdict method.
🪄 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: 2bc1119d-ceef-4aca-80e0-3f23029f2a7b

📥 Commits

Reviewing files that changed from the base of the PR and between c9b9a3a and d8ce4aa.

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

Comment thread crates/phase-ai/src/config.rs
Comment thread crates/phase-ai/src/features/poison.rs
Comment thread crates/phase-ai/src/policies/poison.rs Outdated
Comment thread crates/phase-ai/src/policies/poison.rs
Comment thread crates/phase-ai/src/policies/poison.rs
@matthewevans matthewevans self-assigned this Jul 23, 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

Reviewed current head d8ce4aaf0c167de33e93186389f057f5b9931ef4.

Blocking correctness

  1. Preserve existing PolicyPenalties artifacts. PolicyPenalties derives Deserialize, and persisted tuning artifacts deserialize the policy_penalties section directly in crates/phase-ai/src/bin/ai_tune.rs:294-295. The new poison_clock_pressure field at crates/phase-ai/src/config.rs:466-469 has no serde default, unlike the recently added fields around it, so every artifact created before this change fails to load. Add a shared default_poison_clock_pressure() used by both #[serde(default = ...)] and Default, plus a compatibility test that deserializes a pre-field value.

  2. Handle modal abilities at the actual choice seam. Both poison classifiers call collect_chain_effects, whose documented implementation at crates/phase-ai/src/ability_chain.rs:16-25 traverses only an ability's effect and sub_ability. It omits AbilityDefinition::mode_abilities, so poison-only modes are absent from deck-time detection and the live policy. Mode selection is subsequently a distinct GameAction::SelectModes under WaitingFor::ModeChoice / AbilityModeChoice; crates/phase-ai/src/decision_kind.rs:108-113 routes that decision through ActivateAbility, but PoisonClockPolicy::contribution only handles CastSpell and ActivateAbility at crates/phase-ai/src/policies/poison.rs:59-77. A cast action has no selected modes yet, so it cannot safely stand in for the selected branch.

    Please use or introduce one mode-aware ability-tree authority, have deck detection and live classification share it, and score the selected modal branch at SelectModes rather than treating the pre-selection cast as that branch. Cover poison and non-poison modal alternatives through the policy pipeline. This needs a coherent policy/decision-boundary change, not a local read of a chosen mode from CastSpell.

  3. Make the creature poison clock affect combat choices. The feature's source_count deliberately classifies Toxic/Infect/Poisonous creatures as combat-damage sources (crates/phase-ai/src/features/poison.rs:142-157), but PoisonClockPolicy::decision_kinds only includes CastSpell and ActivateAbility (crates/phase-ai/src/policies/poison.rs:117-119). As written, an infect deck's primary progression action—declaring its poison source as an attacker—gets no poison-clock signal. Route a live poison-source attacker contribution through DeclareAttackers (or demonstrate a correct existing combat authority that owns it), with positive and negative attacker tests.

  4. Use only live opponents for clock state. most_poisoned_opponent filters out the AI seat but not player.is_eliminated (crates/phase-ai/src/policies/poison.rs:95-104). Eliminated seats remain in GameState.players, so a dead player with the highest poison total can produce false lethal/progress pressure. Filter eliminated players and cover that multiplayer case.

  5. Complete opponent-target classification. filter_can_hit_opponent drops TargetFilter::Not through its wildcard (crates/phase-ai/src/features/poison.rs:198-207), even though negated player scopes can target an opponent (for example, Not { SelfRef } and Not { Typed(controller: You) }). Add a sound Not arm consistent with the engine's player-filter semantics and test both matching and non-matching negated scopes.

The existing policy tests exercise activation and helper boundaries, but not PoisonClockPolicy::verdict; the requested modal/combat work should add discriminating runtime-policy coverage for lethal, no-poison proliferate, and nonlethal progress branches as well.

Resolves the five blocking items from the maintainer review of phase-rs#6543.

1. Preserve pre-field PolicyPenalties artifacts. `poison_clock_pressure`
   now has `#[serde(default = "default_poison_clock_pressure")]`, sharing
   one default fn with the `Default` impl (matching every sibling field).
   `ai_tune` deserializes the `policy_penalties` section straight into the
   struct, so an artifact written before this field must still load — a
   compatibility test drives that with a pre-poison artifact.

2. Handle modal abilities at the actual choice seam. `ability_chain` gains
   a typed `AbilityScope` (Unconditional | Potential) and one mode-aware
   authority, `collect_scoped_effects`. Deck-time detection walks
   `Potential` (mode_abilities + else_ability included, CR 700.2 / 608.2c);
   the live policy walks `Unconditional`. A modal cast is no longer credited
   with a mode's poison — the selected branch is scored at `SelectModes`
   (routed via ModeChoice / AbilityModeChoice → ActivateAbility), where the
   mode is actually chosen (CR 601.2b). The engine's own
   `modal_spell_mode_ability_refs` is the shared mode-list authority.

3. Route the creature poison clock through combat. The policy now declares
   `DeclareAttackers` and scores an attack by the poison its sources convert
   (CR 702.90b infect = power, CR 702.164c toxic = summed N, CR 702.70a
   poisonous), summed per defending player, keyed on damage to a PLAYER only
   (a planeswalker/battle attack adds nothing, CR 506.4c).

4. Use only live opponents. `most_poisoned_opponent` and the new
   `live_opponent_poison` filter `is_eliminated` seats (CR 800.4), so a dead
   player's counters can't produce phantom lethal/progress pressure.

5. Complete opponent-target classification. `filter_can_hit_opponent` gains
   a sound `Not` arm via a `filter_matches_every_opponent` complement, so
   `Not { SelfRef }` / `Not { Typed(controller: You) }` read as opponent
   poison while `Not { Opponent }` does not.

Also fixes a latent scoring-contract bug the new verdict tests surfaced: the
sub-lethal delta (`pressure` scalar 6.0 × progress) escaped the preference
band, tripping `score_in_band`'s debug assert and silently clamping in
release. Scoring now routes through `PolicyVerdict::score` with the
non-lethal ceiling held at `STRONG_MAX`, so only reaching ten lands critical.

Adds direct `verdict()` coverage (lethal / no-counters-to-proliferate /
progress-scaled), the modal seam, combat routing (incl. eliminated-seat and
planeswalker cases), negated player scopes, and registry-level routing for
the modal and combat decision kinds.

cargo test -p phase-ai --lib — 1412 passed
cargo clippy -p phase-ai -p engine --all-targets --features proptest — clean

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

Copy link
Copy Markdown
Contributor Author

Addressed all five blocking items in 5c235a5e8.

1 — Preserve pre-field PolicyPenalties artifacts. poison_clock_pressure now carries #[serde(default = "default_poison_clock_pressure")], and both the Default impl and the serde attribute call that one shared fn (matching every sibling field). Added policy_penalties_load_pre_poison_clock_artifact: it strips the field from a serialized artifact, tunes another field, deserializes, and asserts the tuned value survives while the absent one falls back to the shared default — the exact ai_tune TuneGroup::Penalties path.

2 — Modal abilities at the actual choice seam. Introduced a typed AbilityScope { Unconditional, Potential } in ability_chain.rs and one mode-aware authority, collect_scoped_effects, that both deck-time detection and the live policy share. Deck detection walks Potential (visits mode_abilities per CR 700.2 and else_ability per CR 608.2c); the live classifier walks Unconditional. A modal CastSpell is no longer credited with a mode's poison — the selected branch is scored at SelectModes (routed via ModeChoice/AbilityModeChoiceActivateAbility), reading the chosen modes from the engine's own modal_spell_mode_ability_refs / the AbilityModeChoice payload. Poison and non-poison modes of the same ability now score differently; covered end-to-end through the registry.

3 — Creature clock affects combat. The policy declares DeclareAttackers and scores an attack by the poison its sources convert — poison_yield_parts (CR 702.90b infect = power, CR 702.164c toxic = summed total-toxic-value, CR 702.70a poisonous), summed per defending player, keyed on damage to a player only (a planeswalker/battle attack scores nothing, CR 506.4c). Positive/negative attacker tests plus registry routing included.

4 — Live opponents only. most_poisoned_opponent and the new live_opponent_poison filter is_eliminated (CR 800.4), so a dead seat's counters can't produce phantom lethal/progress pressure. Covered with a 4-player case.

5 — Complete opponent-target classification. filter_can_hit_opponent gains a sound Not arm via a filter_matches_every_opponent complement: Not { SelfRef } / Not { Typed(controller: You) } read as opponent poison, Not { Opponent } does not. Both matching and non-matching negated scopes tested, including double negation.

Also fixed a latent scoring-contract bug the new verdict() tests surfaced: the sub-lethal delta (pressure scalar 6.0 × progress) escaped the preference band, tripping score_in_band's debug assert and silently clamping in release — which would have flattened the progress signal. Scoring now routes through PolicyVerdict::score with the non-lethal ceiling held at STRONG_MAX, so only reaching ten lands in the critical band.

Verification

  • cargo test -p phase-ai --lib — 1412 passed (poison suite grew 17 → 50 tests, adding direct verdict() coverage for lethal / no-counters-to-proliferate / progress-scaled, the modal seam, combat routing, negated scopes, and registry routing for the modal + combat decision kinds).
  • cargo clippy -p phase-ai -p engine --all-targets --features proptest -- -D warnings — clean.

Paired-seed AI gate (cargo ai-gate --games 10)

matchup baseline p0% current p0% flips W→L flips L→W sign p status
affinity-mirror 20% 20% 0 0 PASS
enchantress-mirror 50% 40% 3 2 0.3438 WARN
red-mirror 70% 30% 5 1 0.0625 WARN

compare: 0 FAIL, 2 WARN, 1 PASS. Ran the identical gate on the pre-review parent d8ce4aaf0 as a control — the comparison table is byte-identical, so the two WARNs are the same pre-existing baseline staleness noted in the PR body, not introduced here. Consistent with none of the gate matchups running a poison deck, so PoisonClockPolicy::activation returns None and the policy never fires on them. Baseline deliberately not refreshed.

@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/ability_chain.rs`:
- Around line 43-45: Update the doc comment for the Potential variant to cite CR
608.2c instead of CR 603.4 for the else_ability branch, matching the engine’s
existing else_ability references while leaving the other rule citation
unchanged.
🪄 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: 2f898e01-8c84-4dba-a17a-78ff5cdc85a9

📥 Commits

Reviewing files that changed from the base of the PR and between d8ce4aa and 5c235a5.

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

Comment thread crates/phase-ai/src/ability_chain.rs
@matthewevans

Copy link
Copy Markdown
Member

All five blocking items are genuinely resolved in production code — what's left is test-only. The one HIGH item is that your policy's primary seam (DecisionKind::CastSpell) is declared but never registry-tested, so it could be dead in production at 20/20 passing.

Re-reviewed at head 5c235a5e. I verified each of your five claims against the diff rather than taking the summary on trust, and none of them overclaimed — thank you for the precise follow-up.

✅ Five prior blockers → all resolved

# Item Evidence at 5c235a5e
1 Preserve pre-field PolicyPenalties artifacts config.rs:466-471 #[serde(default = "default_poison_clock_pressure")]; Default calls the same fn at :535; shared fn :544-546; registered in UNTUNED_POLICY_PENALTY_FIELDS :739-745
2 Modal abilities at the actual choice seam Typed AbilityScope{Unconditional,Potential} + single authority collect_scoped_effects (ability_chain.rs:39-84); verified against engine.rs:7586-7594, casting_targets.rs:34-70, decision_kind.rs:108,113
3 Creature poison clock affects combat policies/poison.rs:259 + combat_contribution:175-211; yield arithmetic features/poison.rs:189-210 latches infect as a bool per CR 702.90f rather than summing — correct
4 Live opponents only !player.is_eliminated at policies/poison.rs:222 and :238; 4-player test at policies/tests/poison.rs:224, also driven through verdict() at :645
5 Complete opponent-target classification TargetFilter::Not arm features/poison.rs:253 + complement :263-280; truth table hand-traced, double negation terminates

On the serde test specifically — it clears the strict bar. It does not round-trip a Rust-constructed struct. It serializes, removes the key from the JSON object, then deserializes. PolicyPenalties has no container-level #[serde(default)] (config.rs:230 is a bare derive), so an absent field is a hard missing field error. Delete the attribute and from_value errors, the .expect() panics, the test fails. The .remove(...).expect("field must be present before removal") also guards against the test rotting into a no-op on rename, and it matches the real production path at ai_tune.rs:294. This is exactly right.

🔴 Blocker

[HIGH] DecisionKind::CastSpell is declared in the routing table but never registry-tested, and its only negative is vacuous.

policies/poison.rs:257 declares it. But every routed_verdict call site in policies/tests/poison.rs is SelectModes (:731, :738) or DeclareAttackers (:770, :777); the lone CastSpell candidate at :790-810 asserts is_none().

Delete DecisionKind::CastSpell from decision_kinds() and the entire suite stays green:807 still returns None, now because the kind doesn't match rather than because the activation gate fired. Every direct-poison-spell test calls PoisonClockPolicy.verdict() directly, bypassing routing entirely. So the policy's primary seam could be dead in production while 20/20 tests pass.

Fix: one committed_context + routed_verdict assertion on the existing :250 fixture.

🟡 Non-blocking — unexercised arms

  • GameAction::ActivateAbility has zero fixtures (policies/poison.rs:101-111). The obj.abilities.get(*ability_index) lookup, slice::from_ref wrapping, and None fallback are all unexercised, yet :29 names this a first-class seam.
  • WaitingFor::ModeChoice (modal spell) has no fixture (policies/poison.rs:157-162). Every modal test uses AbilityModeChoice. This arm is the sole production consumer of modal_spell_mode_ability_refs, the new engine API this PR adds.
  • TargetFilter::Or/And unexercised in both predicates (features/poison.rs:246,248,273,274). Your own comment at :270-272 documents that universality distributes the opposite way between the two functions — precisely the inversion hand-written recursive predicates get wrong. Swapping .any/.all in filter_matches_every_opponent currently fails no test.
  • Land-exclusion filter unexercised (features/poison.rs:91). No fixture contains a CoreType::Land, so removing the guard silently changes every commitment value.
  • Combat branch treats declared poison as guaranteed to connect (policies/poison.rs:299-301), so a fully-blockable alpha strike reaches the critical band. Your docstring deliberately avoids the board-wide sweep for perf reasons, which I agree with — consider capping the combat branch at STRONG_MAX as the sub-lethal branch already is.
  • PoisonFeature::source_names is dead data with two tests guarding it (features/poison.rs:71-74). No consumer exists. Wire it or drop it.
  • MIN_CLOCK_PROGRESS undiscriminated (policies/poison.rs:309,334). Deleting the floor changes 1.25/1.25 → 0.50/1.00 and every assertion still holds. The paired scalar.min(STRONG_MAX) clamp is guarded.
  • Calibration bounds are one-sided. Your docstring arithmetic is correct — I recomputed it: Modern Infect → 0.9041 ("≈0.90" ✓), superfriends → 0.0608 ("≈0.06" ✓), UW control → 0.0 ✓. But :175 asserts only > 0.85 while weighted_sum clamps at 1.0, so tripling the source weight saturates and still passes; :191 asserts < 0.35 against 0.0608 (5.7× headroom). Two-sided bounds would pin these.
  • CR nits: CR 603.4 at ability_chain.rs:44 mislabels the else_ability branch (603.4 is intervening-if) — the same file correctly cites 608.2c at lines 13 and 76. CR 506.4c at policies/poison.rs:170-172 doesn't describe the annotated code (the load-bearing 702.90b/164c/70a are already right on that line). CR 109.3 at features/poison.rs:247,274 is inherited from landfall.rs:126, not fabricated — that correction belongs on its own repo-wide commit.
  • Multiplayer gap: DirectPoison scores against most_poisoned_opponent but the policy doesn't declare SelectTarget, so in 3+ player games it can score on one seat and target another.

All other CR numbers grep-verified correct: 104.3d, 122.1f, 701.34a, 702.164b/c, 702.90b/f, 702.70a, 601.2b, 700.2, 608.2c, 508.1, 800.4, 704.5c.

✅ Clean

  • New-field threading completeDeckFeatures::analyze threads poison::detect(deck) (features/mod.rs:129); all other construction sites are #[cfg(test)] fixtures where defaulting is correct.
  • Both test modules registered (features/tests/mod.rs:12, policies/tests/mod.rs:13) — no inert false-green.
  • Engine change (ability_utils.rs:842-855) is a clean borrowing view; owned form delegates, no behavior change, and it removes a per-call clone from an inner-loop policy.
  • Vacuity discipline is genuinely good — negatives systematically paired with same-fixture positives (:485:519, :449:466, :337:360), and reason.kind assertions act as real reach-guards. The gaps above are unexercised arms, not vacuous assertions.
  • AI-gate posture correct — paired control on the parent commit, byte-identical report, and a deliberate reasoned refusal to refresh the baseline inside a feature PR. That's the right call.
  • I checked and cleared the apparent pending_mode_abilities filtered-vs-unfiltered index mismatch: the engine treats these as interchangeable (casting.rs:11565,12593, casting_costs.rs:1135,4718), so the policy inherits an existing invariant rather than inventing one. Not a finding.

Recommendation

request-changes, test-only. The production code is right; please add:

  1. A registry-routed CastSpell assertion on the existing :250 fixture (the HIGH).
  2. An ActivateAbility fixture.
  3. A WaitingFor::ModeChoice (modal spell) fixture, exercising the new engine API.
  4. Or/And fixtures for both filter predicates.
  5. One CoreType::Land entry in a calibration deck.
  6. Two-sided calibration bounds (0.904 ± 0.01, 0.061 ± 0.005) and a pinned MIN_CLOCK_PROGRESS flat pair.
  7. Wire or drop source_names.

Also: your "Files changed" list is stale — it omits ability_chain.rs and ability_utils.rs.

Heads-up on your sibling #6544: it looks like it carries the exact defect item 1 fixed here — graveyard_types_progress at its config.rs:466 has no #[serde(default)] and hardcodes 2.5 in the Default impl. Worth fixing there before it lands. No semantic collision between the two PRs; just six mechanical additive conflict hunks, so whichever lands first the other takes a trivial rebase.

Your CI red is not your fault: release: v0.35.2 bumped client/src-tauri/Cargo.toml without updating the separate client/src-tauri/Cargo.lock, redding Tauri compile check and through it the required Rust aggregator across every open PR. #6568 fixes it on main.

@matthewevans matthewevans self-assigned this Jul 23, 2026
…xup)

`AbilityScope::Potential`'s doc cited CR 603.4 for the `else_ability`
branch. CR 603.4 is the intervening-"if" clause rule for triggered
abilities and describes no "Otherwise, ..." leg. The repo convention for
`else_ability` is uniformly CR 608.2c (resolve instructions in the order
written; later text may modify earlier text) — and `push_scoped_effects`
in this same file already cites CR 608.2c for that exact branch, so the
two annotations contradicted each other about one construct.

Also drops the CR 506.4c parenthetical from `combat_contribution`:
CR 506.4c governs a planeswalker or battle being REMOVED from combat,
not an attack aimed at one. The load-bearing justification — CR 702.90b
/ CR 702.164c / CR 702.70a each keying on combat damage dealt to a
*player* — is already cited in the same doc block and stands alone.

Comment-only; no behavior change. All CR numbers grep-verified against
docs/MagicCompRules.txt.

Co-authored-by: minion1227 <romantymkiv1999@gmail.com>

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

Maintainer sign-off. All five prior blocking items verified resolved against source at the seams named, not against the response comment.

The one remaining blocker — the CR 603.4 citation on AbilityScope::Potential for the else_ability branch — is fixed in a maintainer fixup commit on this branch (c351235a), co-authored to you. CR 603.4 is the intervening-"if" clause rule for triggered abilities and describes no "Otherwise, ..." leg; the repo convention for else_ability is uniformly CR 608.2c, and push_scoped_effects ~30 lines below in the same file already cited CR 608.2c for that exact branch, so the two annotations contradicted each other about one construct. This also resolves CodeRabbit's open thread on ability_chain.rs:45.

The same commit drops the CR 506.4c parenthetical from combat_contribution: CR 506.4c governs a planeswalker or battle being removed from combat, not an attack aimed at one. The load-bearing justification — CR 702.90b / CR 702.164c / CR 702.70a each keying on combat damage dealt to a player — was already in the same doc block and stands on its own. Every CR number was grep-verified against docs/MagicCompRules.txt before it was written.

Both edits are comment-only; the reviewed logic is untouched.

Note on CI: Tauri compile check is red repo-wide and is not your fault. The release: v0.35.2 commit (current main tip) bumped the workspace version without regenerating client/src-tauri/Cargo.lock, so cargo check --locked refuses. Because the required Rust (fmt, clippy, test, coverage-gate) aggregator has needs: tauri-check, that failure transitively holds the required check red. Nothing to do on this PR — it will merge once the lockfile is regenerated on main.

Nice work on the scope discipline here: the typed AbilityScope instead of a bool, and the CR 601.2b reasoning that keeps a modal branch from being credited at CastSpell time, are both the right call.

@matthewevans
matthewevans enabled auto-merge July 23, 2026 22:21
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

phase-rs#6543)

Addresses matthewevans' second review. The five original blockers were
confirmed resolved in production; this round closes the HIGH test-gap and the
recommended follow-ups, plus two small correctness fixes he flagged.

HIGH — the policy's primary seam was declared but never registry-tested.
Every direct-poison test called `verdict()` directly, so `DecisionKind::Cast\
Spell` could be deleted from `decision_kinds()` with the whole suite green.
Add `registry_routes_cast_spell_to_the_policy` (poison cast scores through the
pipeline, inert cast routes-but-scores-nil), and companion routed coverage:
  * `registry_routes_activate_ability_to_the_policy` — exercises the
    `obj.abilities.get(index)` lookup, the per-index discrimination, and the
    out-of-range → neutral fallback.
  * `registry_routes_modal_spell_mode_choice_to_the_policy` — drives a real
    `WaitingFor::ModeChoice` + `PendingCast`, the sole production consumer of
    the new `modal_spell_mode_ability_refs` engine API.

Calibration and predicate coverage:
  * Or/And fixtures for BOTH filter predicates, each flipping one branch's
    outcome under a `.any`/`.all` swap, so the opposite-quantifier inversion
    between `filter_can_hit_opponent` and `filter_matches_every_opponent`
    can no longer pass silently.
  * `CoreType::Land` calibration fixture — 20 lands must not move commitment;
    pins the nonland-denominator guard whose removal would silently rescale.
  * Two-sided calibration bounds (Modern Infect 0.904 ± 0.01, superfriends
    0.061 ± 0.005) replacing one-sided asserts that a saturating source weight
    could slip past.
  * `MIN_CLOCK_PROGRESS` pinned by a flat-plateau equality (0 and 1 poison
    score identically); deleting the floor splits them.

Correctness (maintainer-flagged, non-blocking):
  * Combat lethal is capped at `STRONG_MAX`, strictly below the critical band a
    guaranteed direct poison reaches — CR 509.1a: a declared attack can be
    blocked or prevented, so a poison swing is a strong play, not a booked win.
    `score_clock` gains a per-branch `ceiling`.
  * `source_names` had no consumer — dropped the field, its population, and its
    two guarding tests rather than leave dead data.
  * CR nits: `608.2c` (not 603.4) for the `else_ability` branch at
    ability_chain.rs; dropped the off-point `506.4c` from combat_contribution.

Deliberately deferred (per review): the multiplayer `SelectTarget` refinement
(DirectPoison scores vs the most-poisoned seat but doesn't yet own target
choice in 3+ player games) and the inherited `CR 109.3` citation, which the
reviewer noted belongs on its own repo-wide commit.

cargo test -p phase-ai --lib — 1420 passed (poison suite 50 → 58)
cargo clippy -p phase-ai -p engine --all-targets --features proptest — clean

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
auto-merge was automatically disabled July 23, 2026 22:45

Head branch was pushed to by a user without write access

@minion1227

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough re-review and the 608.2c fixup. Pushed 7d23e6c20, rebased on top of your c351235a5 so the maintainer fixup is preserved in history (my own duplicate 603.4→608.2c edit collapsed to a no-op, and my 506.4c reword deferred to your terser deletion on the one line we both touched).

HIGH — primary seam now registry-tested

registry_routes_cast_spell_to_the_policy: a direct-poison CastSpell under WaitingFor::Priority now scores through the registry, with an inert cast as the routes-but-scores-nil control. Deleting DecisionKind::CastSpell from decision_kinds() now fails this test instead of leaving the suite green.

Recommended follow-ups (all in)

  1. ✅ Routed CastSpell assertion (above).
  2. registry_routes_activate_ability_to_the_policy — exercises the obj.abilities.get(index) lookup, per-index discrimination, and the out-of-range → neutral fallback.
  3. registry_routes_modal_spell_mode_choice_to_the_policy — a real WaitingFor::ModeChoice + PendingCast, driving modal_spell_mode_ability_refs (its sole production consumer).
  4. Or/And fixtures for both predicates — each flips one branch under an .any/.all swap, so the opposite-quantifier inversion between filter_can_hit_opponent and filter_matches_every_opponent can no longer pass silently.
  5. CoreType::Land fixture — 20 lands must not move commitment; pins the nonland denominator.
  6. ✅ Two-sided calibration (Modern Infect 0.904 ± 0.01, superfriends 0.061 ± 0.005) + MIN_CLOCK_PROGRESS pinned by a flat-plateau equality (0 and 1 poison score identically; deleting the floor splits them).
  7. source_names had no consumer → dropped the field, its population, and its two guarding tests.

Also

  • Combat lethal capped at STRONG_MAX (your suggestion): CR 509.1a — a declared attack can be blocked or prevented, so a would-be-lethal poison swing is a strong play, not the booked win a guaranteed direct poison earns. score_clock takes a per-branch ceiling; verdict_caps_a_lethal_infect_attack_below_critical pins combat < critical while the identical direct-poison clock reaches it.

Deferred, per your notes

  • The multiplayer SelectTarget refinement (DirectPoison scores vs the most-poisoned seat but doesn't yet own target choice in 3+ player games) — a real design addition I'd rather land on its own commit than half-wire here.
  • The inherited CR 109.3 citation — you flagged it belongs on its own repo-wide commit, so I left it untouched.
  • The stale "Files changed" list in the PR body (omits ability_chain.rs/ability_utils.rs) — noted.

Heads-up ack on #6544: same serde-default defect at its config.rs:466 (graveyard_types_progress hardcodes 2.5 with no #[serde(default)]) — I'll apply the same shared-default fix there.

cargo test -p phase-ai --lib — 1420 passed (poison suite 50 → 58). cargo clippy -p phase-ai -p engine --all-targets --features proptest — clean. CI red on Tauri compile check is the client/src-tauri/Cargo.lock drift #6568 fixes on main, as you noted.

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

Re-approved on current head 7d23e6c209.

The prior approval (22:20:54) was filed against c351235a57; you pushed 7d23e6c209 at 22:44:31, after it. reviewDecision still read APPROVED only because this repo's branch protection doesn't auto-dismiss on push, so the green state was covering a head nobody had reviewed. Re-reviewing the delta before it goes to the queue.

The delta is not test-only despite the test(...) subject — it carries two production changes, both sound and both maintainer-directed:

  1. score_clock gains a per-branch ceiling. DirectPoison/Proliferate may reach CRITICAL_MAX; CombatDamage is capped at STRONG_MAX. This is the CR 509.1a distinction I suggested — a declared attack can be blocked or prevented, so a would-be-lethal poison swing is a strong play, not a booked win. Verified all three call sites thread the new argument, and the sub-lethal path still clamps to STRONG_MAX independent of ceiling, matching the doc comment.
  2. PoisonFeature::source_names dropped. Confirmed zero residue anywhere in crates/phase-ai/ — field, population, and its two guarding tests all removed together. It had no consumer, so this is dead-data removal, not a capability loss.

The HIGH item from my 22:06 review is answered the right way: registry_routes_cast_spell_to_the_policy drives a direct-poison CastSpell under WaitingFor::Priority through the registry, with an inert cast as the routes-but-scores-nil control, so deleting DecisionKind::CastSpell from decision_kinds() now reds the suite instead of leaving it green at 20/20. That is a discriminating test for the exact dead-in-production risk I raised.

On the two earlier red heads: c351235a57 and 5c235a5e86 show rollup: FAILURE, but the jobs are cancelled, not failed — superseded by your next push. No real failure there.

Enqueuing with --auto, so the queue admits it only once required checks report green on this head, including Paired-seed AI gate (currently queued). Note the queue is presently ejecting batches because Tauri compile check fails on any merge-group ref built on main's release tip 21a53d50cb (its Cargo.lock still pins phase-tauri 0.35.1). That is a maintainer-side break, nothing to do with this PR; the fix is #6568, already in the queue, and this will serialize behind it.

@matthewevans
matthewevans enabled auto-merge July 23, 2026 22:50

@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/features/tests/poison.rs`:
- Around line 200-204: Remove the “CR 104.3d” citation from the comment above
the land-exclusion logic, while preserving the explanation of the poison plan’s
nonland payload and the CoreType::Land guard behavior.
🪄 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: 1a58e56a-ac82-400e-9e38-0e465e2a55ce

📥 Commits

Reviewing files that changed from the base of the PR and between c351235 and 7d23e6c.

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

Comment thread crates/phase-ai/src/features/tests/poison.rs Outdated
…est (phase-rs#6543)

CR 104.3d is the ten-poison-counters loss rule; it says nothing about
excluding lands from a commitment denominator. That exclusion is a
deck-modelling choice in `detect`, not a rules requirement, so it carries no
CR citation at all. Per CLAUDE.md a wrong CR number is worse than none — it
creates false confidence that the code was verified against that rule.

Comment-only; the guard and its assertions are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
auto-merge was automatically disabled July 23, 2026 23:44

Head branch was pushed to by a user without write access

@minion1227

Copy link
Copy Markdown
Contributor Author

Fixed the one actionable CodeRabbit finding in 5e83f7e63 (comment-only).

CR 104.3d on the land-exclusion test was wrong. 104.3d is the ten-poison-counters loss rule — it governs nothing about excluding lands from a commitment denominator. That exclusion is a deck-modelling choice in detect, not a rules requirement, so it now carries no CR citation rather than a misleading one. The guard and its assertions are unchanged.

Re-verified the rest of the citations this branch adds: CR 509.1a (the blocker-declaration step, backing the "a declared attack can still be blocked" combat cap) greps clean, and the remainder are the set you already confirmed. CR 109.3 is left untouched per your note that the inherited-from-landfall.rs correction belongs on its own repo-wide commit.

cargo test -p phase-ai --lib poison — 58 passed; cargo clippy -p phase-ai -p engine --all-targets --features proptest -- -D warnings — clean.

@matthewevans
matthewevans enabled auto-merge July 24, 2026 00:24
@matthewevans

Copy link
Copy Markdown
Member

Re-reviewed on the live head 5e83f7e63. Your "comment-only" claim checks out at the line level, and removing the CR 104.3d citation was the right call. Approval will follow the branch update.

📋 Why this needed a re-review at all

My approval was recorded against 7d23e6c20, and you pushed 5e83f7e63 after it. This repo doesn't have dismiss-stale-reviews enabled, so GitHub kept reporting APPROVED on a commit no one had looked at. That's a safety gap on our side rather than anything you did — but it means the approval had to be re-anchored, not assumed.

Verified, not taken on trust: compare/7d23e6c20...5e83f7e63 is ahead_by: 1, one file, +6/−5, all of it in the doc comment on one test fn (crates/phase-ai/src/features/tests/poison.rs:197-208). Filtering the patch for changed lines that aren't /// returns zero lines. The #[test] fn lands_are_excluded_from_commitment_denominator() body, its assertions, and all production code are byte-identical across the delta.

✅ Dropping CR 104.3d was correct

Grep-verified at docs/MagicCompRules.txt:346:

"104.3d If a player has ten or more poison counters, that player loses the game the next time a player would receive priority. (This is a state-based action. See rule 704.)"

The rule is real, but it governs the ten-poison loss SBA and says nothing about excluding lands from a commitment denominator. Citing it there was a genuine defect, and removing it rather than substituting another number is the right resolution — no CR governs a deck-modelling heuristic, and inventing a plausible-looking replacement would have been worse than none. Your replacement sentence ("this is a deck-modelling choice, not a rules requirement, so it carries no CR citation") is the correct thing to leave behind. The real 104.3d citations on LETHAL_POISON / reaches_lethal (features/poison.rs:50, policies/poison.rs:242) are untouched and correct.

✅ Substance re-verified at full bar on the live head

  • Seamfeatures/poison.rs + policies/poison.rs mirrors ~15 sibling axes; wired at features/mod.rs:83,129, policies/mod.rs:41, registry.rs:133,326.
  • Band helpers — only PolicyVerdict::score / ::neutral; no raw Score {} literals anywhere. With poison_clock_pressure = 6.0 (config.rs:546) against STRONG_MAX=5.0 / CRITICAL_MAX=15.0: direct-lethal → 6.0 (critical), combat-lethal → scalar.min(STRONG_MAX) = 5.0 (strong, correctly below a booked win), sub-lethal → ≤4.5 (policies/poison.rs:332-352).
  • Typed over boolAbilityScope { Unconditional, Potential } (ability_chain.rs:39-84) and PoisonContribution (policies/poison.rs:72-84). collect_scoped_effects is one authority shared by the deck-time and live paths, with collect_chain_effects retained as shorthand so existing callers are untouched.
  • Engine change is at the right seammodal_spell_mode_ability_refs (ability_utils.rs:842-855) makes the owned form delegate to the borrowed one, so "which abilities are modes" can't fork.
  • Serialized surface#[serde(default = "default_poison_clock_pressure")] sharing one fn with Default, registered in UNTUNED_POLICY_PENALTY_FIELDS (config.rs:469,535,546,740).
  • Calibration recomputed rather than accepted: 12 infect + 2 proliferate / 37 nonland → 0.65/15 × 19.46 = 0.843 plus 0.15/8 × 3.24 = 0.061 = 0.904, matching your documented ≈0.90. The superfriends anti-calibration lands at 0.061, well under the 0.35 floor.
  • Tests are discriminating. The routing tests go through PolicyRegistry::default().verdicts(ctx) — the real production path, not a direct verdict() call — and assert sibling discrimination (poison mode vs draw mode, attacking vs staying home) plus reason.kind, so deleting a DecisionKind fails a test instead of leaving green. registry_skips_the_policy_for_an_uncommitted_deck sets up the exact 9-poison board that would otherwise score critical, so it's non-vacuous.

The ai-gate evidence is handled better than the bar requires. You paired the gate table (0 FAIL, 3 WARN) with a control run on clean upstream/main (ebd884448) producing a byte-identical report — which proves the WARNs are pre-existing drift rather than yours, without needing anyone to take your word for it. And you deliberately did not refresh the baseline. That's the correct call: absorbing pre-existing divergence into an unrelated feature PR hides upstream drift.

🟡 Two non-blocking

CR 109.3 doesn't describe the annotated code (features/poison.rs:224,233,258). Verified at docs/MagicCompRules.txt:592 — it's the list of an object's characteristics (name, mana cost, color, …) and says nothing about conjunctive filter matching. Not charged to you: it's inherited verbatim from features/landfall.rs:126 and features/enchantments.rs:162, your PR says it mirrors landfall, and it's ours to clean up repo-wide.

Poison-aware target selection isn't scored — and my earlier provenance concern about this is refuted, so I want to state that plainly. policies/poison.rs:286-291 scores DirectPoison off most_poisoned_opponent, a global max. That looked like it could pick the wrong seat, but GameAction::CastSpell.targets is vestigial: the reducer discards it (engine.rs:4314-4335), AI candidate generation always emits targets: Vec::new() (ai_support/candidates.rs:3501), and player targets bind later at WaitingFor::TargetSelection. So the global max is the only information available at the cast seam and is the right optimistic estimate. The residual gap — nothing poison-aware scores the later ChooseTarget node in 3+ player games — is pre-existing and AI-wide (search.rs:867), doesn't affect 1v1, and belongs in a follow-up issue.

📋 Merge mechanics — sequencing matters here

The branch is BEHIND, and the update-branch commit will create a new head. Since protection doesn't auto-dismiss, approving now would just re-strand the approval on a superseded commit — the exact situation this comment exists to fix. So: update the branch first, let CI settle, then approve and enqueue. CI at 5e83f7e63 isn't green yet regardless (Paired-seed AI gate in progress, ten checks still queued).


Recommendation: approve and enqueue once the branch is current and CI is green. No blocking findings. Nothing is open from CodeRabbit either — all seven inline comments across its three rounds are resolved, including the CR 104.3d one this push fixed.

@minion1227

Copy link
Copy Markdown
Contributor Author

Branch updated to current main as you asked — new head a89f3ec91.

Sequencing per your note: updated first so the approval re-anchors on a commit that is actually current, rather than approving now and re-stranding it on a superseded head.

  • gh pr update-branch was refused server-side (refusing to allow a Personal Access Token to create or update workflow .github/workflows/deploy.yml without workflow scope — main's delta touches that workflow). Did the merge locally and pushed over SSH, which isn't subject to that token restriction.
  • Merge of upstream/main (92e126946, 20 commits behind) was clean — zero conflicts, 76 files from main, nothing of mine touched.
  • Re-verified on the merged tree: cargo test -p phase-ai --lib1420 passed, poison suite 58/58.
  • Pre-push divergence check clean (origin was an ancestor of my HEAD, so a plain fast-forward — no maintainer commit at risk).

Thanks for the refutation on the target-selection point, and agreed on CR 109.3 being a repo-wide cleanup rather than this PR's. CI is re-running on the new head.

@matthewevans
matthewevans added this pull request to the merge queue Jul 24, 2026
@minion1227

Copy link
Copy Markdown
Contributor Author

CI has settled on the updated head a89f3ec9114 of 15 green, and the one red is a stale cancelled run, not a failure.

✅ Passing, including the two that matter most here: Paired-seed AI gate and Tauri compile check (the latter now that #6568 is on main). Also Rust lint / both test shards / coverage-gate / card data / WASM / frontend.

🟡 Decision-cost perf gate — cancelled, not failed. The run (30057750960, started 00:59) compiled fine, launched ai-perf-gate, produced zero output for ~28 minutes, then ended in The operation was canceled with orphaned ai-perf-gate processes. It's infrastructure, and it's repo-wide rather than this branch:

  • Every ai-gate.yml run through ~23:44 succeeded, including this branch.
  • fix/5981-gift-recipient-selection (not mine) was cancelled at 00:37 — before I pushed anything tonight.
  • feat/kamigawa-flip-cards started 00:18 and was still running an hour later.
  • On the sibling PR feat(phase-ai): add graveyard type-diversity axis + GraveyardTypesPolicy #6544, the same two gates went red on one push and came back green on the next with no change to their code paths.

I can't re-trigger itgh run rerun refuses (cannot be rerun) since I'm pushing from a fork without actions:write, and a cancelled run has no failed job to retry. I deliberately did not push a no-op commit to force CI: that would mint a new head and re-strand the approval on a superseded commit, which is the exact problem your last comment was about.

So it needs either a re-run on your side or the merge queue's own CI on enqueue. Nothing in the diff has changed since your review — head is still a89f3ec91, and locally the full phase-ai suite (1420) and clippy are green.

Merged via the queue into phase-rs:main with commit 101a2f4 Jul 24, 2026
17 of 18 checks passed
matthewevans added a commit to andriypolanski/phase that referenced this pull request Jul 24, 2026
Resolves the deterministic crates/engine/tests/integration/main.rs mod-line
conflict by keeping both test modules in rustfmt-sorted order. Maintainer-side
port: main advanced under this PR tonight (phase-rs#6538 phase-rs#6543 phase-rs#6545 phase-rs#6554 phase-rs#6563 phase-rs#6579).
The phase-tauri 0.35.1->0.35.2 client/src-tauri/Cargo.lock bump is now a no-op,
since main landed the identical bump in phase-rs#6568.
matthewevans added a commit to minion1227/phase that referenced this pull request Jul 24, 2026
Ports this PR onto phase-rs#6543 (poison-clock deck-feature axis + PoisonClockPolicy),
which landed tonight and registers an axis+policy at the same wiring sites this
PR uses for the graveyard type-diversity axis.

All eight conflicts are both-sides-add at a registration point; every one keeps
BOTH entries, with main's landed entry first:
  - features/mod.rs: `DeckFeatures.poison` + `.graveyard_types` fields, and both
    `detect(deck)` calls in `analyze()`.
  - policies/registry.rs: `PolicyId::{PoisonClock, GraveyardTypes}` and both
    `Box::new(..)` entries in `PolicyRegistry::default()`. Vec order is not
    load-bearing (the registry indexes `by_kind` and applies
    `delta * activation` once per policy).
  - config.rs: both `PolicyPenalties` fields, both `Default` initializers, both
    `UNTUNED_POLICY_PENALTY_FIELDS` entries, and the two independently-written
    pre-artifact serde back-compat tests split back into two whole `#[test]`
    fns (each pins its own field's `#[serde(default)]` wiring).

No baselines refreshed.
jsdevninja pushed a commit to jsdevninja/phase that referenced this pull request Jul 24, 2026
phase-rs#6543)

* feat(phase-ai): add poison-clock deck-feature axis + PoisonClockPolicy

CR 104.3d makes ten poison counters a loss condition entirely independent of
life total, tracked in a dedicated `Player.poison_counters` field. Nothing in
the AI scored progress along it, so an infect/toxic deck's whole plan read as
doing nothing: a proliferate taking an opponent from 9 to 10 poison scored the
same as one taking them from 0 to 1.

Feature (`features/poison.rs`) — three structural axes, no card names:
  * sources: Keyword::Toxic(N) / Infect / Poisonous(N) on a creature face
    (CR 702.164 / 702.90 / 702.70 — all three are combat-damage abilities, so
    a noncreature face carrying the keyword is rejected)
  * direct: Effect::GivePlayerCounter { counter_kind: Poison, target } whose
    target can resolve to an opponent (CR 122.1f). A Controller/SelfRef scope
    is rejected so a self-poison drawback is not read as a payoff
  * proliferate: Effect::Proliferate | ProliferateTarget (CR 701.34a)

Commitment is a weighted sum, not a geometric mean: a dedicated Infect deck
runs zero direct-poison spells and often zero proliferate and must still read
as fully committed. Calibrated to Modern Infect (12 infect creatures + 2
proliferate over 37 nonland) > 0.85, with a superfriends anti-calibration
(2 proliferate, no source) staying below the policy floor.

Policy (`policies/poison.rs`) scores CastSpell + ActivateAbility by clock
progress, critical band when the action reaches ten. The branch structure is
driven by a rules detail: CR 701.34a defines proliferate over permanents and
players that ALREADY have a counter, so proliferating with no poisoned
opponent advances nothing and scores zero rather than a nudge — the inverse
would push the AI to durdle before the clock has started.

Every predicate is card-local or a plain u32 field read; no board-wide sweep,
no affordability call, nothing on the documented inner-loop landmine list.

`poison_clock_pressure` is registered UNTUNED: it is a CR 104.3d win-detector
weight whose magnitude is load-bearing for correctness, not taste.

Boundary with plus_one_counters: that axis already counts Proliferate as a
+1/+1 enabler. The overlap is intentional and the axes stay independent — one
proliferate card genuinely serves both decks, and a Hardened Scales deck
scores high there while scoring ~0 here.

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

* fix(phase-ai): address review on poison-clock axis (phase-rs#6543)

Resolves the five blocking items from the maintainer review of phase-rs#6543.

1. Preserve pre-field PolicyPenalties artifacts. `poison_clock_pressure`
   now has `#[serde(default = "default_poison_clock_pressure")]`, sharing
   one default fn with the `Default` impl (matching every sibling field).
   `ai_tune` deserializes the `policy_penalties` section straight into the
   struct, so an artifact written before this field must still load — a
   compatibility test drives that with a pre-poison artifact.

2. Handle modal abilities at the actual choice seam. `ability_chain` gains
   a typed `AbilityScope` (Unconditional | Potential) and one mode-aware
   authority, `collect_scoped_effects`. Deck-time detection walks
   `Potential` (mode_abilities + else_ability included, CR 700.2 / 608.2c);
   the live policy walks `Unconditional`. A modal cast is no longer credited
   with a mode's poison — the selected branch is scored at `SelectModes`
   (routed via ModeChoice / AbilityModeChoice → ActivateAbility), where the
   mode is actually chosen (CR 601.2b). The engine's own
   `modal_spell_mode_ability_refs` is the shared mode-list authority.

3. Route the creature poison clock through combat. The policy now declares
   `DeclareAttackers` and scores an attack by the poison its sources convert
   (CR 702.90b infect = power, CR 702.164c toxic = summed N, CR 702.70a
   poisonous), summed per defending player, keyed on damage to a PLAYER only
   (a planeswalker/battle attack adds nothing, CR 506.4c).

4. Use only live opponents. `most_poisoned_opponent` and the new
   `live_opponent_poison` filter `is_eliminated` seats (CR 800.4), so a dead
   player's counters can't produce phantom lethal/progress pressure.

5. Complete opponent-target classification. `filter_can_hit_opponent` gains
   a sound `Not` arm via a `filter_matches_every_opponent` complement, so
   `Not { SelfRef }` / `Not { Typed(controller: You) }` read as opponent
   poison while `Not { Opponent }` does not.

Also fixes a latent scoring-contract bug the new verdict tests surfaced: the
sub-lethal delta (`pressure` scalar 6.0 × progress) escaped the preference
band, tripping `score_in_band`'s debug assert and silently clamping in
release. Scoring now routes through `PolicyVerdict::score` with the
non-lethal ceiling held at `STRONG_MAX`, so only reaching ten lands critical.

Adds direct `verdict()` coverage (lethal / no-counters-to-proliferate /
progress-scaled), the modal seam, combat routing (incl. eliminated-seat and
planeswalker cases), negated player scopes, and registry-level routing for
the modal and combat decision kinds.

cargo test -p phase-ai --lib — 1412 passed
cargo clippy -p phase-ai -p engine --all-targets --features proptest — clean

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

* fix(phase-ai): cite CR 608.2c for the else_ability leg (maintainer fixup)

`AbilityScope::Potential`'s doc cited CR 603.4 for the `else_ability`
branch. CR 603.4 is the intervening-"if" clause rule for triggered
abilities and describes no "Otherwise, ..." leg. The repo convention for
`else_ability` is uniformly CR 608.2c (resolve instructions in the order
written; later text may modify earlier text) — and `push_scoped_effects`
in this same file already cites CR 608.2c for that exact branch, so the
two annotations contradicted each other about one construct.

Also drops the CR 506.4c parenthetical from `combat_contribution`:
CR 506.4c governs a planeswalker or battle being REMOVED from combat,
not an attack aimed at one. The load-bearing justification — CR 702.90b
/ CR 702.164c / CR 702.70a each keying on combat damage dealt to a
*player* — is already cited in the same doc block and stands alone.

Comment-only; no behavior change. All CR numbers grep-verified against
docs/MagicCompRules.txt.

Co-authored-by: minion1227 <romantymkiv1999@gmail.com>

* test(phase-ai): registry-route every poison seam + tighten calibration (phase-rs#6543)

Addresses matthewevans' second review. The five original blockers were
confirmed resolved in production; this round closes the HIGH test-gap and the
recommended follow-ups, plus two small correctness fixes he flagged.

HIGH — the policy's primary seam was declared but never registry-tested.
Every direct-poison test called `verdict()` directly, so `DecisionKind::Cast\
Spell` could be deleted from `decision_kinds()` with the whole suite green.
Add `registry_routes_cast_spell_to_the_policy` (poison cast scores through the
pipeline, inert cast routes-but-scores-nil), and companion routed coverage:
  * `registry_routes_activate_ability_to_the_policy` — exercises the
    `obj.abilities.get(index)` lookup, the per-index discrimination, and the
    out-of-range → neutral fallback.
  * `registry_routes_modal_spell_mode_choice_to_the_policy` — drives a real
    `WaitingFor::ModeChoice` + `PendingCast`, the sole production consumer of
    the new `modal_spell_mode_ability_refs` engine API.

Calibration and predicate coverage:
  * Or/And fixtures for BOTH filter predicates, each flipping one branch's
    outcome under a `.any`/`.all` swap, so the opposite-quantifier inversion
    between `filter_can_hit_opponent` and `filter_matches_every_opponent`
    can no longer pass silently.
  * `CoreType::Land` calibration fixture — 20 lands must not move commitment;
    pins the nonland-denominator guard whose removal would silently rescale.
  * Two-sided calibration bounds (Modern Infect 0.904 ± 0.01, superfriends
    0.061 ± 0.005) replacing one-sided asserts that a saturating source weight
    could slip past.
  * `MIN_CLOCK_PROGRESS` pinned by a flat-plateau equality (0 and 1 poison
    score identically); deleting the floor splits them.

Correctness (maintainer-flagged, non-blocking):
  * Combat lethal is capped at `STRONG_MAX`, strictly below the critical band a
    guaranteed direct poison reaches — CR 509.1a: a declared attack can be
    blocked or prevented, so a poison swing is a strong play, not a booked win.
    `score_clock` gains a per-branch `ceiling`.
  * `source_names` had no consumer — dropped the field, its population, and its
    two guarding tests rather than leave dead data.
  * CR nits: `608.2c` (not 603.4) for the `else_ability` branch at
    ability_chain.rs; dropped the off-point `506.4c` from combat_contribution.

Deliberately deferred (per review): the multiplayer `SelectTarget` refinement
(DirectPoison scores vs the most-poisoned seat but doesn't yet own target
choice in 3+ player games) and the inherited `CR 109.3` citation, which the
reviewer noted belongs on its own repo-wide commit.

cargo test -p phase-ai --lib — 1420 passed (poison suite 50 → 58)
cargo clippy -p phase-ai -p engine --all-targets --features proptest — clean

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

* docs(phase-ai): drop the wrong CR 104.3d cite on the land-exclusion test (phase-rs#6543)

CR 104.3d is the ten-poison-counters loss rule; it says nothing about
excluding lands from a commitment denominator. That exclusion is a
deck-modelling choice in `detect`, not a rules requirement, so it carries no
CR citation at all. Per CLAUDE.md a wrong CR number is worse than none — it
creates false confidence that the code was verified against that rule.

Comment-only; the guard and its assertions are unchanged.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants