Skip to content

fix(parser): bind the where-X QUANTITY channel or fail honestly (CR 107.3c)#5721

Merged
matthewevans merged 1 commit into
mainfrom
ship/where-x-quantity
Jul 13, 2026
Merged

fix(parser): bind the where-X QUANTITY channel or fail honestly (CR 107.3c)#5721
matthewevans merged 1 commit into
mainfrom
ship/where-x-quantity

Conversation

@matthewevans

Copy link
Copy Markdown
Member

Sibling of #5706, which fixed the P/T channel of the same defect. A "where X
is " clause DEFINES the value of X in an ability's text (CR 107.3c) — the
controller does not choose it. When the parser could not represent that
definition it fabricated a well-typed placeholder holding the raw Oracle text
and carried on:

QuantityRef::Variable { name: "" }
-> game/quantity.rs:1841, the non-"X" Variable arm
-> state.last_named_choice -> .unwrap_or(0)

Every such node renders as a supported dynamic quantity in the coverage report
while resolving to 0 — or, worse, to an unrelated number left behind by some
earlier "choose a number". Porcuparrot dealt 0 damage. Abby, Merciless Soldier
made 0 tokens. Arek, False Goldwarden drained for nothing. All reported as
supported.

#5706 fixed the P/T half of two of these functions and left the quantity half
fabricating in the same functions; it also missed the CopyTokenOf where-X path
20 lines above the one it fixed. This removes the fabrication at all five sites
(oracle_effect/lower.rs x2, mana.rs, token.rs x2). The where-X rewriter family
is now Option-returning and routes failure through the existing unbound_where_x
-> Effect::unimplemented("where_x_binding", ...) seam — no new machinery. The
explicit return None for "the number of times ..." now surfaces as an honest
red instead of being re-fabricated by the caller.

Binds four expression classes whose typed home AND live resolver both already
existed; zero new enum variants:

"~'s intensity" / "this spell's intensity" -> Intensity { scope: Source }
"the chosen number" -> ChosenNumber
"the amount of mana spent to cast it/her" -> ManaSpentToCast { Total }
"the amount of {S} spent to cast ..." -> ManaSpentToCast { FromSource }
CR 107.4h: {S} refers to mana of any type produced by a snow source spent
to pay a cost. FromSource filters payment-time source snapshots, so the
snow-ness of the producing source is read at payment time.

Two sibling-cluster duplicates retired in the process:

  • oracle_static/anthem.rs carried a private where-X intensity combinator that
    was never wired into the shared interpreter. It now delegates; the grammar
    lives once, in the shared parse_quantity_ref.
  • parse_mana_spent_to_cast_amount re-listed the self-anaphora pronouns instead
    of using parse_mana_spent_self_subject. That duplicate list is exactly why
    Toph, Greatest Earthbender ("...spent to cast her") fell through.

Full-pool measured on 35,396 faces (base c281a86). Probe anchored on the
name key: the export is internally tagged and PtValue::Variable collides on
the variant name ({"type":"Variable","value":"*"}). Non-vacuity control:
name:"X" = 647 faces. Census re-derived as 93 faces / 74 expressions
(supersedes the 94/75 measured on an older base — drift, not contradiction).

DEFECT LEDGER (93 faces; gains and reds separate, never netted):
13 gained binding (a real typed quantity)
80 gained honest-red (an Effect::unimplemented gap — harvest yield)
0 still fabricating
Invariant: zero raw-text QuantityRef::Variable{name} survive pool-wide.
Aliases preserved: "X" (647 faces), "guessed" (The Toymaker's Trap).
The leaked "count" sentinel (Abby) was a real fabrication leak, not a
legitimate channel instance — it is cleared by the ManaSpentToCast bind.

COLLATERAL LEDGER (10 faces outside the defect set; the binds went into the
shared combinator, so they also reached inline "equal to its intensity" and
"for each {S} spent" contexts):
9 gains — and three were SILENT WRONG BEHAVIOR, not mere gaps:
Expel the Interlopers destroyed EVERY creature regardless of power
(the chosen-number filter bound was empty)
Search for Glory always gained exactly 1 life (Fixed{1})
Jarsyl, Dark Age Scion had a swallowed mana-value constraint
1 regression — Piece It Together. Its Draw{1} genuinely worked before and
is now absorbed into a gap. This is a GENUINE FUNCTIONAL REGRESSION, not
a lying-green conversion, and it is named as such. Cause attributed by
toggle probe, not inference: disabling only the intensity arm brings the
Draw back. Once "~'s intensity" parses, the sentence becomes a parseable
conditional-"instead" whose effect is unsupported, and the line merges
into the preceding gap. The bind did not create that defect — it made one
more card reach the CR 614 "instead" defect already under active repair.
Absorbed on unit economics. No pinning test is added: that would cement
the wrong output. It is recorded as a recovery witness on the CR 614 work
instead — when branch lowering lands, the Draw must return.

Tests: 6 parser witnesses + 3 runtime witnesses, each WATCHED FAILING before
the fix (runtime reds produced by disabling the bind arms, then restored). The
first draft of the convert witness was VACUOUS — green pre-fix, because its
Draw phrasing never reached the fabricating path; it was replaced with
Porcuparrot's real DealDamage shape, whose pre-fix failure prints the
fabrication verbatim.

Co-authored-by: matthewevans matthewevans@users.noreply.github.com

…07.3c)

