Skip to content

ship/restriction grammar - #5719

Merged
matthewevans merged 3 commits into
mainfrom
ship/restriction-grammar
Jul 13, 2026
Merged

ship/restriction grammar#5719
matthewevans merged 3 commits into
mainfrom
ship/restriction-grammar

Conversation

@matthewevans

Copy link
Copy Markdown
Member
  • refactor(parser): make parse_inner_condition the primary restriction grammar
  • test(parser): pin the restriction-only fallback and prove the commit is atomic

…grammar

CR 601.3 / CR 602.5: a casting/activation restriction is an ordinary game-state
condition, so it must be recognized by the same combinators that recognize the
identical phrase in an "as long as" static. Invert the ordering:
parse_restriction_condition now runs all_consuming(parse_inner_condition) FIRST
and converts, and the bespoke restriction grammar is a narrow fallback.

- Tri-state SharedRestrictionParse {NoMatch, Converted, Unsupported}. A phrase the
  shared grammar UNDERSTOOD but ParsedCondition cannot hold exactly fails the parse
  outright; it is never re-read by the fallback, which would produce a weaker
  reading of the same words.
- static_condition_to_restriction_condition is exhaustive over all 57 StaticCondition
  variants with no wildcard arm, so a new variant is a compile error rather than a
  silently permissive restriction. Counter bands are rejected, never widened into an
  "at least" that drops the maximum (CR 122.1 / CR 711.2a).
- Compound conditions move into parse_inner_condition as one parameterized connective
  combinator; the legacy " and "/" or " raw-string splitting is deleted. A string split
  cannot see a connector sitting INSIDE an atomic leaf; requiring both sides to parse
  as complete conditions is what makes the decomposition safe. n-ary chains nest
  instead of leaving an unconsumed — and silently swallowed — tail.
- CR 903.3d: "you control a commander" converts to an ObjectCount over the IsCommander
  filter. It previously became subtype "commander" — a subtype no permanent has — so
  Deflecting Swat's free-cast condition could never be satisfied.
- An unparsed condition must never be stored as RequiresCondition { condition: None }.
  That evaluates permissively (Option::is_none_or -> true) AFTER consuming the source
  clause, so the ability reported as fully supported while being activatable in exactly
  the situations the card forbids. All six activation sites now route through one
  atomic commit_requires_condition authority that mutates nothing on failure, leaving
  the clause for the ordinary Effect::Unimplemented fallback.

Full-pool dual export (population 35,396 faces): RequiresCondition{condition: null}
goes 50 occurrences / 48 faces -> 0. 42 faces become honestly unsupported: 35 were
that permissive lie (no restriction enforced at all), 3 were confidently-wrong parses
(a bare-subtype dump that could never be satisfied, a QuantityVsEachOpponent comparing
a value to itself, an "attacking or blocking" narrowed to "attacking"), and 4 are true
regressions in two named classes tracked as follow-ups. 1 gain, 15 dead parsers
deleted (-611 lines net).
…is atomic

Enforcement for the P02-U3 ruling. Both gates were watched failing before being
accepted; reverting the commit order reproduces the original defect verbatim as
[RequiresCondition { condition: None }, OnlyOnceEachTurn].

- restriction_only_fallback_dispatches_exactly_the_pinned_families: a STRUCTURAL pin,
  not prose. The fallback's doc comment tells contributors not to add a phrase there;
  a comment stops nobody. This reads the module's own source, extracts the dispatcher's
  body, and rejects any parse_*(text) call outside the pinned six — each tagged with WHY
  it is retained (restriction-context referent / vocabulary gap / phrasing gap pending
  P02-U3b). Adding a seventh arm turns it red with the question the author must answer:
  is the new phrase referentially special, genuinely inexpressible, or merely spelled
  differently? Only the first two belong there.
- failed_activation_condition_commits_no_cadence_and_leaves_the_clause_unimplemented:
  the witness for commit_requires_condition. An activation gate whose condition fails to
  parse must commit NOTHING — not the cadence restriction sharing the sentence, not a
  permissive RequiresCondition { condition: None }, and not the source text, which must
  survive as Effect::Unimplemented.
- parsed_activation_condition_commits_both_cadence_and_condition: the positive
  reach-guard, so the atomicity test cannot pass merely because the peeling branch never
  ran. It stays green under the revert while the atomicity test goes red.

The source-scanning `.find` calls carry allow-noncombinator annotations: they parse this
module's own bytes, not Oracle text.

