Skip to content

feat(phase-ai): add graveyard type-diversity axis + GraveyardTypesPolicy#6544

Merged
matthewevans merged 5 commits into
phase-rs:mainfrom
minion1227:minion_ai_graveyard_types
Jul 24, 2026
Merged

feat(phase-ai): add graveyard type-diversity axis + GraveyardTypesPolicy#6544
matthewevans merged 5 commits into
phase-rs:mainfrom
minion1227:minion_ai_graveyard_types

Conversation

@minion1227

@minion1227 minion1227 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a graveyard card-type-diversity axis (GraveyardTypesFeature) and its companion GraveyardTypesPolicy — the delirium / descend / threshold / Goyf axis.

CR 207.2c lists delirium, descend and threshold as ability words with no rules meaning, so the mechanical content is entirely the underlying "N or more card types among cards in your graveyard" condition (CR 205.2a). 95 cards in the corpus read that quantity, and nothing in the AI modelled the graveyard's type spread: a self-mill that supplied the fourth distinct card type — switching every delirium payoff on — scored exactly the same as one that added a redundant fifth creature.

Three structural axes, no card-name matching:

Axis AST surface
threshold payoffs QuantityComparison whose lhs is DistinctCardTypes { Zone { Graveyard, Controller } }
scaling payoffs the same quantity as a dynamic magnitude, no threshold (Consuming Blob, Tarmogoyf)
enablers Mill / DiscardCard / Discard / Surveil scoped to the controller

Two details worth flagging for review:

  • StaticDefinition.condition and TriggerDefinition.condition are distinct enums that happen to share the QuantityComparison shape. Each gets its own extractor rather than a forced conversion — Backwoods Survivalists puts the delirium clause on the static, Autumnal Gloom puts it on the trigger.
  • A threshold payoff is deliberately not also counted as a scaling payoff, or delirium cards would be double-weighted in the commitment formula. There is a test pinning this.

highest_threshold is tracked so a descend 8 deck does not think it is finished at four card types. Opponent-scoped counts and opponent-scoped mill are both rejected — punishing an opponent's diverse graveyard, or filling it, does nothing for this deck's own threshold.

Commitment is a geometric mean over (payoff, enabler) — unlike the poison axis, both pillars are mandatory. Payoffs with no enablers never turn on reliably; enablers with no payoff are just self-mill. Neither is this archetype, and both collapse to 0.0 (two tests pin the collapse). Calibrated to a Modern delirium shell (8 threshold + 2 scaling + 8 enablers over 37 nonland) > 0.85, with a lone-Tarmogoyf anti-calibration below the floor.

Policy scores by threshold deficit: strong band when the action supplies the last missing type, scaled down as the deficit grows. Once the count is at or above the deck's highest threshold the branch scores zero — every payoff is already live, so more diversity buys nothing, and scoring it would make the AI durdle with self-mill after delirium is already on.

Files changed

  • crates/phase-ai/src/features/graveyard_types.rs (new)
  • crates/phase-ai/src/features/tests/graveyard_types.rs (new)
  • crates/phase-ai/src/policies/graveyard_types.rs (new)
  • crates/phase-ai/src/policies/tests/graveyard_types.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 207.2c — delirium / descend / threshold / undergrowth are ability words with no rules meaning
  • CR 205.2a — the card types
  • CR 404.1 — a graveyard is its owner's discard pile
  • CR 109.2a + CR 400.1 — zone scoping for the count
  • CR 109.3 — conjunction rule for the And arms of the condition and filter walks
  • CR 701.17 Mill · CR 701.25 Surveil

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

Performance

