perf(streaming): intra-list tail bounding — freeze settled items of an open list (fixes #29) - #39
Merged
Merged
Conversation
…n open list (#29) A long, still-open trailing list kept its whole group in the tail (loose/tight is a whole-list property, so no item could settle), re-rendering, re-sanitizing and re-morphing every item on every commit — the old O(n²) for arguably the most common long LLM output shape. Streaming a 200-item list: 8,870ms -> 1,053ms (8.4x), growth per doubling ~4.1x -> ~2.1x. Settled items now freeze INSIDE the shared <ul>/<ol>: - render-blocks.ts: the list-group knowledge is factored into exported primitives — listGroupSignature / listSliceContinuesGroup / listItemSliceIsMultiParagraph / scanListGroup / renderListItemsSlice / listGroupOpenTag+CloseTag — and collectListGroup is rewired onto them, so rendering and streaming share ONE source of truth for group membership, looseness, and item HTML (an item slice renders byte-identically to the corresponding items of a whole-group render). - streaming-dom-morph.ts: morphElementChildrenFrom (reconcile an element's children from an offset — morphChildren already supported it) and syncAttributes (the shared list element may legitimately change attributes, e.g. gaining class="contains-task-list", while its frozen items must not be touched). - streaming-frozen-tail.ts: intra-list mode. Activation is pure state init after a generic commit whose trailing group is a signature-uniform open list with >=4 items starting at the frozen boundary. Per commit, the unfrozen item slice is scanned (blanks + signature-continuing items, incremental loose evidence per scanListGroup's exact rule), all but the last item are frozen (the last can still absorb continuations; token extension over the boundary trips the existing straddle guard), and the region is reconciled with one wrapped-list morph so settling items keep DOM node identity. When a non-continuing token ends the group, the list is sealed — promoted to a fully frozen top-level node — and the generic path processes what follows in the same commit, with no full-morph. Safety, all via the existing full-morph fallback (correct-but-slower, never wrong): tight->loose flip against frozen items (one-time per list — looseness is monotonic), signature break, unbalanced benign raw inline tags in a would-freeze slice, in-place rewrites (frozenSource byte guard), and any DOM shape mismatch. Mixed-marker giant runs and long open blockquotes stay on the generic path (conservative; noted in the module header). Tests: full-history byte-parity through freeze/seal/ordered-list sequences and every hazard above; frozen-<li> node identity; a deterministic renderedChars CI guard for the list shape (1.95x-1.99x measured, <3x threshold). 472 tests + CommonMark conformance + exhaustive convergence fuzz green. Closes #29. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnAFvAgDY3RVYT6zJqN1fy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #29 — the biggest perf gap left after #23: a long, still-open trailing list kept its whole group in the tail (loose/tight is a whole-list property, so no item could settle), re-rendering/re-sanitizing/re-morphing every item on every commit. That's the old O(n²) for arguably the most common long LLM output shape.
Result: streaming a 200-item list drops 8,870ms → 1,053ms (8.4×); growth per doubling ~4.1× → ~2.1×.
How
Settled items freeze inside the shared
<ul>/<ol>(two sibling lists would break byte-parity — CommonMark renders one), per the design on #29:render-blocks.ts— the list-group knowledge is factored into exported primitives (listGroupSignature,listSliceContinuesGroup,listItemSliceIsMultiParagraph,scanListGroup,renderListItemsSlice,listGroupOpenTag/CloseTag) andcollectListGroupis rewired onto them: rendering and streaming share one source of truth for group membership, looseness, and item HTML. An item slice renders byte-identically to the corresponding items of a whole-group render — the property everything else rests on.streaming-dom-morph.ts—morphElementChildrenFrom(reconcile an element's children from an offset) andsyncAttributes(the shared list element may legitimately change attributes — e.g. gainingclass="contains-task-list"when a later item adds a checkbox — while its frozen<li>s are never touched).streaming-frozen-tail.ts— the intra-list mode. Activation is pure state init after a generic commit whose trailing group is a signature-uniform open list with ≥4 items starting at the frozen boundary. Per commit, the unfrozen item slice is scanned (incremental loose evidence, exactlyscanListGroup's rule), all but the last item freeze (the last can still absorb continuations; token extension over the boundary trips the existing straddle guard), and the region is reconciled with one wrapped-list morph so settling items keep DOM node identity. Sealing: when a non-continuing token ends the group, the list is promoted to a fully frozen top-level node and the generic path handles what follows in the same commit — no full-morph at list end.Safety (tight-optimistic, everything behind the existing full-morph fallback)
frozenSourcebyte guard), DOM shape mismatch → fallback.Verification
startattribute, nested lists, rewrite-while-active.<li>node identity preserved for the rest of the stream.renderedCharsgrows 1.95–1.99× per item-count doubling (< 3× threshold; ~4× = regression).🤖 Generated with Claude Code
https://claude.ai/code/session_01LnAFvAgDY3RVYT6zJqN1fy
Generated by Claude Code