Sibling of #5706, which fixed the P/T channel of the same defect. A "where X
is <expr>" clause DEFINES the value of X in an ability's text (CR 107.3c) — the
controller does not choose it. When the parser could not represent that
definition it fabricated a well-typed placeholder holding the raw Oracle text
and carried on:

  QuantityRef::Variable { name: "<raw oracle text>" }
    -> game/quantity.rs:1841, the non-"X" Variable arm
    -> state.last_named_choice -> .unwrap_or(0)

Every such node renders as a supported dynamic quantity in the coverage report
while resolving to 0 — or, worse, to an unrelated number left behind by some
earlier "choose a number". Porcuparrot dealt 0 damage. Abby, Merciless Soldier
made 0 tokens. Arek, False Goldwarden drained for nothing. All reported as
supported.

#5706 fixed the P/T half of two of these functions and left the quantity half
fabricating in the same functions; it also missed the CopyTokenOf where-X path
20 lines above the one it fixed. This removes the fabrication at all five sites
(oracle_effect/lower.rs x2, mana.rs, token.rs x2). The where-X rewriter family
is now Option-returning and routes failure through the existing unbound_where_x
-> Effect::unimplemented("where_x_binding", ...) seam — no new machinery. The
explicit `return None` for "the number of times ..." now surfaces as an honest
red instead of being re-fabricated by the caller.

Binds four expression classes whose typed home AND live resolver both already
existed; zero new enum variants:

  "~'s intensity" / "this spell's intensity" -> Intensity { scope: Source }
  "the chosen number"                        -> ChosenNumber
  "the amount of mana spent to cast it/her"  -> ManaSpentToCast { Total }
  "the amount of {S} spent to cast ..."      -> ManaSpentToCast { FromSource }
      CR 107.4h: {S} refers to mana of any type produced by a snow source spent
      to pay a cost. FromSource filters payment-time source snapshots, so the
      snow-ness of the producing source is read at payment time.

Two sibling-cluster duplicates retired in the process:
  - oracle_static/anthem.rs carried a private where-X intensity combinator that
    was never wired into the shared interpreter. It now delegates; the grammar
    lives once, in the shared parse_quantity_ref.
  - parse_mana_spent_to_cast_amount re-listed the self-anaphora pronouns instead
    of using parse_mana_spent_self_subject. That duplicate list is exactly why
    Toph, Greatest Earthbender ("...spent to cast her") fell through.

Full-pool measured on 35,396 faces (base c281a86). Probe anchored on the
`name` key: the export is internally tagged and PtValue::Variable collides on
the variant name ({"type":"Variable","value":"*"}). Non-vacuity control:
name:"X" = 647 faces. Census re-derived as 93 faces / 74 expressions
(supersedes the 94/75 measured on an older base — drift, not contradiction).