verdict() runs per candidate per search node, so predicate order is deliberate: the card-local AST check (fills_own_graveyard_parts over just this candidate's abilities) runs first and rejects almost every candidate. Only a confirmed graveyard-filler pays for the graveyard scan, which walks one zone's objects and never touches the battlefield, mana affordability, find_legal_targets, or any SimulationFilter-cloning path. 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 — this branch's exact base) 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 2026-06-17 (#3492), roughly 200 upstream commits ago.

Consistent with that, none of the three gate matchups is a graveyard-diversity deck, so GraveyardTypesPolicy::activation returns None and the policy never fires on them — the control proves it empirically rather than by argument.

I deliberately did not refresh the baseline. Absorbing 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 --lib1381 passed; 0 failed (16 feature tests + 3 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

  • graveyard_types_progress is registered in UNTUNED_POLICY_PENALTY_FIELDS pending a paired-seed calibration.
  • Tests live in features/tests/ and policies/tests/ per the house convention (no #[cfg(test)] in source files).
  • Boundary with reanimator: that axis detects graveyard recursion targets — what is worth bringing back. This one measures the graveyard's type spread. A graveyard of four creatures is excellent for reanimator and useless for delirium; the axes are independent by design.
  • Known limitation: fetchland-style land sacrifice and cycling are not counted as enablers. Both do add a card type in practice, but neither is a direct self-mill/discard effect, so folding them in would need a separate structural signal — a reasonable follow-up rather than a silent widening here.

Tier: Frontier

Summary by CodeRabbit

  • New Features
    • Added graveyard-type feature detection (threshold payoffs, scaling-only payoffs, and self-graveyard enablers) and surfaced it in deck analysis.
    • Introduced a Graveyard Types tactical AI policy that scores actions by distinct graveyard core types, gated by a commitment floor.
  • Bug Fixes
    • Improved recognition of actions that truly fill the player’s own graveyard, with broader targeting/scoping support.
  • Config Updates
    • Added the graveyard_types_progress calibration weight for threshold progress scoring (backward-compatible deserialization).
  • Tests
    • Added unit tests covering feature detection, policy scoring, authoritative graveyard-fill semantics, and legacy config loading.

CR 207.2c lists delirium, descend and threshold as ABILITY WORDS with no rules
meaning — the mechanical content is entirely the underlying "N or more card
types among cards in your graveyard" condition (CR 205.2a). 95 cards in the
corpus read that quantity, and nothing in the AI modelled the graveyard's type
spread: a self-mill that supplied the fourth distinct card type, switching
every delirium payoff on, scored exactly the same as one that added a
redundant fifth creature.

Feature (`features/graveyard_types.rs`) — three structural axes, no card names:
  * threshold payoffs: a `QuantityComparison` condition whose lhs is
    `DistinctCardTypes { Zone { Graveyard, Controller } }`. `StaticDefinition`
    and `TriggerDefinition` carry DISTINCT condition enums that happen to share
    the shape, so each gets its own extractor (Backwoods Survivalists puts the
    clause on the static, Autumnal Gloom on the trigger)
  * scaling payoffs: the same quantity read as a dynamic magnitude with no
    threshold (Consuming Blob, Tarmogoyf). A threshold payoff is deliberately
    NOT also counted here, or delirium cards would be double-weighted
  * enablers: Mill / DiscardCard / Discard / Surveil scoped to the controller.
    An opponent-scoped mill is excluded — filling their graveyard does nothing
    for this deck's threshold

`highest_threshold` is tracked so a descend 8 deck does not think it is done at
four card types.

Commitment is a geometric mean over (payoff, enabler): unlike the poison axis,
BOTH pillars are mandatory. Payoffs with no enablers never turn on reliably and
enablers with no payoff are just self-mill — neither is this archetype, and
both collapse to 0.0. Calibrated to a Modern delirium shell (8 threshold + 2
scaling + 8 enablers over 37 nonland) > 0.85, with a lone-Tarmogoyf
anti-calibration below the floor.

Policy scores CastSpell + ActivateAbility by threshold deficit — strong band
when the action supplies the LAST missing type, scaled down as the deficit
grows. Once the count is at or above the deck's highest threshold the branch
scores zero: every payoff is already live, so more diversity buys nothing, and
scoring it would make the AI durdle with self-mill after delirium is on.

Predicate order is deliberate for the search inner loop: the card-local AST
check runs first and rejects almost every candidate; only a confirmed
graveyard-filler pays for the graveyard scan, which walks one zone and never
touches the battlefield, mana affordability, or find_legal_targets.

`graveyard_types_progress` is registered UNTUNED pending a paired-seed
calibration.

Boundary with `reanimator`: that axis detects graveyard RECURSION TARGETS —
what is worth bringing back. This one measures the graveyard's TYPE SPREAD. A
graveyard of four creatures is excellent for reanimator and useless for
delirium; the axes are independent by design.

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:44
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@matthewevans, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1fe6d214-1b4c-4604-9bc9-f28d12695327

📥 Commits

Reviewing files that changed from the base of the PR and between 691f7e4 and 3bb5eef.

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

Walkthrough

The PR adds structural graveyard card-type detection, exposes the resulting deck feature, introduces a tactical policy for graveyard-filling actions, registers configuration and policy identifiers, expands shared graveyard-fill recognition, and adds feature and policy tests.

Changes

Graveyard type-diversity support

Layer / File(s) Summary
Detect graveyard type-diversity signals
crates/phase-ai/src/features/graveyard_types.rs, crates/phase-ai/src/features/reanimator.rs, crates/phase-ai/src/config.rs
Adds AST-based threshold and scaling detection, self-graveyard enabler recognition, commitment scoring, expanded effect recognition, and backward-compatible policy-penalty deserialization.
Expose and validate the deck feature
crates/phase-ai/src/features/mod.rs, crates/phase-ai/src/features/tests/*
Adds GraveyardTypesFeature to deck analysis and tests classification, scope handling, threshold normalization, enabler detection, deduplication, ability-chain semantics, and commitment behavior.
Score graveyard-filling actions
crates/phase-ai/src/policies/graveyard_types.rs, crates/phase-ai/src/policies/{mod.rs,registry.rs}, crates/phase-ai/src/policies/tests/*
Adds and registers GraveyardTypesPolicy, which evaluates spell and activated-ability effects and scores distinct graveyard-type progress.

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

Suggested reviewers: matthewevans

Sequence Diagram(s)

sequenceDiagram
  participant DeckFeatures
  participant GraveyardTypesFeature
  participant CardAST
  DeckFeatures->>GraveyardTypesFeature: analyze(deck)
  GraveyardTypesFeature->>CardAST: inspect card abilities and effects
  CardAST-->>GraveyardTypesFeature: return graveyard type signals
  GraveyardTypesFeature-->>DeckFeatures: store commitment and threshold
Loading
sequenceDiagram
  participant CandidateAction
  participant GraveyardTypesPolicy
  participant GameState
  CandidateAction->>GraveyardTypesPolicy: evaluate action
  GraveyardTypesPolicy->>GameState: count owned graveyard CoreTypes
  GameState-->>GraveyardTypesPolicy: distinct type count
  GraveyardTypesPolicy-->>CandidateAction: neutral, strong, or scaled verdict
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: a new graveyard type-diversity feature axis and its policy.
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

🤖 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/graveyard_types.rs`:
- Around line 239-244: Update the QuantityRef::DistinctCardTypes handling for
CardTypeSetSource::Zone at ZoneRef::Graveyard so CountScope::All is not
classified as own-graveyard progress; retain only the controller-scoped behavior
unless the feature and distinct_graveyard_types runtime counting are changed
together to honor the selected scope.
- Around line 195-211: Update static_graveyard_type_threshold’s
QuantityComparison and Not handling to accept only comparisons whose direction
rewards reaching at least a positive threshold; reject equality, less-than, and
other fewer-types predicates, and normalize strict greater-than bounds to the
equivalent inclusive threshold before returning the value. Ensure negated
threshold conditions are not treated as positive payoffs, while preserving the
existing And/Or aggregation behavior.
- Around line 145-149: Update the highest_threshold representation so absence of
any threshold is distinct from an actual delirium threshold; do not substitute
DEFAULT_DELIRIUM_THRESHOLD when no threshold cards are present. Propagate this
distinction through the policy logic so scaling-only decks continue scaling
without a ceiling, while mixed decks remain bounded only by their real threshold
value.
- Line 211: Replace the wildcard fallback arms in
static_graveyard_type_threshold, trigger_graveyard_type_threshold,
modification_quantity, and filter_is_controller_scoped with explicit matches for
every currently unsupported enum variant, preserving their existing None/false
behavior while making future variants compiler-visible.

In `@crates/phase-ai/src/policies/graveyard_types.rs`:
- Around line 88-103: Update the ability selection in the graveyard-progress
evaluation around the CastSpell and ActivateAbility match arms so CastSpell
resolves only the spell ability/effect chain actually executed by the cast,
rather than passing the permanent’s full abilities slice. Preserve the existing
ActivateAbility selection and ensure self-mill or discard abilities that are
merely available on a cast permanent do not receive credit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7b61e711-e3b3-4c8b-9229-38f7a4841d9b

📥 Commits

Reviewing files that changed from the base of the PR and between 135e483 and cc62f5d.

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

Comment thread crates/phase-ai/src/features/graveyard_types.rs Outdated
Comment thread crates/phase-ai/src/features/graveyard_types.rs Outdated
Comment thread crates/phase-ai/src/features/graveyard_types.rs
Comment thread crates/phase-ai/src/features/graveyard_types.rs
Comment thread crates/phase-ai/src/policies/graveyard_types.rs Outdated
@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.

Thanks for the focused implementation. I need a revision before we can merge this current head.

  1. PolicyPenalties is deserialized as a complete value from existing tuning artifacts (ai_tune.rs), but the new graveyard_types_progress field has no serde default. Every artifact produced before this PR therefore fails to load with a missing-field error instead of retaining the shipped default. Please add the same field-level default pattern used by the neighboring recently-added penalties, and cover deserializing an artifact that predates this field.

  2. The scaling-only case is given DEFAULT_DELIRIUM_THRESHOLD in the feature even though it has no threshold. The policy then returns neutral as soon as the graveyard has four types, so it stops rewarding progress for a payoff that continues to scale. Model the absence of a threshold distinctly and add a verdict-level test that a scaling-only deck still receives the intended progress signal above four types.

  3. Threshold extraction ignores Comparator, treats Not as positive, and takes the maximum threshold under both And and Or. This misclassifies conditions such as “fewer than four types” or negated comparisons as delirium payoffs; Or also does not make every child a mandatory gate. Please normalize only genuinely positive mandatory comparisons (including the correct strict-> boundary) and reject/infer the rest according to the condition's truth semantics. Tests need to discriminate GE, GT, negative comparators, negation, and compound conditions.

  4. quantity_reads_own_graveyard_types accepts CountScope::All, while distinct_graveyard_types deliberately scans only the AI player's owned graveyard. Consequently an all-graveyards payoff is detected as an own-graveyard plan but the policy computes a different quantity (e.g. the parsed all-graveyards Tarmogoyf-class shape). Either keep the feature/policy strictly controller-scoped or carry the source scope through and resolve it consistently at decision time.

  5. The candidate lookup does not use the action's authoritative ability semantics. On CastSpell it walks every ability on the object, so casting a permanent with an activated self-mill/discard ability is rewarded even when the spell itself does not fill a graveyard. On ActivateAbility it indexes GameObject::abilities directly even though cast_facts::effective_activated_ability documents that this is not the engine's authoritative enumerated index space for runtime-granted abilities. Please use the established cast/activated-ability fact helpers (and the spell-only cast effect chain) and add regression coverage for both decisions.

I agree with the substantive current CodeRabbit reports on the threshold, scope, and cast-action issues and am leaving those threads intact. Its wildcard-exhaustiveness observation is a non-blocking style concern; the semantic fixes above are required first.

…-rs#6544)

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

1. Preserve pre-field PolicyPenalties artifacts. `graveyard_types_progress`
   now carries `#[serde(default = "default_graveyard_types_progress")]`, and
   both the `Default` impl and the serde attribute call that one shared fn —
   the same pattern as the neighbouring recently-added penalties. `ai_tune`
   deserializes the `policy_penalties` section straight into the struct, so a
   pre-field artifact previously failed with a missing-field error; a
   compatibility test now strips the key from a serialized artifact, tunes a
   neighbour, and asserts the tuned value survives while the new one falls
   back to the shared default.

2. Model "no threshold" distinctly. `highest_threshold` becomes
   `Option<u32>`; the fabricated `DEFAULT_DELIRIUM_THRESHOLD` fallback (and
   the const) are gone. A scaling-only deck (Consuming Blob, Tarmogoyf) has
   no threshold to finish, so the policy no longer goes neutral at four types:
   a threshold deck races to its gate, and any deck with a scaling payoff
   keeps earning a diminishing-but-nonzero signal past it. Covered by
   verdict-level tests, including the required "scaling-only deck still gets
   progress above four types".

3. Respect comparison truth semantics. Threshold extraction now normalizes
   only genuinely positive mandatory gates: `>=` / `>` (with the strict-bound
   off-by-one, `> 3` ⟺ `>= 4`), in either orientation (`N <= types` flips to
   `types >= N`). `<`, `<=`, `=`, `!=` are rejected — they reward fewer or an
   exact count. `Not` no longer passes through as positive (negating "N or
   more" is a "fewer than N" condition). `And` takes the max mandatory
   threshold; `Or` yields one only when EVERY branch is a graveyard threshold
   (then the minimum), since a single non-graveyard branch lets the payoff
   fire without delirium. Tests discriminate GE, GT, each negative
   comparator, negation, mirrored orientation, and both compounds.

4. Keep the axis strictly controller-scoped. `quantity_reads_own_graveyard_types`
   no longer accepts `CountScope::All`: the policy counts only the AI's owned
   graveyard, so an all-graveyards payoff would be detected as an own-graveyard
   plan while the policy measured a different quantity. Scope match is explicit
   (Controller/Owner accepted per CR 108.3 + CR 109.5).

5. Use the action's authoritative ability semantics. `CastSpell` now inspects
   only the spell's own resolution chain via `CastFacts::primary_effects`, so
   casting a permanent that merely HAS an activated self-mill ability is no
   longer credited; `ActivateAbility` resolves through
   `cast_facts::effective_activated_ability`, the engine's enumerated index
   space (correct for runtime-granted abilities where `GameObject::abilities`
   is not). Regression coverage for both decisions, plus the negative case.

Also routes the verdict through `PolicyVerdict::score` so a tuned-up scalar
auto-bands instead of tripping a band assertion.

cargo test -p phase-ai --lib — 1401 passed (graveyard suite 12 → 39)
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 ec075b9d5.

1 — Preserve pre-field PolicyPenalties artifacts. graveyard_types_progress now carries #[serde(default = "default_graveyard_types_progress")], with Default and the serde attribute calling that one shared fn (matching the neighbouring recently-added penalties). Added policy_penalties_load_pre_graveyard_types_artifact: it strips the key from a serialized artifact, tunes a neighbour, and asserts the tuned value survives while the absent field falls back to the shared default — the exact ai_tune.rs TuneGroup::Penalties path.

2 — Model the absence of a threshold distinctly. highest_threshold is now Option<u32>; the fabricated DEFAULT_DELIRIUM_THRESHOLD fallback and the const itself are gone. The policy branches accordingly: a threshold deck races to its gate; any deck with a scaling payoff keeps earning a diminishing-but-nonzero signal past it, so a Consuming Blob / Tarmogoyf deck is no longer truncated at four types. Covered by verdict_still_rewards_scaling_only_deck_above_four_types (the test you asked for), plus verdict_mixed_deck_keeps_scaling_past_threshold and scaling_only_deck_has_no_threshold.

3 — Respect comparator direction and negation. Extraction now normalizes only genuinely positive mandatory gates:

  • >= / > accepted, with the strict-bound off-by-one (> 3>= 4), in either orientation (N <= types flips to types >= N).
  • <, <=, =, != rejected — they reward fewer or an exact count, so self-mill toward N is not what they want.
  • Not no longer passes through as positive: negating "N or more types" is a "fewer than N" condition.
  • And takes the max mandatory threshold. Or yields one only when every branch is a graveyard threshold (then the minimum) — a single non-graveyard branch means the payoff can fire without delirium, so it is not a mandatory gate.

Tests discriminate GE, GT boundary, each negative comparator, negation, mirrored orientation, and both compounds.

4 — Keep the axis strictly controller-scoped. quantity_reads_own_graveyard_types no longer accepts CountScope::All. Since distinct_graveyard_types counts only the AI's owned graveyard, an all-graveyards payoff would otherwise be detected as an own-graveyard plan while the policy measured a different quantity (exactly the Tarmogoyf-class mismatch you flagged). The scope match is now explicit — Controller/Owner per CR 108.3 + CR 109.5 — with all_graveyards_scope_is_not_own_graveyard pinning it.

5 — Use the action's authoritative ability semantics. CastSpell now inspects only the spell's own resolution chain via CastFacts::primary_effects, so casting a permanent that merely has an activated self-mill ability is no longer credited. ActivateAbility resolves through cast_facts::effective_activated_ability — the engine's enumerated index space, correct for runtime-granted abilities where GameObject::abilities is not. Regression coverage for both: verdict_ignores_cast_of_a_body_with_an_activated_mill (fails if the cast path regresses to walking every ability), verdict_credits_the_activated_mill_ability, and verdict_credits_a_spell_that_mills_on_resolution.

Also: the verdict now routes through PolicyVerdict::score rather than the band-specific helpers, so a tuned-up scalar auto-bands instead of tripping score_in_band's assertion — the same latent-fragility fix that surfaced on #6543.

Verification

  • cargo test -p phase-ai --lib — 1401 passed (graveyard suite 12 → 39 tests, adding the whole verdict layer against a real PolicyContext).
  • cargo clippy -p phase-ai -p engine --all-targets --features proptest -- -D warnings — clean.

On CodeRabbit's wildcard-exhaustiveness note: I left the large StaticCondition / TriggerCondition matches on _ => None as you indicated it is non-blocking style, but made the CountScope arm explicit since item 4 required reasoning about it anyway.

@matthewevans

Copy link
Copy Markdown
Member

All five prior blockers are genuinely fixed — I re-verified each independently rather than taking the summary. Two new blockers remain, and they're about reach, not correctness: as built, the axis switches itself off for the flagship delirium shell.

✅ The five prior blockers — verified fixed

# Item Evidence
1 PolicyPenalties deserialize default config.rs:469-470 per-field #[serde(default = "default_graveyard_types_progress")], shared fn at :543-545, and Default calls that same fn at :535 — so the default can't drift between the two paths. Registered in UNTUNED_POLICY_PENALTY_FIELDS :740-744.
2 Invented four-type threshold Gone. highest_threshold: Option<u32> at features/graveyard_types.rs:70; DEFAULT_DELIRIUM_THRESHOLD greps clean across all ten files.
3 Comparator semantics positive_graveyard_threshold :260-287 — GE takes the bound, GT takes bound+1, LT/LE/EQ/NE yield None, exhaustive match with no _ arm, plus flip_comparator :299-308 for the mirrored orientation.
4 Not / Or composition NotNone (:216, :237); Or yields a threshold only when every branch has one, then takes the min (:246-250, with empty-Or correctly collapsing to None); And takes the max (:203-206, :230-233).
5 CountScope::All quantity_reads_own_graveyard_types :319-331 matches `Controller

The serde fix is the real thing rather than a half-fix: policy_penalties_load_pre_graveyard_types_artifact (config.rs:1520-1544) removes the key, tunes a neighbouring field to -3.5, and asserts both that the neighbour survives and that the absent field lands on the shared default. And PolicyVerdict::score is a band helper, not a raw sentinel — registry.rs:195-208 dispatches by magnitude into nudge/preference/strong/critical and clamps at CRITICAL_MAX. No violation there.

🔴 Blocker 1 — enabler detection never reads face.triggers, which zeroes the whole axis

features/graveyard_types.rs:131 calls fills_own_graveyard_parts(&face.abilities) and nothing else.

The consequence is not "some cards score slightly low" — it's total inertness, and the mechanism is worth spelling out because it's non-obvious:

  • compute_commitment ends at :183 with commitment::geometric_mean(&[payoff_density, enabler_density]).
  • A geometric mean is zero if any factor is zero. So enabler_count == 0enabler_density == 0commitment == 0.0.
  • GraveyardTypesPolicy::activation (policies/graveyard_types.rs:81-86) returns None below GRAVEYARD_TYPES_FLOOR = 0.35.

So a deck whose enablers are all trigger-borne scores nothing at all. Stitcher's Supplier — the archetypal delirium enabler — is literally abilities: [] with two Mill triggers (verified in card-data.json). Same shape for Aberrant Researcher, Acolyte of Affliction, Aftermath Analyst.

Scale: walking abilities vs triggers over card-data.json, I count 237 faces carrying a controller-scoped Effect::Mill in triggers and none in abilities. (Method: recursive effect-node walk over each face's abilities and triggers, target ∈ {Controller, You}, a- Alchemy variants included. Treat it as the right order of magnitude rather than an exact figure — a stricter triggers[*].execute-only walk yields a somewhat lower number.)

The house pattern already exists: features/aristocrats.rs:313 and features/tokens_wide.rs:120-124 walk face.abilities and face.triggers[*].execute together. Fix is to mirror tokens_wide::is_token_generator_parts.

🔴 Blocker 2 — threshold extraction misses the third condition carrier, and the obvious fix still misses it

features/graveyard_types.rs:101-112 reads static_abilities[*].condition and triggers[*].condition. There is a third carrier: AbilityDefinition.condition: Option<AbilityCondition> (engine/src/types/ability.rs:16265), which holds the same QuantityCheck shape via AbilityCondition::ConditionInstead { inner }.

The important detail — and this is a correction to make before you code it: on Traverse the Ulvenwald the gate is not at abilities[0].condition. It's at abilities[0].sub_ability.condition:

/abilities[0]/sub_ability/condition
  {"type":"ConditionInstead","inner":{"type":"QuantityCheck",
   "lhs":{"type":"Ref","qty":{"type":"DistinctCardTypes",
          "source":{"type":"Zone","zone":"Graveyard","scope":"Controller"}}},
   "comparator":"GE","rhs":{"type":"Fixed","value":4}}}

That falls out of the card's own grammar — "Delirium — If there are four or more card types among cards in your graveyard, instead search…" is a replacement of the second clause, so it lowers onto the sub-ability. A fix that folds only face.abilities[*].condition into the .max() chain would still return None for Traverse. The extractor has to walk the sub_ability / else_ability chain.

Scale: of 67 faces reading DistinctCardTypes over a Controller/Owner graveyard anywhere, 13 carry it only in the abilities chain — Traverse the Ulvenwald, Descend upon the Sinful, Invasive Surgery, Pick the Brain, Might Beyond Reason, To the Slaughter, Demonic Counsel, Beastie Beatdown, Convert to Slime, Impossible Inferno and others. (Method: static_abilities / triggers / abilities-with-chain-walk, same file.)

These two blockers are mirror images of each other, which is what makes me confident it's a systematic omission rather than two unrelated misses: enabler detection reads abilities but not triggers; threshold detection reads triggers but not abilities. Worth fixing as one change that gives both detectors the same three-carrier walk. Note the doc comment at :51 already claims detection is structural over "static_abilities, .triggers and .abilities" — the intent was right, only one detector got each half.

🟡 Non-blocking

Effect::Dig { rest_destination: Graveyard } isn't recognized (:390-403 matches Mill/DiscardCard/Discard/Surveil only). This is arguably the best shape on this axis — "reveal N, keep one, rest to graveyard" deposits several distinct types at once. I count 76 faces using it; verified both spellings of the pattern: Satyr Wayfinder carries it in triggers (abilities: []), Grisly Salvage in abilities. So this one needs the three-carrier walk from Blocker 1 and a new effect arm.

The CastSpell gate discards CastFacts::immediate_etb_triggers (policies/graveyard_types.rs:161-163 uses facts.primary_effects only). Excluding activated abilities was correct; this overshoots to also exclude ETB triggers, which do fire as a consequence of the cast — that's why CastFacts carries them as a separate field (cast_facts.rs:230-235). Net effect: casting Stitcher's Supplier or Satyr Wayfinder, i.e. the actual delirium play, returns graveyard_types_na. The doc at :152-156 cites CR 601.2, which I grep-verified at docs/MagicCompRules.txt:2455 — it supports excluding activated abilities, not ETB triggers.

Second authority for "does this fill my own graveyard". features/reanimator.rs:208-260 already defines effect_fills_own_graveyard and documents itself verbatim as "Single authority — … Shared with the live policy." This PR re-implements it at features/graveyard_types.rs:384-417 with a different answer set: reanimator accepts TargetFilter::Any, this one doesn't; this one accepts SelfRef and Typed { controller: Some(You) }, reanimator doesn't. Two single authorities disagreeing about one question is precisely the drift the single-authority rule exists to prevent. Extend the reanimator helper and have both call it.

Docs contradict the item-4 fix. features/graveyard_types.rs:59, :67 and policies/graveyard_types.rs:19, :130 all name Tarmogoyf as a detected scaling payoff. Tarmogoyf counts types "among cards in all graveyards"CountScope::All, which :319-331 now correctly rejects, so it's never detected. Consuming Blob is the right example throughout.

policies/graveyard_types.rs:31-32 claims the scan "walks one zone's objects and never touches the battlefield"; distinct_graveyard_types :53-63 iterates state.objects.values() and filters. GameState has no zone index, so the implementation is the only option and matches house practice — the comment is just wrong.

Dead branch at policies/graveyard_types.rs:114-118: if deficit == 1 { scalar } else { scalar / f64::from(deficit) } — both arms are identical when deficit == 1.

Fixture path-divergence is why all four gaps ship green. Every enabler fixture in both test files is an ability-borne Effect::Mill { target: Controller } (features/tests/graveyard_types.rs:95-106, policies/tests/graveyard_types.rs:90-97). No fixture is trigger-borne, Dig-borne, or carries a threshold in the abilities chain. When you fix the detectors, please add at least one of each — otherwise the next regression here is invisible again.

📋 Not your defect — two notes

The two CR 109.3 annotations (features/graveyard_types.rs:201, :413) don't describe the code. I grep-verified docs/MagicCompRules.txt:592: CR 109.3 is the list of an object's characteristics (name, mana cost, color, …) and says nothing about conjunction. But this is inherited verbatim from features/landfall.rs:126 and features/enchantments.rs:162, and your PR says it mirrors landfall — so it's ours to clean up repo-wide, not yours to fix here. Logical conjunction of a parsed condition tree simply isn't a CR concept.

Every other CR in the PR grep-verifies and describes its code correctly: 207.2c, 205.2a, 108.3, 109.5, 109.2a, 400.1, 404.1, 701.17 (Mill), 701.25 (Surveil), 601.2, 602.2.

cargo ai-gate is a CI check here ("Paired-seed AI gate"), not something you're expected to run locally. It was in_progress at this head along with the Rust shards and the Decision-cost perf gate — nothing failing, just unfinished.


Recommendation: request changes on the two reach blockers. The correctness work you did on the five prior items is solid and I don't want it lost in the noise — the comparator normalization with its exhaustive match and the empty-Or edge case are better than most first attempts at that shape. The remaining problem is that the detectors only see one of three condition carriers each, so the axis never activates for the deck it was written for. One change giving both detectors the same three-carrier, chain-walking scan fixes both blockers and the Dig gap together.

…alk (phase-rs#6544)

Addresses the two reach blockers from the second review. They were mirror
images of one omission — enabler detection read `abilities` but not `triggers`;
threshold detection read `triggers` but not `abilities` — so this is one change
that gives both detectors the same three-carrier, chain-walking scan.

Blocker 1 — enabler detection never read `face.triggers`, which zeroed the axis.
`compute_commitment` ends in a geometric mean, so `enabler_count == 0` forces
`commitment == 0.0`, which drops the deck under `GRAVEYARD_TYPES_FLOOR` and
switches the policy off entirely. Stitcher's Supplier — the archetypal delirium
enabler — is `abilities: []` with mill triggers, so the flagship shell scored
nothing at all. `fills_own_graveyard_parts` now takes `(abilities, triggers)`
and walks `triggers[*].execute` too, mirroring
`tokens_wide::is_token_generator_parts`.

Blocker 2 — threshold extraction missed the third condition carrier,
`AbilityDefinition.condition`. On Traverse the Ulvenwald the gate is not at
`abilities[0].condition` but at `abilities[0].sub_ability.condition` (the
"…instead search…" clause lowers onto the sub-ability, wrapped in
`ConditionInstead`), so folding only the top level in would still have missed
it. Added `ability_graveyard_type_threshold` (QuantityCheck / ConditionInstead /
And-max / Or-all-then-min / Not-None, matching the two existing extractors) plus
`ability_chain_graveyard_type_threshold`, which walks the sub_ability and
else_ability chain. All three carriers now feed one `.max()`.

Also, per the same review:
  * `Effect::Dig { rest_destination: Graveyard }` is now an enabler (CR 701.20e)
    — the densest type-spread filler on the axis. Needed the three-carrier walk
    too: Satyr Wayfinder carries it in triggers, Grisly Salvage in abilities.
  * The `CastSpell` gate no longer discards `CastFacts::immediate_etb_triggers`.
    Excluding *activated* abilities was right (CR 601.2); excluding ETB triggers
    overshot and made casting Stitcher's Supplier return `graveyard_types_na`.
  * Single authority restored: `reanimator::effect_fills_own_graveyard` is
    extended (DiscardCard, Surveil, rest-to-graveyard Dig; SelfRef / Typed-You /
    Or / And scopes) and both graveyard axes now call it, instead of two
    "single authorities" disagreeing about one question.
  * Docs: Tarmogoyf is `CountScope::All` and correctly rejected, so Consuming
    Blob is the example throughout; the zone-scan claim now matches the
    implementation; the enabler list includes Dig.
  * Dead branch removed — `scalar / deficit` already peaks at `scalar` when the
    deficit is 1.

Fixtures were the reason all of this shipped green: every enabler fixture was an
ability-borne Mill. Added trigger-borne mill, Dig-borne (both carriers) with a
rest-to-library negative, ability-chain threshold (incl. participation in
`highest_threshold`), a commitment-path guard that trigger-only enablers clear
the floor, and ETB-trigger cast coverage with a non-filling control.

Left alone per the review: the two inherited `CR 109.3` annotations, which are
a repo-wide cleanup rather than this PR's.

cargo test -p phase-ai --lib — 1409 passed (graveyard suite 39 → 47)
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

Both reach blockers fixed in f6239086f — as one change, since they were mirror images of the same omission.

🔴 Blocker 1 — enabler detection now reads face.triggers

fills_own_graveyard_parts takes (abilities, triggers) and walks triggers[*].execute, mirroring tokens_wide::is_token_generator_parts. Your diagnosis of the mechanism was the important part: the geometric mean in compute_commitment turns enabler_count == 0 into commitment == 0.0, which falls under GRAVEYARD_TYPES_FLOOR and switches the policy off — so this wasn't "scores low", it was inert for the exact deck the axis targets.

Guarded two ways: trigger_borne_mill_is_an_enabler (the Stitcher's Supplier shape, abilities: [] + mill trigger) and trigger_only_enablers_still_clear_the_floor, which drives the real commitment path and asserts a trigger-only shell clears the floor — so a regression fails on the consequence, not just the predicate.

🔴 Blocker 2 — the third condition carrier, walked down the chain

Thanks for the correction before I coded it — it was load-bearing. ability_graveyard_type_threshold handles QuantityCheck and unwraps ConditionInstead, with the same And-max / Or-all-then-min / Not-None rules as the other two extractors; ability_chain_graveyard_type_threshold then walks sub_ability and else_ability. A top-level-only fold would indeed still have returned None for Traverse, so the fixture puts the gate on abilities[0].sub_ability.condition exactly as you quoted. All three carriers now feed one .max(), verified by ability_chain_threshold_participates_in_highest_threshold.

🟡 Non-blocking, all taken

  • Effect::Dig { rest_destination: Graveyard } is now an enabler (CR 701.20e). It needed the three-carrier walk as you predicted — covered from both carriers, plus a rest-to-library negative so the arm discriminates on the destination rather than on "is a Dig".
  • CastSpell gate no longer drops immediate_etb_triggers. Agreed the CR 601.2 reasoning supports excluding activated abilities and overshot to ETB. verdict_credits_a_cast_whose_etb_trigger_mills covers the delirium play, with a non-filling-ETB control.
  • Single authority restored. Extended reanimator::effect_fills_own_graveyard (adds DiscardCard, Surveil, rest-to-graveyard Dig; scopes SelfRef / Typed{You} / Or / And) and both axes call it. Flagging the cross-axis effect explicitly: this widens reanimator's own enabler detection too — surveil and dig-to-graveyard genuinely do fill a graveyard for reanimation, so I believe it's a fix rather than drift, and reanimator's 32 tests still pass. Say the word if you'd rather that widening land separately.
  • Docs: Tarmogoyf is CountScope::All and correctly rejected, so Consuming Blob is the example throughout; the "walks one zone's objects" claim now matches the state.objects filter; the enabler list includes Dig.
  • Dead branch removedscalar / deficit already peaks at scalar when the deficit is 1.

On the fixtures

You were right that path-divergence is why all four gaps shipped green, so the fixture set now covers each previously-invisible path: trigger-borne mill, Dig-borne (both carriers) + negative, ability-chain threshold + its highest_threshold participation, the commitment-path floor guard, and ETB-trigger casts + control.

Left the two inherited CR 109.3 annotations alone per your note that they're a repo-wide cleanup.

cargo test -p phase-ai --lib1409 passed (graveyard suite 39 → 47). cargo clippy -p phase-ai -p engine --all-targets --features proptest -- -D warnings — clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/phase-ai/src/features/tests/graveyard_types.rs (1)

658-683: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing Not/And/Or regression coverage for the new ability-chain threshold carrier.

The static- and trigger-condition carriers each got dedicated tests for negation and compound-condition semantics (lines 280-390, 391-479), but the new ability-chain carrier (ability_graveyard_type_threshold) only has positive-path coverage here. Since that function explicitly special-cases Not to reject inverted "fewer types" semantics, a regression test mirroring the existing Not-rejection tests for AbilityCondition would close the gap and match this PR's stated goal of end-to-end building-block coverage.

🤖 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/features/tests/graveyard_types.rs` around lines 658 -
683, The ability-chain threshold carrier lacks regression coverage for negated
conditions. Add a test alongside threshold_in_the_ability_chain_is_detected
using ability_graveyard_type_threshold through the ability-chain fixture,
asserting that a Not-wrapped “fewer types” condition is rejected and does not
contribute to threshold_payoff_count or highest_threshold; mirror the existing
AbilityCondition Not-rejection test pattern.
🤖 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/reanimator.rs`:
- Around line 281-289: Make target_fills_own_graveyard exhaustive by replacing
the wildcard arm with explicit matches for every remaining TargetFilter variant.
Preserve the existing behavior for Controller, Any, SelfRef, Typed, Or, and And,
while ensuring future controller-scoped variants cannot be silently treated as
false.

---

Nitpick comments:
In `@crates/phase-ai/src/features/tests/graveyard_types.rs`:
- Around line 658-683: The ability-chain threshold carrier lacks regression
coverage for negated conditions. Add a test alongside
threshold_in_the_ability_chain_is_detected using
ability_graveyard_type_threshold through the ability-chain fixture, asserting
that a Not-wrapped “fewer types” condition is rejected and does not contribute
to threshold_payoff_count or highest_threshold; mirror the existing
AbilityCondition Not-rejection test pattern.
🪄 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: 04640db3-94d5-4e16-b19f-2e861e551b0c

📥 Commits

Reviewing files that changed from the base of the PR and between ec075b9 and f623908.

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

Comment on lines 281 to +289
fn target_fills_own_graveyard(filter: &TargetFilter) -> bool {
matches!(filter, TargetFilter::Controller | TargetFilter::Any)
match filter {
TargetFilter::Controller | TargetFilter::Any | TargetFilter::SelfRef => true,
TargetFilter::Typed(typed) => matches!(typed.controller, Some(ControllerRef::You)),
TargetFilter::Or { filters } => filters.iter().any(target_fills_own_graveyard),
// Every constraint of a conjunction must hold for the match.
TargetFilter::And { filters } => filters.iter().all(target_fills_own_graveyard),
_ => false,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "pub enum TargetFilter" -A 60 crates/engine/src/types/ability.rs

Repository: phase-rs/phase

Length of output: 3818


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== target_fills_own_graveyard ==\n'
rg -n "fn target_fills_own_graveyard|target_fills_own_graveyard\\(" crates/phase-ai/src/features/reanimator.rs -A 20 -B 8

printf '\n== TargetFilter match sites ==\n'
rg -n "TargetFilter::" crates -g '*.rs' | sed -n '1,220p'

printf '\n== TargetFilter enum tail ==\n'
sed -n '4640,4725p' crates/engine/src/types/ability.rs

Repository: phase-rs/phase

Length of output: 32435


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('crates/phase-ai/src/features/reanimator.rs')
for i, line in enumerate(p.read_text().splitlines(), 1):
    if 260 <= i <= 305:
        print(f"{i}:{line}")
PY

Repository: phase-rs/phase

Length of output: 2642


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Effect definitions ==\n'
rg -n "pub enum Effect|Mill \\{|Discard \\{|DiscardCard \\{|Surveil \\{|Dig \\{" crates/engine/src/types -A 8 -B 3

printf '\n== Conversions feeding mill/discard/surveil/dig ==\n'
rg -n "Effect::Mill|Effect::DiscardCard|Effect::Discard|Effect::Surveil|Effect::Dig" crates -g '*.rs' -A 4 -B 4 | sed -n '1,260p'

printf '\n== TargetFilter variants handling in convert ===\n'
rg -n "TargetFilter::(Not|SpecificObject|SpecificPlayer|StackAbility|StackSpell|Opponent|GrantingObject|SourceOrPaired|LastCreated|AttachedTo|TriggeringSource|ParentTarget|ExiledBySource|Player|Controller|SelfRef|Any)" crates/mtgish-import/src/convert crates/engine/src -g '*.rs' | sed -n '1,260p'

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('crates/engine/src/types/ability.rs')
lines = p.read_text().splitlines()
for i, line in enumerate(lines, 1):
    if 4700 <= i <= 4795:
        print(f"{i}:{line}")
PY

Repository: phase-rs/phase

Length of output: 6106


Make target_fills_own_graveyard exhaustive (crates/phase-ai/src/features/reanimator.rs:281-289) TargetFilter is a closed enum here, so _ => false can hide a new controller-scoped variant and silently undercount self-enabling mill/discard effects. Match the remaining variants explicitly.

🤖 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/features/reanimator.rs` around lines 281 - 289, Make
target_fills_own_graveyard exhaustive by replacing the wildcard arm with
explicit matches for every remaining TargetFilter variant. Preserve the existing
behavior for Controller, Any, SelfRef, Typed, Or, and And, while ensuring future
controller-scoped variants cannot be silently treated as false.

Source: Path instructions

…er (phase-rs#6544)

The static- and trigger-condition carriers each have negation and
compound-condition regression tests; the new `AbilityCondition` carrier shipped
with positive-path coverage only, so its `Not => None` rejection and its
And-max / Or-all-then-min rules were unguarded.

Adds, through the Traverse-shaped sub_ability fixture:
  * negated "N or more types" is not a payoff (the inverted "fewer than N"
    gate), matching the other two carriers,
  * a non-positive comparator on the same carrier is likewise rejected,
  * `And` takes the highest mandatory threshold, `Or` yields one only when
    every branch is a graveyard threshold (else the payoff can fire without
    delirium).

cargo test -p phase-ai --lib — 1412 passed (graveyard suite 47 → 50)
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 the CodeRabbit round in 691f7e44f, and one note on the red CI.

✅ Taken — Not/And/Or coverage for the new ability-chain carrier. Fair catch: the static and trigger carriers each had negation and compound tests, but the third carrier shipped positive-path only, so its Not => None rejection and And-max / Or-all-then-min rules were unguarded. Added through the Traverse-shaped sub_ability fixture: negated "N or more types" rejected, a non-positive comparator on the same carrier rejected, And takes the highest mandatory threshold, and Or yields one only when every branch is a graveyard threshold.

⏭️ Skipped — making target_fills_own_graveyard exhaustive over TargetFilter. Two reasons: matthewevans already classified the wildcard-exhaustiveness observation as a non-blocking style concern on the previous round, and TargetFilter has ~30 variants, so enumerating them inside reanimator.rs — a shared authority I only minimally extended — is churn in another feature for no semantic gain. The _ => false is deliberately fail-closed, which is the safe direction for "is this my graveyard": a new variant under-counts an enabler rather than mis-crediting an opponent-facing mill as a self-enabler. Happy to do it if you'd rather, ideally as its own repo-wide pass alongside the CR 109.3 cleanup.

cargo test -p phase-ai --lib1412 passed (graveyard suite 47 → 50). cargo clippy -p phase-ai -p engine --all-targets --features proptest -- -D warnings — clean.


On the red Paired-seed AI gate / Decision-cost perf gate: those are infrastructure, not this PR. Both jobs produced zero binary output and ended in The operation was canceled with orphaned ai-gate / ai-perf-gate processes — a hang/cancel, not a threshold breach. Repo-wide evidence:

  • Every ai-gate.yml run through ~23:44 succeeded, including both of my branches.
  • fix/5981-gift-recipient-selection (not mine) was cancelled at 00:37, before either of my pushes (00:59, 01:20).
  • feat/kamigawa-flip-cards started 00:18 and was still running an hour later.

That pattern points at runner/concurrency exhaustion rather than anything in the diff, so I've left it alone per the "external CI → note, don't touch" rule. Locally this branch is green on the full suite and clippy; happy to re-run the gate once the runners drain.

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

Copy link
Copy Markdown
Contributor Author

Confirming your merge 3bb5eefa8 — I verified it rather than assuming, and took yours instead of pushing my own.

Timing overlap: #6543 landed while this loop was mid-cycle, so I resolved the same eight conflicts locally. Before pushing, my pre-push divergence check reported DIVERGED against 3bb5eefa8, so I discarded my merge and reset onto yours — never force-pushing over a maintainer commit. Confirmed 691f7e44f is an ancestor of your merge, so none of this PR's work was dropped.

Our resolutions agreed on every hunk, including the two non-obvious ones:

  • the UNTUNED_POLICY_PENALTY_FIELDS entry, where a naive keep-both fuses the two (name, reason) pairs into a single 4-element tuple (a type error, not a silent one — worth noting for the next axis PR that collides here);
  • the two artifact-compat tests, which share a doc prefix and tail so git interleaves them into one function — both of us split them back into two whole #[test] fns, each pinning its own field's #[serde(default)].

Verified on your merged tree:

  • cargo test -p phase-ai --lib1470 passed
  • graveyard suite 50, poison suite 58, and both policy_penalties_load_pre_*_artifact tests present and passing — so neither axis lost coverage in the port.

Agreed the registry Vec order isn't load-bearing. Nothing further from me on the merge; the two reach blockers and the CodeRabbit round remain addressed as of 691f7e44f.

@minion1227

Copy link
Copy Markdown
Contributor Author

@matthewevans — requesting a re-review when you have a moment.

Everything from both of your rounds is addressed and the branch is green on your merge head 3bb5eefa8:

  • Round 1 (5 blockers) — serde-default artifact compat, Option<u32> "no threshold", comparator/Not/Or truth-semantics, controller-only scope, and CastFacts/effective_activated_ability action semantics.
  • Round 2 (2 reach blockers) — both detectors now share the same three-carrier, chain-walking scan (f6239086f): enabler detection reads triggers[*].execute, threshold detection walks AbilityDefinition.condition down the sub_ability/else_ability chain. Also took the Dig-to-graveyard enabler, restored ETB triggers to the cast gate, and consolidated onto the single reanimator::effect_fills_own_graveyard authority.
  • CodeRabbitNot/And/Or regression coverage added for the new ability-chain carrier (691f7e44f); skipped the TargetFilter exhaustiveness nit with a reason, per your earlier note that it's non-blocking style.

CI is 15/15 green (Paired-seed AI gate, Decision-cost perf gate, Tauri compile check, full Rust suite). Locally: cargo test -p phase-ai --lib 1470 passed, graveyard suite 50, poison suite 58 (both axes coexist after the port), clippy clean.

The CHANGES_REQUESTED showing is your original review — since the repo doesn't auto-dismiss stale reviews, it needs a fresh look to clear. No rush; flagging it as ready whenever you're free.

@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-reviewed at 3bb5eefa. All five findings from the prior round are resolved, and each is now pinned by a test that discriminates the exact failure it fixes. Approving.

Findings, closed

  1. serde default#[serde(default = "default_graveyard_types_progress")] on the field, wired into Default, with the pre-existing-artifact rationale in the doc comment. ai_tune artifact load is green in CI.
  2. threshold absencehighest_threshold: Option<u32>; the verdict routes a None-threshold scaling deck into the has_scaling branch (scalar / (current+1), diminishing but never zero), so a Consuming Blob deck keeps being rewarded past four types. Pinned by verdict_still_rewards_scaling_only_deck_above_four_types and descend_eight_tracks_highest_threshold.
  3. comparator / Not / And / Orpositive_graveyard_threshold handles both orientations (flip_comparator), the strict-> off-by-one (GTbound+1), and rejects </<=/=/!= with an exhaustive Comparator match. NotNone; And → max (mandatory conjunction, CR 109.3); Or → threshold only when every branch is a graveyard gate, else None. Pinned by non_positive_comparators_are_not_thresholds, mirrored_orientation_normalizes_to_lower_bound, negated_threshold_is_not_a_payoff, and_takes_the_highest_mandatory_threshold, or_is_a_gate_only_when_every_branch_is_graveyard — and the ability-chain twins.
  4. CountScope consistencyquantity_reads_own_graveyard_types now matches only Controller | Owner, so an all-graveyards (Tarmogoyf-class) payoff is no longer misread as an own-graveyard plan against the controller-scoped distinct_graveyard_types. Pinned by all_graveyards_scope_is_not_own_graveyard.
  5. authoritative ability semanticscandidate_fills_own_graveyard uses cast_facts() (primary_effects + immediate_etb_triggers, correctly excluding activated abilities on a cast per CR 601.2) and effective_activated_ability() (the runtime-enumerated index per CR 602.2) instead of walking GameObject::abilities. Pinned by verdict_ignores_cast_of_a_body_with_an_activated_mill, verdict_credits_the_activated_mill_ability, and the ETB-trigger pair.

Gates

Paired-seed AI gate and Decision-cost perf gate both green — the live registry addition doesn't regress AI strength or the per-candidate verdict() cost, and no baseline bump was required. The condition combinators are decomposed into typed helpers with CR annotations rather than card-name matching, and the extractor is shared across all three condition carriers (static / trigger / ability-chain) — the right seam. Nice work.

@matthewevans matthewevans added the quality For high-quality minimal to no-churn PRs label Jul 24, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 24, 2026
@minion1227

Copy link
Copy Markdown
Contributor Author

Status on head 3bb5eefa8: all 14 required checks green — including the Paired-seed AI gate and the Decision-cost perf gate — and the branch is MERGEABLE/CLEAN against main. Thanks for the review round.

One CodeRabbit thread is still open — the exhaustiveness nit on target_fills_own_graveyard (crates/phase-ai/src/features/reanimator.rs:289). I've left the code as-is deliberately, and want to record why rather than reset an approved head:

  • The match already handles every controller-scoped TargetFilter explicitly: Controller, SelfRef, Any, Typed { controller: You }, plus the Or/And recursions (with And requiring every conjunct, per CR 608.2c). No current self-graveyard variant falls through.
  • The _ => false arm therefore only catches opponent-facing scopes (Player, Opponent) — correctly not own-graveyard fillers — and any future variant. Its failure direction is undercount: at worst a new variant makes a card not register as an enabler, which nudges commitment down. It can never over-credit or misfire the policy.

Given that, and that the PR is already approved and the paired-seed gate is expensive to re-run, I'd rather tighten this to an explicit exhaustive match in a small follow-up than push a new commit onto an approved head now. Happy to do that immediately if you'd prefer it in-PR instead — just say the word.

Merged via the queue into phase-rs:main with commit 22e7716 Jul 24, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request quality For high-quality minimal to no-churn PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants