Skip to content

fix(parser): guard the binary-choice splitter on the target-cardinality list#5619

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
jaytbarimbao-collab:fix/choose-one-cardinality-guard
Jul 12, 2026
Merged

fix(parser): guard the binary-choice splitter on the target-cardinality list#5619
matthewevans merged 3 commits into
phase-rs:mainfrom
jaytbarimbao-collab:fix/choose-one-cardinality-guard

Conversation

@jaytbarimbao-collab

@jaytbarimbao-collab jaytbarimbao-collab commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #5613.

What this fixes (corrected)

This is a stack-depth robustness fix, not a parse-output fix — the coverage-parse-diff sticky correctly shows zero card-parse changes, and that is the expected result.

try_parse_choose_one_of_inline splits a clause at a bare " or ". A divided/distributed effect's target-count quantifier embeds a bare " or " inside its cardinality list ("one, two, or three" / "one or two") that enumerates a target count, not a disjunction of clauses. The splitter splits there and trial-parses the orphan tail back through the full effect→subject→static→imperative cascade. That trial parse fails and is discarded, so the final parse output is unchanged — but it re-enters the clause parser several frames deep, and the parser's large per-frame AST locals can overflow a small (~1–2 MB) thread stack. So the ~32 counter-distribution cards were already parsing correctly on main; the wasted trial parse just burns stack.

Fix

Key the splitter's bail on the target-cardinality list rather than the distribution verb. CR 601.2d divides an effect "(such as damage or counters)", so the cardinality list is the axis shared by both halves; the old verb guard (DISTRIBUTION_KEYWORDS) named only the damage half, so the counter-distribution templating still hit the wasted trial parse. Removing that trial parse is what this PR does.

Also removed the "distributed among" divided-damage needle — it matches 0 of 34,632 cards (independently verified), so it only guarded phrasing that does not exist.

Evidence

  • Stack depth (the actual regression), measured on the full parse_oracle_text pipeline: peak stack for "Distribute three +1/+1 counters among one, two, or three target creatures." is ~3.5 MiB on main vs ~1.4 MiB with the guard. Sweep (survives / ABORTS): main 4 MiB / 3 MiB; fix 1.5 MiB / 1.25 MiB.
  • Discriminating test: distribute_cardinality_clause_parses_within_a_bounded_stack parses on a 2.5 MiB thread — mid-gap, ~1.1 MiB headroom here and ~1 MiB overflow margin on main. It fails (aborts) if the guard is reverted (AI-CONTRIBUTOR.md §5(i)); the in-thread asserts also pin the output (PutCounter + captured distribution).
  • Full parser suite green (7956). Parser-combinator gate + clippy clean.

Also addresses the review (already pushed in 1a39f1251, before this body edit)

  • Single-authority vocabulary: collapsed the four cardinality spellings onto one stem+bounds BOUNDED_TARGET_CARDINALITIES = [("one or two",1,2),("one, two, or three",1,3)]; each consumer composes its noun off the stem via nom tag sequencing (strip_bounded_targets_placeholder, strip_bounded_target_prefix, subject's each-of arm, oracle_target's bare bounded-count arm, and the splitter bail). BOUNDED_TARGET_PHRASES and the interim BOUNDED_TARGET_CARDINALITY_LISTS are gone.
  • Honest DISTRIBUTION_KEYWORDS doc: rewritten — one caller now, the splitter coupling and its set-drift hazard removed, and the "durable fix" it deferred is this PR.

Defect #2 (a general recursion-depth bound in ParseContext) is left as a separate follow-up, as the issue frames it.

…ty list

`try_parse_choose_one_of_inline` splits a clause at a bare " or ", but a
divided/distributed effect's target-count quantifier embeds a bare " or " inside
its cardinality list ("one, two, or three" / "one or two") that enumerates a
target COUNT, not a disjunction of two imperative clauses. Splitting there hands
the orphan tail back through the full effect→subject→static→imperative cascade as
a trial parse; not being a standalone effect, it re-enters the clause parser
several frames deep and can overflow a small parser thread stack.

