Skip to content

feat(analysis): static ability-graph combo-candidate extractor (Engine B, PR-4a) - #4493

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
lgray:ship/combo-detect-pr4a
Jun 28, 2026
Merged

feat(analysis): static ability-graph combo-candidate extractor (Engine B, PR-4a)#4493
matthewevans merged 3 commits into
phase-rs:mainfrom
lgray:ship/combo-detect-pr4a

Conversation

@lgray

@lgray lgray commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Combo-detector series

Part of the staged, offline-first infinite-combo / loop detector. Each PR links its predecessor so humans can follow the implementation trail end to end.

Pos PR Delivers
PR-0 #4092 ResourceVector + modulo-resource loop equality (additive, no behavior change).
PR-1 #4097 Analysis sim harness around GameRunner::act feeding ResourceVector.
PR-2 #4119 Net-progress detect_loopLoopCertificate + loop-certificate corpus harness.
PR-3 #4480 Live mandatory-loop winner shortcut for drain-cascade combos (CR 704.5a).
PR-4a #4493 (this PR) Engine B static ability-graph extractor: graph scaffold + 5 effect families + SCC + candidate coverability.
PR-4b #4534 Engine B effect/trigger breadth + life-symmetry cost.
PR-5 #4547 cargo combo-verify CLI over the 53-row corpus.

Predecessor: PR-3 — #4480#4480 (Engine A's live mandatory-loop winner shortcut; Engine B introduced here is the over-approximate static proposer that Engine A's sound confirmer downstream-filters).


What

Adds analysis::ability_graph — Engine B of the infinite-combo detector: an offline, static extractor that, given a list of card faces, builds a directed ability/resource graph, finds strongly-connected components (Tarjan), and emits candidate cycles naming the unbounded ResourceAxis each would pump. This is the over-approximate proposer; Engine A (detect_loop, already shipped) remains the sound, stateful confirmer. Either runs standalone. Purely additive — zero game-behavior change.

This is PR-4a of the static extractor, sliced from PR-4: the graph scaffold + the five priority effect families (mana, counter, damage, tap, cast) + SCC + candidate coverability. The remaining effect families, trigger-event edge breadth, and the life axis land in PR-4b.

Design highlights

  • Four exhaustive, no-wildcard classifiers are compile-time drift gates — a new combo-relevant variant is a compile error until classified:
    • effect_projection over all 207 Effect variants (5 modeled, rest Unmodeled)
    • trigger_axis over all 169 TriggerMode variants (Some-set = {cast, counter, tap, mana})
    • impl From<&ResourceAxis> for AxisKey over all 16 ResourceAxis variants
    • the AbilityCost fold over all 29 variants (polarity/sign-aware)
  • Recall-first edge keying: a single color-agnostic AxisKey::Mana (an off-color cost satisfied by off-color production is a deliberate, recall-safe false positive that Engine A's exact floating-pool color accounting filters downstream) and a single AxisKey::Landfall.
  • Engine A is untouched. net_progress_for / unbounded_axes_for move from private loop_check fns to pub(crate) ResourceVector methods that detect_loop now delegates to — byte-identical logic. Engine B's PR-4-only unbounded_production coverability override lives only in ability_graph and never touches Engine A.
  • CandidateCycle is a distinct type from LoopCertificate — an unconfirmed candidate must never masquerade as a sound certificate (soundness invariant).

Comprehensive Rules

Edge/coverability semantics annotated and grep-verified: CR 500.8 (extra phases), CR 701.21a (sacrifice), CR 701.26a/b (tap/untap), CR 732.2a (loop shortcut), CR 106.1 (mana), CR 119.1 (life), CR 120.1 (damage), CR 122.1 (counters), CR 704.5a, among others. (Tarjan / coverability are annotated as CS theory, not CR.)

Testing

  • ability_graph unit tests + the analysis suite green; cargo fmt/clippy -D warnings clean; full cargo test -p engine green.
  • Discriminating revert-probe-backed tests for each load-bearing rule: the unbounded-production coverability override, the {Q}-untap-cost → Tap edge, the sacrifice-event producer polarity, and the color-agnostic mana collapse (a colored producer feeding a generic cost forms a candidate — flips to 0 candidates under per-color keying).
  • Real-data smoke: Priest of Titania + Umbral Mantle forms a candidate naming Mana(Green) from a committed fixture (skips gracefully when the gitignored card export is absent).

@lgray
lgray requested a review from matthewevans as a code owner June 27, 2026 20:57

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces Engine B, a static ability-graph extractor that generates offline candidate cycles by building a directed resource graph and finding strongly-connected components. It also refactors the controller-scoped net-progress logic in loop_check.rs and resource.rs to be shared between Engine A and Engine B. The review feedback highlights two violations of the style guide's prohibition on boolean fields (R2) for tracking model completeness, and identifies a correctness issue where player-keyed axes are hardcoded to the opponent instead of dynamically resolving the active player ID.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

/// Axes this node costs/needs + the trigger-event axis that fires it.
pub requires: BTreeSet<AxisKey>,
/// ≥1 collected effect was `Unmodeled` (candidate confidence flag).
pub any_unmodeled: bool,

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.

medium

R2 Violation: Use of bool field any_unmodeled

According to the Repository Style Guide (R2), bool fields should not be used to express the design space. Instead, we should parameterize with a typed enum to carry more meaning and allow for future extensibility.

Consider introducing a ModelCompleteness enum:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelCompleteness {
    FullyModeled,
    ContainsUnmodeled,
}

And replacing any_unmodeled: bool with completeness: ModelCompleteness in both AbilityNode and CandidateCycle.

References
  1. R2. No bool fields — parameterize with existing typed enums (link)

/// Tentative win classification.
pub win_kind: WinKind,
/// ≥1 member node had unmodeled effects (lower confidence).
pub any_unmodeled: bool,

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.

medium

R2 Violation: Use of bool field any_unmodeled

According to the Repository Style Guide (R2), bool fields should not be used to express the design space. Consider replacing any_unmodeled: bool with the ModelCompleteness enum suggested on AbilityNode.

References
  1. R2. No bool fields — parameterize with existing typed enums (link)

Comment on lines +1369 to +1371
AxisKey::Damage => Some(ResourceAxis::DamageDealt(OPPONENT)),
AxisKey::Life => Some(ResourceAxis::Life(OPPONENT)),
AxisKey::Library => Some(ResourceAxis::LibraryDelta(OPPONENT)),

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.

medium

Correctness: Player-keyed axes should dynamically resolve the correct player ID

Currently, AxisKey::Damage, AxisKey::Life, and AxisKey::Library are hardcoded to map to OPPONENT in axis_key_to_resource. While this works for opponent-directed loops (like drain or mill), it is incorrect for controller-directed loops (such as unbounded lifegain or self-mill advantage engines).

By dynamically inspecting the net resource vector, we can correctly attribute the unbounded axis to either CONTROLLER or OPPONENT based on which player's resource is actually moving.

        AxisKey::Damage => {
            if net.damage_dealt.get(&CONTROLLER).copied().unwrap_or(0) > 0 {
                Some(ResourceAxis::DamageDealt(CONTROLLER))
            } else {
                Some(ResourceAxis::DamageDealt(OPPONENT))
            }
        }
        AxisKey::Life => {
            if net.life.get(&CONTROLLER).copied().unwrap_or(0) > 0 {
                Some(ResourceAxis::Life(CONTROLLER))
            } else {
                Some(ResourceAxis::Life(OPPONENT))
            }
        }
        AxisKey::Library => {
            if net.library_delta.get(&CONTROLLER).copied().unwrap_or(0) != 0 {
                Some(ResourceAxis::LibraryDelta(CONTROLLER))
            } else {
                Some(ResourceAxis::LibraryDelta(OPPONENT))
            }
        }

@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 large Engine B pass. I found one blocker in the current head.

AbilityCost::OneOf is disjunctive runtime semantics, but fold_cost currently treats it the same as Composite:

AbilityCost::Composite { costs } | AbilityCost::OneOf { costs } => {
    for c in costs {
        fold_cost(acc, c);
    }
}

That sums every alternative cost into one node, so a card with {G} or {2} ends up requiring/spending both branches. The existing type docs say OneOf means the paying player chooses one branch, and the runtime/payability code confirms that by detouring to ActivationCostOneOfChoice and using any() payability for sub-costs. For the static candidate generator this is not just a conservative false positive: it can become a false negative by introducing extra required axes or net-negative mana that the real loop never has to pay.

Please model OneOf as alternative cost branches rather than an AND fold. A good regression would build a candidate where one OneOf branch closes the cycle but another branch would add an unrelated/unsustainable requirement; the candidate should still be emitted for the payable branch. Composite can stay as the all-subcost fold.

Evidence checked: current PR head f4d08533f81bdbd194678b3b6c260cfb95c64357, GitHub files API diff, exact-head CI green/CLEAN, and local worktree inspection. Relevant repository evidence: AbilityCost::OneOf docs in types/ability.rs describe one-of-payment semantics; runtime casting.rs routes it through ActivationCostOneOfChoice; cost_payability.rs treats it as payable when any branch is payable.

lgray pushed a commit to lgray/phase that referenced this pull request Jun 28, 2026
…elCompleteness enum, player-keyed axes

Resolves the maintainer blocker and the gemini findings on phase-rs#4493.

BLOCKER: fold_cost treated AbilityCost::OneOf as a Composite AND-fold, summing every alternative branch (a {G} or {2} cost required BOTH) and inventing required axes / net-negative mana no single branch pays -> false negatives in candidate generation. Split OneOf into fold_one_of: the optimistic envelope over branches (produces union, requires intersection, net per-axis max with missing-component=0, unbounded union, completeness join). A static proposer maximizes recall; Engine A is the sound confirmer that filters false positives. CR 118.12a (disjunctive alternative cost); Composite stays the conjunctive total-cost fold (CR 601.2h).

any_unmodeled: bool -> ModelCompleteness enum (FullyModeled / ContainsUnmodeled) on AbilityNode, CandidateCycle, NodeAcc, with an exhaustive lattice-join merge() (no wildcard, mirroring the drift-gate discipline). axis_key_to_resource now resolves CONTROLLER vs OPPONENT for Damage/Life/Library from net sign.

Tests: OneOf envelope discrimination (revert to AND-fold => candidate count 1->0), per-axis-max + requires-intersection, player resolution per axis, completeness merge. clippy -D warnings: 0; analysis::ability_graph: 23/23.

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray

lgray commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Thanks for the careful review. All four items are addressed in 98c1bdb1e.

BLOCKER — fold_cost treated OneOf like Composite. Correct — folding OneOf as an AND sum invents required axes / net-negative mana that no single branch pays, which for a static candidate generator is a false negative. Split OneOf out into fold_one_of, an optimistic envelope over the branches: produces = ∪, requires = ∩ (only axes every branch requires are unavoidable), net = per-axis max with missing-component = 0 (a cost only one branch pays is dodgeable → drops to 0), unbounded = ∪, completeness = join. Because the static generator is a proposer (Engine A is the sound confirmer that filters false positives), this maximizes recall and never fabricates a requirement. Composite keeps the all-subcost AND-fold.

Regression added as you suggested: a candidate where one OneOf branch closes the cycle while another would add an unsustainable requirement is still emitted — reverting fold_one_of to the AND-fold drops the candidate count 1→0 and the test fails. CR 118.12a (the rule AbilityCost::OneOf is itself annotated with — disjunctive/alternative cost); Composite annotated CR 601.2h.

any_unmodeled: boolModelCompleteness enum (gemini R2). Replaced on AbilityNode, CandidateCycle, and NodeAcc, with an exhaustive (no-wildcard) lattice-join merge() — consistent with this PR's drift-gate discipline (a future finer-grained variant forces a compile error rather than being silently absorbed).

Player-keyed axes (gemini). axis_key_to_resource now resolves CONTROLLER vs OPPONENT for Damage/Life/Library from the net sign (controller-directed lifegain / self-mill vs opponent drain / mill).

clippy -D warnings: 0; analysis::ability_graph: 23/23 (4 new tests).

Lindsey Gray added 3 commits June 28, 2026 00:34
…e B, PR-4a)

Add `analysis::ability_graph` — an offline, static extractor that builds a
directed ability/resource graph from card-face ASTs, finds SCCs (Tarjan), and
emits coverable candidate cycles naming the unbounded ResourceAxis each would
pump. This is Engine B (the over-approximate proposer); Engine A (detect_loop)
remains the sound confirmer. Either runs standalone.

PR-4a is the graph scaffold + the 5 priority effect families (mana, counter,
damage, tap, cast) + SCC + candidate coverability. Edge keying uses a single
color-agnostic AxisKey::Mana (a deliberate recall-first over-approximation;
Engine A's exact color accounting filters off-color cases downstream) and a
single AxisKey::Landfall. Four exhaustive no-wildcard classifiers are the
compile-time drift gates: effect_projection over all 207 Effect variants,
trigger_axis over all 169 TriggerMode variants, From<&ResourceAxis> over all
16 ResourceAxis variants, and the AbilityCost fold over all 29 variants — a
new combo-relevant variant is a compile error until classified.

The candidate coverability check (with a PR-4-only unbounded-production
override) is layered ON TOP of a shared, behavior-preserving extraction:
net_progress_for / unbounded_axes_for move from private loop_check fns to
pub(crate) ResourceVector methods that detect_loop now delegates to — Engine A
behavior is unchanged. CandidateCycle is a distinct type from LoopCertificate
(an unconfirmed candidate must never masquerade as a sound certificate).

CR 500.8 (extra phases), CR 701.21a (sacrifice), CR 701.26a/b (tap/untap),
CR 732.2a (loop shortcut), among others (grep-verified). Remaining effect
families, trigger-event edge breadth, and the life axis land in PR-4b.

Assisted-by: ClaudeCode:claude-opus-4.8
…elCompleteness enum, player-keyed axes

Resolves the maintainer blocker and the gemini findings on phase-rs#4493.

BLOCKER: fold_cost treated AbilityCost::OneOf as a Composite AND-fold, summing every alternative branch (a {G} or {2} cost required BOTH) and inventing required axes / net-negative mana no single branch pays -> false negatives in candidate generation. Split OneOf into fold_one_of: the optimistic envelope over branches (produces union, requires intersection, net per-axis max with missing-component=0, unbounded union, completeness join). A static proposer maximizes recall; Engine A is the sound confirmer that filters false positives. CR 118.12a (disjunctive alternative cost); Composite stays the conjunctive total-cost fold (CR 601.2h).

any_unmodeled: bool -> ModelCompleteness enum (FullyModeled / ContainsUnmodeled) on AbilityNode, CandidateCycle, NodeAcc, with an exhaustive lattice-join merge() (no wildcard, mirroring the drift-gate discipline). axis_key_to_resource now resolves CONTROLLER vs OPPONENT for Damage/Life/Library from net sign.

Tests: OneOf envelope discrimination (revert to AND-fold => candidate count 1->0), per-axis-max + requires-intersection, player resolution per axis, completeness merge. clippy -D warnings: 0; analysis::ability_graph: 23/23.

Assisted-by: ClaudeCode:claude-opus-4.8
Rebasing onto current upstream/main surfaced two variants the exhaustive no-wildcard drift gates did not yet cover:

- ManaProduction::AnyCombinationOfObjectColors -> project_mana_production's count-driven colorless-sentinel group (color-flexible, count-seeded). - TriggerMode::EntersOrHauntedCreatureDies -> trigger_axis's deferred-to-PR-4b None group, beside its sibling HauntedCreatureDies (zone-entry/death events are not modeled as producers in PR-4a).

No wildcard added; cargo check confirmed these were the only two. Verified with the full CI command (clippy --workspace --features engine/proptest + test-engine).

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray
lgray force-pushed the ship/combo-detect-pr4a branch from 98c1bdb to 3a4e9e5 Compare June 28, 2026 05:46
@lgray

lgray commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Rebased onto current main and classified two enum variants that main added since the branch point, surfaced by the exhaustive drift gates: ManaProduction::AnyCombinationOfObjectColors → the count-driven colorless-sentinel group in project_mana_production; TriggerMode::EntersOrHauntedCreatureDies → the deferred-to-PR-4b None group in trigger_axis, beside its sibling HauntedCreatureDies. No wildcard added. Verified green with the full CI command (clippy --workspace --exclude phase-tauri --all-targets --features engine/proptest + cargo test -p engine).

@matthewevans matthewevans self-assigned this Jun 28, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed current head 3a4e9e5. Prior OneOf blocker is resolved with a disjunctive envelope plus discriminating regression coverage; Gemini R2/player-axis findings are addressed on head. Exact-head CI is green and local merge check with current main is conflict-free.

@matthewevans matthewevans added the enhancement New feature or request label Jun 28, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jun 28, 2026
@matthewevans matthewevans removed their assignment Jun 28, 2026
@lgray

lgray commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Thanks for the review. The OneOf-as-AND-fold blocker you flagged was on head f4d08533f; it's already fixed in the current head 3a4e9e509 (the branch was force-pushed after a rebase onto main since your review). Pointers in the current head:

  • OneOf is no longer AND-folded. fold_cost now routes it to a dedicated disjunctive fold:
    • crates/engine/src/analysis/ability_graph.rs:1108AbilityCost::OneOf { costs } => fold_one_of(acc, costs) (explicitly "NOT AND-fold").
    • fold_one_of at :1180 models the branches as alternatives via an optimistic per-axis envelope (produces ∪, requires ∩ / per-axis MAX with missing=0), so a {G} or {2} card never requires/spends both branches and an unsustainable branch can't introduce a false-negative required axis.
  • Regression test matching your suggested shape (one branch closes the cycle, another would add an unsustainable requirement; candidate still emitted for the payable branch) at :2245, with a discrimination probe that reverts fold_one_of to the AND-fold.

Also addressed in the same head (gemini review comments):

  • any_unmodeled: bool → typed ModelCompleteness enum (:927, lattice join), any_unmodeled fully removed.
  • axis_key_to_resource now resolves the player by net sign instead of hardcoding OPPONENT (:1564, test axis_key_to_resource_resolves_player_by_net_sign at :2366).

CI is green on the current head. Could you re-review 3a4e9e509 when you have a moment? Happy to adjust further.

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