Skip to content

feat(math): display math on one box engine (stage 2) - #9

Draft
oetiker wants to merge 61 commits into
mainfrom
feat/math-stage-2
Draft

feat(math): display math on one box engine (stage 2)#9
oetiker wants to merge 61 commits into
mainfrom
feat/math-stage-2

Conversation

@oetiker

@oetiker oetiker commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Stage 2 of LaTeX math: display math, and one engine for both modes.

$$\frac{-b \pm \sqrt{b^2-4ac}}{2a}$$ draws as four centred rows of box art, and
$E = mc^2$ still draws as E = mc² on the line — out of the same box tree. A box
with above == 0 && below == 0 is what "inline" means; there is no second walk.
src/math/inline.rs is deleted in Task 6.

Draft while the 15 tasks of
docs/superpowers/plans/2026-08-20-math-stage-2.md are implemented one at a time.
Not ready for review.

🤖 Generated with Claude Code

oetiker added 29 commits August 21, 2026 09:07
Fifteen tasks for display-math layout, built on one engine rather than
two. Spec section 4 says inline is the same box tree under the
constraint above == 0 && below == 0; stage 1 shipped it as a separate
string walk, so Task 6 re-points render_inline at the tree and deletes
src/math/inline.rs, with stage 1's 1407 tests as the gate.

Two departures from the spec, both stated in the Self-Review rather than
smuggled: spacing.rs is new, because stage 1's final review asked for a
(left class x right class) table after five spacing defects at the seams
between five individually-correct rules; and there is no atoms.rs,
because the script table already shipped as scripts.rs and what remained
is one function with one caller.

The Self-Review lists the four defects it caught in this plan, and names
the four things the plan is most likely to be wrong about.
Three numbers per box -- width, above, below -- and the arithmetic that
composes them. Pure geometry: no parsing, no drawing, no dependency on
pulldown-latex.

The inline constraint lives here as MathBox::is_inline, which is exactly
above == 0 && below == 0. Stage 1 had inline and display as two ideas;
from here they are one tree and one predicate over it.

Every addition saturates. A formula is attacker-supplied text and this
module may not panic.

Two corrections to the plan's literal code, both against spec section 4
line 122, "the script's lowest row sits one above the base's baseline":
a three-row superscript occupies rows +1..+3, so above is its height, 3,
not 4. The plan asserted 4 while its own sibling test asserted height for
the one-row case. The doc comment claiming a further row of clearance was
wrong for the same reason.
The first 15 tests were all ASCII, all short-based and all symmetric, so
several wrong implementations passed them green. Nine tests close that.

Measurement: text() is now pinned against both len() and chars().count()
via a CJK pair and a combining cluster, so the one load-bearing call to
crate::text cannot be swapped for either.

Geometry: scripts() gains the two cases where the BASE is the taller
side, which nothing exercised -- dropping base.above entirely used to
pass. radical() gains its index, whose overhang line was dead in the
suite.

Structure: no test asserted what any constructor STORED, so fraction
could swap num and den, fenced could swap left and right, and every
number stayed identical. Each is now pinned on content, not on size.

Every one of these was confirmed RED against the mutation it names and
GREEN after reverting it.

Also folded in: fenced() states its width as one per-side cost per
delimiter present rather than a width plus a padding that happened to
equal it, and its last plain + becomes saturating_add so the file's
panic-freedom claim needs no hand-checked exception. radical()'s doc
comment now records the owner's ruling that the index takes the single
free column over the stroke, which is what the code already computes.
The previous commit restated fenced()'s width as one per-side cost per
delimiter present. That rewrote the expression computing the one-sided
case while no test covered it, so the line was carried on hand-checked
arithmetic alone. Three assertions replace that: an inline one-sided
fence, a tall one-sided fence, and the bare \left. ... \right. that has
no delimiters at all and must still cost nothing and stay inline.

Worth recording for whoever changes this expression next: the tall
TWO-sided case has no power over it. With two sides at two columns each,
sides * per_side and sides + per_side are both 4, so the case that comes
to mind first is exactly the fixed point where the two disagree least.
Turning the multiply into an add is caught only by the one-sided and the
inline cases -- both added here.
Four defects found while executing task 1 and by a pre-flight scan of the
whole plan. Each is a value the plan asserts, not a property it omits --
the class of defect that survives a green suite by pinning the wrong
answer.

Task 1: a tall superscript is 3 rows above the baseline, not 4. Spec line
122 puts the script's LOWEST row one above the baseline, so a three-row
script occupies +1..+3. The plan's own one-row sibling test only agrees
with that reading, and the spec's worked example breaks under the other.

Task 7: the indexed root drew rows of 3 and 4 columns, which
check_invariants rejects, and the leading space contradicted the task's
own stroke expression. The tall radicand's art is left UNRULED with a
stop sign, because spec section 6.2's illustration is ambiguous and
bending correct drawing code to a wrong string is how the last stage
shipped a wrong answer.

Task 8: the tall fence dropped the enclosed fraction's own characters,
which the task 4 drawing code always draws.

Task 11: Canvas::indent takes three arguments, not two, and returns a
canvas of width + left + right -- so padding one side would break the
invariant that every renderer returns exactly width columns. Verified at
src/canvas/ops.rs:544 rather than from the doc comment the plan cited.

Also records that measure_block does have a Theme in scope by way of
Ctx, which resolves an open design question the plan flagged; that a new
module lands dead under -D warnings and needs allow(dead_code) until its
caller arrives; and that task 14's preamble does not reach two live call
sites, one of which bypasses the bridge entirely.
Stage 1 answered "what goes between these two pieces" with five rules
that each read correctly alone. The joins between them were five
defects: -x, \sin x, 2\sin^2 x, 2{\sin x}^2 and 2{ab}^2. A table has no
joins.

The script-operand exception stays out of the table deliberately. It is
a property of where the operand sits, which only the builder knows, so
the builder multiplies the gap by zero. Two independent decisions rather
than one flag threaded through both.

The literal table in the plan was non-exhaustive: (Ordinary, Unary) and
(Close, Unary) were unmatched, which the compiler caught as E0004 rather
than a silent default. Neither pairing can arise from a correct
classification (a `-` with an Ordinary or Close to its left is a Binary,
not a Unary), but the match is over Class x Class and must be total, so
both cells are filled in explicitly at 0 -- the same "touches" default
as the rest of the Ordinary/Close row -- with a comment recording why.
This also removed a redundant `Close` on the right of the final arm,
which rustc flagged as an unreachable pattern.
group is the only helper task 3 gives as prose rather than code, and task
8 step 4 edits its internals -- "replace the Grouping::LeftRight handling
in group" assumes a match whose arm can be addressed on its own. An
implementer free-handing the body could reasonably write two if-lets and
leave task 8's instructions applying to nothing.

Records the shape task 8 requires and the signature its one call site
already fixes, and says to stop and report rather than deviate quietly.
The body itself is still the implementer's to write.
Review round 1 found eight cells decided purely by match-arm order,
including a Critical (Function/Large before an opening delimiter) and
an Important (a leading Unary sign's row overriding "a relation is
always spaced"). An overlapping match is the same defect class stage 1
shipped, one level down: whichever arm came first silently won at a
cell two arms both claimed.

Replace it with a literal 10x10 grid. Class::index is an exhaustive
match with no wildcard arm, so adding an eleventh class fails to
compile rather than silently landing on the wrong row. The rows are
named for what they mean (touches, parts-from-its-operand,
always-spaced, borders-nothing, spaced-except-at-the-edge) and reused
by identity where the ruling says two classes must answer alike: Unary
uses the same TOUCHES_ROW constant as Ordinary, not a hand-kept copy of
it, per the TeXbook's Bin-to-Ord reclassification.

Also settles two degenerate cells nothing else speaks to -- (Punct,
Punct) and (Punct, Close) both take a space, following TeX -- and
keeps (Relation, Relation) spaced as a deliberate departure from TeX,
per the owner's unconditional ruling.

Replaces the old totality test, which only checked gap() <= 1 and was
vacuously true by inspection of every arm's return value, with an insta
snapshot of the whole grid: any single-cell change anywhere now shows
up as a labelled diff in review.
Task 3 asserted -\sin x as "-sin x" and called it "the stage-1 defect,
now a table cell". It is not one of the five stage-1 defects. Stage 1
classified "- sin x" as correct -- merely loose -- and pinned it at
src/math/tests.rs:321 precisely so stage 2 would change a test rather
than drift. The tight value rested on that mislabel.

Task 2's table gives the Unary row the Ordinary row, following TeX's
reclassification of a leading sign to Ord, so a sign before an operator
name is spaced while -x stays tight. That preserves what the pager
already ships rather than changing it on a wrong premise.
The previous commit made Unary's row an alias of Ordinary's, on the
theory that TeXbook's Bin-to-Ord reclassification settles it. That
overturned a decision the plan made deliberately and pre-authorised:
Task 3's own brief asserts inline(r"-\sin x") == "-sin x" (tight), and
src/math/tests.rs:321's stage-1 test is pinned specifically so this
task's rewrite would change that expectation on purpose. Aliasing the
whole row would have made that assertion wrong before Task 3 even
started.

What was actually broken is narrower: (Unary, Relation) fell out to 0
under the old blanket (Unary, _) => 0 rule, contradicting the owner's
unconditional "one space either side of a relation always". That
contradiction stands, and (Unary, Binary) has the same shape under the
same ruling.

UNARY_ROW is now its own row: zero everywhere -- a sign with no left
operand binds tight to what follows, including a function name or a
large operator -- except before a Relation or a Binary operator, where
the owner's ruling overrides the "binds tight" pattern. Both exception
cells are reachable only on degenerate input (`-=`, `-+`); correct if
reached, not because a real formula hits them.
Reverts the previous commit's change to task 3's expectation.

-\sin x rendering "- sin x" is one of the two carried stage-1 defects
this plan sets out to fix, named as such in task 5 and recorded in the
stage-1 handoff. The pinned test's own comment gives the cause: spaced
suppresses both sides of a leading operator while spaced_word suppresses
only its own leading side, and neither knows about the other. That is a
seam, and the tight value is the fix.

The previous commit argued the tight value rested on a mislabel, because
a reviewer correctly noted that -\sin x is not one of the FIVE seam
cases. It is not -- but the plan never said it was. The five and the two
carried defects are different lists, and conflating them cost two
reversals on one table cell.

The committed table already has the correct row.
Two doc comments cited src/math/tests.rs's
a_function_name_parts_from_its_argument as pinning -\sin x -> "-sin x".
That name exists nowhere in src/ -- it is Task 3's planned name from
the plan document, not a test that runs today.

The test that actually pins this cell right now is
a_leading_unary_minus_before_a_function_name_keeps_a_space
(src/math/tests.rs:321), and it asserts the opposite value on purpose,
until Task 5 renames it and flips the expectation. Cite that instead,
and say plainly that it is pinned loose until then, so a reader who
follows the citation lands on an assertion that agrees with what they
were told to expect.

Also: restore the two messageless assertions the Unary-row correction
dropped, and state UNARY_ROW's precedence claim precisely -- the
owner's relation ruling wins over a wildcard here, but loses to the
delimiter-hugging rule at a delimiter boundary ((Open, Relation) and
(Relation, Close) are both 0). No table value changes.
The note added during task 1 said task 3 removes boxes.rs's allow and
task 4 removes spacing.rs's. Nobody measured that, and it is probably
wrong: dead_code analysis is transitive, so build.rs calling
boxes::fraction does not make boxes live while build.rs itself has no
live caller. The allows may have to survive until task 6 wires
render_inline to the new engine.

Replaced with an instruction to let the compiler decide at each task and
correct the paragraph with what it says. An unmeasured claim in a plan is
what this stage keeps tripping over.
build.rs turns an event stream into the box tree of spec section 4. Mode
is consulted here and nowhere else: inline is the constraint above == 0
&& below == 0 over the same tree, not a second walk.

Flat formulas only. Fractions, radicals, scripts, big operators,
delimiters and environments each return NotInline by name, and each is
replaced by its own task.

The unary pass runs before any gap is computed. Whether a sign has a
left operand is a fact about the sequence; what goes between two classes
is a fact about the pair. Keeping them apart is what removes the seam
that produced five defects in stage 1.

Three tests beyond the plan's, each pinning something that passed both
ways without it. The plan's two-character-relation test used \not=,
which is a Visual(Negation) plus a single-char = relation, not a
two-char one; it now uses \approxcolon, and \not= gets its own test,
because a walk that dropped Visual would set a = b for a \not= b.
LeftRight refuses by name rather than losing the delimiters it carries
in its own fields. And a suppressed run is asserted directly, since no
caller reaches it until scripts land and the multiply-by-zero would
otherwise be deletable in silence.

build.rs carries its own allow(dead_code): measured, not assumed. With
it removed the compiler calls all eleven items here dead, because
dead_code is transitive and this module has no live caller yet -- which
is also why boxes.rs and spacing.rs keep theirs. All three come out
together when the renderer calls in.
Every one is a claim the plan makes about code, and every one was found
by reading the source rather than the plan.

- Grouping::LeftRight is owned by task 8, the tall-delimiter task, not
  task 10, which wires the renderer and touches nothing here.
- The unary pass condition is TeX's bin-to-ord list, and the plan's prose
  named four of the six cases while its own code named five. Written out
  in full, with Function and Large added -- see the following commit for
  why that is a behaviour change and not just prose.
- A relation of two characters is not the one the plan cited: U+2260 and
  U+2A7D are single chars. The real sixteen are the multirelation calls
  at primitives.rs:1157-1172.
- Event has eight variants, not seven. The list beneath the count was
  already right; a reader checks an exhaustive match against the count.
- event.rs:315 -> :316, inline.rs:464-470 -> :469-473.
- Two steps expected a literal "1407 passed". That was stale by task 3
  and a literal total cannot survive a task that adds tests.
The unary pass had five of TeX's six conditions. TeX reclassifies a Bin
atom as Ord when it is first in the list or follows Bin, Op, Rel, Open
or Punct; Op is one atom class there and two here, Function and Large,
and both were missing.

What it cost: \sin -x classified the sign as Binary and set sin - x,
which reads as \sin minus x rather than as the sine of -x. \sum -x the
same. Both now bind the sign to its own operand.

The doc comment now names the rule and its source rather than listing
the classes. Five classes with no citation read as arbitrary and invite
a sixth to be added ad hoc, which is how stage 1's spacing grew the
seams this table exists to remove. The list is closed: a case that
seems to want another entry is a case where the class is wrong.

spacing.rs trades its module-level allow(dead_code) for a targeted
cfg_attr on Class::ALL. Measured: with build.rs in the tree, gap, Class
and all ten variants are live and ALL is the only item that is not, so
the blanket was broad enough to hide the next dead item. Removing the
targeted one gives exactly one error, so it is not stale either.
Both TeXbook references named a chapter number nobody on this branch
could verify. The rules themselves are well established and are what
the code is checked against; the locations were recalled, not read.

Every defect found in this module so far has been a confidently-stated
wrong value, twice in a comment that read as obviously correct. An
unverifiable chapter number is that same shape, so it goes. The
attribution stays: an unverified location is not an unknown source, and
a reader who knows the book will find both rules without a number.

build.rs's assemble carries the note, including the invitation to add
the reference back for anyone who has the book to hand. spacing.rs
points at it rather than repeating it.

No test moves; this is comment text only.
The source stopped citing a TeXbook chapter because neither the
implementer nor I could check the number, and a number that might be
wrong is worse than none. This line is the plan's own statement of the
same rule and carried the same citation.

Leaving it would have been worse than never removing any of them: the
next implementer reads "ch. 18" here, finds no chapter in the code, and
reasonably concludes the code lost a citation it should have had.

Caught by the task 3 implementer, who declined to edit the plan file
itself and said so.
The placeholder comment in task 4's drawer said "Task 8 and Task 10 fill
these in". Task 7 fills Radical and task 8 fills Fenced; task 10 wires
the renderer and never opens this file.

This one ships. The comment is inside task 4's implementation block, so
it lands in src/math/draw.rs and a reader grepping there for "Task 10"
finds nothing at all. Found by the pre-flight scan of the whole plan,
which was dispatched in parallel with task 1.
The doc said the left-context list was the TeXbook's rule, complete. It
was one half. An operator is only an operator with an operand on each
side, and the second half says so from the other direction: a Bin with
nothing on its right -- immediately before Rel, Close or Punct --
becomes Ord too. (a+) set (a +) and a+,b set a +, b.

Written as a right-neighbour test rather than TeX's look-back from the
next atom, so that Edge covers the end of the list the way it already
covers the start. a+ now sets a+, the mirror of -x. The demotion is to
Ordinary, which is what TeX says; Unary would render identically here
but it is documented as a sign with no LEFT operand, and this one has
one.

The word complete is why this is worth fixing rather than qualifying.
The same block tells later tasks the rule is closed and that a case
wanting another entry is a class bug. A wrong value in a comment
written to be obeyed costs more than the cells it describes.

Three holes found beside it, none of them cosmetic:

An explicit space from the source went straight to the row without
consulting the spacing policy, so it survived inside a script operand.
That is the one guarantee the policy exists to give, and it was true
only of table gaps. x^{a\,b} would have fallen back to a source dump.

A NEGATIVE space rendered as a positive one. \! and its relatives are
Space { width: Some(-3/18 em) } and width.is_some() is true for them, so
a\!b drew wider than ab -- the opposite of what the author asked for. A
terminal cannot set a negative width, so the answer is no column.
\int\!\!\!\int keeps one space, but it is the table's (Large, Large)
cell and not the \!s, which is what its test asserts by comparison
against \int\int rather than by a literal.

build_run and group recursed without a bound. Measured here: 500 levels
build, 5000 abort with SIGABRT -- not a panic, uncatchable, and so worse
than the panic spec section 9 forbids. The parser is iterative and hands
back all 10001 events happily, so this recursion was the only thing
between a formula and the process. Capped at 64 and refused by name
through NotInline, which already routes both modes to the framed source.

Six tests added. Four arms that had no test at all now have one: a
script, a state change, every Visual name and every Grouping name. Each
was a silent catastrophe -- skipping Script drew x^2 as x2, dropping
StateChange's increment hung the walk, and the name arms could all be
deleted for the catch-all with everything still green.
Both comments cite it as the example of a RelationContent holding two
chars. It holds one: single_char('∥') at primitives.rs:1066. The real
ones are the sixteen multirelation calls at primitives.rs:1157-1172,
six of them a base character plus U+FE00.

tests.rs named shortparallel and then asserted \coloneq on the next
line, so the example contradicted the input it was explaining.

The identical defect was corrected in build.rs's copy of this comment
when it was written; this closes the original rather than leaving the
copy right and the source wrong. Comments only, no test moves.
The depth test asserted the refusal at 5000 levels, which is why
removing the guard took the whole test binary down with SIGABRT: the
test itself recursed 5000 deep. Asserting first at MAX_NESTING + 1
means 65 levels recurse fine without the guard, build returns Ok, and
expect_err fails by name in microseconds. The deep case still runs
second and still documents the hazard, reached only when the guard is
there to stop the first assertion panicking.

Same coverage, a diagnosis instead of a corpse. It also converts group
passing depth instead of depth + 1 from an abort into a named failure,
which the earlier shape could not distinguish from any other overflow.

Written against MAX_NESTING rather than a literal, so it follows the
cap. The cap itself now carries a compile-time assertion that it stays
under 256: the behavioural tests hold for anything from 33 to 4999, so
a cap raised to 4096 passed both while sitting inside the unmeasured
band between 500-builds and 5000-aborts. The property is what matters,
not the number, so the property is what is pinned.

Three more holes, each found by asking what a test would still pass
with:

A zero-width kern was untested, so > 0.0 could become >= 0.0 with
everything green. \kern takes an arbitrary dimension and zero asked for
nothing.

Condition 2 firing at a brace boundary was true but unasserted. Edge
bounds a run rather than the formula, so {a+}b sets a+b -- which is
where an over-eager reading of the rule would show first, and is the
check that raised the trailing-Bin case above a guess. The doc now says
that case is held at high confidence and not certainty, and names what
would reverse it.

The Ordinary-not-Unary choice in condition 2 was defended by a comment
in this file, which nobody editing UNARY_ROW will ever read. spacing.rs
now carries the canary itself: if any row starts distinguishing the two
columns, the test that fails is the one next to the table, and it says
to come and read this doc rather than to update the grid.

The module doc claimed the script-operand exception was applied by
multiplying by zero. That names one of its two arms and left the arm
that had a hole in it sounding covered.
Task 3 caps nesting because build_run -> group -> build_run overflows
the stack on a few thousand braces, and a stack overflow aborts the
process uncatchably. The counter is incremented in exactly one place
today.

Tasks 5, 7, 8 and 11 each add a recursive arm and nothing makes them
thread it. An arm that forgets reopens the hole for its own construct
only, and the existing test nests braces, so it cannot catch a forgetful
Script arm. Says so, and says to add a nesting case per new arm.

Raised by the task 3 reviewer as a forward risk rather than a finding.
assemble's doc makes two claims and unobservability needs both. The
canary checked that Ordinary and Unary are identical as a right operand
and not that they are identical as a left operand where condition 2 can
put them, while its name promised both.

The asymmetry is the point. The first half iterates every class; the
second cannot, because the two rows genuinely differ at Function and
Large -- an operator name parts from an Ordinary and hugs a Unary. That
is harmless only because condition 2 fires solely before Edge,
Relation, Close or Punct, so the second list is the rule's own reach
and has to move with the right-context set in assemble. Iterating
everything there would assert something false.

Mutating UNARY_ROW's Relation or Punct cell now fails the canary by
name. Both change a row rather than a column, so the original loop
never sees them: the two halves are independent rather than
overlapping, which is what makes adding the second one worth anything.
The builder makes a source space an ordinary piece, so "\,x" builds
" x", and the "{}-x" idiom builds " - x" because an empty group is a
zero-width ordinary and the sign therefore has a left operand.

Both are faithful to TeX and neither is a task 3 defect, but task 6 is
where render_inline is re-pointed at this engine, so it is where the
question lands. Written into the task so it is ruled on rather than
found by a reader.

Raised by the task 3 reviewer as an out-of-scope observation.
Two outputs from one tree: a string for the inline path, a canvas for
the display path. to_row refuses a box that is not one row, so the
constraint of spec section 4 cannot be forgotten at a call site.

Placement is one recursive walk carrying a baseline row and a left
column. Where the formula goes on the page is not decided here -- that
is spec section 7 and needs the measure, which this module cannot see.

Both walks carry a depth and stop at MAX_DEPTH. build.rs caps source
nesting, but a box tree is deeper than the source that made it and
these entry points take a MathBox from anywhere. Measured on a debug
build: 3200 levels of nested fraction draw, 3400 abort the process on
a stack overflow, which no Result can catch.

Radicals and fences reserve their space but draw nothing yet. A gap is
an honest placeholder; misplaced neighbours would not be.
`theme.body` does not exist and never did: `body` belongs to `TextStyles`,
reached as `theme.text.body` and exposed as `Theme::base()`. Task 4's
implementer found this and used `theme.base()`, but only in its own file.
The plan still said `theme.body` in ten places, seven of them in unstarted
tasks, so Tasks 7, 8 and 13 would each have shipped code that does not build
and Task 13 was told to replace an identifier that is not there.

Task 4 also added a recursion bound the plan never asked for. `place` and
`write_flat` now take a `depth` and every arm passes `deeper`. The plan's
Task 7 and Task 8 call `place` with the old five arguments; appending
`depth` instead of `deeper` compiles and silently reopens the unbounded
walk the cap exists to close, so both blocks are corrected and the shipped
signatures are written down where an arm author will read them.

The same two blocks carried the `as` casts and bare arithmetic Task 4 was
corrected away from, and a `while` loop whose manual bump cannot terminate
at i32::MAX. Saturating throughout, and a `for` over an inclusive range,
because an arm that forgets to advance is what hung Task 4 before.

Two more the reviewer caught: the `to_canvas` interface bullet said the
canvas is exactly `width` columns, contradicting the Decision four lines
below it, which a Task 12 reader would have taken at face value; and the
`write_flat` catch-all asserted that no other variant can be zero-height,
which is false for `Scripts` and `Limits` with both operands `None` and
would have taught Task 6 to keep dropping them.

The radical's box art stays unruled. None of this decides geometry.
`write_flat` and `place` are separate matches over the same enum with a
doc comment promising they agree. Nothing compared them, and they did not:
a `Scripts` or `Limits` with both operands `None` is zero-height, so
`is_inline` lets it through, and the flat walk dropped its base while the
canvas walk drew it. The catch-all comment asserted that no other variant
can be zero-height, which is false for exactly those two and was an
instruction to Task 6 to keep dropping them. The flat walk now recurses
into the base as `place` does, and the comment says what is actually true:
a fraction's `above` is the numerator's height and a radical's is its
radicand's plus the overline row, so those two are the ones that can never
be inline. Checked against `boxes.rs`, not assumed, and pinned by a test.

The agreement itself is now a test over every shape of inline box the
drawer can be handed. It pins the padding to zero -- `display_width(flat)`
must equal the box's width -- rather than trimming: a later arm that
reserves columns it does not write is then a failure that names the
shortfall, where a `trim_end()` would have swallowed it together with any
real trailing-space difference in the drawn row.

The fence is the one shape allowed to disagree, and it gets its own test
saying so. A one-row fence draws `(x)` flat and three blanks on the canvas
because Task 8 has not written the arm; `build.rs` still refuses
\left(...\right) by name, so no shipped state renders a blank fence. The
reason is in the assertion message rather than only in a comment, and it
tells Task 8 to delete the test and move the fence into the agreement
test. That failure is Task 8's acceptance criterion.

`to_row`'s refusal was pinned only at one row and three. The mutation
`!b.is_inline()` -> `b.height() > 2` survived all 1086 lib tests: a
superscript is two rows, and under the mutant it would be accepted and
return its base alone -- a formula that loses its exponent silently
instead of raising NotInline. That is the only guarantee Task 6's
`render_inline` has to keep a display construct off the prose row, so the
two-row case joins the existing test and the mutation now fails it.

Also the one-line reason for `usize::try_from(baseline)`, which reads as
defensive noise without it: a negative baseline is a row above the canvas,
reachable only after a saturation upstream, and skipping the draw clips it
the way the canvas would.

Deliberately not here: a fence or a radical inside a row. That geometry is
correct today and untested, and it lands beside the arm it protects.
… state

Task 8 now has a red test waiting for it. The Task 4 fix round recorded
the fence gap as a canary asserting that `place` draws three blanks where
`write_flat` draws "(x)", so that closing the arm fails a test instead of
closing a hole nobody watches. The assertion message names Task 8 and says
what to do, but the plan said nothing, and the cheap reading -- delete the
test, ship the arm -- drops the fence out of the agreement check that the
ONE ENGINE ruling rests on. Step 6 now asks for both halves: delete the
canary AND move the fence into the agreement test.

Task 7's obligation is the opposite one and just as easy to get wrong. A
radical is never zero-height, so there is no flat-walk arm to write, and an
implementer who assumes symmetry with the fence will hunt for one. Said so
in as many words, with the test that pins it.

Both tasks also carry the review's D5, which was deferred to land beside the
arm it protects rather than beside the canary: no test puts an undrawn box
inside a row, so a mutation that stops advancing the column across one
survives the whole suite. Whichever task lands first takes it.
@oetiker
oetiker force-pushed the feat/math-stage-2 branch from f93360f to a953f62 Compare August 21, 2026 07:12
oetiker and others added 30 commits August 21, 2026 12:20
Design spec §5.1 says a script group is substituted only when every
character in it has a Unicode form, and that otherwise *that group*
is written flat with `^` or `_`. The engine turned the declining
substitution into `MathError::NotInline` instead, so `x_b` took §9's
fallback and the reader was shown `$x_b$`, dollars and all, where
stage 1 has always drawn `x_b`. The rule is all-or-nothing per group,
never per formula: `x_i^q` lowers the subscript and writes only the
superscript flat.

The fallback now has one home. `raised`, `lowered` and `flat` move out
of stage 1's `inline.rs` into `scripts.rs`, where §5.1 already lives,
and both walks reach the rule through them -- a second copy in
`build.rs` is the drift this workstream exists to remove. Behaviour of
the moved code is unchanged.

Three tests changed with it. The renamed
`a_script_group_with_no_form_is_named_by_which_side_it_is_on` pinned
the refusal as the correct answer for `x^{2q}`, `x_b` and `x_b^q`,
which is how the defect stayed invisible; it now pins the spec's own
examples, the two cases that say *per group* rather than per formula
or per character, and the refusal that genuinely survives -- a script
operand that cannot be built at all. `x^\frac{a}{b}`, `x^\,` and
`x^\bf` likewise draw now instead of refusing.

Two more class defects, each with a mutation that survived the sweep:

* A fraction and a radical are `Ordinary`, and nothing said so. Every
  test placed them where several classes give the same cells, so
  `Class::Ordinary` -> `Class::Binary` survived at both sites.
  `x\frac{a}{b}y` and `x\sqrt{y}z` frame them between two neighbours
  that discriminate, and each mutation now fails there.

* A positive-width source space was pushed as a classed `Ordinary`
  piece, which is what the arm's own comment argues against -- it
  applied to zero-width spaces only because the code did. `\,-x` drew
  `  − x`, giving the sign a left operand that TeX skips over. A
  source space is glue: it carries cells and no class, and `assemble`
  reads through it on both sides of a `Binary`. `{}-x` is unchanged,
  because an empty group *is* an Ord atom in TeX.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 8's builder half becomes Task 8a and runs before Task 6; the
drawing half stays where Task 8 was, as Task 8b. The order is now
1-5, 8a, 6, 7, 8b, 9-15, and it is stated where a reader hits it.

Without the split, eight shipped assertions in src/math/tests.rs turn
into Err(NotInline("a delimited group")) the moment Task 6 points
render_inline at the engine, and stay that way for two tasks. The
builder half depends on nothing that Task 1 and Task 4 have not already
shipped and tested -- boxes::fenced, write_flat's Fenced arm, and
is_one_atom already treating Fenced as one atom -- and delim.rs is a
drawing dependency, never a builder one. So it is one match arm and one
test. Tasks 9-15 keep their numbers because the ledger cites them.

The arm snippet Task 8 carried does not compile: three arguments to a
four-argument build_run, and slice indexing where the sibling Normal
arm uses events.get(..).unwrap_or_default(). Rewritten against the
shipped code. 8a's gate is the build.rs test that pins the refusal
today, rewritten to pin all eight strings through the engine, because
the tests.rs assertions still measure stage 1's walk at that point and
cannot see the change at all.

Task 6's Step 4 table named five stage-1 tests. Five was not the set:
eight tests, twelve assertions, each now carrying its category. The
triage framework gains the fourth category the sequencing analysis
asked for -- a temporary loss with a named owner -- including the guard
that an unowned loss is a regression and not a canary. No row falls in
it, which is what moving 8a was for.

Two groups are written in as off the list. The eight \left...\right
assertions are 8a's. The seven script-fallback assertions were a
permanent regression against design spec section 5.1 when the analysis
was written and were fixed in 4b7c324; all seven are pinned engine-side
in build.rs with the values tests.rs asserts, and math:: is green at
137. A reader who finds either group red should stop, not triage.

Findings 7 and 8 of the Task 5 review land with it: the five extra
tests are placed, and the piece rule now names tests.rs:226 and :227,
which do pin the divergence the Task 5 report called unpinned. The
deferred minors F5 and F6 become explicit Task 6 obligations.

Two citations went stale in 4b7c324 and are corrected: scripts::
superscript is private now, so Task 6's indexed root cannot reach it as
written, and raised is the wrong substitute because it never declines.
Grouping::LeftRight stops refusing and builds boxes::fenced instead.
Task 1 wrote that constructor and Task 4 wrote write_flat's arm for it,
so the one-row form draws today; delim.rs and place's arm are the
drawing half and stay where Task 8 is.

Without this the swap in the next task loses eight shipped assertions
to a framed-source fallback for two tasks, and the suite would have to
pin the worse answer and pin it back again.

The test that pinned the refusal becomes the gate: the same eight
strings as src/math/tests.rs:373-392, measured against the engine
rather than stage 1's walk. They are drawn through draw::to_row and
not the test module's flatten, which is a structural assertion that
panics on a Fenced box and would have to grow a second copy of
write_flat's delimiter rule to accept one.

A fence also counts toward the nesting cap, which the shipped cap test
cannot show: it nests braces, and those go through the other arm.
`is_one_atom` answered `true` for any `Fenced` box regardless of its delimiter
fields, so a fence that draws no brackets -- `\left.`, or a one-sided fence --
was still treated as if it did. Five shipped renderings would have regressed at
Task 6's swap, and none of them is in `src/math/tests.rs`, so nothing went red:
`\frac{\left.a+b\right.}{c}` set `a + b/c` for `(a + b)/c`, and `\sqrt{\left.a+b\right.}`
set `√a + b` for `√(a + b)`.

A fence answers on its fields now: two delimiters and it brackets whatever it
encloses, otherwise the body is naked at that end and the atom count is the
body's. Not the blunter "bracket any non-`Row`", which would take
`\frac{\left(a+b\right)}{c}` back to stage 1's doubled `((a + b))/c`; the new
test pins that case too, so the improvement cannot be lost by a later
simplification.

One deliberate new diff against stage 1: `\left.a+b\right.^2` now sets `(a + b)²`
where stage 1 sets `a + b²`. That was a defect both engines carried; the same
§5.2 reason repairs it, and the test names it so Task 6's triage sees it.

Also pins the group's class. `a + \left(b\right)` cannot show it -- `gap(Binary,
Open)` and `gap(Binary, Ordinary)` are both 1 -- yet the comment claimed it did.
A relation is the neighbour that separates them, so `\left(x\right) = y` holds
the group at `Ordinary`; classed `Open` it sets `(x)= y`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
F5 told Task 6 to bracket any non-Row box. That is the wrong fix: it
sets \frac{\left(a+b\right)}{c} as ((a + b))/c, which is stage 1's
answer and worse than the engine's (a + b)/c. The rule that shipped in
230f00b -- a fence is one atom only where it actually brackets its body
-- is test-protected, and F5 now says so and keeps only the plain-Text
remainder as outstanding work.

Task 8a's dependency list certified the same _ => true arm that F5
condemned. One page certifying what another condemns is how the defect
survived authorship, so that entry is qualified to the two-sided case
and cross-references F5.

The piped-gate idiom is swept from all fifty sites: a pipe throws away
the failures above tail's window and masks cargo's exit status. The
Execution constraints now cover every gate rather than only the full
suite run.

Also in Task 8a, each verified against the tree first: Step 2's test
used inline(), whose flatten panics on a Fenced box, so it could not
run; its class claim was undetectable, since gap(Binary, Open) and
gap(Binary, Ordinary) are both 1, and a relation on the right is what
discriminates; "one changed test" was an undercount, because dropping
depth + 1 survives all eight assertions; and Step 1's snippet is
reflowed to the form rustfmt accepts.

Task 6's triage gains four diffs no stage-1 test pins, stage-1 values
measured through render_inline and engine values pinned or derived
from the spacing table.
src/math/inline.rs is deleted. render_inline is four lines over
build + draw, and inline is what design spec section 4 says it is:
the same tree under the constraint above == 0 && below == 0.

Stage 1's suite is the gate and it stays green. Eight of its tests
change; each value is argued in the test's own comment, and five are
renamed because they were named for defects in a walk that no longer
exists. No expectation was widened and no test was deleted.

The diffs were found by enumerating constructs, not by sweeping the
test file: 83 constructs by 29 frames, both engines run side by side,
784 differing formulas, all classified. Eight causes the triage table
did not list, of which the sharpest is that stage 1 drew a matrix used
as a script base as its contents -- silently wrong output no test on
either side covered.

The indexed root draws again: \sqrt[3]{x} is back to the shipped and
tested ³√x. Where the index has no raised form the root refuses, because
there is no caret notation for a root index and stage 1's ^α√x was
nonsense rather than a plainer rendering.

Two deferrals are ruled. F5: a Text box is one atom unless the cells it
draws contain a space, so \frac{\text{if x}}{b} stops setting if x/b --
spec 5.2's own misreading example -- while 12/5, sin/c and x²/c stay
bare. F6 falls out of the same rule with no branch of its own. And
render_inline returns the row as built and trims nothing, so \,x keeps
its column: trimming would decide spacing outside spacing.rs and split
the one engine in two again.
RULED 2026-08-21 by the owner. `{x^2}^2` set `x²²`, which reads as *x to the
twenty-second*, and `\sqrt{x^2}` set `√x²`, which hides where the root ends.
Both draw wrong mathematics on the reader's terminal; a redundant pair of
brackets never does. So `is_one_atom`'s `Text` arm now answers "no" when the
last character is a raised form, and the two set `(x²)²` and `√(x²)` again.

The rule is keyed on the superscript table alone and on the last character
only, which is what keeps `{x_i}^2` at `xᵢ²` and so consistent with the
unbraced `x_i^2`: nothing can be read into a lowered character from the right.
The predicate is `scripts::is_raised_form`, in the module that owns the table,
rather than the table itself made visible -- a survey of drawn output character
by character is what `scripts.rs`'s header exists to forbid.

The price, measured with `--no-fail-fast` before any expected string was
touched: exactly two assertions, both a raised fraction operand.
`\frac{x^2}{c}` is now `(x²)/c` and `\frac{\sqrt{a}}{b^2}` is `(√a)/(b²)`.
Both carry a comment naming the ruling and its date.

All four ruled renderings are pinned beside the `\frac{x_i}{c}` line that pins
the same rule, the two controls included. They were asserted nowhere before,
which is how an owner-facing rendering the report escalated for a ruling stayed
free to move in either direction with the suite green.

Also, two things this commit is not about but that were unnamed or wrong:

- An unbraced `\left…\right` operand keeps its delimiters -- `x^\left(x\right)`
  sets `x⁽ˣ⁾` where stage 1 set `xˣ` -- because such a group reaches `element`
  as a `Begin` like any other. A behaviour change of the engine swap that no
  braced frame could see. Named in the arm and pinned, braced control included.
- `a_two_character_relation_survives_whole` cited a test name that exists
  nowhere. It now cites the test below it that it meant,
  `a_negation_strikes_the_element_it_applies_to_and_keeps_its_class`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pulldown_latex::parse can abort the whole process with SIGABRT. That is a
stack overflow, not a panic: catch_unwind never sees it and the pager is
simply gone, which is strictly worse than the panic design spec 9 forbids.
MAX_NESTING does not protect it -- it counts box-tree levels after a parse
that has already aborted.

Two independent ways in, and neither cap catches the other; both halves are
measured:

  \overline x 227 + " x"   2045 B, run 227   needs 3555328 B = 1.7x a 2 MiB
                                             thread -- a byte cap alone lets
                                             it through
  %\n x 5930               11860 B, run 0    aborts a 2 MiB debug thread --
                                             a run cap cannot see it at all

So build::parse now scans the source before Parser::new, and refuses:

  - a source longer than MAX_SOURCE_BYTES = 2048 bytes. The test profile
    binds, measured rather than assumed: the shipped binary parses on the
    8 MiB main thread and survives to 112128 bytes, while libtest gives a
    test a 2 MiB thread in debug, which aborts at 8742. 8742 / 2048 = 4.27x.
    2048 is 5.7x the largest formula in pulldown-latex's own 385 fixtures.
  - a longest run of consecutive control-sequence tokens over
    MAX_COMMAND_RUN = 32. The parser recurses once per control sequence when
    a command's argument is an unbraced control sequence and stops at a group
    or a character, so the run length is the bound. Worst measured construct
    is the \overline class at 133 parses / 134 aborts. 134 / 32 = 4.2x.

Both reuse MathError::NotInline with distinct payloads, per the standing
ruling that nesting depth gets no new variant, and the scan never rewrites
the source. Three const asserts pin the margins and the numbers the messages
spell out, beside the constants, as MAX_NESTING does.

The tests assert the exact caption, never merely "is an error", because a
patched parser that returns an error would otherwise answer for the guard.
Two inputs the parser is provably happy with are refused anyway and run
through Parser::new directly to say so: 1025 bare comments (2050 B, longest
run zero) and 33 chained \relax (\relax takes no argument, so nothing
recurses). Each cap was raised past its input with the other left at its
shipped value, and each RED came back as SIGABRT rather than a test failure.

Rendered the binary on a 120006-byte formula: with the guard, rc=0 and the
source shown verbatim in the prose; with the one call commented out, rc=134,
"thread 'main' has overflowed its stack", core dumped.

Two existing tests moved, both as corrections. nest(5000) is 10001 bytes and
is now the byte cap's business, so the MAX_NESTING test uses nest(1023), the
deepest brace nest inside 2048 bytes, and pins the handover explicitly. The
\sqrt-chain test is renamed: that chain is refused by the scan at 32 links,
half of MAX_NESTING, so element's copy of the nesting cap is no longer what
answers for it. Its old closing assertion read the margin backwards -- 129
repeats was two below the abort at 137, not a margin.

MAX_NESTING's doc comment claimed the abort sits "between 129 and 144
repeats" and that this cap "has to stay well under the parser's, which it
does". The figures are 136/137 for \sqrt and 133/134 for \overline, and the
chain opens no group, so depth never increments and MAX_NESTING never fires
on it. A comment claiming a coverage that does not exist is worse than no
comment; corrected, along with element's.

Report: .superpowers/sdd/2026-08-20-math-stage-2/guard-report.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pulldown-latex 0.8.0 can abort the process with a stack overflow -- not a
panic, so a caller cannot contain it -- on two unbounded recursions in its
parser: an unbraced control-sequence argument chain, and a run of tokens that
emit no event. Both are fixed on the fork, on two independent branches off
upstream main, merged into mdmost-integration:

  fix/argument-recursion-depth  6e10f81  a depth counter in handle_primitive
  fix/next-tail-recursion       49effe3  Parser::next and lex::token driven
                                         by a loop instead of recursion
  mdmost-integration            c3582a0  the merge this pins

[patch.crates-io] rather than a changed dependency line, because it applies to
local builds only: the published manifest still says pulldown-latex = "0.8.0",
so a released build gets the unpatched parser and src/math/build.rs's
MAX_COMMAND_RUN and MAX_SOURCE_BYTES are what protect it. The guard is not
redundant with this patch and does not go away when upstream merges.

The suite is unchanged at 1513 -- 1502 from `cargo test --all-targets` plus 11
doctests -- which is what a guard that refuses before the parser runs should
do: no formula reaches the parser's recursion from either side of the patch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
README.md~ and demo/mdmost.toml~ had been untracked in the checkout since
2026-08-16, showing up in every git status and in every handoff's open-questions
list. Both are gone; so is a zero-byte mdmost.mp4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pulldown-latex 0.8.0 emits an unbraced multi-token optional argument as bare
events, so the extent of the `[...]` is absent from the event stream and
`\sqrt[n+1]{x}` drew as the nth root, plus one -- different mathematics, drawn
silently, inherited from stage 1 rather than introduced by this engine.

fix/optional-argument-extent (357ce1d) hands the index to handle_argument as a
Group instead of pushing a raw SubGroup. One line. It adds nothing to the public
event vocabulary: Begin(Normal)/End is already what `\sqrt[{n+1}]{x}` emits.
Upstream's own corpus moves by four lines, all of them an mrow wrapping an index,
in the single fixture that uses `\sqrt[`.

Unlike the two stack-overflow fixes, this one changes rendered output, so the pin
is now load-bearing for the suite: the `\sqrt[n+1]{x}` assertion becomes ⁿ⁺¹√x
and fails against unpatched 0.8.0. That is deliberate. The owner ruled today that
0.3.0 waits for an upstream release carrying all three fixes rather than shipping
a binary that differs from the published crate, so a red line there means the
release dependency went backwards, not that the test needs relaxing.

The suite is unchanged at 1513 -- 1502 from `cargo test --all-targets` plus 11
doctests -- with one expected string rewritten and no test added or removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ling

The plan carried three entries that a reader would work through as separate
open questions: the triage table's row for the single-atom exemption, the
line-227 spacing question, and F5 on `is_one_atom`'s `_ => true` arm. The
table pre-judged half of what F5 deferred, so an implementer working the
table first would ship one half as settled and then rule the other half to
contradict it.

They were one ruling, made on 2026-08-21 and shipped in 20f2fae: a piece is
one atom only when it draws no gap and does not end in a raised form. That
settles F5 -- `\frac{\text{if x}}{b}` sets `(if x)/b` and `\frac{12}{5}` sets
`12/5` by rule -- and it settles line 226 in the same arm. The surviving
`_ => true` is correct, because only two-dimensional contents reach it and
those have already refused in `Mode::Inline`.

Line 227 was ruled separately and in the same session: `2{\log}_2` sets
`2log₂`, the braces staying load-bearing because a group's class is Ordinary
and the reader wrote them to ask for that reading.

Each entry now says what shipped and keeps its original text below, so a
reader who followed a citation into the old wording still lands on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`place` had no `Radical` arm: the box reserved the space and nothing was
drawn into it. This fills that arm and corrects the width the box reserves.

A one-row radicand keeps the plain sign and an overline spanning the
radicand alone. A taller one is drawn as a vertical stem capped by a square
right angle, with the tick beside the diagonal on the bottom row, and that
costs four columns rather than two -- tick, diagonal, stem, gap. The corner
sits on the stem column, so the overline meets it and covers the gap column
as well. The two forms do not share a left offset; that is deliberate.

The index is right-aligned so that its last column is the stroke's first:
over the sign in the one-row form, over the tick in the tall one. The tick
is the top of the short initial stroke, so this is the settled one-row rule
transcribed rather than a second rule invented for the tall case. Exactly
one column is free in either form, which makes the overhang
`index.width - 1` -- a constant, not a function of the stroke. Deriving it
from the stroke, as the old `STROKE - 1` did, would silently grant a tall
root three free columns; the three it would claim are the diagonal, the
stem and the gap.

Discharges defect D5 from the Task 4 review: no test put a radical inside a
row, so a `Row` arm that advanced by zero across it survived the whole
suite. The new test pins the neighbour's column for both an indexed one-row
root and a tall one, and was proved to fail under that mutation rather than
merely skip.

Adds U+203E to the glyph inventory and the manual. Nothing emits it yet --
the inventory check is one-way, emitted into documented -- so the corpus
line that exercises it is still to come.
`boxes::radical` gives the index no rows of its own -- `above` is the
radicand's plus the overline and nothing else -- so a two-dimensional index
drew onto rows the box never reserved. It did not merely overflow them, it
overwrote the stroke. Measured before the fix:

    \sqrt[\frac{a}{b}]{x}     ["─ ─", "b x"]
    \sqrt[\sqrt[3]{2}]{x}     ["√ 2 ─", "  √ x"]
    \sqrt[\sqrt[q]{2}]{x}     ["√ 2 ─", "  √ x"]

The radical sign is replaced by the index's own denominator in the first,
and the last two are different formulas with one rendering. On a tall root
the index took the tick instead, so the four-column art was no safer than
the two-column one.

The index is now built in inline mode whatever the outer mode is, so it
arrives as one row and the geometry it was drawn under still holds.
Flattening a one-row index is a no-op, so `\sqrt[3]{x}` is unmoved.
Reserving rows instead would have been a second index-geometry ruling
rather than a sizing fix: the index sits on a single row over the tick, and
a three-row index has no tick to sit over on a one-row root and would rise
past the cap on a tall one.

The `index_spacing` match still keys off the outer mode, which is correct
and now tested: it decides the index's spacing, not its shape. An inline
root raises its index and there is no raised space; a display root sets the
index at normal size, where `\sqrt[a + b]{x}` reads `a + b`.

`boxes::radical` enforces the precondition itself as well, by dropping an
index that is not one row. The defect was found through that constructor
rather than through the parser, and a constructor that silently returns a
box which erases its own radical sign is not one a later caller should be
able to build by accident. Dropped rather than asserted, because a panic
there is reachable from a hostile formula; dropped rather than flattened,
because flattening belongs to `draw` and `draw` is built on `boxes`.
…d it

The index is placed by two rules, and they hold at any size: its rightmost
column is the stroke's first column -- over the sign in the one-row form,
over the tick in the tall one -- and its last row is one above the bottom
row. On a one-row root with a one-row index those two rules are exactly
["3 ─", "√ x"], so nothing settled moves.

What was missing is that the box did not reserve the rows the second rule
asks for. `above` was the radicand's plus the overline, so a taller index
drew off the top of its own box and over the stroke: the sign vanished on a
one-row root, the tick on a tall one, and `\sqrt[\sqrt[3]{2}]{x}` and
`\sqrt[\sqrt[q]{2}]{x}` collapsed onto one rendering. `above` is now the
larger of that and what the index reaches, which is its height less the
radicand's descent -- the descent already carries the bottom row down, so a
three-row index over a tall radicand costs no extra row at all.

The drawing arm anchors the overline and the cap to the RADICAND rather
than to the top of the box. Those were the same row only while the index
fitted under the stroke's own ascent; with a taller index, the top of the
box is the top of the index, and an overline drawn there is stranded rows
clear of what it covers.

This is the big shape nesting in the index, which it already did in the
radicand -- `\sqrt{\sqrt{\sqrt{x}}}` has always drawn three stems side by
side, and that is now pinned too.

Reverts the flatten of the previous commit pair, which refused
`\sqrt[\sqrt[q]{2}]{x}` -- a formula that draws perfectly well, because a
root index has no descent and so fits above the tick. Flattening also put
design spec §5.1's inline caret fallback on a display canvas, so
`\sqrt[\hat{a}]{x}` set the literal `a^^`; building the index in display
mode stacks the accent instead.
Design spec section 6.4. Box drawing rather than the Unicode
bracket-piece block, with light arcs for round brackets; a one-row body
takes the plain character.

pieces returns char, not a static string. A delimiter is never replaced
by a different one -- box art is a delimiter's own tall form, and one
with no designed tall form repeats itself rather than borrowing another
delimiter's bar. A static string cannot carry a character the table does
not list, so it forces either a substituting catch-all or a String per
row; a char carries all 40 delimiters pulldown-latex can deliver at no
cost, because every piece is exactly one char of exactly one column.

That set is closed and measured, not assumed: it is the union of
pulldown-latex's two delimiter tables, every member is one column wide,
and the tests state the no-substitution rule over the whole set rather
than over a handful of examples.

Nothing calls pieces yet -- the place arm is the next commit -- so the
non-test build warns it is unused until then.
place's Fenced arm, so the display walk stops leaving blank columns
where a fence is. Each side that has a delimiter costs one column, and
two against a tall body; a side that is None is a \left. and costs
nothing, so the advance is per side rather than a constant.

The Task 4 canary that recorded the blank columns is deleted and the
fence joins the two-walk agreement test in its place -- as six cases,
not one: both sides, left only, right only, neither, a fence inside a
row, and a fence whose delimiters have no box-art form.

Task 7's carried-over defect self-heals here. A root index that is a
fence reserved its rows and drew nothing into them, so
\sqrt[\binom{n}{k}]{x} came out with two blank rows on top; it now
fills them, which is also the check that the reserve and the fence
agree about height.

Every delimiter pulldown-latex can deliver is added to the glyph
inventory and to the manual's TERMINAL SETUP list. That set is closed
at 40 characters and every one of them is exactly one column wide, so
the per-side arithmetic holds for all of them.

Pinned as it stands, not fixed here: \binom draws a fraction rule it
should not. It arrives as an explicitly ruleless fraction and build.rs
discards the thickness, which predates this task and belongs to the
builder half.
Two rulings of 2026-08-24.

A delimiter's shape is a property of the character, not of the end it
sits at -- which is why { and } are separate arms rather than one arm
and a side. None of the 40 delimiters pulldown-latex can deliver is
side-dependent, so pieces no longer takes a side and the Side enum is
gone. A parameter nothing reads tells a reader that left and right
differ when they do not.

And the double bar grows into its own tall form, exactly as the single
bar already did: three stacked bar signs read as three norms where one
joined double rule reads as one. Both cases are pinned, because they
differ -- a one-row body still takes the author's plain character.

The double vertical joins the Box Drawing row and the manual. Nothing
emits it yet, so that row goes green either way; the corpus line that
makes it real is still to come.

Recorded there, not fixed here: a sequence diagram's nested activation
bar is the same character and can never reach the page. Bars draw in
close order, so an inner bar is drawn first and the outer bar paints
over every row of it. Checked by rendering it both ways rather than
assumed from the constant.
The module's face for display math. Two entry points rather than one
with a Mode parameter: they return different types, so a caller that
picked the wrong one gets a type error instead of a surprise.

TooWide plays the part MermaidError::TooNarrow plays for diagrams, with
one simplification. A diagram can be re-laid out narrower and is
searched for; a formula has exactly one width, so `needed` is the answer
and not a hint, and there is no probe budget.

A formula that draws no cells returns an empty canvas. Spec 16.3 states
that rule over the result, so a definition-only block, a comment and
whitespace all behave alike with no list to keep. The spec's own example
spelling, \newcommand{\R}{\mathbb{R}}, does not parse: the pinned
pulldown-latex makes the [n] argument count mandatory where LaTeX
defaults it to zero. Pinned as it stands so the fork's fix has a test to
change rather than a silence to find.

Every dead_code suppression under src/math/ came off and was
re-measured. Seven were stale -- to_canvas, place, write_delimiter,
centre, MathBox::is_empty and Mode::Display are all reachable from the
lib target now, and the eighth, spacing::Class::ALL, is the only one
clippy still asks for.
to_canvas said "the caller compares" while it had no caller at all. It
has one now, and that caller returns TooWide before calling, so the
widening branch is unreachable from production and the canvas is always
exactly the width asked for. The display proptest asserts that rather
than arguing it.

The floor stays: this function is handed a box and cannot know whether
its caller means to scroll, so deciding that here would put the
renderer's policy in the drawer.
Owner ruling of 2026-08-24. Task 9 proved that display callers raise this
variant on input a reader can really write: build::parse refuses a source past
the byte or command-run cap before the mode is consulted at all, and a grid is
refused in either mode. So both the name and the message lied.

The message moves with the name -- "{0} cannot be drawn on one row" becomes
"{0} cannot be drawn here". "a formula nested too deeply cannot be drawn on one
row" is simply false when a display caller raises it, and "here" ties the claim
to the caller's context without asserting anything global. That matters for the
second family of payloads -- a matrix, a multi-row environment -- which display
will draw once stage 3 lands. The payload strings themselves do not change:
Task 10's captions read them and they were already correct.

Arity, type and derives are identical. Four doc comments were rewritten rather
than substituted, because they were wrong prose and not a wrong word:

- The variant's own doc described only the inline-only family. It now names
  both, and says why the message says "here".
- render_display's # Errors carried a paragraph explaining that the name was
  misleading. A comment that exists to apologise for a name dies with the name;
  its factual half survives in two lines.
- render_inline's # Errors said "cannot be written on one row", which is false
  of a source past the byte cap.
- build's # Errors said the variant is raised only under Mode::Inline. Three of
  the four returns reachable through it are mode-independent. That was wrong
  before this commit and a pure rename would have left it wrong.

One test is renamed, in src/error.rs: it was named for the claim the old
message made. It now asserts the Display string for one payload from each
family, so both are pinned on MathError's own Display rather than only through
build. The test count is unchanged at 1551 + 11 doctests.

CHANGES.md gets no entry: MathError did not exist at v0.2.0, so no published
consumer ever saw the variant and this is not a break they can feel.

In the plan, the five citations a future task reads are updated. Task 9's
closing Note claimed Mode::Display never returns this variant -- Task 9's own
test disproves it -- and is corrected in place rather than renamed into a fresh
falsehood. The citations inside completed Tasks 3-8 stay as the record of what
those tasks wrote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The parameter count of \newcommand was mandatory in pulldown-latex, so
\newcommand{\R}{\mathbb{R}} -- valid LaTeX, design spec 16.3's own example,
and the spelling every author writes -- did not parse. Fixed on the fork as
fix/newcommand-optional-arg-count and merged into mdmost-integration.

Task 9 asserted that failure as a pinned defect rather than papering over
it, with the instruction that the line goes red when the fork gains the
default and is then the place to delete. It went red on exactly that
assertion and nothing else, so it is deleted and the bare spelling joins
the cases that must draw nothing.

Gated on the bumped pin: cargo test -j4 --all-targets 1551 passed 0 failed,
--doc 11, clippy --all-targets -D warnings clean, fmt clean.
The dependency has been a git dependency on our fork since the four defects in
0.8.0 turned out to be blocking. That fork is a second repository the build
reaches over the network for, on a commit nothing in this tree pins except a
`rev` string. Vendoring makes the code we actually compile visible in the diff,
and makes a clean checkout build without reaching github.com/oetiker at all.

This commit is the import and nothing else -- no wiring, no manifest changes at
the root. Building mdmost is unaffected until the next commit adds the
[workspace] table.

It is a workspace member crate with its own manifest rather than a module inside
mdmost, and that is what makes the import mechanical. `#![forbid(unsafe_code)]`
in src/lib.rs is crate-level, so it does not reach here, and the three `unsafe`
blocks upstream has (parser.rs, mathml.rs, parser/error.rs) stay exactly as
upstream wrote them. The crate keeps its name, so every `use pulldown_latex::`
in mdmost is unchanged and a diff against upstream stays readable.

Source: oetiker/pulldown-latex, branch mdmost-integration, commit
cf0b98d138e6ce06a083aa9db25acb4d500bcb55 (2026-08-24) -- upstream main at
1067fd2 plus four merged fix branches. VENDORED.md records all of it: the four
patches with their upstream PR numbers and states (#73 closed unmerged, #74/#75
#76 open), what was dropped from the upstream tree and the capability lost with
it, and the conditions under which this whole directory goes away again.

LICENSE travels with the code; MIT requires it.

Dropped: the browser-driver test, the five corpus round-trip suites and their
libtest-mimic/inventory/heck/anyhow harness, benches, fuzz, site, font,
examples, and the crate's own Cargo.lock. Kept: tests/errors.rs,
tests/mathml.rs and tests/atom_types.rs -- the standard harness, no
dev-dependency at all, and between them every regression test the four patches
added.
Owner ruling, 2026-08-24: "lets vendor and drop crates.io for now". These are
one decision, not two. A path dependency cannot be published, and publishing was
the only thing that stood in the way of carrying the fixed parser in this
repository -- so giving up crates.io is what buys a build that compiles the code
you can read, with no second repository to fetch and no `rev` string standing in
for it.

The [patch.crates-io] block is gone with the `rev`; both were only ever local
overrides. Everything it documented that is still true has moved to
vendor/pulldown-latex/VENDORED.md, which says it at more length. One sentence
did NOT move: its claim that "crates.io refuses a git dependency outright, so
this cannot be published" was measured false on 2026-08-24 -- `cargo publish
--dry-run --locked` exits 0 and silently ships against stock 0.8.0, which is the
worse failure, not the safer one. `publish = false` on both packages is what
actually refuses now.

Gate scoping. This is a workspace, so a bare cargo invocation no longer means
what it used to: the root manifest carries a [package], so cargo's default
member is mdmost alone and the vendored crate would silently drop out of
`cargo test`. Every command now names its package:

    cargo fmt --check -p mdmost                            unchanged, 0
    cargo clippy --all-targets -p mdmost -- -D warnings    unchanged, 0
    cargo test --all-targets -p mdmost                     1551, unchanged
    cargo test --doc -p mdmost                             11, unchanged
    cargo test --all-targets -p pulldown-latex             72   (new)
    cargo test --doc -p pulldown-latex                     5    (new)

The mdmost numbers are the 1551/11 this project has always quoted, unmoved. The
workspace total is 1623, which is 1551 + 72 and nothing else.

Clippy is split in two halves, and only one of them is load-bearing. Naming
`-p mdmost` is what keeps `-D warnings` off vendored code, because trailing args
reach only the selected package; the [lints] table in the vendored manifest is
insurance against a future toolchain, and was measured to change nothing today
-- that tree is clean under `--workspace -- -D warnings` with the table removed.
Neither half can reach mdmost's own code. Both directions were fault-injected:
a needless `return` in src/lib.rs fails the gate, the same line in
vendor/pulldown-latex/src/config.rs does not.

`cargo fmt --check` is scoped the same way, for consistency rather than
necessity: the vendored tree is rustfmt-clean today, and this is refusing to
inherit a failure from a rustfmt release in code this repository has no business
editing.

release.yml loses the `publish-crate` job entirely. Nothing declared `needs:` on
it -- it was a leaf beside `homebrew` -- so the tarball, checksum, Homebrew and
bottle paths are untouched. The `version` job's comment about Cargo.lock is
corrected: the reason the lock must move with Cargo.toml is that the release
commit would otherwise be internally inconsistent, which was always the real
reason; "fatal at cargo publish" is no longer one of them. Its
`cargo update --workspace` now spans two members, and does the right thing --
only the root manifest's version is edited, and the vendored crate keeps 0.8.0.

Also dropped: the CRATES_IO_TOKEN step in docs/maintainer-notes.md, which told a
maintainer to create a credential nothing reads any more. Revoke it if it exists.

README swaps `cargo install mdmost`, which will now fail to resolve, for
`cargo install --git https://github.com/oetiker/mdmost`. It does not oversell
it: the existing note that neither Rust route installs a man page still stands.

CHANGES.md records both halves under Unreleased, and says what the Breaking
section means now that there is no `cargo publish` consumer to break. Its
entries are kept as written. Whether to keep tracking API breaks at all is
raised there as the owner's call, not settled.
…e numbers

Five comments in src/math/ navigate into `pulldown-latex`. Two of them named
things that no longer exist -- `Cargo.toml`'s `[patch.crates-io]` block, and a
`~/.cargo/registry/src/index.crates.io-*/pulldown-latex-0.8.0/` path -- and the
rest pointed at a registry copy of 0.8.0 that is not what this repository
builds. The source is in the tree now, so these can be checked, and checking
them found that three were wrong:

    sixteen `multirelation` calls   1157-1172  ->  1171-1186
    `\approxcolon`                       1165  ->       1179
    `shortparallel`                      1066  ->       1080

The vendored tree is 0.8.0 plus 83 upstream commits and four of ours, so the
registry line numbers were about fourteen lines stale. Two references were
already right and only needed the path: `Content` at event.rs:102 and
`Grouping::LeftRight` at event.rs:316. Every number above was read out of
vendor/pulldown-latex/ rather than carried over.

Two comments also said things that were true of the pin and are not true of the
vendor. `build.rs`'s `\sqrt[n+1]{x}` assertion said "FIXED UPSTREAM" and that
0.3.0 waits for an upstream release: it is fixed on our fork, upstream PR #75
was still open when the parser was vendored, and the release no longer waits for
anything. What a red line there means has changed with it -- it now means
someone re-synced `vendor/` to an upstream that has not taken the patch.

No behaviour changes. cargo test --all-targets -p mdmost 1551, --doc 11, both
unmoved; clippy and fmt clean.
The name and three releases are on crates.io -- 0.1.0, 0.1.2 and 0.2.0 --
and all three are yanked, so cargo install mdmost fails rather than
finding an old binary. "not on crates.io" invited a reader to check and
find otherwise; the practical claim was right and the literal one was not.
The section named one file's arm and stopped there. Four things it omits
are real work: a math fence reaches the same arm, five existing tests pin
the behaviour being replaced, three snapshots change, and one sentence of
the corpus fixture becomes false.

The fifth is the trap. The caption-junction test works only because
"display math is not laid out yet" is 32 columns wide, long enough to
reach the gutter's junction. Every replacement caption is shorter, so
re-pointing that test at a failing formula would leave it green against
the very defect it was written for. The note prescribes the measurement
and the mutation instead of a formula to use.

Also: MathSyntax::NONE does not exist, and the gate commands predate the
workspace.
render_block_ctx stops dumping the source for $$...$$ and for a ```math
fence -- doc::convert turns both into the same Math { display: true },
so the one arm serves both spellings -- and draws the box art instead,
falling back to the framed source when the formula will not parse or
will not fit. That fallback is the permanent failure path of spec
section 9, not a placeholder, which is why the same frame serves both
reasons; the caption is now the MathError's own Display, so it names
which one.

A block whose layout draws no cells returns before resize_width, so
spec 16.3's definition-only block leaves no hole in the page.

Six existing tests pinned the behaviour being replaced and are
re-pointed rather than deleted. Four of them needed a formula that
genuinely fails, and a matrix is the honest one: it is well-formed and
no mode draws it until stage 3.

The caption-junction test was checked against a mutation rather than
assumed. At width 60 the gutter is four columns, which puts the
junction on the frame's column 4 while a caption starts on column 2, so
a caption of three columns or more already stands on it and the
twenty-nine of "a matrix cannot be drawn here" reach it comfortably.
With the junction check reverted the caption comes back corrupted after
its second character, which is the test going red.

The table-cell test needed the same care for the opposite reason: it
asserted on the rounded top-left corner to prove the cell held a
framed block, and the table draws that same corner itself, so the check
would have stayed green with the cell rendering nothing at all. It now
reads the three rows of the drawn fraction off the canvas.

Seven snapshots move. Three are the corpus render at 40, 80 and 120,
which now draw the quadratic formula; three are the math-off render and
one the outline, all following the corpus fixture's own sentence, which
said display math was not laid out and stopped being true here.
The manual and the Unreleased changelog both said display math "is not
laid out in this version". That stopped being true at 509e8b3, and both
lines are the ones a reader would be misled by if the stage were cut
short here, so they are corrected now rather than at Task 15 -- the same
ruling that moved the corpus fixture's own sentence.

Task 10's note C had the mechanism backwards. It said the caption must be
long enough to reach the gutter junction; the junction is near the LEFT
corner, four columns in, and a caption starts on column 2, so three
columns already reach it. The evidence was in the test's own comment --
"display" corrupted to "di^display" is the junction landing on the third
column, not the thirty-second. The prescribed action was right anyway,
and proving the test red is what caught the error.

Task 11 inherits the two findings Task 10 reported: TooWide never reaches
a reader because render_widened widens until the formula draws, and a
drawn formula is not centred at any width yet.
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