Skip to content

perf(streaming): incremental committed-prefix rendering (fixes #21) - #23

Merged
jonathanKingston merged 4 commits into
mainfrom
claude/issue-21-ccvlpo
Jul 5, 2026
Merged

jonathanKingston merged 4 commits into
mainfrom
claude/issue-21-ccvlpo

Conversation

@jonathanKingston

@jonathanKingston jonathanKingston commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes the O(n²) committed-prefix re-rendering described in #21. Plain-prose DOM streaming now grows ~2×/doubling (down from ~4×) — ~12× faster at 100 paragraphs — while staying byte-identical to a fresh whole-string render.

Layer 1 — tokenize once per update (5166457)

A single update() / renderStreamingMarkdown() call re-tokenized the streamed string ~5–8×. This threads the tokens computed during the split back through the helpers:

  • splitForStreaming returns tokenizeBlocks(content) via the new StreamingSplitWithTokens; the boundary logic moves to an internal splitForStreamingCore.
  • getIncompleteFenceSource / getIncompleteTableSource / completeEndsInOpenTable / pendingLineBelongsInTable accept an optional pre-tokenized array.
  • renderMarkdown accepts options.tokens.

Instrumented result: ~4.5–6.0 → ~1.5–2.0 tokenizations per update (now ~1 amortized after the review fixes' per-commit cache). Stateless refactor, no behavioral change.

Layer 2 — freeze the settled prefix, re-render only the tail (e566e60)

stream-complete is split into a frozen region (top-level groups that can never change again; rendered/sanitized once, permanent node identity) and a tail group (the last still-mergeable group; the only thing re-rendered per commit, morphed so a growing table/list keeps node identity). FrozenTailRenderer.update is constant-time per commit regardless of prefix length. Every uncertainty falls back to today's exact full-morph behaviour.

Scaling fixes (2610f02)

  1. Tail-scope the pending-sync queries — pending elements always live at the streaming tail, so the per-frame whole-subtree querySelectors (~700µs each in jsdom) become lastElementChild-scoped (~8µs); pending-only frames are O(1).
  2. Keep the frozen boundary monotonic without a fallback storm — complete is not append-only (inline-hold retreat); never retreat frozenEnd, and fall back only when a later chunk extends an earlier block over the boundary (tokenStraddles).

Adversarial review + fixes (e8e287b)

A multi-agent review (line-by-line scan, removed-behavior audit, cross-file trace, plus differential fuzzes vs main) found 4 confirmed regressions, all reproduced, all fixed with regression tests:

  1. Indented pending list item after a paragraph — the tail-scoped findOpenListItemHost could return the pending <li> as its own nesting host, detaching the pending subtree and accumulating empty <ul>s. Now skips the pending item and reuses the trailing wrapper list.
  2. Non-append-only rewrites kept stale frozen DOM — # Alpha → # Bravo showed Alpha forever (every guard passed). New frozenSource byte-prefix guard (complete.startsWith(frozenSource), a memcmp) falls back on mismatch.
  3. Unclosed benign raw inline tags broke byte-parity — whole-string sanitize re-opens an unclosed <b> into later blocks (formatting-element reconstruction); per-fragment sanitize can't. A delta with unbalanced benign tags is never frozen; the stream degrades to full-morph (correct output).
  4. Node identity destroyed at each block's freeze frame — the delta was re-parsed instead of adopting the byte-identical existing tail nodes (selection/animation/media reset once per block). Delta+tail are now reconciled in one morphInnerHtmlFrom; insertFrozenDelta deleted.

Plus: serializeLinkRefs no longer embeds literal NUL bytes (the file was git-binary — its diff is now reviewable); pending-only frames no longer re-tokenize complete or re-scan for | (per-commit instance cache, reusing split.blocks when everything commits); delta/tail token slices are binary-searched instead of two O(#tokens) filters.

Verification

  • 445 tests + CommonMark conformance green.
  • Exhaustive convergence fuzz (STREAMING_FUZZ_ALL=1, every prefix of every baseline example) plus two independent review-time differential fuzzes vs main (~1,400 randomized streams).
  • Byte-parity at every fully-committed frame of a full streaming history; deterministic node-identity guards (pre→post freeze, and across 30 later commits).
  • Targeted tests for every settling hazard: setext retro-conversion, loose-list flip, table-row append, blockquote blank-run continuation, indented raw-HTML, trailing-open re-tokenization, lazy-continuation absorption, in-place rewrite, unclosed raw inline HTML, the forward-reference fallback, seam/sweep edges, and pending-kind transitions.
  • bench-streaming.mts scaling section + regression guard (mean growth < 3×/doubling; currently ~2.2×).

Known bounds (documented in the module header)

  • Limitation K: the per-update tokenizeBlocks and per-commit parseLinkReferenceDefinitions string scans stay O(prefix) (measured: 4% of wall-clock at 9 KB, 17% at 37 KB, 32% at 74 KB), so the asymptote is still Θ(n²); true linearity needs an incremental tokenizer + link-ref scan.
  • Long open lists: a trailing group that never settles (one long bullet list) keeps the whole group in the tail, degrading toward old per-commit cost for list-shaped output. Tail-bounding is follow-up work.
  • Layer 3 (folding lastComplete into frozenEnd) intentionally skipped.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LnAFvAgDY3RVYT6zJqN1fy

claude added 3 commits July 5, 2026 12:26
A single StreamingMarkdownRenderer.update() / renderStreamingMarkdown()
call previously re-tokenized the streamed string ~5-8 times: once in
splitForStreaming, again in getIncompleteFenceSource, a third time inside
formingTableSource's own fence check, a fourth in getIncompleteTableSource,
plus up to three tokenizations of `complete` via renderMarkdown and the
pendingLineBelongsInTable checks.

Thread the tokens computed during the split back through the helpers:

- splitForStreaming now returns the tokenizeBlocks(content) result via the
  new StreamingSplitWithTokens; the boundary logic moves to an internal
  splitForStreamingCore that takes the pre-computed tokens.
- getIncompleteFenceSource / getIncompleteTableSource / completeEndsInOpenTable
  / pendingLineBelongsInTable accept an optional pre-tokenized array.
- renderMarkdown accepts options.tokens to reuse the render-path tokenization.
- update() / renderStreamingMarkdown() compute tokenizeBlocks(complete) at
  most once, lazily (pending-only frames with no table pipe never force it),
  and pass content tokens into the forming-source helpers.

Net effect (instrumented): ~4.5-6.0 -> ~1.5-2.0 tokenizations per update.
Stateless refactor, no behavioral change; full suite and CommonMark
conformance unchanged (428 tests, 2 conformance suites green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LnAFvAgDY3RVYT6zJqN1fy
… (Layer 2, #21)

The committed prefix of a streaming message used to be fully re-rendered,
re-sanitized and re-morphed on every block commit, making a whole stream
O(n²) in the message length — the render/sanitize/morph layer dominated.

Split the committed subtree (`stream-complete`) into two regions:

- a *frozen* region — DOM for top-level groups that can never change again.
  Rendered, sanitized and inserted exactly once; node identity is permanent.
- a *tail group* — the last still-mergeable group (open list, growing table,
  open blockquote, or an unsettled trailing paragraph). The only thing
  re-rendered per commit, and morphed (not rebuilt) so a growing table/list
  keeps its node identity.

`FrozenTailRenderer.update` is now constant-time per commit regardless of how
long the committed prefix already is (verified: a settled paragraph keeps one
DOM node instance across a 10-commit stream). Correctness is guarded by a
full-morph fallback to today's exact behaviour on any uncertainty (a changed
committed link-ref map, a non-monotonic boundary), so the fast path is a pure
optimization — a mishandled case degrades to slower-but-correct output, never
to wrong output.

Details:
- New `src/streaming-frozen-tail.ts`: `settledTailStart` computes the boundary
  (conservative, always a top-level group boundary; open blanks stay in the
  tail so a `" " + "***"` re-tokenization can't strand a frozen node), and
  `FrozenTailRenderer` owns the frozen/tail split with the link-ref guard.
- `morphInnerHtmlFrom(container, startIndex, html)` reconciles only the tail
  region, leaving the frozen prefix untouched.
- Guard `findLastCommittedTable`'s whole-subtree `querySelectorAll('table')`
  behind a cheap `complete.includes('|')` check — a committed GFM table always
  contains a pipe — removing an O(prefix) DOM scan from every table-free update.
- Byte-parity is asserted across the CommonMark baseline corpus at every
  fully-committed frame of a full streaming history, plus targeted tests for
  each settling hazard (setext retro-conversion, loose-list flip, table body
  append, blockquote blank-run continuation, indented raw-HTML, trailing-open
  re-tokenization), the forward-reference fallback, and the seam/sweep edges.

The DOM path now runs well below the full-re-render string path in the bench
(dom/str ~0.3-0.7×, was ~0.9-1.3×). Residual per-update O(prefix) costs remain
outside this layer (issue #21 limitation K): the full-string tokenize, the
per-commit link-ref scan, and the pending-sync `querySelector` traversals —
follow-up work, documented in the module header.

Full suite (439 tests) and CommonMark conformance unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LnAFvAgDY3RVYT6zJqN1fy
…notonic (#21)

Follow-up to the frozen/tail split: two O(prefix)-per-update DOM costs remained
in the streaming path, keeping whole-stream growth at ~4×/doubling despite the
committed-prefix render being flattened. Both are fixed here; the plain-prose
bench now grows ~2×/doubling (down from ~4×) — ~12× faster at 100 paragraphs.

1. Tail-scope the pending-sync DOM queries. `clearListContinuationDom`,
   the pending-`<li>` / direct-block lookups, and `findOpenListItemHost` used
   `querySelector`/`:scope >` over the whole committed subtree — ~700µs each on
   a large tree in jsdom, run every frame. Pending elements always live at the
   streaming tail (inside the last element child, or as the last direct child),
   and every clear runs before the next append, so scope the queries to
   `completedEl.lastElementChild` (~8µs). New `tailPendingDescendant` /
   `tailDirectPendingBlock` helpers; pending-only frames are now O(1).

2. Keep the frozen boundary monotonic without a fallback storm. `complete`
   is NOT append-only — a new unresolved inline in the trailing open paragraph
   retreats the split, shrinking `complete` and nudging the settled boundary
   back over a committed blank. The old `settledOffset < frozenEnd` fallback
   fired on that jitter ~once per paragraph, each time resetting frozen state
   and re-rendering the whole prefix (the residual O(n²)). Replace it: never
   retreat `frozenEnd` (clamp the advance), and fall back only when `frozenEnd`
   no longer lands on a block boundary — i.e. a later chunk extended an earlier
   block over it via lazy paragraph continuation (`"Foo\n"` + `"    ***"` → one
   paragraph). New `tokenStraddles` binary-searches for that case.

Verification: 441 tests + CommonMark conformance green; exhaustive convergence
fuzz (every prefix of every baseline example) passes. New tests: frozen first
paragraph keeps one node instance across a 30-paragraph inline-heavy stream (the
jitter path that used to churn), lazy-continuation absorption byte-parity, and
no stale pending element across pending-kind transitions. bench-streaming.mts
gains a scaling regression guard (mean growth < 3×/doubling).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LnAFvAgDY3RVYT6zJqN1fy
@jonathanKingston jonathanKingston changed the title perf(streaming): incremental committed-prefix rendering (fixes #21 Layers 1–2) perf(streaming): incremental committed-prefix rendering (fixes #21) Jul 5, 2026
…ath costs)

An adversarial review of the frozen/tail work surfaced four confirmed
correctness regressions (each reproduced on the branch; all pass on main)
plus two hot-path inefficiencies and a tooling problem. All fixed here,
each with a regression test.

1. Indented pending list item after a non-list block (streaming.ts).
   The tail-scoped findOpenListItemHost could return the pending <li>
   itself as the nesting host, so syncListPendingDom nested a list inside
   it, then detached it — the pending item's text became invisible and a
   fresh empty <ul> accumulated every other frame. findOpenListItemHost
   now skips the pending item (the host must be a committed <li>), and the
   indent>0 path with no committed host falls through to the trailing
   top-level list, reusing the wrapper created on the previous frame.

2. Non-append-only updates kept stale frozen DOM (streaming-frozen-tail.ts).
   Rewriting content in place ("# Alpha" -> "# Bravo") keeps block
   boundaries intact, so every existing guard passed and the frozen region
   showed the old text forever. New frozenSource guard: the exact source
   bytes of the frozen region are kept and each commit checks
   complete.startsWith(frozenSource) (a memcmp, no allocation), falling
   back to the full morph on mismatch. Subsumes the frozenEnd-past-end check.

3. Unclosed benign raw inline tags broke byte-parity (streaming-frozen-tail.ts).
   Whole-string sanitization re-opens an unclosed <b> into every later
   block (HTML formatting-element reconstruction); per-fragment sanitize
   cannot reproduce that. A delta whose rendered HTML has unbalanced
   benign raw inline tags (b/i/u/s/del/ins/sub/sup/kbd/mark) is now never
   frozen — the stream degrades to the full-morph path, correct output.

4. Node identity was destroyed at each block's freeze frame
   (streaming-frozen-tail.ts). insertFrozenDelta parsed fresh nodes for the
   newly-settled delta while the byte-identical existing tail nodes were
   discarded — CSS transitions restarted, selection dropped, media
   reloaded, once per block. The delta and tail are now reconciled in ONE
   morphInnerHtmlFrom call, adopting the existing tail nodes; the frozen
   node count advances via an O(delta) probe parse of the delta fragment
   alone. insertFrozenDelta is deleted.

Also:
- serializeLinkRefs embedded literal NUL bytes as field separators, which
  made this source file binary to git (unreviewable diffs). Entries are
  now JSON-encoded lines — unambiguous and printable.
- Pending-only frames no longer pay O(prefix): tokenizeBlocks(complete)
  and complete.includes('|') are computed once per COMMIT and cached on
  the renderer (committedTokens/committedHasPipe); when the split commits
  everything, split.blocks is reused instead of re-tokenizing.
- The per-commit deltaTokens/tailTokens are now binary-searched slices
  (shared lowerBound, also used by tokenStraddles) instead of two
  O(#tokens) filter passes.
- Module header now documents the long-open-list tail bound honestly.

445 tests + CommonMark conformance green; exhaustive convergence fuzz
(every prefix of every baseline example) passes; bench scaling holds at
~2.2x/doubling under the <3x regression guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LnAFvAgDY3RVYT6zJqN1fy
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.

2 participants