refactor(parser): Plan 05b — U0-61 and U0-40, the two rows T9 unblocked - #6726
Merged
Conversation
…cognizer to IR
`oracle_class.rs`'s effect/spell-like line arm ("You may play an additional
land...") emitted a pre-lowered `AbilityDefinition`; it now emits
`OracleNodeIr::Spell(AbilityIr)` and lets `lower_oracle_ir` do the lowering.
Byte-identical BY CONSTRUCTION, not by corpus. `parse_effect_chain(t, k)` **is**
`lower_ability_ir(&parse_ability_ir_standalone(t, k))` — that is the function's
literal body at `oracle_effect/mod.rs`, not a claim about it:
pub fn parse_effect_chain(text: &str, kind: AbilityKind) -> AbilityDefinition {
lower_ability_ir(&parse_ability_ir_standalone(text, kind))
}
So splitting the call into its two halves moves WHERE the lowering happens, not
WHAT it produces. This is the same algebraic identity every T8 R1 row used.
The `has_unimplemented` gate still reads a LOWERED def —
`has_unimplemented(&lower_ability_ir(&ir))` — the shape A3 used at U0-12 and T9a
used at U0-39. Whether to emit at all is control flow, and `has_unimplemented` is
defined over an `AbilityDefinition`, so the predicate must see the definition the
site actually emits. An IR-level `has_unimplemented` over `EffectChainIr` would
be a rival authority free to diverge from the one the rest of the parser uses.
Only safe on top of T9b: before the `Spell` payload carried `AbilityIr`, this
hoist would have dropped `finalize_effect_chain` and the owner-library reveal
anchor. Based on T9b's tip (#6724, not yet merged).
Scope: line 334 only. `oracle_class.rs:398` (the class-level trigger's `execute`)
is a different site and is untouched; the `make_unimplemented` residual beneath
this block is U0-62, which is blocked on a user ruling.
Burn-down: `oracle_class.rs` drops 5 -> 4 in scripts/prelowered-ratchet.txt.
`lower_as_enters_becomes_choice_modal` (CR 208.2b modal "As ~ enters, it becomes
your choice of <P/T profile>, ..." — Aquamorph Entity, Primal Clay, Primal
Plasma) stops pushing pre-lowered definitions into a scratch `ParsedAbilities`
and returns typed IR instead: N `StaticIr` + 1 `ReplacementIr`.
This introduces the heterogeneous `Vec<OracleNodeIr>` producer the recensus says
to introduce ONCE, deliberately (no such producer existed anywhere in the
parser). It is built as a general mechanism — `DocEmitter::emit_ir_nodes_at`
emits any ordered node sequence at one line — not as a special case for this
site.
Which drain caller this is: `drain_result_vectors` has two callers. oracle.rs
4304 is the Priority-1 modal block feeder (`parse_oracle_block` ->
`lower_oracle_block`), which is U0-18 and belongs to T10h; it is untouched. This
row is the OTHER one, the Priority-8 replacement-adjacent site, identified by
content: it is guarded by `is_as_enters_becomes_choice_pattern` and calls
`lower_as_enters_becomes_choice_modal`, and its in-code CR annotations (208.2b /
614.1c / 614.12a) are exactly the census row's citations for U0-40.
Byte-safe by construction. Converting these definitions to IR newly subjects
them to `lower_static_ir`'s two transforms and to `lower_replacement_ir`; all
three are provably inert here:
* `lower_replacement_ir` is `ir.definition.clone()` — identity, by definition.
* `populate_active_zones_from_condition` collects only `SourceInZone` (through
And/Or/Not); `StaticCondition::ChosenLabelIs` hits the `_ => {}` arm, so
`zones` stays empty and the `!zones.is_empty()` guard never fires.
* `bind_counter_anaphor_to_recipient` rewrites only
`QuantityRef::CountersOn { scope: Anaphoric }`. Anaphoric counter scopes are
produced by exactly one combinator, `parse_deferred_counter_pronoun` (via
`parse_for_each_counters_on_source`), and survive only through the two
`_deferred` entry points — every other path runs
`settle_deferred_counter_anaphor_ref`, which collapses Anaphoric -> Source at
parse time. Both non-test callers of the deferred entry are in
`oracle_static/anthem.rs`. These modifications come from
`animation_modifications_with_replacement` <- `parse_animation_spec`, whose
dynamic-P/T routes are `parse_quantity_ref_complete` and
`parse_dynamic_pt_clause` -> `nom_quantity::parse_quantity_ref`; neither
reaches a deferred entry point. The condition axis is inert for the same
reason as above: `bind_anaphoric_counters_in_condition` matches only
`UnlessPay`/And/Or/Not.
Emission order is preserved deliberately. `drain_result_vectors` emitted by
CATEGORY (abilities, triggers, statics, replacements) regardless of push order,
and `emit_at` stamps `ordinal_within_span` in emission order, so the caller emits
the CR 614.1e face-up residual first, then the statics, then the replacement —
which is NOT the order the recognizer constructs them in.
`emit_ir_nodes_at` dispatches through the typed `*_ir_at` helpers rather than
straight to `emit_at`, so the `last_static` peek mirror that `static_at`
maintains today keeps being maintained;
`parsed_result_recently_granted_flashback` reads it mid-loop.
The CR 614.1e residual stays a bare `AbilityDefinition` the caller emits via
`ability_at`: it is a fixed strict-failure marker with no parsed body, and
constructing a pre-lowered node inside `oracle_replacement.rs` would move a
producer into a file the burn-down ledger does not track. Giving it a real IR
node is U0-62's question, which is out of scope.
matthewevans
enabled auto-merge
July 28, 2026 11:07
Contributor
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughThe PR refactors as-enters choice modal lowering to return typed IR, adds ordered heterogeneous IR emission, and changes effect-like sentence parsing to emit ChangesTyped Oracle IR migration
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant oracle_replacement
participant DocEmitter
participant ParsedAbilities
oracle_replacement->>DocEmitter: return AsEntersChoiceModalIr
DocEmitter->>ParsedAbilities: emit residual ability and ordered IR nodes
DocEmitter->>DocEmitter: update last_static and last_trigger peeks
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Parse changes introduced by this PR✓ No card-parse changes detected. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Plan 05b — the two rows T9 unblocked: U0-61 (the Class effect-sentence recognizer) and U0-40
(the heterogeneous IR node mechanism). Follows #6724, which completed Unit 3b.
These were the only remaining rows needing no design decision. Everything else left in the plan —
U0-62 and all nine T10 sub-units — is blocked on rulings and is untouched here.
One commit per row.
U0-61 — byte-identical by construction, and the identity is the function body
parse_effect_chain(t, k)islower_ability_ir(&parse_ability_ir_standalone(t, k)). That is not adoc claim; it is the entire body at
oracle_effect/mod.rs:26761-26763:Splitting the call into its two halves moves where lowering happens, not what it produces. No
corpus argument is used or needed — this is the same algebraic identity every T8 R1 row relied on.
The recensus's warning was precisely calibrated: this could not land before the
Spellpayload carriedAbilityIr, because emittingSpell(EffectChainIr)would have lowered vialower_effect_chain_iranddropped
finalize_effect_chain+ the owner-library anchor — the exact defect #6722 spent a PR fixingat U0-39. #6724 removed the hazard.
has_unimplementedkeeps reading a lowered def (has_unimplemented(&lower_ability_ir(&ir))), theU0-12/U0-39 shape. No IR-level predicate was invented — a second authority over
EffectChainIrwouldbe free to diverge from the real one.
Scope held to line 334.
oracle_class.rs:398(the class-level triggerexecute) and themake_unimplementedfallback (U0-62, blocked on a ruling) are untouched.Ratchet LOWERED:
oracle_class.rs5 → 4, count now equal to ceiling.U0-40 — which
drain_result_vectorscaller, and how we knowdrain_result_vectorshas two callers and one of them is U0-18, which belongs to T10h and isblocked. Since line numbers in
oracle.rshave drifted ~+300 across this campaign, proximity to thecensus's cited line could not settle it. Four independent lines of evidence identify
oracle.rs:5630::4304sits insideif let Some((block, next_i)) = parse_oracle_block(..)and callslower_oracle_block, which is verbatim the U0-18 census row.:5630is guarded byis_as_enters_becomes_choice_pattern.:5642is "CR 208.2b + CR 614.1c + CR 614.12a", exactlythe U0-40 census row's citations.
:5630is +304 from the cited ~5326;:4304is +161 from U0-18's cited 4094, thecensus's own recorded drift for that row.
this recognizer's frame matches.
:4304is unmodified, anddrain_result_vectorsitself is unmodified — U0-18 still uses it unchanged.The inherited safety argument held, but one of its REASONS was wrong
U0-40's byte-safety analysis was carried over from T9b's executor and verified rather than
inherited. Three claims confirmed as stated; one had a wrong reason and was not shipped as written:
lower_replacement_iris identity — confirmed, literallyir.definition.clone().collect_source_in_zonesignoresChosenLabelIs— confirmed; it matches onlySourceInZone/And/Or/Not, sozonesstays empty and the!zones.is_empty()guard never fires.Corroborated in the artifact: every static of all four cards has
"active_zones":[].bind_counter_anaphor_to_recipientis inert — conclusion confirmed, reason corrected. Theinherited reason was "these modifications are fixed P/T (the recognizer returns false unless power
and toughness are both
Some)". That guard does not exclude dynamic P/T:parse_animation_spec's"where X is …" path sets
spec.dynamic_powerbefore the fixed-P/T parse and is not gated on it,and
split_animation_dynamic_pt_clausecan fire aftersplit_animation_base_pt_clausehas alreadyset fixed P/T, so both can coexist — and
SetPowerDynamic/SetToughnessDynamicare in theSome-arm. The real reason:
bind_anaphoric_countersrewrites onlyCountersOn { scope: Anaphoric }; that scope is produced by exactly one combinator (parse_deferred_counter_pronoun) andsurvives only through the two
_deferredentry points, whose only non-test callers are both inoracle_static/anthem.rs. The animation path reaches neither. Corroborated: all 43 modificationsacross the four cards are in the
Nonearm, with zeroDynamic|CountersOn|Anaphoricoccurrences.construction, matching
drain_result_vectorsdeliberately. See the caveat below.Full-pool measurement
Baseline re-measured on this branch's own base, not inherited.
sha256 c3f8431c494c1a9710e08fc15161eab75203a2dc98a5aa35e3f7a993ec43c245, 99,201,978 bytescmpbyte-identicalPopulation is real: 35,516 face-keyed entries, generated from the worktree repo root against the
main checkout's
AtomicCards.json(158 MB) symlinked in,MTGJSON_SKIP_REFRESH=1, generator rebuiltfresh at each commit. The fixture population is 519 KB and cannot produce those numbers. Coverage
percentages and all parse-warning counts are identical base vs tip, including the span-sensitive
swallowed-clause audit (963 warnings / 868 cards).
Liveness — the probe was watched go red, not assumed (§5.3: a probe that shows nothing is broken
until proven otherwise):
(&str, &mut ParsedAbilities) -> bool→(&str) -> Option<AsEntersChoiceModalIr>forced the call site.aquamorph entity has 2 statics / 1 replacement / 1 ability (the CR 614.1e face-up residual), so
the residual-first ordering branch is live, not dead. U0-61's single pool ability is
rogue class→CastFromZonewith asub_ability— i.e. it exercisesfinalize_effect_chain,the exact machinery refactor(parser): Plan 05b T9b — the payload swap, all nine spell producers at once #6724 was the prerequisite for.
lower_as_enters_becomes_choice_modalto returnNonemoved the filtered output hash
ec05c400…→2781bafd…; aquamorph entity went 2 statics/1 rep →0/0, primal plasma 3/1 → 0/0. The gate destroys exactly what the conversion produces. Probe
fully reverted.
Gates (all on the final commit, re-run after commit)
Zero snapshots changed — the diff is four source/ledger files only,
.snap.newcount 0. Statedhonestly: that is vacuous for these rows, since neither row's cards are in the
parse_two_layercorpus, so the snapshot gate supplied no signal here. The byte measurement and the disable probe are
the evidence.
CR gate: exactly two numbers added — CR 208.2b and CR 614.1e — zero unverified, text read rather
than existence checked.
Judgement call worth a look
The charter said U0-40 keeps "one residual
PreLoweredSpell… which the caller can emit directly."Putting it in the node vector made Gate P fail immediately — the gate counts the token textually
and
oracle_replacement.rshas no ledger entry, so a new pre-lowered producer in an untracked file isexactly what it exists to catch. The residual is therefore a bare
Option<AbilityDefinition>field onthe returned struct, emitted via the existing
ability_at. That follows the charter's own wording andkeeps the burn-down honest.
emit_ir_nodes_atdispatches through the typed*_ir_athelpers rather than rawemit_at, becausestatic_atmaintains thelast_staticpeek mirror thatparsed_result_recently_granted_flashbackreads mid-loop — raw emission would have silently stopped updating it.
Harvest (§5.4)
oracle_static/shared.rs—bind_counter_anaphor_to_recipient's doc saysparse_counter_object_scope"parks those pronouns onObjectScope::Anaphoric". False: thatcombinator has no
Anaphoricarm at all and yields onlySource/Target. The real producer isparse_deferred_counter_pronoun. The doc names the wrong combinator.result.modalcheck at the U0-40 site is incidental.lower_as_enters_becomes_choice_modalnever wroteresult.modal, andresultis shared across thedispatch loop, so the check can only ever observe a modal set by an earlier line. Preserved
verbatim for byte-identity, but it is dead-or-cross-line-leaky as written.
reaches it. Mercurial Transformation carries "becomes your choice of" but lacks the "As ~ enters"
frame and correctly does not.
abilities → statics → replacements ordering specifically — a cross-category reorder is largely
unobservable there, since within-category order is untouched and the replacement is a singleton. The
order is preserved by construction; the corroboration is that the span-sensitive swallowed-clause
counts are also unchanged.
oracle-gen --output X --filter "A|B"runs in seconds against thereal pool and diffs cleanly. Given a full-pool regen costs ~5 min plus ~7 GB of
tool-profileartifacts, future tranches should reach for it first.
Summary by CodeRabbit
New Features
Bug Fixes