DEFECT LEDGER (93 faces; gains and reds separate, never netted):
  13  gained binding      (a real typed quantity)
  80  gained honest-red   (an Effect::unimplemented gap — harvest yield)
   0  still fabricating
  Invariant: zero raw-text QuantityRef::Variable{name} survive pool-wide.
  Aliases preserved: "X" (647 faces), "guessed" (The Toymaker's Trap).
  The leaked "count" sentinel (Abby) was a real fabrication leak, not a
  legitimate channel instance — it is cleared by the ManaSpentToCast bind.

COLLATERAL LEDGER (10 faces outside the defect set; the binds went into the
shared combinator, so they also reached inline "equal to its intensity" and
"for each {S} spent" contexts):
   9  gains — and three were SILENT WRONG BEHAVIOR, not mere gaps:
        Expel the Interlopers destroyed EVERY creature regardless of power
          (the chosen-number filter bound was empty)
        Search for Glory always gained exactly 1 life (Fixed{1})
        Jarsyl, Dark Age Scion had a swallowed mana-value constraint
   1  regression — Piece It Together. Its Draw{1} genuinely worked before and
      is now absorbed into a gap. This is a GENUINE FUNCTIONAL REGRESSION, not
      a lying-green conversion, and it is named as such. Cause attributed by
      toggle probe, not inference: disabling only the intensity arm brings the
      Draw back. Once "~'s intensity" parses, the sentence becomes a parseable
      conditional-"instead" whose effect is unsupported, and the line merges
      into the preceding gap. The bind did not create that defect — it made one
      more card reach the CR 614 "instead" defect already under active repair.
      Absorbed on unit economics. No pinning test is added: that would cement
      the wrong output. It is recorded as a recovery witness on the CR 614 work
      instead — when branch lowering lands, the Draw must return.

Tests: 6 parser witnesses + 3 runtime witnesses, each WATCHED FAILING before
the fix (runtime reds produced by disabling the bind arms, then restored). The
first draft of the convert witness was VACUOUS — green pre-fix, because its
Draw phrasing never reached the fabricating path; it was replaced with
Porcuparrot's real DealDamage shape, whose pre-fix failure prints the
fabrication verbatim.

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
@matthewevans matthewevans enabled auto-merge July 13, 2026 04:05
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

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

Copy link
Copy Markdown

Parse changes introduced by this PR · 101 card(s), 127 signature(s) (baseline: main 9aad1a5deaca)

35 card(s) · ability/where_x_binding · added: where_x_binding

Examples: Aloy, Savior of Meridian, Astarion's Thirst, Aven Shrine (+32 more)

11 card(s) · ability/create · added: create

Examples: Gift Horse, Goblin Negotiation, Hell to Pay (+8 more)

10 card(s) · ability/where_x_binding · added: where_x_binding (duration=until end of turn)

Examples: Anzrag's Rampage, Florian, Voldaren Scion, Fraying Sanity (+7 more)

9 card(s) · ability/where_x_binding · added: where_x_binding (targets=0-X)

Examples: Archipelagore, Black Hole, Bound (+6 more)

4 card(s) · ability/where_x_binding · added: where_x_binding (kind=activated)

Examples: Porcuparrot, Pygmy Giant, Surveyor's Scope (+1 more)

3 card(s) · ability/deal · removed: deal

Examples: Bellowsbreath Ogre, Ribald Shanty, Static Discharge

3 card(s) · ability/starting · removed: starting

Examples: Piece It Together, Ribald Shanty, Static Discharge

2 card(s) · ability/DealDamage · added: DealDamage (amount=intensity, target=any target)

Examples: Bellowsbreath Ogre, Static Discharge

2 card(s) · ability/ExileTop · removed: ExileTop (count=their total power, player=controller)

Examples: Kylox, Visionary Inventor, Witch-king, Sky Scourge

2 card(s) · ability/GainLife · field amount: ~'s intensityintensity

Examples: Arek, False Goldwarden, Sundering Sentinel

2 card(s) · ability/LoseLife · field amount: ~'s intensityintensity

Examples: Arek, False Goldwarden, Chittering Skullspeaker

2 card(s) · ability/where_x_binding · added: where_x_binding (conditional=when you do)

Examples: Ill-Timed Explosion, Mount Velus Manticore

1 card(s) · ability/Bounce · removed: Bounce (target=mv your devotion to Jeskai- opponent controls creature)

Examples: Devoted Jeskai

1 card(s) · ability/CastFromZone · field target: in graveyard you control cardmv intensity in graveyard you control card

Examples: Jarsyl, Dark Age Scion

1 card(s) · ability/ChangeZone · field target: mv the amount of {S} spent to cast this spell- in graveyard you control creature or mv the amount of {S} spent to cast …mv mana spent to cast (SelfObject, FromSource { source_filter: Typed(TypedFilter { type_filters: [], controller: None, …

Examples: Blood on the Snow

1 card(s) · ability/ChangeZone · removed: ChangeZone (duration=until end of combat, from=graveyard, target=mv the number of players you attacked this combat- in graveyard you control creature, to=battl…

Examples: Custodi Soulcaller

1 card(s) · ability/ChangeZone · removed: ChangeZone (from=graveyard, target=in graveyard you control card, targets=0+, to=hand)

Examples: Pair o' Dice Lost

1 card(s) · ability/ChangeZone · removed: ChangeZone (from=graveyard, target=in graveyard you control card, targets=0-the number of black permanents target opponent controls as you cast this spell, to=…

Examples: Reap

1 card(s) · ability/ChangeZone · removed: ChangeZone (from=graveyard, target=in graveyard you control card, targets=0-the number of colors that creature was, to=hand)

Examples: Bound

1 card(s) · ability/ChangeZone · removed: ChangeZone (from=graveyard, target=mv the amount of damage ~ dealt to that player- in graveyard you control creature, to=battlefield)

Examples: Venerable Warsinger

1 card(s) · ability/ChangeZoneAll · removed: ChangeZoneAll (from=exile, target=tracked set #0, to=hand)

Examples: Knight-Errant of Eos

1 card(s) · ability/DamageAll · removed: DamageAll (amount=the greatest mana value among cards discarded this way, conditional=when you do, filter=creature)

Examples: Ill-Timed Explosion

1 card(s) · ability/DealDamage · added: DealDamage (amount=intensity, target=creature or planeswalker)

Examples: Ribald Shanty

1 card(s) · ability/DealDamage · field amount: the chosen numberchosen number

Examples: Liquid Fire

1 card(s) · ability/DealDamage · field amount: ~'s intensityintensity

Examples: Sundering Sentinel

… 102 more signature(s) (102 card-changes) — see parse-diff.json
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=2 plus the number of cards in your graveyard that are instant cards, sorcery cards, and/or have an Adventure, target=creature)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=3 plus the amount of damage dealt to ~ this turn by other sources named ~, target=creature)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=a number chosen at random from 0 to 4, target=creature)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=a number in the sacrificed creature's text box, kind=activated, target=creature)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=the greatest amount of damage dealt by a source to a permanent or player this turn, duration=until end of turn, target=creature, targets=0-t…
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=the number of Mountains you control as you cast this spell, target=any target, targets=0-the number of Mountains you control as you cast thi…
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=the number of card types the discarded card has, conditional=when you do, target=any target)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=the number of cards revealed, target=any target)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=the number of cards with a cycling ability in your graveyard, target=any target)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=the number of creatures on the battlefield as you cast this spell, target=creature, targets=0-the number of creatures on the battlefield as …
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=the number of different color pairs among permanents you control that are exactly two colors, target=any target)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=the number of noncreature spells you've cast since the beginning of your last turn, target=opponent)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=the number of nontoken permanents of the chosen color they control, target=scoped player)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=the number of times ~ has mutated, kind=activated, target=any target)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=the number of untapped lands they controlled at the beginning of this turn, duration=until end of turn, target=scoped player)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=the total power of the creatures sacrificed this way, kind=activated, target=any target)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=twice the number of cards in all graveyards with the same name as that spell, target=triggering player)
  • 1 card(s) · ability/Destroy · removed: Destroy (target=another creature, targets=0-the number of Attractions you've visited this turn)
  • 1 card(s) · ability/DestroyAll · field filter: creaturepower ≥chosen number creature
  • 1 card(s) · ability/Dig · field filter: mv the number of creatures that convoked ~- creature
  • 1 card(s) · ability/Dig · field keep_count: 20
  • 1 card(s) · ability/Dig · field reveal: true
  • 1 card(s) · ability/Dig · field up_to: true
  • 1 card(s) · ability/Dig · removed: Dig (count=Variable { name: "the amount of life paid with ~" }, keep_count=1, to=hand)
  • 1 card(s) · ability/Dig · removed: Dig (count=Variable { name: "the number of unique vowels on that sticker" }, keep_count=1, rest_to=library, to=hand)
  • 1 card(s) · ability/Dig · removed: Dig (count=Variable { name: "the total amount of life your opponents lost this turn" }, duration=until end of turn, keep_count=0)
  • 1 card(s) · ability/Discover · removed: Discover (conditional=previous excess amount > 0, mv limit=Ref { qty: Variable { name: "that excess damage" } }, player=Controller)
  • 1 card(s) · ability/Discover · removed: Discover (mv limit=Ref { qty: Variable { name: "the greatest power among them" } }, player=Controller)
  • 1 card(s) · ability/Draw · added: Draw (conditional=previous effect outcome, count=intensity)
  • 1 card(s) · ability/Draw · added: Draw (count=divide(intensity, 2, rounded down), kind=activated, timing=sorcery speed)
  • 1 card(s) · ability/Draw · field count: ~'s intensityintensity
  • 1 card(s) · ability/Draw · removed: Draw
  • 1 card(s) · ability/Draw · removed: Draw (count=the difference between the chosen creatures' powers, duration=until end of turn)
  • 1 card(s) · ability/ExileTop · removed: ExileTop (count=one plus the number of spells cast this turn, duration=until end of turn, player=controller)
  • 1 card(s) · ability/ExileTop · removed: ExileTop (count=the amount of damage dealt, player=triggering player)
  • 1 card(s) · ability/ExileTop · removed: ExileTop (count=the number of Attractions you've visited this turn, duration=until end of turn, player=controller)
  • 1 card(s) · ability/ExileTop · removed: ExileTop (count=the number of artifacts that were put into graveyards from the battlefield this turn, duration=until end of turn, player=controller)
  • 1 card(s) · ability/ExileTop · removed: ExileTop (count=the number of players being attacked, player=controller)
  • 1 card(s) · ability/GainLife · field amount: 1mana spent to cast (SelfObject, FromSource { source_filter: Typed(TypedFilter { type_filters: [], controller: None, pro…
  • 1 card(s) · ability/GainLife · field amount: this spell's intensityintensity
  • 1 card(s) · ability/GainLife · removed: GainLife (amount=the number of cards in all graveyards with the same name as that spell)
  • 1 card(s) · ability/GainLife · removed: GainLife (amount=the number of unique vowels on that sticker)
  • 1 card(s) · ability/GainLife · removed: GainLife (amount=twice the damage dealt to you so far this turn by artifacts)
  • 1 card(s) · ability/Investigate · removed: Investigate
  • 1 card(s) · ability/LoseLife · removed: LoseLife (amount=that Saga's mana value)
  • 1 card(s) · ability/LoseLife · removed: LoseLife (amount=the number of familiars you control)
  • 1 card(s) · ability/LoseLife · removed: LoseLife (amount=the number of times ~ has mutated)
  • 1 card(s) · ability/LoseLife · removed: LoseLife (amount=the other result)
  • 1 card(s) · ability/LoseLife · removed: LoseLife (amount=the total number of pins attached to you and creatures you control)
  • 1 card(s) · ability/Mana · added: Mana (expiry=EndOfTurn, mana={C} xmana spent to cast (SelfObject, FromSource { source_filter: Typed(TypedFilter { type_filters: [], controller: None, propertie…
  • 1 card(s) · ability/Mill · removed: Mill (count=the number of cards put into their graveyard from anywhere this turn, duration=until end of turn, target=any target)
  • 1 card(s) · ability/Mill · removed: Mill (count=the total of those results, target=player)
  • 1 card(s) · ability/Pump · removed: Pump (conditional=when you do, duration=until end of turn, p/t=-1/-1, target=creature, targets=0-the number of unique vowels on that sticker)
  • 1 card(s) · ability/PutCounter · field counter: Variable { name: "the amount of mana spent to cast her" } P1P1ManaSpentToCast { scope: SelfObject, metric: Total } P1P1
  • 1 card(s) · ability/PutCounter · field counter: Variable { name: "the amount of {S} spent to cast this spell" } P1P1ManaSpentToCast { scope: SelfObject, metric: FromSource { source_filter: Typed(TypedFilter { type_filters: [], controll…
  • 1 card(s) · ability/PutCounter · field targets: 0-the amount of {S} spent to cast this spell0-mana spent to cast (SelfObject, FromSource { source_filter: Typed(TypedFilter { type_filters: [], controller: None, p…
  • 1 card(s) · ability/PutCounter · removed: PutCounter (counter=Variable { name: "the mana value of the permanent exiled this way" } P1P1, target=you control creature)
  • 1 card(s) · ability/PutCounter · removed: PutCounter (counter=Variable { name: "the number of creature cards in your graveyard as you cast this spell" } P1P1, target=you control creature, targets=0-the…
  • 1 card(s) · ability/PutCounter · removed: PutCounter (counter=Variable { name: "the number of times you've rolled the planar die this turn" } P1P1, duration=until end of turn, target=legendary you cont…
  • 1 card(s) · ability/PutCounter · removed: PutCounter (counter=Variable { name: "the power of that blocked creature" } ~, duration=until end of turn, target=creature)
  • 1 card(s) · ability/PutCounter · removed: PutCounter (counter=Variable { name: "the power of the card you exiled" } P1P1, target=creature)
  • 1 card(s) · ability/PutCounter · removed: PutCounter (counter=Variable { name: "the power of the creature exiled this way" } P1P1, target=commander you control creature)
  • 1 card(s) · ability/PutCounter · removed: PutCounter (counter=Variable { name: "the total power of creatures goaded this way" } P1P1, target=self)
  • 1 card(s) · ability/Scry · field count: the amount of {S} spent to cast this spellmana spent to cast (SelfObject, FromSource { source_filter: Typed(TypedFilter { type_filters: [], controller: None, pro…
  • 1 card(s) · ability/Scry · removed: Scry (count=the number of opponents who voted for a choice you didn't vote for)
  • 1 card(s) · ability/SearchLibrary · removed: SearchLibrary (count=up to the number of players who control at least two more lands than you, find=basic land, kind=activated)
  • 1 card(s) · ability/Seek · removed: Seek (count=the highest mana value among creatures you control, filter=creature or enchantment or planeswalker)
  • 1 card(s) · ability/Tap · removed: Tap (target=creature, targets=0-the number of times ~ has mutated)
  • 1 card(s) · ability/Tap · removed: Tap (target=creature, targets=0-the number of u's in name stickers on ~)
  • 1 card(s) · ability/Token · field token: the number of counters among creatures target player controls× +1/+1 Green Saproling (Creature Saproling)counters on target player controls creature× +1/+1 Green Saproling (Creature Saproling)
  • 1 card(s) · ability/Token · removed: Token (conditional=referenced object's mana value < 10, token=the difference× Treasure (Artifact Treasure) tapped)
  • 1 card(s) · ability/Token · removed: Token (duration=until end of turn, token=the number of nontoken creatures you had enter the battlefield under your control× Food (Artifact Food))
  • 1 card(s) · ability/Token · removed: Token (token=that result× +3/+3 Green Beast (Creature Beast))
  • 1 card(s) · ability/Token · removed: Token (token=the amount of excess damage dealt to that creature this way× +1/+1 Red Goblin (Creature Goblin))
  • 1 card(s) · ability/Token · removed: Token (token=the amount of excess damage dealt to that creature this way× Blood (Artifact Blood))
  • 1 card(s) · ability/Token · removed: Token (token=the amount of excess damage dealt to that creature this way× Treasure (Artifact Treasure) tapped)
  • 1 card(s) · ability/Token · removed: Token (token=the amount of mana they paid this way× +1/+1 Green Squirrel (Creature Squirrel))
  • 1 card(s) · ability/Token · removed: Token (token=the amount of mana they paid× Treasure (Artifact Treasure))
  • 1 card(s) · ability/Token · removed: Token (token=the difference between those results× +1/+1 Red Goblin (Creature Goblin))
  • 1 card(s) · ability/Token · removed: Token (token=the difference× +4/+4 Green Troll Warrior (Creature Troll Warrior) with Trample)
  • 1 card(s) · ability/Token · removed: Token (token=the difference× +4/+4 Scarecrow (Artifact Creature Scarecrow) with Vigilance)
  • 1 card(s) · ability/Token · removed: Token (token=the other result× +2/+2 White Knight (Creature Knight) with Vigilance)
  • 1 card(s) · ability/Token · removed: Token (token=the other result× Treasure (Artifact Treasure))
  • 1 card(s) · ability/Untap · removed: Untap (target=land, targets=0-the greatest power among those creatures)
  • 1 card(s) · ability/add · removed: add
  • 1 card(s) · ability/add dynamic power, add dynamic toughness, grant Trample · removed: add dynamic power, add dynamic toughness, grant Trample (affects=you control creature, duration=until end of turn, grants=add dynamic power, grants=add dynamic…
  • 1 card(s) · ability/create · added: create (conditional=referenced object's mana value < 10)
  • 1 card(s) · ability/create · added: create (duration=until end of turn)
  • 1 card(s) · ability/draw · removed: draw (conditional=previous effect outcome)
  • 1 card(s) · ability/draw · removed: draw (kind=activated, timing=sorcery speed)
  • 1 card(s) · ability/grant Haste · removed: grant Haste (affects=parent target, duration=until end of turn, grants=grant Haste, kind=activated, target=you control creature, targets=0-the number of Bobble…
  • 1 card(s) · ability/perform · removed: perform
  • 1 card(s) · ability/power +1, toughness +0, grant Indestructible · removed: power +1, toughness +0, grant Indestructible (affects=parent target, duration=until end of turn, grants=grant Indestructible, grants=power +1, grants=toughness…
  • 1 card(s) · ability/reveal · added: reveal
  • 1 card(s) · ability/starting · added: starting
  • 1 card(s) · ability/where_x_binding · added: where_x_binding (conditional=previous excess amount > 0)
  • 1 card(s) · ability/where_x_binding · added: where_x_binding (conditional=when you do, duration=until end of turn, targets=0-X)
  • 1 card(s) · ability/where_x_binding · added: where_x_binding (duration=until end of combat)
  • 1 card(s) · ability/where_x_binding · added: where_x_binding (duration=until end of turn, kind=activated, targets=0-X)
  • 1 card(s) · ability/where_x_binding · added: where_x_binding (duration=until end of turn, kind=activated, targets=0-X, timing=sorcery speed)
  • 1 card(s) · ability/where_x_binding · added: where_x_binding (duration=until end of turn, targets=0-X)
  • 1 card(s) · ability/where_x_binding · added: where_x_binding (targets=0+)

2 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

Merged via the queue into main with commit 42f4a51 Jul 13, 2026
13 checks passed
@matthewevans matthewevans deleted the ship/where-x-quantity branch July 13, 2026 04:33
matthewevans added a commit that referenced this pull request Jul 13, 2026
…channel field (CR 120.6)

Rebase fallout: main gained 23 bare-form QuantityRef::PreviousEffectAmount
sites after this unit's base (mostly #5721-era where-X test pins), all E0533
against the new struct variant. Every site is Total-channel semantics
(drain gains, reveal-draw counts, for-each counter tails, repeat_for), so
expression sites get channel: DamageChannel::Total explicitly and pattern
sites match { .. } per the unit's own idiom. Census: CI's full E0533 list
(21 engine sites) plus 2 phase-ai sites CI could not reach because the
engine crate failed first; grep census cross-checked against both.
matthewevans added a commit to real-venus/phase that referenced this pull request Jul 13, 2026
* fix(parser): bind the chain-set anaphor across its participle axis (CR 608.2c)

`QuantityRef::TrackedSetAggregate` and its live resolver both already existed,
but the parser's anaphor list recognized only the EXILE forms ("those exiled
cards", "the card exiled this way"). Every other participle fell through to an
honest red and the whole surrounding clause was dropped:

  Ill-Timed Explosion     "the greatest mana value among cards discarded this way"
  Sword of the Ages       "the total power of the creatures sacrificed this way"
  Reign of the Pit        (same expression)
  Ruinous Intrusion       "the mana value of the permanent exiled this way"
  Astarion's Thirst       "the power of the creature exiled this way"
  Verdant Rejuvenation    "the HIGHEST mana value among creatures you control"

Three axes were enumerated as phrase tables where they are independent choices,
so each new card needed a new full-string tag. They are now composed:

  - extremum adjective  (greatest | highest | largest)      -> AggregateFunction::Max
  - object property     (power | toughness | mana value)
  - chain-set anaphor   ([the ] <noun> <participle> this way)

`parse_tracked_set_anaphor` is now the single authority for chain-set anaphora,
composed on noun x participle rather than listed as permutations. Verdant
Rejuvenation needed nothing but the word "highest" — the aggregate axis had been
a table of six full-phrase tags.

A SINGULAR "this way" referent ("the <property> of the <noun> <participle> this
way") carries no aggregate adjective, so it cannot ride the extremum/total
prefix. It gets its own gated arm, reading the same chain set — `Sum` over a
one-member set is that member's value, following the precedent already set for
the singular "the card exiled this way".

PARTICIPLE LIST IS DELIBERATELY RESTRICTED to the causes the engine actually
stamps (`ThisWayCause::{Exiled, Discarded, Sacrificed, Milled}`, via
`publish_tracked_set_with_causes`). "goaded" is EXCLUDED: `effects/goad.rs`
publishes no tracked set, so binding Havoc Eater's "the total power of creatures
goaded this way" would aggregate over an empty set and silently resolve to 0 —
a well-typed lie of exactly the class phase-rs#57 exists to kill. It stays an honest red
and is pinned by a negative-control test.

The bare PRE-nominal form ("the exiled card") stays reachable only behind an
aggregate prefix, where it is unambiguous. Offering it as a bare referent
regressed two other referents that claim the same words — the linked-exile pool
("the mana value of the exiled card") and the craft-material pool ("... used to
craft it"). Caught by the existing suite; the explicit "this way" shape is what
makes the bare-referent arm safe.

FULL-POOL LEDGER (35,396 faces, base 4e6e5aa; whole-face structural diff,
gains and reds separate, never netted):
   8  faces changed
   8  gained binding
   0  gained honest red
   0  re-shaped
  Target faces: Verdant Rejuvenation, Ill-Timed Explosion, Ruinous Intrusion,
  Astarion's Thirst, Reign of the Pit, Sword of the Ages (partial - its "those
  creature cards" exile clause is a separate, still-open red).
  COLLATERAL GAINS (2), both verified correct, neither in the unit's brief:
    Fateful Tempest        "the total mana value of cards milled this way"
    Shadowgrange Archfiend "the greatest power among creatures sacrificed this way"

Tests: 4 parser witnesses + 4 runtime witnesses (3 positive + 1 negative
control). The runtime witnesses hand the parsed QuantityExpr to the live
resolver against a populated chain set, and were WATCHED FAILING pre-fix via a
toggle probe (participle arms disabled -> both aggregate witnesses read 0;
restored). The discarded-mana-value witness was VACUOUS in its first draft:
`add_creature` leaves the mana cost empty, so every member's mana value was 0
and the assertion would have passed for the wrong reason (0 == 0). It now
establishes the costs explicitly via `with_mana_cost`.

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>

* fix(engine): give the previous-effect quantity its EXCESS channel (CR 120.10)

`DamageChannel` existed. `GameState::last_effect_excess_amount` existed, was
stamped by the damage effects, and was cleared at depth-0. The CONDITION peer
`AbilityCondition::PreviousEffectAmount { channel }` already read both channels.
Only the QUANTITY side was missing its channel:

  AbilityCondition::PreviousEffectAmount  -> channel: DamageChannel  -> reads both
  QuantityRef::PreviousEffectAmount       -> BARE, no channel        -> total only

`ability.rs` even documented `QuantityRef::PreviousEffectAmount` as the
total-channel twin. The excess twin was simply never built, so there was no way
to TYPE "the amount of excess damage dealt to that creature this way" — and the
whole surrounding instruction was dropped:

  Goblin Negotiation  "Create ... 1/1 red Goblin creature tokens equal to the
                       amount of excess damage dealt to that creature this way."
  Hell to Pay         (same shape, tapped Treasures)
  Lacerate Flesh      (same shape, Blood tokens)

Adds `channel: DamageChannel` to the variant (serde-elided `Total`) plus the
resolver arm. This is a parameterization, not a new leaf: a sibling
`PreviousEffectExcessAmount` would be the textbook sibling-cluster smell, and
the axis lies wholly inside CR 120 (120.6 total / 120.10 excess), so it does not
cross a rule-section boundary. The mtgish FIELD gate does not apply — it is
scoped to the `Effect` enum, and mtgish-import never names this variant
(confirmed by a clean `cargo check --workspace`, not by grep).

The compiler was the census: 20 sites across 11 files, including one in
`phase-ai` that a `crates/engine` grep would have missed. No wildcard arms were
used to absorb the new field.

"THAT EXCESS DAMAGE" IS DELIBERATELY NOT BOUND, and that is a CORRECTNESS
constraint. The bare demonstrative's antecedent is fixed by its sibling clause,
and the two readings resolve from DIFFERENT state:

  Contest of Claws     "If excess damage was dealt THIS WAY, ... where X is that
                       excess damage."  -> same resolution, field is live -> Excess is right.
  Fall of Cair Andros  "Whenever a creature ... is dealt excess noncombat damage,
                       amass Orcs X, where X is that excess damage."
                       -> the antecedent is the TRIGGERING EVENT. The triggered
                       ability resolves as its own top-level chain and the depth-0
                       prelude CLEARS last_effect_excess_amount, so the bind reads
                       None -> 0.

I shipped that bind into a local export and the whole-face ledger caught it: Fall
of Cair Andros moved from `Amass { count: Variable{name:"X"} }` (the phase-rs#57 residual
fabrication, resolving to 0) to `Amass { count: PreviousEffectAmount{Excess} }` —
the SAME wrong answer wearing a more convincing type. Strictly worse. A
channel-scoped ledger would have missed it: it shows up only as "red count
unchanged, tree changed".

A context-free leaf combinator cannot separate those two readings; the
disambiguator lives one layer up. So only the shape carrying an explicit "this
way" IN THE PHRASE is bound. The bare demonstrative stays an honest red, pinned
by a negative control, and the clause-layer rebind is filed as follow-up work
(along with the Fall of Cair Andros phase-rs#57 residual leak, which hides inside the
legitimate name:"X" alias population and is therefore invisible to t57's probe).

FULL-POOL LEDGER (35,396 faces, base 4e6e5aa; whole-face structural diff,
cumulative with the chain-set anaphor commit; gains and reds separate, never
netted):
  11  faces changed
  11  gained binding
   0  gained honest red
   0  re-shaped
  This commit's share: Goblin Negotiation, Hell to Pay, Lacerate Flesh.
  BYTE-IDENTITY: the diff is empty outside the bound-face census, so the `Total`
  serde elision leaves every pre-existing PreviousEffectAmount face unchanged.

NOT CLAIMED, and filed instead of hidden:
  - Archaic's Agony is a LYING GREEN — "Exile cards ... equal to the excess damage
    dealt to that creature this way" parses to ChangeZone with count: null and ZERO
    Unimplemented. This bind does not reach it: the top-of-library exile mover never
    consults the quantity parser for its count. Filed as a mover-path defect.
  - Mephit's Enthusiasm, Molten Impact (`note`), Brazen Boarding (`conjure`),
    Overclocked Electromancer (energy `get`, and turn-scoped -> wants
    DamageDealtThisTurn{Excess}, a third channel) are OUTPUT-INERT: their
    surrounding effect is still unimplemented, so no gain is claimed for them.

Tests: 4 runtime witnesses (2 positive, 2 negative controls). WATCHED FAILING
pre-fix via a toggle probe: with the excess arm disabled, every positive witness
fails with `Unimplemented { name: "effect_structure" }` — proving the clause was
dropped whole, not merely mis-valued. The Total-channel control passes in both
states, so a resolver that always read the excess field would not survive it.

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>

* fix(engine): migrate 23 main-drift PreviousEffectAmount sites to the channel field (CR 120.6)

Rebase fallout: main gained 23 bare-form QuantityRef::PreviousEffectAmount
sites after this unit's base (mostly phase-rs#5721-era where-X test pins), all E0533
against the new struct variant. Every site is Total-channel semantics
(drain gains, reveal-draw counts, for-each counter tails, repeat_for), so
expression sites get channel: DamageChannel::Total explicitly and pattern
sites match { .. } per the unit's own idiom. Census: CI's full E0533 list
(21 engine sites) plus 2 phase-ai sites CI could not reach because the
engine crate failed first; grep census cross-checked against both.

---------

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
matthewevans added a commit to jaytbarimbao-collab/phase that referenced this pull request Jul 13, 2026
… parity rows it exposes (CR 603.3b + CR 603.4) (phase-rs#5732)

`integration_cards.json` is a cached subset of the card-data export, but it was
last FULLY regenerated at b9685cc (phase-rs#5695). Nineteen parser/types PRs merged
since then; every fixture touch in between was surgical (phase-rs#5672 +1, phase-rs#5679 +2,
phase-rs#5727 2 entries), so the parse values silently drifted. Regenerated from the
export at 8c35dc5 (oracle-gen, MTGJSON 5.3.0+20260629).

Population (json deep-equality, not line counts — the file is one line):
committed 2658 entries -> 2726. 70 added, 2 removed, 44 changed values.

Attribution of the 44 changed (causal: exports built at phase-rs#5720 / phase-rs#5717 / phase-rs#5730
and compared, NOT shape-guessing):

  phase-rs#5723 (P02-U3b shared condition grammar) ...... 3
      archive trap, temple of civilization, thaumaton torpedo
      (all gained the comparator/lhs/rhs/qty/scope condition shape)

  phase-rs#5721 + phase-rs#5719 (where-X quantity channel + ..... 21
   restriction grammar; both merged BEFORE phase-rs#5717 —
   merge order != PR-number order)
      bellowsbreath ogre, cryptex, deadly rollick, deflecting swat, desert,
      dread wanderer, esquire of the king, flesh, fraying sanity,
      gloomlake verge, great desert hellion, gutterbones,
      officious interrogation, once upon a time, potioner's trove,
      ribald shanty, rock jockey, second little pig, shifting woodland,
      snuff out, starport security

  phase-rs#5695..phase-rs#5720 no-regen window (bloc) ........... 20
      Stale already at phase-rs#5720, so attributable to the 19-PR window above the
      b9685cc anchor, not to any single PR: alrund god of the cosmos,
      animal friend, approach of the second sun, cavernous maw, fblthp the lost,
      from father to son, hour of revelation, increasing vengeance,
      jodah the unifier, mana reflection, misty salon, puca's eye, ram through,
      reidane god of the worthy, secrets of the key, sevinne's reclamation,
      temple of the dead, the dining car, unleash the flux, valgavoth terror eater

The 70 added keys are new test-source card references the generator collects;
phase-rs#5729 (tests-only) contributed zero parse delta, as expected.

Corrected premise: 44 entries are truly stale, not 7. Six of the seven
originally reported reproduce; `osteomancer adept` is NOT stale (committed ==
fresh).

The regen turns `ordering_parity_sweep` red, so the gate's evidence rows ship
ATOMICALLY with it. Both rows are population entries, not ordering regressions:
the sweep skips Unimplemented-bearing triggers, so a card only enters it once
its parse binds.

  great desert hellion -> BATCH_GENUINE_ROWS. Its LTB Draw was Unimplemented
  until phase-rs#5721/phase-rs#5719 bound Intensity{Source}. Each co-departing Hellion draws off
  its OWN intensity but discards the SHARED hand, so the second trigger discards
  the cards the first just drew: with intensities a != b the final hand, graveyard
  and library differ by order. The members are not identical functions, so
  commutation genuinely fails and the new prompt is the CR 603.3b choice the
  legacy serde walk wrongly auto-ordered (CR 603.5: each "may" is chosen on
  resolution).

  planar collapse -> DOCUMENTED_OVER_PROMPT (L8-held family). New fixture key.
  Upkeep ObjectCount(Creature) >= 4 intervening-if x DestroyAll + self-Sacrifice:
  the first copy's sweep drives the census to 0, so the sibling's CR 603.4
  re-check is false and it does nothing. Monotone and self-limiting — identical
  siblings commute up to relabeling, so the prompt is conservative, fail-closed
  and rules-correct.

Neither row weakens the gate: both are direction-gated over-prompts (an
under-prompt is never suppressible), and both are consumed by the ledger's
exact-set asserts (over_prompt_hit 18->19, batch_genuine_hit 1->2), so a
misclassification still trips the STRICT PROOF-GATE.

Verification: engine lib 16481/16481 pass (was 16480 + 1 red); integration
2929/2929 pass.

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant