Skip to content

perf(webview): narrow the TABLE-DELIM guard arm to a per-line shape delta - #398

Merged
mtskf merged 13 commits into
mainfrom
chore/narrow-structural-guard-table-delim
Sep 5, 2026
Merged

perf(webview): narrow the TABLE-DELIM guard arm to a per-line shape delta#398
mtskf merged 13 commits into
mainfrom
chore/narrow-structural-guard-table-delim

Conversation

@mtskf

@mtskf mtskf commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Summary

Narrows the TABLE-DELIM arm of the shared bounded-rebuild guard from a presence test ("the changed line holds a | ⇒ full rebuild") to a per-line shape delta, and closes two soundness holes in the SHAPE arm that the presence test had been masking by accident.

Typing inside a table cell is tableSkeletonField's own primary scenario, and every such keystroke was giving up the parse reuse the field exists for. That is now back on the bounded path.

Why this is a soundness change, not a tuning change

The narrowing is only correct once two pre-existing holes are closed, both found by the proof this PR adds:

  • Setext. Editing a === underline flips a whole run between SetextHeading and Table/Paragraph. No arm saw it — the line has no pipe, no newline, no >, no blank flip, no indent change.
  • Container-relative indent. SHAPE anchored its fence / HTML-open / ATX / underscore alternations at CommonMark's top-level [ \t]{0,3}. Inside a nested list # h is a real heading. The presence-based TABLE-DELIM arm covered that case only by accident, because the line happened to hold a |.

Both are fixed by widening STRUCTURAL (a wider guard can only cost full rebuilds, never correctness).

The predicate

tableRowShapeChanged fires when the changed line is delimiter-shaped on either side (in
either the raw or the whitespace-stripped reading), or when either of two per-line facts
changed: hasPipe, or parseRow's cell count.

The retreat to presence on delimiter rows specifically is the design, not a hedge. A
per-line delta is sound only where the parser's decision is a pure function of that one line,
and for a delimiter row @lezer/markdown's is not:

  • TableParser.nextLine gates delimiterLine behind line.next ∈ {-,:,|}, computed after
    skipping only space/tab, while the regex's \s also accepts NBSP / U+3000 / \f / \v; and
  • endLeaf measures the delimiter line's parseRow from the preceding line's basePos.

Neither is recoverable from the changed line alone. Editing a delimiter row is rare; typing
inside a table cell is not, and that is the class this PR recovers.

What review caught

The first draft of this PR used a four-fact per-line delta with no presence fallback. Two
independent reviewers each found a CRITICAL soundness hole in it, both reproduced against the
production parser, and both cases the pre-PR presence arm did fire on:

  1. Unicode-whitespace-led delimiter. " :---|"":---|" (deleting an NBSP, U+3000,
    \f or \v): all four facts constant, every arm silent, a Table appears. The oracle
    could not see it because ALPHABET held only space and tab.
  2. Delimiter line as a lazy continuation. In "- l\n h | e\n|--|-\n r1\n r2\n", a
    one-character edit to the delimiter row leaves all four facts constant with every arm
    silent while Table@L1-L4 is destroyed — because endLeaf measured that line from the
    header line's basePos.

Both geometries and the four Unicode whitespace characters are now in the corpus, so neither
class can silently return.

How it is proven

A bounded-exhaustive differential oracle (cm-structural-guard-exhaustive.test.ts): every single-character insert, delete and replace at every offset of a 44-document corpus, run against the production parser. For every edit the guard stays silent on, the block identity the bounded consumers reuse must be unchanged.

  • Against the shipped guard it is red — 225 residuals, in both classes above.
  • After the fixes it is green — 0 residuals over 75,750 edits, with arms-silent rising from 9,848 to 29,245.

A chained differential fuzz over all six consumer StateFields (cm-structural-guard-fuzz.test.ts) covers the consumer level: 793 comparisons, 334 on the bounded path, 72 of them on a pipe-carrying line inside a real Table node, 0 mismatches. ⚠️ That last metric is the narrowness pin, and its first version was vacuous: inCellBounded > 20 passed under the pre-PR arm too (measured 23). It now requires the edited line to carry a | — 0 under the base guard at every seed, 72–114 under the shipped one.

A mutant-kill measured why the exhaustive oracle is the primary evidence, not the fuzz. Forcing tableRowShapeChanged to return false:

harness caught the mutant
bounded-exhaustive oracle ✅ red
800-mutation random fuzz ❌ stayed green

Reaching the counterexample needs a specific geometry and one specific offset. Uniform sampling does not get there.

Measurements

In-cell keystroke, 30-run averages, before = the presence arm restored:

field / fixture before after
tableSkeletonField, 25 KB / 150 tables 0.39–0.41 ms ~0.00–0.03 ms
tableSkeletonField, ~200 KB / 1200 tables 3.35–3.48 ms 0.06–0.23 ms
imageBlockField, 19 KB / 150 images 0.54–0.58 ms ~0.00–0.06 ms
imageBlockField, 154 KB / 1200 images 4.47–4.54 ms 0.07–0.14 ms

Editing a delimiter row itself now always full-rebuilds (it used to bound). That is the price
of the presence fallback and it is the right trade: delimiter rows are edited rarely, cell
bodies constantly.

The widening's cost is stated rather than implied. Six worst-case edits that now full-rebuild where they used not to (each asserted touchesStructuralReparse(tr) === true before timing, ~208 KB / 1200-table document) measured 3.75–4.44 ms — all under the 5 ms bar, with as little as ~0.6 ms headroom. Full breakdown in PERF-log.md.

SHAPE stays presence-based, so a list-item body, a blockquote line, an ATX heading and every Enter still take the full walk. Narrowing that arm is filed as a follow-up.

Guarding the mirror

@lezer/markdown is declared as ^1.6.4 — a caret range — and this code mirrors private internals. cm-lezer-table-internals-tripwire.test.ts asserts the mirrored regex, the raw-vs-sliced call-site asymmetry, the parseRow loop, the basePos start-position contract, and the resolved version, so an upstream change is a red rather than a silent divergence. It also pins that fenced-code-collapse.ts keeps its own deliberately narrower guard, with a behavioural fixture proving its in-fence hot path survives.

Test plan

  • pnpm compile / pnpm lint clean
  • pnpm test:unit — 278 files, 5271 tests green
  • pnpm build, pnpm package — vsix audit 0 violations
  • Force-installed and reloaded locally

mtskf added 13 commits September 6, 2026 00:03
… a line-shape delta

Swap the TABLE-DELIM arm of touchesStructuralReparse from a bare pipe-
presence check to tableRowShapeChanged, so typing inside an existing
table cell no longer forces a full rebuild for tableSkeletonField and
the other bounded-recompute consumers. Move the oldLine/newLine reads
up so the new arm can use them and drop the now-duplicate declarations
below. Rewrite the TABLE-DELIM doc paragraphs to describe the delta
contract instead of the old presence-based one.
…e mirror

Adds a source-text tripwire that reds if @lezer/markdown 1.6.4's private
table-formation internals (delimiterLine, the endLeaf/nextLine raw vs.
stripped asymmetry, the parseRow cell-counting loop, and the hasPipe/
parseRow call-site offsets) ever drift out from under the guard's mirror,
plus a version pin so a caret-range bump is a conscious edit.

Also pins that fenced-code-collapse.ts deliberately keeps its own
narrower STRUCTURAL guard rather than sharing the widened one, both as a
source-text assertion and as a behavioural fixture (editing a hash
comment / equals line / underscore rule / four-space-indented hash
inside an 11-line fence body stays equivalent to the full-recompute
oracle).
Chained differential fuzz over tableSkeletonField, imageBlockField,
calloutMarkerConcealField, and the three fold gutter fields: 160 chains
of 5 random single-character edits each, comparing the evolved state
against a fresh full build from the same document. Mutant-killed by
temporarily forcing tableRowShapeChanged to return false — the
exhaustive oracle reds as required; the fuzz stays green, empirically
confirming random sampling alone would not have caught this bug class.
Correct the field headers to describe the delta-based TABLE-DELIM arm
instead of the old presence-based one: table-skeleton.ts's PERF
paragraph now explains that only a delimiter row completing/breaking
or a cell-count change takes the full walk, and image-field.ts drops
the false 'a table cell' entry from its list of SHAPE-only regressing
classes.

Repair the two cm-table-skeleton.test.ts comments whose stated reason
('the line carries a |') stopped being true once TABLE-DELIM became a
per-line shape delta; the offsets and negative pins they document are
unchanged.

PERF.md, PERF-log.md, LEARNING.md and TODO.md are updated in the
separate local-only .claude/ tree (per this repo's TODO-local-mode
convention) and are not part of this commit.
…shaped line

The per-line four-fact delta is only sound where the parser's verdict is a pure
function of the one line that changed. For a delimiter-shaped line it is not, at
either @lezer/markdown call site, and neither fact is recoverable from that line:

  - TableParser.nextLine reaches `delimiterLine` only behind its own
    `line.next == 45 || 58 || 124` gate, and `line.next` is decided by a
    `skipSpace` that advances over charCodes 32/9 only, while `delimiterLine`'s
    `\s` also accepts NBSP / U+3000 / \f / \v.
  - endLeaf runs `parseRow(cx, next, line.basePos)` — the peeked delimiter line
    measured from the PRECEDING line's basePos. When the delimiter line is less
    indented than its header, `[0, basePos)` of it is table content: neither
    whitespace (so the mirrors' offset-equivalence argument fails) nor a
    container marker (so SHAPE does not fire either).

Both were reproduced against the real parser destroying a whole Table with all
four facts constant and every arm silent. So a line that is delimiter-shaped on
either side now fires unconditionally; pipe presence and cell count stay a delta
everywhere else. A header or data row is never delimiter-shaped, so the class
this narrowing exists for — typing inside a table cell — stays bounded. The
accepted cost is that editing a delimiter row always takes the full walk.

Also corrects the three parallel descriptions that had drifted: the roster of
"keystroke classes that now fire" still listed a table cell against its own
TABLE-DELIM note; table-skeleton.ts named two of the arm's facts while saying
"only"; image-field.ts described the pre-PR arm as document-global when it was
per changed line.
The oracle stayed green through both holes above because it could not enumerate
either class: SHAPE_CORPUS had no geometry pairing an indented header with a
less-indented delimiter line, and ALPHABET held only " " and "\t", so no edit
could separate `delimiterLine`'s `\s` from lezer's `skipSpace`. Add the two
geometries and the four non-ASCII whitespace characters. Measured with the
pre-repair predicate restored: 104 residuals over 19 documents, including the
ASCII-only `|--|-` -> `|- |-` edit that deletes a Table with every arm silent.

Also carries the enumerated edit offset on SingleCharEdit instead of letting the
consumer re-derive it with a first-difference scan (measured: 1277 of 75750
offsets differ, 30 on a different line — same document today, nothing red), drops
the oldLine/newLine fields nothing read, and hoists the loop-invariant base
EditorState to one per corpus document. The predicate never reads the syntax
tree, so settling it per edit bought nothing: 93672 edits now build 44 base
states instead of 93672.
…serialiser

`inCellBounded > 20` passed under the pre-PR presence arm too (measured 23 at the
shipped seed), so it pinned nothing about this change: a table's trailing
overshoot line carries no pipe, and editing it is bounded under either arm.
Require the edited line to carry a `|` as well and raise the threshold. Measured
across six seeds: 72 / 94 / 86 / 104 / 97 / 72 under the shipped arm, 0 at every
seed with the arm reverted — under which the assertion now reds.

serializeGutter took a hand-rolled parameter typing `value` as `unknown` and
recovered `elementClass` by unchecked cast. It is the only thing distinguishing
one gutter marker from another, so losing that property would degrade every
entry to `from-to:undefined` on both sides and silently reduce the differential
to range-only. Typed against RangeSet<GutterMarker>, that is a compile error.

Corrects the fenced-collapse comment claiming its four in-fence fixtures make the
two guards' divergence observable: step 3 of the reducer routes the bounded field
and the full-recompute oracle through the same buildFullState, so a widened guard
keeps the equivalence green. The fixtures do pin bounded correctness for those
markers; the narrowness is pinned by the tripwire's source-text assertions.
The retreat to presence on delimiter-shaped lines left four sites still explaining
themselves in terms of the pre-retreat four-fact delta:

- the admission test's "raw-only delimiter flip" is not raw-only (the stripped
  reading of ` :---|` is `:---|`, which also matches); what the case still pins is
  the consequence — an enclosing list's extent moving across a blank line
- the two shape-test rows labelled "RAW delimiter-ness only" isolate nothing now;
  they are kept as the counterexamples that forced the retreat
- "a header or data row is never delimiter-shaped" is false for `| - | - |`, which
  does match the regex and takes the presence path (a perf edge, not correctness)
- the corpus header's "~44k edits" predates the corpus and alphabet growth

Also records, against the SHAPE follow-up, that the same presence cost now reaches
delimiter-looking lines inside code blocks and prose.
…ndant

The second lazy-continuation document added in cycle 2 was measured to be the
sole carrier of nothing: with the PRESENCE retreat reverted to the four-fact
delta and the Unicode whitespace removed from ALPHABET (which isolates the
borrowed-basePos class from the `\s`-vs-`skipSpace` one), all 4 residuals land
on the FIRST document. Dropping it costs 2,201 enumerated edits and no
detection; the surviving entry's comment now records that measurement so the
line is not re-added on a hunch.

`SingleCharEdit.before` went dead when the enumerator started carrying `pos`
— the first-difference scan was its only reader — so the field and its three
initialisers go with it, and `armsFire` takes the edit record instead of three
re-spread fields (plus the explicit return type it was missing).

Comments: the "do NOT simplify a DELTA clause" warning was stated twice in
structural-guard.ts, and `tableRowShapeChanged`'s doc comment restated the
cell-body rationale its own header paragraph already carries. One copy of each
survives. The two consumer headers this PR edited in place are rewrapped to
the width the rest of their block uses, and the fuzz test's seed-sweep numbers
are re-measured against the shortened corpus (77 / 96 / 94 / 95 / 79 / 81,
still 0 at every seed under the pre-PR presence arm).
… heading

Two totals for one enumeration survived the corpus edit — the header was updated
while an undated `1277 of 75750 edits` eight lines below was not. Date it and say
which enumeration it measured, so the ratio outlives the next corpus change.

The shape test's section heading still read `the four facts, one per row`, naming
the reverted design; several rows beneath it fire on more than one clause now, and
the two it labelled as clause-isolating were relabelled last cycle as isolating
nothing.
@mtskf
mtskf merged commit 8765d81 into main Sep 5, 2026
2 checks passed
@mtskf
mtskf deleted the chore/narrow-structural-guard-table-delim branch September 5, 2026 19:52
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