The existing guard keyed on the distribution VERB (`DISTRIBUTION_KEYWORDS`,
divided damage only). CR 601.2d divides an effect "(such as damage or counters)",
so the verb guard names one half and left the counter-distribution templating
("Distribute three +1/+1 counters among one, two, or three target creatures")
unguarded — ~32 cards on `main` (Ajani, Mentor of Heroes; Biogenic Upgrade;
Abzan Charm; Armament Corps; Blessing of Frost; …) still mis-split.

Key the bail on the target-cardinality list instead — the axis shared by both
halves. `BOUNDED_TARGET_CARDINALITY_LISTS` ("one or two target" / "one, two, or
three target") is the same typed vocabulary the target-count layer strips via
`strip_bounded_target_prefix`; the `target` stem (no trailing `s`) matches both
the "… targets" (damage) and "… target <noun>" (counters) forms, and it is the
complete set of bare-" or "-bearing cardinality lists in the pool.

Also removed the "distributed among" divided-damage needle: it matches 0 of
34,632 cards (no Magic card uses that templating).

Verified: the ~32 counter cards now parse whole as `PutCounter`/`DealDamage` with
the distribution captured; Abzan Charm's 3-mode modal is intact (mode 3 =
distribute counters); the divided-damage cards are unregressed. Full parser suite
green (7956). New test:
`distribute_counters_cardinality_list_is_not_binary_split`.

Fixes phase-rs#5613.
@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 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 taking this one — and for aiming it at the right axis. Keying the bail on the target-cardinality list rather than the distribution verb is exactly the durable fix, because it is the axis CR 601.2d actually shares across both halves of the rule ("such as damage or counters"). Removing the "distributed among" needle is right too; it matches 0 of 34,632 cards. I approved the CI run (it was gated on new-contributor approval, which is why nothing had run).

Two things to fix before I can approve, both cheap, both structural.

1. This leaves DISTRIBUTION_KEYWORDS's doc comment lying about its own callers

lower.rs:5764-5781 currently reads:

Two callers must agree on this set. try_parse_distribute_damage bounds its Pattern-B quantity slice with it, and try_parse_choose_one_of_inline bails out of the generic binary-choice splitter for any clause containing one... If the two sets were allowed to drift, a newly supported distribution phrasing would parse correctly here yet still be split there.

...The durable fix keys the bail on the target-cardinality list rather than on the distribution verb.

After this PR all three of those statements are false. There is one caller, not two (try_parse_distribute_damage at lower.rs:5832). The drift hazard it warns about no longer exists, because the coupling it describes is the one you just removed. And the "durable fix" it defers to the future is this PR.

I wrote that comment, so this is my mess as much as yours — but a doc comment that describes a caller which no longer exists is worse than no comment, because the next person to touch DISTRIBUTION_KEYWORDS will preserve a coupling that isn't there. Please rewrite it to describe what the constant actually is now: the divided-damage verb set, with a single consumer that uses it to bound a quantity slice.

2. This is now the fourth copy of the same cardinality vocabulary

The strings "one or two" / "one, two, or three" are now spelled out in four places:

site form
lower.rs:4962 BOUNDED_TARGET_PHRASES ("one or two targets", 1, 2) — plural
lower.rs:5026 inline in strip_bounded_target_prefix ("one or two target ", 1, 2) — trailing space
oracle_target.rs:632 "one or two targets" — plural, no bounds
new BOUNDED_TARGET_CARDINALITY_LISTS "one or two target" — stem

Your doc comment even notes the overlap ("BOUNDED_TARGET_PHRASES / strip_bounded_target_prefix consume the same lists at the target-count layer") — so the duplication was seen and then cloned anyway. Four spellings of one vocabulary means a fifth cardinality list ("one, two, three, or four target...") has to be added in four places, and CLAUDE.md's rule is explicit: "Did I duplicate logic that an existing helper already handles? ... Do not leave architectural debt for later — fix it now, in the same change."

I'm not asking for this on a whim: the guard this PR replaces was blocked in review for a two-copy version of exactly this problem. It would be incoherent to hold my own code to that bar and not this.

The single authority is the cardinality stem plus its bounds:

/// CR 115.1d: The bounded target-cardinality lists. The stem carries the bare
/// " or " that enumerates a target COUNT, never a disjunction of clauses.
pub(super) const BOUNDED_TARGET_CARDINALITIES: &[(&str, usize, usize)] =
    &[("one or two", 1, 2), ("one, two, or three", 1, 3)];

Each consumer then composes the suffix it needs off the stem — strip_prefix(stem)?.strip_prefix(" target"), " targets", or " target " — which is the std-primitive composition CLAUDE.md asks for, rather than four pre-baked string tables. That collapses all four sites onto one list and makes your guard a two-line consumer of it.

Still required before approve

CI had never run on this branch (3 checks: a pull_request_target labeller, a trust check, and an external scanner — the real pull_request jobs were gated). It's running now. The coverage-parse-diff sticky is required evidence for a parser change — it is the pool-level oracle, and the test fixture is only ~7% of the card pool. Your body claims ~32 counter cards now parse and that divided-damage is unregressed; the sticky is what settles both. I'll read it rather than take the claim on faith — that's not about you, it's that the sticky has overturned a reviewer's prediction four times this week, twice against me.

The test itself is well built: asserting PutCounter and distribute.is_some() is discriminating, because a binary split fails both. That's the right shape.

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 8 card(s), 5 signature(s) (baseline: main 1c3eee9dd9cb)

3 card(s) · static/Continuous · field affects: you control creature Face-downface-down you control creature

Examples: Etrata, Deadly Fugitive, Roshan, Hidden Magister, Secret Plans

2 card(s) · static/Continuous · field affects: you control creature Untappeduntapped you control creature

Examples: Builder's Blessing, Castle

1 card(s) · static/Continuous · field affects: you control creature Blockingblocking you control creature

Examples: Crescendo of War

1 card(s) · static/Continuous · field mods: add dynamic power, add dynamic toughnessadd dynamic power, add dynamic toughness, add subtype Assassin

Examples: Reaper's Scythe

1 card(s) · ability/PutCounter · added: PutCounter (counter=1 blood, kind=activated, target=granting object)

Examples: Rakdos Riteknife

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

@matthewevans

Copy link
Copy Markdown
Member

The coverage-parse-diff sticky has landed, and it refutes this PR's headline claim. It also refutes something I told you in my last review, so let me correct that first.

The pool oracle says zero cards change

✓ No card-parse changes detected.

The body claims "~32 cards on main … still mis-split" and that they "now parse whole as PutCounter with the distribution captured." If 32 cards went from mis-split to correctly parsed, the sticky would show 32 cards changing. It shows zero.

Non-vacuity control (because a zero is worthless if the probe is dead): #5619's sticky ran at 23:31:40Z against head f4a2951c (pushed 23:08:23Z), so it is fresh and on this head. Minutes earlier, on this same repo, #5612's sticky reported 36 cards / 13 signatures. The job detects changes when changes exist. This zero is a real zero.

Your own comment explains why

because that tail is not a standalone effect, the cascade re-enters this clause parser several levels deep, and the parser's large per-frame AST locals overflow small (~1-2 MB) thread stacks

A trial parse that fails is discarded — the cascade falls back and the final parse output is unchanged. That is precisely what the sticky measured. Those ~32 cards were already parsing whole as PutCounter on main; the split was attempted, rejected, and the correct handler claimed the clause anyway.

The bug is real, but it is not a parse-output bug. It is a stack-depth bug: the wasted trial parse burns frames, and on a small (~1–2 MB) stack that overflows. Your own analysis says this. The body's claim of 32 fixed cards contradicts it.

Your probe — "probed Ajani, Biogenic Upgrade, Abzan Charm, Armament Corps, Blessing of Frost — all Unimpl=0" — measured only the after state. Unimpl=0 proves nothing unless you also measured it before and watched it be non-zero.

Therefore the test is vacuous — and I was wrong to call it discriminating

My previous review said the test "is discriminating (asserts PutCounter and distribute.is_some() — a binary split fails both)." That was wrong. It assumed the binary split succeeds on main. It doesn't; it's tried and thrown away.

distribute_counters_cardinality_list_is_not_binary_split asserts only on parse_effect_chain output:

assert!(matches!(*def.effect, Effect::PutCounter { .. }), ...);
assert!(def.distribute.is_some(), ...);

The sticky proves that output is identical on main for exactly this templating. So both assertions pass on main too. The test cannot fail if this fix were reverteddocs/AI-CONTRIBUTOR.md §5(i), named there as the single most common gap on parser PRs.

What I want

Keep the fix. The seam is right: CR 601.2d divides an effect "(such as damage or counters)", so keying the bail on the target-cardinality list rather than the distribution verb is the axis that covers both halves. The dead-needle removal is independently verified ("distributed among" → 0 of 34,632 cards). None of that changes.

Three things to change:

  1. Correct the body. This PR does not fix 32 cards' parse output. It removes a wasted trial parse that can overflow a small stack. Claim that — it's a real fix and it's defensible; the current claim isn't.

  2. Write a test that fails on main. The only thing that regresses here is stack depth, so the test has to measure stack depth — parse the text on a std::thread::Builder::new().stack_size(N) thread sized to overflow on main and survive here. If you can't make that reliable, say so plainly in the body and state that the PR ships without a discriminating test; don't leave a passing assertion standing in for one.

  3. The two findings from my last review are still open (same head, nothing pushed):

    • lower.rs:5768-5781 — the DISTRIBUTION_KEYWORDS doc comment still says "Two callers must agree on this set" when this PR removes one of them (only try_parse_distribute_damage at lower.rs:5832 remains), and still defers to a "durable fix" that is this PR. It's my comment; it's now false; please delete or rewrite it.
    • The cardinality vocabulary now has a fourth spelling (BOUNDED_TARGET_PHRASES at lower.rs:4962, the inline list in strip_bounded_target_prefix at lower.rs:5026, oracle_target.rs:632, plus the new BOUNDED_TARGET_CARDINALITY_LISTS). The guard this PR replaces was blocked in review for a two-copy version of exactly this; I'm not going to hold my own code to that bar and not yours. Collapse onto one stem + bounds authority.

To be explicit about the good news: the diagnosis, the seam, and the CR reading are all correct, and you found a genuine robustness bug. The problem is entirely in what the PR claims and what its test proves.

… doc

Addresses review on phase-rs#5619.

1. Collapse the four near-duplicate target-cardinality spellings onto one
   authority — the cardinality STEM plus its bounds:
       BOUNDED_TARGET_CARDINALITIES = [("one or two", 1, 2),
                                       ("one, two, or three", 1, 3)]
   Each consumer composes the trailing noun it needs off the stem via nom `tag`
   sequencing (the combinator mandate) rather than re-spelling the list:
     - `strip_bounded_targets_placeholder`  → stem + " targets"
     - `strip_bounded_target_prefix`        → stem + " target "
     - `subject`'s "each of one or two targets" arm → stem + " targets"
     - `oracle_target`'s bare bounded-count target arm → stem + " targets"
     - the binary-choice splitter bail        → stem + " target"
   A fifth cardinality list is now added in exactly one place. Removes the
   `BOUNDED_TARGET_PHRASES` and `BOUNDED_TARGET_CARDINALITY_LISTS` copies.

2. Rewrite `DISTRIBUTION_KEYWORDS`'s doc comment, which this PR made false: there
   is now ONE caller (`try_parse_distribute_damage`), not two; the splitter's
   coupling to it — and the set-drift hazard the comment warned about — is gone
   (the splitter now keys on the cardinality axis); and the "durable fix" the
   comment deferred to the future is this change.

Verified: counter cards (Biogenic Upgrade, Abzan Charm) parse whole with the
distribution captured; the strip-consumer cards (Prismari Charm "each of one or
two targets"; Electrolyze "among one or two target creatures") are unregressed;
full parser suite green (7956); gate + clippy clean.
@jaytbarimbao-collab

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — both addressed in 1a39f1251.

1. Single-authority vocabulary. You're right, a fourth spelling was exactly the debt the guard it replaces was blocked for. Collapsed all of them onto the cardinality stem + bounds:

pub(crate) const BOUNDED_TARGET_CARDINALITIES: &[(&str, usize, usize)] =
    &[("one or two", 1, 2), ("one, two, or three", 1, 3)];

Each consumer composes the noun it needs off the stem — strip_bounded_targets_placeholder (" targets"), strip_bounded_target_prefix (" target "), subject's each-of arm (" targets"), oracle_target's bare bounded-count arm (" targets"), and the splitter bail (" target"). BOUNDED_TARGET_PHRASES and my BOUNDED_TARGET_CARDINALITY_LISTS are both gone; a fifth list now lands in one place. I composed via nom tag sequencing rather than strip_prefix chains so it stays inside the combinator gate — same de-duplication, one authority.

2. Honest DISTRIBUTION_KEYWORDS doc. Rewritten. It now states the truth: one caller (try_parse_distribute_damage), the splitter coupling and its set-drift hazard removed, and the "durable fix" it deferred is this PR.

On the sticky: agreed it's the real oracle — my parse-diff claims should be read off it, not taken on faith. The push re-triggers CI, so a fresh coverage-parse-diff will regenerate; I'll be reading it too and will flag anything unexpected rather than wait for you to catch it. Locally the ~32 counter cards parse whole (Biogenic Upgrade, Abzan Charm, Ajani, Armament Corps, Blessing of Frost) and the strip-consumers (Prismari Charm, Electrolyze) are unregressed; full parser suite green (7956).

Left defect #2 (the recursion depth-bound) out of scope for this PR, as you framed it.

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

1a39f1251 — both structural findings are cleanly fixed, and the vocabulary collapse is exactly right.

BOUNDED_TARGET_CARDINALITIES as stem + bounds, with each consumer composing the noun it needs (" targets" / " target " / " target") via nom tag sequencing rather than a fifth spelling — that is the single authority I asked for, done the way the combinator mandate wants it. The DISTRIBUTION_KEYWORDS doc is now honest about having one caller. The dead "distributed among" needle is gone with the 0-of-34,632 evidence in the comment. Good work, and thank you for taking the DRY finding seriously rather than arguing it.

Two asks from the last review are still open, though, and one of them is the blocker.

1. The body still asserts a delta the pool oracle refuted

The body still says ~32 cards on main "still mis-split" and "now parse whole as PutCounter." The sticky said zero card-parse changes, and your reply reads as agreeing in principle while leaving the claim in place.

Your evidence for the claim is "locally the ~32 counter cards parse whole (Biogenic Upgrade, Abzan Charm, Ajani, …)". That is the after state. It is not a delta. Those cards parse whole on main too — which is precisely what the sticky is telling you. A claim of the form "N cards were broken and are now fixed" can only be settled by a before-vs-after comparison, and the sticky is that comparison.

Do not wait on the fresh sticky to settle this. 1a39f1251 is a pure refactor: the bail matched "one or two target" / "one, two, or three target" before via BOUNDED_TARGET_CARDINALITY_LISTS and matches the identical strings now via format!("{stem} target"). Same strings, same behavior. The new sticky will say zero again, for the same reason.

2. The test cannot fail if the fix is reverted — this is the blocker

distribute_counters_cardinality_list_is_not_binary_split is unchanged (tests.rs isn't in 1a39f1251 at all). It asserts on parse_effect_chain output:

matches!(*def.effect, Effect::PutCounter { .. })
def.distribute.is_some()

The sticky proves that output is identical on main for exactly these clauses. So the test passes on main, and it passes with your fix — it cannot fail if the fix were reverted. Per AI-CONTRIBUTOR.md §5 check (i), that is a shape test, not a regression test.

This is not a criticism of the fix. The fix is right — right seam per CR 601.2d, and the cardinality axis genuinely is the one shared by the rule's "damage or counters" halves. The problem is that you have written a correct fix for a stack-depth bug and then tested for a parse-output bug, which is not the bug you fixed. On main the binary split is attempted, the trial parse of the orphan tail fails, the cascade falls back, and the correct handler claims the clause anyway — burning stack frames on the way. Output identical; depth different. That is why the sticky is zero and why your fix is still worth landing.

Write the test against the axis you actually moved. Drive the parse on a deliberately small stack and assert it completes:

#[test]
fn distribute_counters_does_not_exhaust_a_small_parser_stack() {
    // CR 601.2d: on `main` the cardinality list is split, the orphan tail
    // re-enters the clause cascade, and the parser's large per-frame AST
    // locals overflow a small stack. Output is unchanged either way — the
    // regression is DEPTH, so the test must bound depth, not shape.
    let handle = std::thread::Builder::new()
        .stack_size(1 << 20) // 1 MiB — representative of the real parser threads
        .spawn(|| {
            parse_effect_chain(
                "Distribute three +1/+1 counters among one, two, or three target creatures.",
                AbilityKind::Spell,
            );
        })
        .unwrap();
    assert!(handle.join().is_ok(), "parse overflowed a 1 MiB stack");
}

Please confirm it fails on main and passes here — that red-to-green transition is the whole evidentiary value, and it is the one thing neither of us has actually observed yet. If it turns out not to overflow at 1 MiB, tell me: that would mean the stack-overflow premise itself needs re-examining, and I would rather know that than land a fix for a bug we cannot demonstrate.

Keep the current output test alongside it if you like — as a guard that the bail didn't change parse output, which is a real thing to protect. Just don't let it stand in for the regression test.

Then correct the body to say what this PR does: removes a stack-depth hazard on ~32 counter-distribution cards whose parse output was already correct, plus a real DRY consolidation and a dead-needle removal. That is a genuinely good PR. It just isn't the PR the body currently describes.

…ed-stack)

The prior test asserted only on parse OUTPUT, which the coverage-parse-diff
sticky proved is identical on `main` for this templating — so it passed on `main`
too and could not fail if the guard were reverted (AI-CONTRIBUTOR.md §5(i)).

The only property this fix changes is stack DEPTH: the removed trial parse
re-enters the clause cascade several frames deep. Measure that directly. Peak
stack for the exemplar clause on the full `parse_oracle_text` pipeline is ~3.5 MiB
on `main` vs ~1.4 MiB with the guard; the test parses on a 2.5 MiB thread —
mid-gap, ~1.1 MiB headroom here, ~1 MiB overflow margin on `main`. Reintroducing
the wasted trial parse overflows this thread's guard page and aborts the binary,
so the test fails when the fix is reverted. In-thread asserts still pin the
output (PutCounter + captured distribution).
@jaytbarimbao-collab

Copy link
Copy Markdown
Contributor Author

You're right on every point — the sticky settles it and I'd rather claim what the PR actually does. Pushed 779023d5c + a body rewrite.

The body over-claimed; corrected. This is a stack-depth robustness fix, not a parse-output fix. The ~32 cards were already parsing whole on main; the binary split is tried, fails, and is discarded, so output is unchanged (exactly what "✓ No card-parse changes" reports). The value is removing the wasted trial parse that re-enters the cascade and burns stack. Body now says that.

The test was vacuous; replaced with a discriminating one. You called it: asserting on output alone can't fail on main. The only property that changes is stack depth, so I measured it and test it directly.

Peak stack for "Distribute three +1/+1 counters among one, two, or three target creatures." on the full parse_oracle_text pipeline (std::thread::Builder::stack_size sweep, survives / ABORTS):

stack main this branch
4 MiB ok ok
3 MiB abort ok
1.5 MiB abort ok
1.25 MiB abort abort

So main's floor is ~3.5 MiB, this branch's ~1.4 MiB. distribute_cardinality_clause_parses_within_a_bounded_stack parses on a 2.5 MiB thread — mid-gap, ~1.1 MiB headroom here and ~1 MiB overflow margin on main. It aborts the test binary if the guard is reverted; the in-thread asserts still pin PutCounter + a captured distribution.

Your findings #1 and #3 were already fixed in 1a39f1251 (pushed just after the sticky ran on f4a2951c, so your review predated it):

  • DISTRIBUTION_KEYWORDS doc rewritten — one caller, coupling + drift hazard gone, the deferred "durable fix" is this PR.
  • Cardinality vocab collapsed to one stem+bounds authority (BOUNDED_TARGET_CARDINALITIES); all four spellings gone, each consumer composes its suffix off the stem.

Left the general ParseContext depth-bound (defect #2) as its own follow-up, as you framed it. Full suite green (7956), gate + clippy clean.

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

Approving. All four findings addressed, and the last one you addressed the right way — by measuring, not by arguing.

The stack sweep is what makes this PR. I asked you to run the bounded-stack test on main and to tell me if it didn't fail, because a stack overflow was something neither of us had ever actually observed — we were both inferring it from the shape of the code. You ran the sweep and it went red: main aborts at 3 MiB, this branch survives to 1.5 MiB. That converts the premise from reasoned to observed, and it's why distribute_cardinality_clause_parses_within_a_bounded_stack at 2.5 MiB is a real regression test — reverting the guard trips the guard page and aborts the binary. It also passed in CI's environment, so the threshold isn't an artifact of your machine.

The body is now honest, and specifically it says the sticky's zero is the expected result rather than treating it as something to explain away. That's the correct reading: the binary split is tried, fails, and is discarded, so parse output was never wrong — only the stack was. A test that asserts output can't fail on main; a test that asserts survival can.

On the fresh sticky — ignore its headline; it is not yours. It reports 8 cards / 5 signatures against baseline main 1c3eee9dd9cb, which is stale: #5612 (3fdf1d2526) and #5621 (1832824c5e) merged after that commit. Every card in it belongs to one of those two — Reaper's Scythe and Rakdos Riteknife are #5621's; the you control creature Untappeduntapped you control creature status-filter reorderings are #5612's. Your real footprint is still zero, exactly as predicted. Nothing for you to do; I'm noting it so a stale baseline doesn't get read as a regression on your branch. (Filed on my side as a tooling defect.)

Structural findings from the prior round both landed cleanly: BOUNDED_TARGET_CARDINALITIES is a genuine stem+bounds single authority with all five consumers composing their noun off the stem via nom tag sequencing — no fifth spelling, and it stays inside the combinator gate. The DISTRIBUTION_KEYWORDS doc I left lying is now accurate.

Leaving the general ParseContext depth bound as its own follow-up per #5613 is the right call — the guard fixes this class, the depth bound is the systemic backstop, and they shouldn't ride together.

@matthewevans matthewevans added the bug Bug fix label Jul 12, 2026
@matthewevans matthewevans added this pull request to the merge queue Jul 12, 2026
Merged via the queue into phase-rs:main with commit 700d436 Jul 12, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

parser: binary-choice splitter guards damage distribution but not counters (32 cards), and the underlying re-entry is unbounded

2 participants