docs(math): frame the first inline-math vertical slice

The obvious next unit was Tier 2 (parser) plus Tier 3 (layout). This framing
rejects that for the arc's own reason: the substrate lane's Q#LX5 refused to
land the parser ahead of layout because MathNode's shape is only validated by
a consumer, and that argument does not stop at Tier 2. MathBox is only
validated once a renderer consumes it, so Tiers 2+3 with no Tier 4 reproduce
the same objection one layer up.

The unit is therefore thin and vertical: characters, sub/superscript and
fractions, detected, parsed, laid out against a real OpenType MATH table, and
drawn — with breadth deferred to follow-on work against an API that has been
exercised rather than only designed.

The scout also found a load-bearing error in the parent's integration table.
It places math_parse.rs and math_layout.rs in the core crate, which cannot
work: pmacs-gpu depends only on pmacs-protocol and never on pmacs, so a
core-crate parser is unreachable from the frontend that renders it, and
ttf-parser reaches only pmacs-gpu. Both also contradict the parent's own
"instance never learns a pixel" contract and its "pure frontend
responsibility in v0" protocol section. Q#MS1 moves both modules into
pmacs-gpu.

Two decisions buy the slice most of its simplicity. Rendering is suppressed
whenever the caret is inside a span, which makes editing-shows-source and
moving-away-renders mutually exclusive states and removes the
caret-inside-math problem rather than approximating it. And layout positions
characters rather than glyph IDs, which is sufficient for this subset and
explicitly does not generalise: glyph variants are what stretchy fences and
big operators need, and both are deferred.

Acceptance puts every claim that something reaches the screen on real pixels
through the existing headless render seam, because a layout engine wired to
nothing would pass any weaker test.

Framing only; no implementation, no runtime code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-24 15:15:36 -04:00
parent 352bf0b2ab
commit f8020320e2
1 changed files with 336 additions and 0 deletions

View File

@ -0,0 +1,336 @@
# Inline math — the first vertical slice (framing)
**Revision 1 — pre-implementation, framing only. Ground truth scouted against
canonical `main` @ `352bf0b`, protocol v20, 2026-07-24.**
Parent arc: `docs/inline-math-framing.md` (rev 2, merged as #154). Sibling
substrate lane: `docs/latex-grammar-math-substrate-framing.md` (rev 3), whose
Stage 1 landed as #144.
This lane builds the **first end-to-end slice** of the parent's four-tier
pipeline: a deliberately small LaTeX-math subset that is detected, parsed,
laid out against a real OpenType MATH table, and **actually drawn on screen**.
## 0. Why a slice, and not "Tier 2 + Tier 3"
The obvious next unit was the parser (Tier 2) plus the layout engine
(Tier 3). It is rejected here for the parent arc's own reason.
The substrate lane's **Q#LX5** refused to land the parser ahead of layout
because *"the `MathNode` shape is only validated once [a layout consumer]
exists"*. That argument does not stop at Tier 2. `MathBox` is only validated
once a **renderer** consumes it: an unrendered layout engine can be
self-consistent and still have the wrong shape — wrong units, wrong origin
convention, a baseline the draw path cannot use. Landing Tiers 2+3 with no
Tier 4 reproduces exactly the objection Q#LX5 raised, one layer up.
So the unit of work is **thin and vertical, not broad and horizontal**: the
smallest grammar subset worth rendering, carried all the way to pixels. Every
layer acquires a real consumer immediately. Breadth — big operators, stretchy
fences, radicals, accents, display math — becomes follow-on work against an
API that has already been exercised rather than one that has only been
designed.
The cost is honest and named in §7: the slice touches
`pmacs-gpu/src/main.rs`'s render path, which two other arcs also want.
## 1. Ground truth (scouted 2026-07-24 @ `352bf0b`)
### 1.1 Crate boundaries — the parent's file placement cannot work
The parent framing's integration table lists `src/math_parse.rs` and
`src/math_layout.rs`, i.e. the **core `pmacs` crate**. Verified against the
tree, that placement is unusable:
- **`pmacs-gpu` depends only on `pmacs-protocol`** (`pmacs-gpu/Cargo.toml:60`;
there is no `pmacs` dependency). A parser in the core crate is therefore
**unreachable from the frontend that renders it**.
- **`ttf-parser` reaches only `pmacs-gpu`.** Per-crate check: `pmacs` no,
`pmacs-protocol` no, `pmacs-gpu` yes (via `fontdb``cosmic-text`
`glyphon`). A layout module in the core would be a genuinely new dependency
there, which is not what the parent's C1 established.
Both also contradict the parent's own prose — its design contract ("the
instance never learns a pixel") and its protocol section ("math rendering is a
pure frontend responsibility in v0"). The table was the outlier. Q#MS1 fixes
it.
### 1.2 The GPU text pipeline this slice hooks
- `rebuild_code_slice` (`pmacs-gpu/src/main.rs:6136`) shapes **only the
visible byte slice**; spans/decorations/adornments arrive in whole-file
coordinates and are clipped and rebased onto it.
- Per line, `chunks_for_line` (`:5100`) produces `RichChunk`s whose
`ChunkSource` (`:7715`) is one of `Source { start }`,
`SourceTab { start }`, `Adornment { anchor }`.
- **Every existing variant is additive.** Adornments (inlay hints) inject text
*between* source bytes; nothing today *replaces* a source range with a box
of chosen width. That mechanism is what this slice must build (Q#MS4).
- `build_hit_runs` (`:7739`) derives the projected→source hit map from the
same chunks that feed glyphon, so the map and the shaped buffer cannot
disagree. Any new chunk kind must participate here or clicks land wrong.
- Custom drawing precedent: `SquiggleRenderer` (`:2825`) owns its WGSL shader
and pipeline; the menu/background quad pipeline is the precedent for filled
rectangles.
- Fonts are embedded with `include_bytes!` from `pmacs-gpu/fonts/` under OFL
(`JETBRAINS_MONO`, `:63`); `build_font_system` (`:217`) loads them into
`fontdb`.
### 1.3 The acceptance seam already exists
`headless_or_skip(w, h, text)` builds a real headless GPU state and
`render_offscreen()` returns mapped pixels (`copy_texture_to_buffer` at
`:6570`). `headless_diag_face_recolors_band_counter_despite_unchanged_text`
(`:12022`) is the precedent: render, mutate, render again, and assert on the
pixel difference. Real-GPU tests run under `PMACS_REQUIRE_GPU=1`.
This matters because the slice's central claim — *math is actually drawn*
is exactly the kind of claim that a non-rendering test would pass vacuously.
## 2. What ships
One PR: detection (inline `$…$` only) → parse → layout against the MATH table
→ draw, for the subset in Q#MS2, with the raw source shown whenever the
cursor is inside the span (Q#MS5).
Explicitly **not** in this slice: display math `$$…$$`, big operators,
stretchy fences, radicals, accents, `\text{}`, style overrides, tree-sitter
injection detection, any wire surface, and the TUI.
## 3. Decisions
### Q#MS1 — Both modules live in `pmacs-gpu`
`pmacs-gpu/src/math_parse.rs` and `pmacs-gpu/src/math_layout.rs`. Not
`src/`, for the three independent reasons in §1.1. This keeps v0 exactly what
the parent says it is — a pure frontend responsibility — and keeps the core
crate free of a font-metrics dependency it has no use for.
If instance-side detection ever lands (the parent's v1 `MathSpans`), the
*parser* may move to a shared crate at that point. Nothing in this slice
should assume it will.
### Q#MS2 — The subset: characters, sub/superscript, fraction
`MathNode` for this slice:
```rust
enum MathNode {
Char(char), // x, 2, +
Symbol(char), // \alpha → U+03B1 (seed map)
Group(Vec<MathNode>),
Script { base: Box<MathNode>, sub: Option<Box<MathNode>>, sup: Option<Box<MathNode>> },
Fraction { num: Box<MathNode>, den: Box<MathNode> },
}
```
This subset is chosen because it is the smallest one that **forces the MATH
table to matter**. Characters alone could be positioned by guesswork and prove
nothing. Scripts require `ScriptPercentScaleDown`, `SuperscriptShiftUp` and
`SubscriptShiftDown`; fractions require `AxisHeight` and the fraction rule
constants, plus nested box composition. Get those right and the remaining node
kinds are more of the same; get them wrong and no amount of breadth helps.
The symbol map ships as a **seed** (Greek letters only, ~50 entries), not the
parent's full ~200. Growing it is mechanical and needs no design.
### Q#MS3 — Detection is the frontend byte scanner, inline only
A two-pass scan over the visible slice for unescaped `$…$` pairs, run where
the parent specifies — in `rebuild_code_slice`, after shaping decisions, not
on the edit path. `\$` is an escape and does not open or close a span. An
unpaired `$` yields no span (acceptance 6 of the parent).
Tree-sitter injection detection is deliberately not used, even though #144
gives us `math_environment` / `math_delimiter` for `.tex`: that path is
instance-side, the substrate lane already deferred it to this arc, and the
slice must work in the grammar-less buffers where most inline math is typed.
It stays available as the natural upgrade.
### Q#MS4 — Suppression is a new chunk kind, and it owns its hit runs
`ChunkSource` gains a variant carrying the suppressed source range and the
projected width the box occupies. The chunk contributes **no glyphs** to the
cosmic-text buffer; it reserves width so the surrounding text lays out around
it, and the math is drawn over that reserved space in a later pass.
The invariant `build_hit_runs` exists to preserve — that the hit map is
derived from the same chunks glyphon shaped — is not weakened: the new
variant participates like any other. A click inside a math box maps to the
**start byte of the suppressed range**, the same "snap to anchor" rule
`Adornment` already uses. Sub-expression hit-testing is deferred; it needs a
box→byte map that this slice deliberately does not build.
### Q#MS5 — The cursor rule: render math only when the cursor is outside
When the caret is anywhere inside a math span (or on either delimiter), that
span is **not** suppressed — the raw `$…$` renders as ordinary source text.
This is the parent's Q#IM5 proposal ("when the cursor approaches the boundary,
the raw `$` reappears") adopted as a hard rule, and it buys the slice a great
deal: there is no caret-inside-rendered-math problem to solve, because the two
states are mutually exclusive. Editing math shows source; moving away renders
it. Q#IM6's "best-effort fractional cursor projection" is then not needed at
all in v0, and is deferred rather than approximated.
It also gives the feature an honest, self-explaining interaction model, which
is worth more in v0 than sub-glyph caret fidelity.
### Q#MS6 — Layout positions CHARACTERS, not glyph IDs
```rust
struct MathBox { width: f32, ascent: f32, descent: f32, items: Vec<MathItem> }
enum MathItem {
Glyph { ch: char, x: f32, baseline: f32, size_px: f32 },
Rule { x: f32, y: f32, width: f32, thickness: f32 }, // fraction bar
}
```
Positions are in pixels relative to the box origin, resolved by the frontend
that owns font metrics — consistent with the parent's contract.
**Characters, not glyph IDs, is a deliberate boundary.** Glyph-ID work exists
to select *variants* from the MATH table's `GlyphVariantRecord` / `Glyph
Construction` chains — which is precisely what stretchy fences and big
operators need, and precisely what this slice defers. Positioning characters
lets each item be drawn with the existing text machinery. The slice must not
pretend this generalises: when stretchy delimiters arrive they will need glyph
IDs, and `MathItem` will gain a variant then.
The fraction rule is a filled quad on the existing quad pipeline, not a glyph.
### Q#MS7 — The MATH font and its feature declaration
Bundle **Latin Modern Math** (OFL, GUST) as `pmacs-gpu/fonts/`, embedded with
`include_bytes!` beside JetBrains Mono, with its licence file. Two consumers
read the same bytes: `fontdb`/cosmic-text for drawing, and `ttf-parser`
directly for the MATH table, which cosmic-text does not expose.
Declare the dependency exactly as the parent's rev-2 C1 records:
```toml
ttf-parser = { version = "0.25", default-features = false, features = ["opentype-layout"] }
```
Bare `ttf-parser = "0.25"` unions `std` in and rebuilds the font chain.
A font whose MATH table is absent or unparseable is a **hard startup error in
the math path only** — math spans fall back to raw source (Q#MS8), the editor
does not fail. Bundled-font regressions must not be silent.
### Q#MS8 — Failure is always "show the source"
Unparseable expression, unsupported node kind, missing MATH constant, or a box
that would exceed the line: the span is not suppressed and renders as ordinary
source. The parent's red-squiggle treatment (its Q#IM4) is **deferred** — it
reuses the diagnostic squiggle path, which is a second integration this slice
does not need in order to be correct.
Consequence worth stating plainly: **an unsupported construct is
indistinguishable from ordinary text in v0.** That is acceptable precisely
because the subset is small and documented; it stops being acceptable when
breadth arrives, which is when Q#IM4 should land.
### Q#MS9 — Caching is deferred
The parent's hash-keyed `MathBox` cache is **not** in this slice. Layout runs
per visible span per reshape. This is a slice: the subset is tiny, the visible
span count is small, and an unmeasured cache is a guess. The parent's latency
targets stay as targets; the first measurement comes from this slice's own
render path, and the cache lands when a number justifies its invalidation
cost.
## 4. Bets (falsifiable)
- **B1 — the reserved-width chunk composes with the existing pipeline.**
Falsified if reserving width for a suppressed range requires changing how
cosmic-text shapes the surrounding line, rather than adding a chunk kind
that `chunks_for_line` and `build_hit_runs` both already iterate.
- **B2 — scripts and fractions are enough to validate `MathBox`.** Falsified
if adding a deferred node kind later forces a change to `MathBox`'s width /
ascent / descent / origin contract, rather than only adding a `MathItem`
variant.
- **B3 — character positioning suffices for the subset.** Falsified if any
node in Q#MS2 cannot be drawn correctly without selecting a glyph variant.
- **B4 — the cursor rule removes the caret problem rather than hiding it.**
Falsified if any caret position, selection, or click inside or across a math
span still needs a projected-position approximation to behave correctly.
- **B5 — `ttf-parser` supplies every constant the subset needs.** Falsified if
script or fraction layout requires a MATH value `ttf-parser` does not
expose.
## 5. Acceptance
Parser and layout are pure and get ordinary unit tests. Everything that claims
something reaches the screen runs on a real device through
`headless_or_skip` + `render_offscreen`, under `PMACS_REQUIRE_GPU=1`.
1. **Parser**`x^2`, `x_i`, `x_i^2`, `\frac{a}{b}`, `\alpha`, nested
`\frac{x^2}{y}` produce the expected `MathNode` trees. Unbalanced `{`,
unknown command, and an empty span are errors, not panics.
2. **Detection**`$x^2$` yields one span; `Price: $5.00` yields none;
`\$5` yields none; `$a$ and $b$` yields two.
3. **MATH constants are actually consulted** — layout of `x^2` with the real
font places the `2` above the baseline and scaled down. Bite: stubbing
`ScriptPercentScaleDown` to 100% changes the laid-out box, proving the
constant is read rather than hardcoded.
4. **Fraction geometry** — numerator above, denominator below, rule at the
axis height, box ascent/descent enclose both.
5. **It renders** — a buffer containing `$x^2$` renders differently from the
same buffer with the math span suppressed. Asserted on pixels, so a layout
engine wired to nothing cannot pass it.
6. **The fraction rule is drawn**`$\frac{a}{b}$` produces horizontal rule
pixels between the two operand rows.
7. **Cursor rule** — with the caret inside `$x^2$`, the raw `$x^2$` glyphs
render and no math is drawn; moving the caret out re-renders the math.
Both directions asserted.
8. **Hit mapping** — a click on a rendered math box places the caret at the
span's start byte, and the surrounding text's hit runs are unchanged.
9. **Failure shows source**`$\frac{a$` and `$\unknown{}$` render as
ordinary source text with no panic and no missing glyphs.
10. **Surrounding layout is undisturbed**`before $x^2$ after` keeps
`before`/`after` at the same positions as when the span is not math.
11. **Feature declaration**`cargo tree -e features` shows `ttf-parser`
without `std`, i.e. the declaration did not widen the shared feature set.
12. Full gate suite per `CLAUDE.md`, including `PMACS_REQUIRE_GPU=1`.
## 6. Deferred (named)
Display math `$$…$$` and `\[…\]`; big operators; stretchy fences and glyph
variant/assembly (with the `MathItem` glyph-ID variant they require);
radicals; accents; `\text{}`; style overrides; the full ~200-entry symbol map;
the red-squiggle error treatment (parent Q#IM4); the `MathBox` cache (Q#MS9);
sub-expression hit-testing and caret projection inside rendered math (parent
Q#IM6); colour-by-context (parent Q#IM2); tree-sitter injection detection and
any `MathSpans` wire surface; the TUI's distinct-face fallback; Lua-registered
delimiters.
## 7. Interaction with other work
The slice's Tier 4 half edits `pmacs-gpu/src/main.rs`'s render path, which two
other lanes also claim:
- **Bottom panel** — Stage 1 is in review as **#155**; its **Stage 2** takes
this render path *and* the next protocol version.
- **Folding Stage 3 (GPU)** — next ranked, still unframed, and inherits the
`BufferSnapshot` fold-mirror-clear obligation on the same path.
This lane reserves **no protocol version** and adds no wire surface, so it
cannot collide there. For the render path the rule is the one the other two
framings already apply to each other: **whichever lands second re-scouts
against the first.** The parser and layout modules are new files and collide
with nothing; only the `rebuild_code_slice` / chunk / render hunks are
contended, and they are small and localised by design.
Sequencing preference: land after #155's Stage 1, whose merge does not touch
this path, and re-scout if bottom-panel Stage 2 or folding Stage 3 lands
first.
## 8. Prior art in pmacs
`SquiggleRenderer` (`pmacs-gpu/src/main.rs:2825`) for owning a custom pipeline
beside glyphon; the menu/background quad path for filled rectangles; inlay
hints (`ChunkSource::Adornment`) for interleaving non-source content and for
the anchor-snapping hit rule; `headless_diag_face_recolors_band_counter…`
(`:12022`) for asserting a rendering claim on real pixels; #144's query
overlay for the eventual tree-sitter detection upgrade.