Test-only; parse output unchanged.
@matthewevans
matthewevans enabled auto-merge July 13, 2026 04:01
@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!

Two PlayerFilter imports lost their last user when #5677's HasCounters
arm replaced the bespoke lowering, and the describe() test helper lost
its callers when SharedRestrictionParse::Unsupported dropped its payload.
Local 'cargo test' passes with warnings; CI's -D warnings does not.
@github-actions

Copy link
Copy Markdown

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

34 card(s) · ability/activate · added: activate

Examples: Acidic Dagger, Angus Mackenzie, Arcum's Sleigh (+31 more)

5 card(s) · ability/activate · added: activate (duration=until end of turn)

Examples: Boxing Ring, Churning Reservoir, Izzet Generatorium (+2 more)

2 card(s) · ability/CastFromZone · added: CastFromZone (free cast=yes, target=any target)

Examples: Erase (Not the Urza's Legacy One), Remodel

2 card(s) · cost/CastingOption:CastWithoutManaCost · removed: CastingOption:CastWithoutManaCost

Examples: Erase (Not the Urza's Legacy One), Remodel

2 card(s) · ability/activate · added: activate (conditional=not (source is tapped))

Examples: Krovikan Plague, Veteran's Voice

2 card(s) · ability/activate · added: activate (conditional=source is creature)

Examples: Chronatog Totem, Flywheel Racer

1 card(s) · ability/CastFromZone · added: CastFromZone (conditional=# of you control creature < # of opponent controls creature, target=any target)

Examples: Even the Odds

1 card(s) · ability/CastFromZone · added: CastFromZone (target=any target)

Examples: Suffocation

1 card(s) · ability/CastFromZone · field conditional: # of attacking you creature ≥ 1 and # of in battlefield you control Forest ≥ 1 and # of in battlefield you control Plai…

Examples: Qasali Ambusher

1 card(s) · ability/ChangeZone · removed: ChangeZone (target=artifact, to=exile)

Examples: Remodel

1 card(s) · ability/ChangeZone · removed: ChangeZone (target=enchantment, to=exile)

Examples: Erase (Not the Urza's Legacy One)

1 card(s) · trigger/ChangesZone · field active in: battlefieldgraveyard

Examples: Skyclave Shade

1 card(s) · trigger/ChangesZone · field condition: source is in graveyard and during you's turn

Examples: Skyclave Shade

1 card(s) · ability/DealDamage · removed: DealDamage (amount=4, duration=until end of turn, target=any target)

Examples: Suffocation

1 card(s) · ability/GrantCastingPermission · added: GrantCastingPermission

Examples: Skyclave Shade

1 card(s) · ability/LoseLife · field conditional: cards in hand (scoped player) = 0 and quest counters on self ≥ 2

Examples: Quest for the Nihil Stone

1 card(s) · trigger/Phase · field condition: Elf cards in your graveyard ≥ 1 and has 1+ counters

Examples: Creakwood Safewright

1 card(s) · trigger/Phase · field condition: cards in hand (scoped player) = 0 and has 2+ counters

Examples: Quest for the Nihil Stone

1 card(s) · ability/RemoveCounter · field conditional: Elf cards in your graveyard ≥ 1 and M1M1 counters on self ≥ 1

Examples: Creakwood Safewright

1 card(s) · ability/Seek · field conditional: life lost this turn (sum of opponents) ≥ 1 and life gained this turn (you) ≥ 1instead if (life lost this turn (sum of opponents) ≥ 1 and life gained this turn (you) ≥ 1)

Examples: Dusk's Landing

1 card(s) · ability/Token · removed: Token (token=3× +1/+1 White Soldier (Creature Soldier))

Examples: Even the Odds

1 card(s) · ability/activate · added: activate (conditional=not (source is Spirit))

Examples: Second Little Pig

1 card(s) · ability/activate · added: activate (conditional=source is attacking or source is blocking)

Examples: Sawback Manticore

1 card(s) · ability/if · removed: if

Examples: Skyclave Shade

1 card(s) · ability/only · added: only

Examples: Entirely Normal Armchair

… 1 more signature(s) (1 card-changes) — see parse-diff.json
  • 1 card(s) · ability/unknown · added: unknown

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

@matthewevans
matthewevans added this pull request to the merge queue Jul 13, 2026
Merged via the queue into main with commit 4e6e5aa Jul 13, 2026
13 checks passed
@matthewevans
matthewevans deleted the ship/restriction-grammar branch July 13, 2026 04:35
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