Skip to content

refactor(parser): Plan 05b T8-A2 — the four R1 sites that run both extract_* stages - #6716

Merged
matthewevans merged 2 commits into
mainfrom
ship/plan05b-t8a2
Jul 28, 2026
Merged

refactor(parser): Plan 05b T8-A2 — the four R1 sites that run both extract_* stages#6716
matthewevans merged 2 commits into
mainfrom
ship/plan05b-t8a2

Conversation

@matthewevans

@matthewevans matthewevans commented Jul 28, 2026

Copy link
Copy Markdown
Member

Plan 05b tranche T8-A2 — the four R1 sites that run both extract_* chain-structure folds:
U0-23 Channel · U0-24 Boast · U0-25 Exhaust · U0-27 Forecast.

Follows #6715 (A1). This is the first tranche to consume ShellStage, so it deletes that type's
#[allow(dead_code)] — A0 assigned the allow to whichever tranche added the first producer.

Two commits: the conversion, and a §5.3 remediation that pins restriction order at all four sites.

The conversion

All four originals were read individually rather than assumed uniform, and all four turned out
verbatim identical in entry point — parse_effect_chain_with_context(&effect_text, AbilityKind::Activated, &mut ctx) — so all four take parse_ability_ir_with_context, chosen by
name
per §7.1.

site envelope composed restriction order stages
U0-23 Channel activation_zone = Hand [parsed] — no implicit at all [ExtractCostReduction, ExtractManaSpendTrigger]
U0-24 Boast ability_tag = Boast [parsed…, OnlyOnceEachTurn, RequiresCondition{SourceAttackedThisTurn}] same
U0-25 Exhaust ability_tag = Exhaust [parsed…, OnlyOnce] same
U0-27 Forecast activation_zone = Hand [parsed…, DuringYourUpkeep, OnlyOnceEachTurn] same

Each order was read from that site's current code. The four are not unified behind a shared
builder: they differ in zone, tag, restriction set and restriction order, and a shared builder would
normalize away exactly the asymmetry A0's ordered-Vec design exists to protect.

U0-23's =-not-extend was verified, not assumed. activation_restrictions appears in
crates/engine/src/parser/oracle_effect/ exactly three times, all inside
apply_ability_shell_envelope itself, so the root field is provably empty when the shell runs and
extend-onto-empty reproduces the assignment.

§5.3: reachability first, then perturbation

A reachability probe ran before any perturbation, because a green perturbation is otherwise ambiguous
between "identical" and "never executed" — the ambiguity that misled A1's first reading.

site --lib integration
U0-23 Channel 4 0
U0-24 Boast 4 11
U0-25 Exhaust 3 4
U0-27 Forecast 2 0

No --lib zeroes this time. A decoy was caught by probe message rather than by name:
power_up_keyword::wonder_man_does_not_raise_non_power_up_once_limit reaches the Exhaust site.

perturbation result
reorder the two stages, all 4 sites green — finding
drop ExtractCostReduction, all 4 red (via boseiju_who_endures)
drop ExtractManaSpendTrigger, all 4 green — finding
drop an implicit restriction / envelope stamp, all 4 red — 10 lib + 6 integration
swap parsed vs implicit order (U0-24/25/27) green — finding
swap the two implicit restrictions (U0-24/27) green — finding
flip mode wrapper to Standalone green — null, matching A1

The ExtractCostReduction red is behavioral rather than serialization — phase A emits
PreLoweredSpell(lower_ability_ir(&ir)), so stages never reaches a snapshot:

< "sub_ability": null,        < "cost_reduction": { amount_per: 1, … }
> "sub_ability": { "effect": { "type": "Unimplemented",
>   "description": "This ability costs {1} less to activate for each legendary creature you control" } }

That red is also what makes the stage-reorder green a real inertness measurement rather than an
untested path.

Why the order perturbations were silent: every pre-existing fixture at all four sites is degenerate
on the restriction-order axis. The reminder text stating the restrictions is stripped before
strip_activated_constraints, so constraints.restrictions is empty, and the boast_*/exhaust_*/
forecast_* assertions use order-insensitive .contains(..). Same defect class A1 found at U0-22 —
and it now looks general across the whole keyword-labelled activated family.

Remediation (second commit)

Four real pool cards, Oracle text verbatim from the card pool, each watched go red with the other
three staying green so every red is attributable to its own site:

fixture closes red on
Ghost-Lit Stalker (Channel) parsed vec reaching the lowered def at all deleting the AsSorcery block
Liliana the Repentant (Exhaust) parsed-vs-implicit order OnlyOnce relocating ahead of AsSorcery
Arni Brokenbrow (Boast) implicit-vs-implicit order OnlyOnceEachTurn transposing past RequiresCondition
Govern the Guildless (Forecast) implicit-vs-implicit order DuringYourUpkeep/OnlyOnceEachTurn transposing

Ghost-Lit Stalker and Liliana are the genuinely non-degenerate pair: their "Activate only as a
sorcery." sits outside the reminder parens, yielding [AsSorcery] and [AsSorcery, OnlyOnce] in
the lowered snapshots. Arni and Govern state everything inside reminder text, so they pin
implicit-vs-implicit order only — which is stated plainly rather than papered over.

Gates

cargo fmt --all                                      clean
cargo clippy -p engine --lib -- -D warnings          zero warnings
cargo nextest run -p engine --lib                    17849 passed, 6 skipped   (17845 → +4, exactly the new fixtures)
cargo nextest run -p engine --test integration       4123 passed, 2 skipped
./scripts/check-prelowered-ratchet.sh                Gate P PASS
./scripts/check-skill-doc.sh                         ✓ oracle-parser skill references valid
./scripts/check-parser-combinators.sh                Gate G PASS / Gate A PASS

Zero existing snapshots changed. git diff --name-status is 8 A / 3 M; the three modified
files are all source. find . -name '*.snap.new' → 0. oracle.rs unmoved at 16 on the ratchet;
scripts/prelowered-ratchet.txt unedited.

Parser string-dispatch diff gate: clean — zero .contains/starts_with/find/rfind/split* in
added non-comment lines under parser/.

CR-annotation diff gate: 11 numbers added, zero unverified, each grep-checked against
docs/MagicCompRules.txt with the rule text read rather than existence alone — 106.6, 207.2c,
601.2f, 602.1a, 602.1b, 602.5d, 603.3, 702.57a, 702.57b, 702.142a, 702.177a. Confirms channel is
in CR 207.2c's ability-word list while boast/exhaust/forecast are not, consistent with those three
being keywords.

Harvest (§5.4)

  1. ShellStage::ExtractManaSpendTrigger is dead at all four sites on today's pool. Dropping it is
    silent across all 34 reaching tests, and a scan of all 35,516 cards finds zero A2-site lines
    whose root effect is Effect::Mana (Careful Cultivation's "Add {G}" is inside a token's granted
    ability, not the root). Retained anyway — it reproduces the pre-conversion call sequence, and a
    conversion must not quietly drop a fold. This is also why the stage reorder is inert: only one
    stage ever fires.
  2. ExtractCostReduction fires only at Channel — the Kamigawa legendary land cycle (Boseiju,
    Eiganjo, Otawara, Sokenzan, Takenuma). Boast/Exhaust/Forecast run a fold that cannot fire for them.
  3. U0-24 Boast pushes its two implicit restrictions in the reverse of CR 702.142a's stated order.
    The rule reads "Activate only if this creature attacked this turn and only once each turn"; the code
    pushes OnlyOnceEachTurn first. Semantically harmless — the restrictions are a conjunction — but
    pre-existing and now pinned by arni_brokenbrow. Preserved, not fixed.
  4. U0-23's if !…is_empty() guard was already redundant — assigning an empty vec ≡ skipping it.
  5. Every pre-existing fixture at all four sites is degenerate on the restriction-order axis. Same
    class as A1's U0-22 finding; it generalizes across the keyword-labelled activated family.
  6. Forecast had no two-layer snapshot and no integration coverage at all — its entire reaching set
    was 2 parser tests.
  7. ChainLoweringMode is extensionally inert at all four sites, matching A1's null.

Two axes left honestly unwitnessed

Reported rather than designed around:

  1. No Boast or Forecast card in the pool states its activation instruction outside reminder text,
    so parsed-vs-implicit order at U0-24/U0-27 cannot be witnessed by any real card. Inventing one would
    violate the verbatim-Oracle-text rule. Documented in the test file and commit message.
  2. ExtractManaSpendTrigger cannot be witnessed at any A2 site (harvest chore: update coverage stats and badges #1), so its ordering
    relative to ExtractCostReduction is unwitnessed. The stage-order property is preserved by
    construction but not test-guarded: if a later tranche reorders stages, nothing goes red today.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of Channel, Boast, Exhaust, and Forecast activated abilities.
    • Correctly preserves activation costs, restrictions, descriptions, zones, and related mana triggers during parsing.
    • Ensures activation restrictions are applied in the correct order for more accurate ability behavior.
  • Tests

    • Added coverage for representative cards using these mechanics to verify parsing and ability generation.

…tract_* stages (U0-23/24/25/27)

`ShellStage`'s first consumers. Four recipe-R1 activated-ability recognizers
converted from "lower, then stamp the lowered def" to "stamp the shell, then
lower", each carrying a **two-element ordered `stages` vec**.

* **U0-23** — Channel (CR 207.2c ability word + CR 602.1). Stamps
  `activation_zone = Hand`; its restriction vec is the parsed constraints alone.
* **U0-24** — Boast (CR 702.142a-b). Parsed constraints LEAD, then
  `OnlyOnceEachTurn`, then `RequiresCondition{SourceAttackedThisTurn}`.
* **U0-25** — Exhaust (CR 702.177a). Parsed constraints LEAD, then `OnlyOnce`.
* **U0-27** — Forecast (CR 702.57a-b). Stamps `activation_zone = Hand`; parsed
  constraints LEAD, then `DuringYourUpkeep`, then `OnlyOnceEachTurn`.

Every vec is composed in its own site's original sequence and applied verbatim.
The four are deliberately NOT unified behind a shared builder: they differ in
zone, tag, and restriction set *and order*, and a shared builder would normalize
away exactly the asymmetry A0's ordered-`Vec` design exists to protect. Note
U0-24 pushes its two implicit restrictions in the REVERSE of the order CR
702.142a states them ("attacked this turn" then "only once each turn"); that is
a pre-existing property of the site, preserved here rather than quietly fixed.

**U0-23's `=` is equivalent to the shell's `extend`, verified not assumed.** Its
original wrote `if !constraints.restrictions.is_empty() { def.activation_restrictions
= constraints.restrictions }`. Nothing reachable from `lower_ability_ir` writes the
root's `activation_restrictions` — `rg activation_restrictions
crates/engine/src/parser/oracle_effect/` returns 3 hits, all inside
`apply_ability_shell_envelope` itself — so the field is empty when the shell runs
and `extend` onto empty reproduces the assignment exactly. The is-empty guard was
already redundant: assigning an empty vec and skipping the assignment are the
same state.

All four called `parse_effect_chain_with_context`, so all four take the
`parse_ability_ir_with_context` wrapper. The mode is inherited by NAME, not by
judgment — crossing the wrappers silently moves ~113 die-roll cards (§7.1).
Verified per site, not assumed uniform.

All four list `[ExtractCostReduction, ExtractManaSpendTrigger]`, matching the
call order each site ran the two folds in. `lower_ability_ir` runs stages last,
after the envelope stamps, which is the pinned order the sites had by hand.

CR-faithfulness: **by construction**, for the whole class of text each recognizer
handles — no property of any card's text participates.
`parse_effect_chain_with_context(t,k,cx)` IS
`lower_ability_ir(&parse_ability_ir_with_context(t,k,cx))`
(`oracle_effect/mod.rs`), and phase-A `ability_ir_at(l, ir)` IS
`ability_at(l, lower_ability_ir(&ir))` (`oracle.rs`). The old site computed
`stamp_then_fold(lower_ability_ir(ir))`; the converted site computes
`lower_ability_ir(ir_with_shell)`, and the shell reproduces the stamps and then
the folds in the same order.

CR numbers grep-verified against docs/MagicCompRules.txt, text read not just
number matched: 207.2c (:1497, ability-word list — "channel" is in it; boast,
exhaust and forecast are NOT, consistent with them being keywords), 602.1
(:2514), 602.1a (:2516), 602.1b (:2519), 702.142a (:5044), 702.142b (:5046),
702.177a (:5321), 702.57a (:4442), 702.57b (:4444), 601.2f (:2468), 106.6
(:425), 603.3 (:2582).

`ShellStage`'s `#[allow(dead_code)]` is DELETED, not moved — A0 assigned it to
whichever tranche added the first producer, and both variants now have four.

Gates: `Gate P PASS` (oracle.rs unmoved at 16 — the marker text lives in consumer
arms and emitter bodies, never at producer call sites; ledger unedited),
skill-doc `✓`, Gate G + Gate A PASS, `cargo clippy -p engine --lib -D warnings`
clean, `cargo nextest run -p engine --lib` 17845 passed / 6 skipped, integration
4123 passed / 2 skipped, and **zero snapshot churn** (no `*.snap.new`;
`git status` shows only the two source files).
…t the four keyword recognizers (§5.3 remediation)

The §5.3 non-vacuity probe on T8-A2 measured a real gap, so this lands the
missing guards rather than reporting a clean run.

**What the probe measured.** Unlike A1's site, all four A2 recognizers ARE
reached by `--lib` tests (34 tests total across `--lib` and the integration
binary, mapped by temporary `panic!`). But their restriction *order* was
unwitnessed. With the conversion in place:

* swapping parsed constraints against implicit ones at Boast/Exhaust/Forecast —
  **green** on all 34;
* swapping the two implicit restrictions against each other at Boast and
  Forecast — **green** on all 34.

Cause: every pre-existing fixture is degenerate on this axis. The reminder text
that states the restrictions ("(Activate only if this creature attacked this
turn and only once each turn.)") is stripped before `strip_activated_constraints`
runs, so `constraints.restrictions` is EMPTY, and the `boast_*` / `exhaust_*` /
`forecast_*` parser tests assert with order-insensitive `.contains(..)`.

**The fixtures.** Four real pool cards, verbatim Oracle text from
`data/card-data.json`, each closing a specific measured hole:

* **Ghost-Lit Stalker** (Channel) — its "Activate only as a sorcery."
  (CR 602.5d) is outside the reminder parens, so `constraints.restrictions` is
  non-empty. Channel is the site whose vector is the parsed constraints ALONE
  and whose original wrote `=`; no existing fixture could observe that vector
  arriving at the lowered def at all.
* **Liliana the Repentant** (Exhaust) — the only Exhaust card in the pool whose
  activation instruction sits outside the reminder parens. Pins parsed
  `AsSorcery` first, implicit `OnlyOnce` (CR 702.177a) second.
* **Arni Brokenbrow** (Boast) — pins `OnlyOnceEachTurn` before
  `RequiresCondition{SourceAttackedThisTurn}`, which is the REVERSE of the order
  CR 702.142a states them in. Pre-existing, preserved, now pinned.
* **Govern the Guildless** (Forecast) — pins `DuringYourUpkeep` before
  `OnlyOnceEachTurn` (CR 702.57b's own order). Also Forecast's first two-layer
  snapshot coverage of any kind.

**Watched red, not assumed.** Each fixture was watched fail under its own
perturbation before being committed: Liliana relocates `OnlyOnce` ahead of
`AsSorcery` under an implicit-first swap; Arni and Govern transpose their two
implicit restrictions; Ghost-Lit Stalker loses `AsSorcery` entirely when the
`activation_restrictions` assignment is deleted. In each case the other three
fixtures stayed green, so each red is attributable to its own site.

**Two axes remain honestly unwitnessed and are reported, not papered over:**

1. No Boast or Forecast card in the pool states its activation instruction
   outside reminder text, so the parsed-vs-implicit axis at those two sites is
   unwitnessable by any real card. Inventing a card to cover it would violate
   the verbatim-Oracle-text rule, so it stays a known gap.
2. `ShellStage::ExtractManaSpendTrigger` is dead at all four sites on today's
   pool — dropping it entirely is silent across all 34 reaching tests, and a
   pool scan finds no A2-site line whose root effect is `Effect::Mana`. It is
   retained because it reproduces the pre-conversion call sequence exactly; the
   conversion must not quietly drop a fold. Consequently the stage *reorder* is
   inert too, since only one of the two stages ever fires.

`ShellStage::ExtractCostReduction` IS witnessed, at Channel only, by the
pre-existing `boseiju_who_endures` snapshot: dropping it leaves the
"costs {1} less to activate" node unfolded in `sub_ability` as
`Effect::Unimplemented` and `cost_reduction` absent.

Additive only — eight NEW `*.snap` files, **no existing snapshot changed**, so
the tranche's zero-churn property is preserved.
@matthewevans
matthewevans enabled auto-merge July 28, 2026 02:21
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dcdc31a7-745d-40d5-b670-bc2200dee88e

📥 Commits

Reviewing files that changed from the base of the PR and between 7545b8f and cfb4922.

⛔ Files ignored due to path filters (8)
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__arni_brokenbrow_ir.snap is excluded by !**/*.snap, !**/snapshots/**
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__arni_brokenbrow_lowered.snap is excluded by !**/*.snap, !**/snapshots/**
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__ghost_lit_stalker_ir.snap is excluded by !**/*.snap, !**/snapshots/**
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__ghost_lit_stalker_lowered.snap is excluded by !**/*.snap, !**/snapshots/**
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__govern_the_guildless_ir.snap is excluded by !**/*.snap, !**/snapshots/**
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__govern_the_guildless_lowered.snap is excluded by !**/*.snap, !**/snapshots/**
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_the_repentant_ir.snap is excluded by !**/*.snap, !**/snapshots/**
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_the_repentant_lowered.snap is excluded by !**/*.snap, !**/snapshots/**
📒 Files selected for processing (3)
  • crates/engine/src/parser/oracle.rs
  • crates/engine/src/parser/oracle_ir/effect_chain.rs
  • crates/engine/src/parser/oracle_ir/snapshot_tests.rs

📝 Walkthrough

Walkthrough

Channel, Boast, Exhaust, and Forecast handlers now use shell-based ability IR parsing with staged extraction. New snapshots cover activation-restriction ordering for four card fixtures.

Changes

Activated keyword parsing

Layer / File(s) Summary
Shell-stage handler migration
crates/engine/src/parser/oracle.rs, crates/engine/src/parser/oracle_ir/effect_chain.rs
The four handlers populate ir.shell fields and use ExtractCostReduction and ExtractManaSpendTrigger stages instead of direct extraction calls.
Activation-restriction ordering snapshots
crates/engine/src/parser/oracle_ir/snapshot_tests.rs
Adds two-layer snapshots for Ghost-Lit Stalker, Liliana the Repentant, Arni Brokenbrow, and Govern the Guildless.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OracleHandlers
  participant AbilityParser
  participant AbilityIrShell
  participant SnapshotTests
  OracleHandlers->>AbilityParser: parse activated keyword with context
  AbilityParser->>AbilityIrShell: populate cost, description, restrictions, zone, and tag
  AbilityIrShell->>AbilityIrShell: apply staged cost and mana-trigger extraction
  SnapshotTests->>AbilityParser: parse four card fixtures
  AbilityParser-->>SnapshotTests: return IR and lowered snapshots
Loading

Possibly related PRs

Suggested labels: bug

Suggested reviewers: ntindle, lgray

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is specific and matches the main refactor: four parser sites updated to run both extract_* stages.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ship/plan05b-t8a2

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

@matthewevans
matthewevans added this pull request to the merge queue Jul 28, 2026
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

Merged via the queue into main with commit e001db0 Jul 28, 2026
15 checks passed
@matthewevans
matthewevans deleted the ship/plan05b-t8a2 branch July 28, 2026 02:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant