From 718c7cea2f8595e7bc9a36e1d36b36b3b3e0ab48 Mon Sep 17 00:00:00 2001 From: oldschoola Date: Mon, 29 Jun 2026 17:47:22 -0700 Subject: [PATCH] perf(coding-agent): memoize incremental grapheme slicing in streaming reveal Each 30fps streaming-reveal tick re-segmented the whole revealed prefix via sliceGraphemes (per-tick slice cost grew with the prefix). BlockUnitCounter already memoized per-block grapheme counts; apply the same idea to slicing: cache the slice boundary per block index and re-segment only the per-step delta from the boundary cluster, so per-update cost grows with the delta instead of the rendered prefix. buildDisplayMessage gains an optional sliceOf seam (default = sliceGraphemes, so existing callers are unchanged); the controller wires its BlockUnitCounter.slice through it via a #build helper that also de-duplicates six previously-repeated buildDisplayMessage call sites. Only an exact (text, units) hit skips segmentation; the incremental guard (text === cached.text || text.startsWith(cached.text)) && units >= cached.units re-segments from the boundary cluster, so an append that extends the final cluster (e.g. a -> a combining acute, ZWJ family merge) is never stale. Benchmark (bench/streaming-throughput.bench.ts, 30520-grapheme message, 61 ticks/episode): 23.78ms -> 2.27ms per episode (~10x). Regression tests vs a pure Intl.Segmenter reference cover fixed-text growing units, append growth, boundary-cluster extension, multi-block indices, shrink/regrow, full replace, and a 400-step seeded fuzz. --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/bench/rendering.ts | 35 +++-- .../bench/streaming-throughput.bench.ts | 125 +++++++++++++++++ .../src/modes/controllers/streaming-reveal.ts | 128 ++++++++++-------- .../test/streaming-reveal.test.ts | 100 ++++++++++++++ 5 files changed, 326 insertions(+), 66 deletions(-) create mode 100644 packages/coding-agent/bench/streaming-throughput.bench.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c68409f79..107297e40 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Sped up smooth streaming reveal ~10x for large messages: each 30fps tick now slices only the per-step grapheme delta via memoized incremental slicing in `BlockUnitCounter`, instead of re-segmenting the whole revealed prefix every tick. + ## [16.2.6] - 2026-06-29 ### Changed diff --git a/packages/coding-agent/bench/rendering.ts b/packages/coding-agent/bench/rendering.ts index 8e32428a6..b35f6917e 100644 --- a/packages/coding-agent/bench/rendering.ts +++ b/packages/coding-agent/bench/rendering.ts @@ -7,7 +7,7 @@ import { AssistantMessageComponent } from "../src/modes/components/assistant-mes import { TranscriptContainer } from "../src/modes/components/transcript-container"; import { Settings } from "../src/config/settings"; import { getEditorTheme } from "../src/modes/theme/theme"; -import { buildDisplayMessage, nextStep, visibleUnits } from "../src/modes/controllers/streaming-reveal"; +import { BlockUnitCounter, buildDisplayMessage, nextStep, visibleUnits } from "../src/modes/controllers/streaming-reveal"; import * as os from "node:os"; import * as path from "node:path"; import * as fs from "node:fs"; @@ -55,11 +55,12 @@ bench("WelcomeComponent.render", () => { // ── A2: streaming reveal + editor render baselines ────────────────────────── // -// Diagnostic series, not a fixed-iteration micro-op. `streamingReveal` proves -// or refutes the O(N^2) reveal hypothesis: per-step cost (visibleUnits + -// buildDisplayMessage, the work every stream delta/30fps tick does) is sampled -// at growing revealed lengths. Rising per-step ms => O(N) per tick => O(N^2) -// over the message. Flat per-step => already linear. +// Diagnostic series, not a fixed-iteration micro-op. The full-reveal loops +// mirror the controller: a per-episode BlockUnitCounter feeds countOf + sliceOf +// (memoized, O(delta)/tick). `streamingReveal` (C1) instead measures the DEFAULT +// pure-sliceGraphemes path at a fixed revealed length — the un-memoized cost the +// counter avoids. Representative controller-path throughput lives in +// bench/streaming-throughput.bench.ts. function makeMarkdownCorpus(targetGraphemes: number): string { const para = @@ -106,7 +107,7 @@ const REVEAL_CORPUS = makeMarkdownCorpus(6000); const REVEAL_CHECKPOINTS = [1000, 2000, 3000, 4000, 5000, 6000]; const STEP_REPS = 40; -console.log("\nstreamingReveal (isolated C1: visibleUnits + buildDisplayMessage per delta):"); +console.log("\nstreamingReveal (C1: default pure-slice path, fixed revealed length, no memoization):"); for (const n of REVEAL_CHECKPOINTS) { const msg = makeTextMessage(REVEAL_CORPUS.slice(0, n)); const revealed = Math.floor(n * 0.9); @@ -117,13 +118,18 @@ for (const n of REVEAL_CHECKPOINTS) { console.log(` len=${n}: ${ms.toFixed(4)}ms/step`); } -// Real streaming cost: text GROWS every tick, so Markdown's text-keyed cache -// misses each step (the actual interactive path). Total ms to fully reveal an -// N-grapheme message in nextStep increments — the number C1+C2 reduce. -console.log("\nstreamingRevealFull (C1+C2: full incremental reveal, growing text => cache-miss/tick):"); +// Controller path: a per-episode BlockUnitCounter memoizes count + slice, so +// buildDisplayMessage is O(delta)/tick. The Markdown render (component.render) +// still re-lexes the growing text each step here (no { transient: true }), so +// total ms is dominated by the render, not the slice. Total ms to fully reveal +// an N-grapheme message in nextStep increments. +console.log("\nstreamingRevealFull (controller-path counter + Markdown render, growing text):"); try { for (const n of REVEAL_CHECKPOINTS) { const full = makeTextMessage(REVEAL_CORPUS.slice(0, n)); + const counter = new BlockUnitCounter(); + const countOf = (index: number, text: string): number => counter.count(index, text); + const sliceOf = (index: number, text: string, units: number): string => counter.slice(index, text, units); const total = visibleUnits(full, false); const component = new AssistantMessageComponent(); const start = Bun.nanoseconds(); @@ -131,7 +137,7 @@ try { let steps = 0; while (revealed < total) { revealed = Math.min(total, revealed + nextStep(total - revealed)); - component.updateContent(buildDisplayMessage(full, revealed, false)); + component.updateContent(buildDisplayMessage(full, revealed, false, countOf, sliceOf)); component.render(WIDTH); steps++; } @@ -153,6 +159,9 @@ try { const thinking = makeMarkdownCorpus(2500); for (const n of [2000, 4000, 6000]) { const full = makeThinkingPlusText(thinking, REVEAL_CORPUS.slice(0, n)); + const counter = new BlockUnitCounter(); + const countOf = (index: number, text: string): number => counter.count(index, text); + const sliceOf = (index: number, text: string, units: number): string => counter.slice(index, text, units); const total = visibleUnits(full, false); const component = new AssistantMessageComponent(); const start = Bun.nanoseconds(); @@ -160,7 +169,7 @@ try { let steps = 0; while (revealed < total) { revealed = Math.min(total, revealed + nextStep(total - revealed)); - component.updateContent(buildDisplayMessage(full, revealed, false)); + component.updateContent(buildDisplayMessage(full, revealed, false, countOf, sliceOf)); component.render(WIDTH); steps++; } diff --git a/packages/coding-agent/bench/streaming-throughput.bench.ts b/packages/coding-agent/bench/streaming-throughput.bench.ts new file mode 100644 index 000000000..ca51fdcd9 --- /dev/null +++ b/packages/coding-agent/bench/streaming-throughput.bench.ts @@ -0,0 +1,125 @@ +/** + * Streaming-reveal per-tick compute benchmark. + * + * Mirrors `StreamingRevealController`'s per-tick work: re-count the visible + * units of the target (memoized `BlockUnitCounter.count`) and rebuild the + * display message (`buildDisplayMessage`), which slices each text block to the + * revealed prefix. One `BlockUnitCounter` is created per episode and shared by + * `countOf` + `sliceOf`, exactly as the controller holds one `#unitCounter` per + * streaming episode. + * + * The Markdown render is intentionally excluded: every controller tick passes + * `{ transient: true }` to `updateContent`, which disables the L2 cache and code + * highlighting, so the dominant per-tick cost is the slice of the growing prefix + * (re-segmented from offset 0 by the baseline `sliceGraphemes`). This isolates + * exactly that path. + * + * Metric (lower is better): total wall-clock to fully reveal one representative + * large assistant message through the controller's `nextStep` progression, + * averaged over episodes. `reveal_ms_per_step` is the same work divided by the + * number of reveal ticks. + * + * Run: bun run packages/coding-agent/bench/streaming-throughput.bench.ts + */ +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { BlockUnitCounter, buildDisplayMessage, nextStep } from "../src/modes/controllers/streaming-reveal"; + +const HIDE_THINKING = false; +const PROSE_ONLY = true; +const WARMUP_EPISODES = 6; +const MEASURE_EPISODES = 40; + +/** Prose + code + accented text + multibyte grapheme clusters (ZWJ families, + * flag sequences, skin-tone modifiers, CJK), representative of LLM output that + * makes Intl.Segmenter do real per-cluster work. */ +const CHUNK = `Here is an overview of the rendering pipeline changes. + +The streaming reveal controller now advances the revealed prefix each tick. Consider the helper: + +\`\`\`ts +function sliceToUnits(text: string, units: number): string { +\tlet end = 0; +\tfor (const { index, segment } of segmenter.segment(text)) { +\t\tif (--units < 0) break; +\t\tend = index + segment.length; +\t} +\treturn text.slice(0, end); +} +\`\`\` + +This handles café, naïve résumés, and emoji clusters like the 👨‍👩‍👧‍👦 family, the 🏳️‍🌈 flag, and the 👩🏽 skin-tone modifier. CJK text such as 日本語のテスト also segments correctly, and the heart ❤️ beats steadily. Each grapheme cluster is one user-perceived character: "👨‍👩‍👧‍👦" is a single unit, not seven code points. The decomposed sequence e followed by a combining acute accent (e + \\u0301) is likewise a single cluster, distinct from the precomposed form. + +The adaptive step is \`nextStep = max(3, ceil(backlog / 8))\`, so the tick count stays roughly constant while the per-tick slice cost grows with the rendered prefix. Incremental slicing lowers that per-update cost from the prefix length to the per-step delta. + +`; + +function makeMessage(textBlocks: string[]): AssistantMessage { + return { + role: "assistant", + content: textBlocks.map(text => ({ type: "text" as const, text })), + api: "anthropic-messages", + provider: "anthropic", + model: "mock", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 0, + }; +} + +/** Total visible graphemes of the target's text blocks via the counter (mirrors + * the controller's `#visibleUnits` for text-only blocks). */ +function textUnits(target: AssistantMessage, counter: BlockUnitCounter): number { + let total = 0; + for (let i = 0; i < target.content.length; i++) { + const block = target.content[i]; + if (block?.type === "text") total += counter.count(i, block.text); + } + return total; +} + +/** Drive one full reveal episode: a fresh counter shared by countOf + sliceOf, + * an initial render at revealed = 0 (mirrors `begin`), then the `nextStep` + * catch-up loop (mirrors `#tick`). Returns the number of reveal ticks. */ +function revealEpisode(target: AssistantMessage): number { + const counter = new BlockUnitCounter(); + const countOf = (index: number, text: string): number => counter.count(index, text); + const sliceOf = (index: number, text: string, units: number): string => counter.slice(index, text, units); + buildDisplayMessage(target, 0, HIDE_THINKING, PROSE_ONLY, countOf, sliceOf); + let revealed = 0; + let ticks = 0; + for (;;) { + const total = textUnits(target, counter); + if (revealed >= total) break; + revealed = Math.min(total, revealed + nextStep(total - revealed)); + buildDisplayMessage(target, revealed, HIDE_THINKING, PROSE_ONLY, countOf, sliceOf); + ticks += 1; + } + return ticks; +} + +// Two text blocks of differing lengths exercise multi-block counter indexing. +const target = makeMessage([CHUNK.repeat(16), CHUNK.repeat(12)]); +const sizingCounter = new BlockUnitCounter(); +const graphemes = textUnits(target, sizingCounter); + +for (let episode = 0; episode < WARMUP_EPISODES; episode++) revealEpisode(target); + +let totalTicks = 0; +const start = performance.now(); +for (let episode = 0; episode < MEASURE_EPISODES; episode++) totalTicks += revealEpisode(target); +const elapsedMs = performance.now() - start; + +const msPerEpisode = elapsedMs / MEASURE_EPISODES; +const msPerStep = elapsedMs / totalTicks; + +console.log(`METRIC reveal_ms_per_episode=${msPerEpisode.toFixed(4)}`); +console.log(`METRIC reveal_ms_per_step=${msPerStep.toFixed(5)}`); +console.log(`ASI graphemes=${graphemes} episodes=${MEASURE_EPISODES} ticks_per_episode=${(totalTicks / MEASURE_EPISODES).toFixed(2)} warmup=${WARMUP_EPISODES}`); +console.log(`(reveal: ${graphemes} graphemes, ${(totalTicks / MEASURE_EPISODES).toFixed(1)} ticks/episode, ${msPerEpisode.toFixed(3)} ms/episode, ${msPerStep.toFixed(4)} ms/step)`); diff --git a/packages/coding-agent/src/modes/controllers/streaming-reveal.ts b/packages/coding-agent/src/modes/controllers/streaming-reveal.ts index 2491c8b78..fa4ae12ab 100644 --- a/packages/coding-agent/src/modes/controllers/streaming-reveal.ts +++ b/packages/coding-agent/src/modes/controllers/streaming-reveal.ts @@ -11,6 +11,7 @@ export const CATCHUP_FRAMES = 8; type AssistantContentBlock = AssistantMessage["content"][number]; type DisplayThinkingContentBlock = Extract & { rawThinking?: string }; type StreamingRevealComponent = Pick; +type GraphemeSlicer = (index: number, text: string, units: number) => string; type StreamingRevealControllerOptions = { getSmoothStreaming(): boolean; @@ -44,12 +45,29 @@ function countGraphemesFrom(text: string, start: number): { count: number; tailS } return { count, tailStart }; } +/** Segment `text` from code-unit offset `start`, walking up to `clusters` + * graphemes. Returns the code-unit END of the final cluster walked, its START + * (`lastStart`), and how many clusters were found (`count` may be less than + * `clusters` if the suffix is shorter than requested). */ +function segmentFrom(text: string, start: number, clusters: number): { end: number; lastStart: number; count: number } { + let count = 0; + let lastStart = start; + let end = start; + for (const seg of getSegmenter().segment(start === 0 ? text : text.slice(start))) { + count += 1; + lastStart = start + seg.index; + end = start + seg.index + seg.segment.length; + if (count >= clusters) break; + } + return { end, lastStart, count }; +} /** Memoizes per-block grapheme counts across reveal ticks. Streaming blocks only * grow by appending, and an append can only alter the final grapheme cluster of * the previous text, so only the suffix from that cluster needs re-segmenting. */ -class BlockUnitCounter { +export class BlockUnitCounter { #entries = new Map(); + #sliceEntries = new Map(); count(index: number, text: string): number { const entry = this.#entries.get(index); @@ -69,6 +87,29 @@ class BlockUnitCounter { reset(): void { this.#entries.clear(); + this.#sliceEntries.clear(); + } + /** Slice `text` to its first `units` graphemes. Memoized across reveal ticks: + * streaming blocks grow only by appending and the reveal target advances + * monotonically, so a previously sliced prefix is reused and only the suffix + * from the boundary cluster is re-segmented. Only an exact (text, units) hit + * skips segmentation entirely — an append can extend the boundary cluster, so + * the incremental path still re-segments from that cluster's start. */ + slice(index: number, text: string, units: number): string { + if (units <= 0 || text.length === 0) return ""; + const entry = this.#sliceEntries.get(index); + if (entry !== undefined && entry.text === text && entry.units === units) { + return entry.end >= text.length ? text : text.slice(0, entry.end); + } + if (entry !== undefined && (entry.text === text || text.startsWith(entry.text)) && units >= entry.units) { + const extra = units - entry.units + 1; + const seg = segmentFrom(text, entry.lastStart, extra); + this.#sliceEntries.set(index, { text, units, end: seg.end, lastStart: seg.lastStart }); + return seg.end >= text.length ? text : text.slice(0, seg.end); + } + const seg = segmentFrom(text, 0, units); + this.#sliceEntries.set(index, { text, units, end: seg.end, lastStart: seg.lastStart }); + return seg.end >= text.length ? text : text.slice(0, seg.end); } } @@ -104,20 +145,24 @@ function revealTextBlock( block: Extract, remaining: number, units: number, + index: number, + sliceOf: GraphemeSlicer, ): AssistantContentBlock { if (remaining <= 0) return block.text.length === 0 ? block : { ...block, text: "" }; if (remaining >= units) return block; - return { ...block, text: sliceGraphemes(block.text, remaining) }; + return { ...block, text: sliceOf(index, block.text, remaining) }; } function revealThinkingBlock( block: Extract, remaining: number, units: number, + index: number, + sliceOf: GraphemeSlicer, ): AssistantContentBlock { if (remaining <= 0) return block.thinking.length === 0 ? block : { ...block, thinking: "" }; if (remaining >= units) return block; - return { ...block, thinking: sliceGraphemes(block.thinking, remaining) }; + return { ...block, thinking: sliceOf(index, block.thinking, remaining) }; } export function buildDisplayMessage( @@ -126,6 +171,7 @@ export function buildDisplayMessage( hideThinking: boolean, proseOnly = true, countOf: (index: number, text: string) => number = (_index, text) => countGraphemes(text), + sliceOf: GraphemeSlicer = (_index, text, units) => sliceGraphemes(text, units), ): AssistantMessage { let remaining = Math.max(0, Math.floor(revealed)); const content: AssistantContentBlock[] = []; @@ -133,7 +179,7 @@ export function buildDisplayMessage( const block = target.content[i]!; if (block.type === "text") { const units = countOf(i, block.text); - content.push(revealTextBlock(block, remaining, units)); + content.push(revealTextBlock(block, remaining, units, i, sliceOf)); remaining = Math.max(0, remaining - units); } else if (block.type === "thinking" && !hideThinking) { const formatted = formatThinkingForDisplay(block.thinking, proseOnly); @@ -144,7 +190,7 @@ export function buildDisplayMessage( thinking: formatted, rawThinking: block.thinking, }; - content.push(revealThinkingBlock(displayBlock, remaining, units)); + content.push(revealThinkingBlock(displayBlock, remaining, units, i, sliceOf)); remaining = Math.max(0, remaining - units); } else { content.push(block); @@ -174,6 +220,8 @@ export class StreamingRevealController { #smoothStreaming = true; readonly #unitCounter = new BlockUnitCounter(); readonly #countOf = (index: number, text: string): number => this.#unitCounter.count(index, text); + readonly #sliceOf = (index: number, text: string, units: number): string => + this.#unitCounter.slice(index, text, units); constructor(options: StreamingRevealControllerOptions) { this.#getSmoothStreaming = options.getSmoothStreaming; @@ -181,6 +229,16 @@ export class StreamingRevealController { this.#getProseOnlyThinking = options.getProseOnlyThinking; this.#requestRender = options.requestRender; } + #build(target: AssistantMessage, revealed: number): AssistantMessage { + return buildDisplayMessage( + target, + revealed, + this.#hideThinkingBlock, + this.#proseOnlyThinking, + this.#countOf, + this.#sliceOf, + ); + } begin(component: StreamingRevealComponent, message: AssistantMessage): void { this.stop(); @@ -192,10 +250,7 @@ export class StreamingRevealController { this.#smoothStreaming = this.#getSmoothStreaming(); if (!this.#smoothStreaming) { const total = this.#visibleUnits(message); - component.updateContent( - buildDisplayMessage(message, total, this.#hideThinkingBlock, this.#proseOnlyThinking, this.#countOf), - { transient: true }, - ); + component.updateContent(this.#build(message, total), { transient: true }); return; } const total = this.#visibleUnits(message); @@ -203,18 +258,9 @@ export class StreamingRevealController { // A tool call is a transcript-order boundary: finish any leading // assistant text before EventController renders the separate tool card. this.#revealed = total; - component.updateContent( - buildDisplayMessage( - message, - this.#revealed, - this.#hideThinkingBlock, - this.#proseOnlyThinking, - this.#countOf, - ), - { - transient: true, - }, - ); + component.updateContent(this.#build(message, this.#revealed), { + transient: true, + }); return; } this.#renderCurrent(); @@ -229,10 +275,7 @@ export class StreamingRevealController { if (!this.#component) return; if (!this.#smoothStreaming) { const total = this.#visibleUnits(message); - this.#component.updateContent( - buildDisplayMessage(message, total, this.#hideThinkingBlock, this.#proseOnlyThinking, this.#countOf), - { transient: true }, - ); + this.#component.updateContent(this.#build(message, total), { transient: true }); return; } const total = this.#visibleUnits(message); @@ -241,18 +284,9 @@ export class StreamingRevealController { // assistant text before EventController renders the separate tool card. this.#revealed = total; this.#stopTimer(); - this.#component.updateContent( - buildDisplayMessage( - message, - this.#revealed, - this.#hideThinkingBlock, - this.#proseOnlyThinking, - this.#countOf, - ), - { - transient: true, - }, - ); + this.#component.updateContent(this.#build(message, this.#revealed), { + transient: true, + }); return; } if (this.#revealed > total) { @@ -309,16 +343,7 @@ export class StreamingRevealController { // Every controller render is an in-flight streaming snapshot, even when // smooth reveal has temporarily caught up to the current target. The // message_end handler performs the only stable non-transient render. - this.#component.updateContent( - buildDisplayMessage( - this.#target, - this.#revealed, - this.#hideThinkingBlock, - this.#proseOnlyThinking, - this.#countOf, - ), - { transient: true }, - ); + this.#component.updateContent(this.#build(this.#target, this.#revealed), { transient: true }); } #syncTimer(total = this.#target ? this.#visibleUnits(this.#target) : 0): void { @@ -356,12 +381,9 @@ export class StreamingRevealController { return; } this.#revealed = Math.min(total, this.#revealed + nextStep(total - this.#revealed)); - component.updateContent( - buildDisplayMessage(target, this.#revealed, this.#hideThinkingBlock, this.#proseOnlyThinking, this.#countOf), - { - transient: true, - }, - ); + component.updateContent(this.#build(target, this.#revealed), { + transient: true, + }); this.#requestRender(); if (this.#revealed >= total) { this.#stopTimer(); diff --git a/packages/coding-agent/test/streaming-reveal.test.ts b/packages/coding-agent/test/streaming-reveal.test.ts index 76c397095..ac1d93ec9 100644 --- a/packages/coding-agent/test/streaming-reveal.test.ts +++ b/packages/coding-agent/test/streaming-reveal.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; import { + BlockUnitCounter, buildDisplayMessage, CATCHUP_FRAMES, MIN_STEP, @@ -11,6 +12,7 @@ import { visibleUnits, } from "@oh-my-pi/pi-coding-agent/modes/controllers/streaming-reveal"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { getSegmenter } from "@oh-my-pi/pi-tui"; beforeAll(async () => { await initTheme(false); @@ -297,3 +299,101 @@ describe("streaming reveal", () => { expect(requestRender).toHaveBeenCalledTimes(1); }); }); + +/** Pure Intl.Segmenter grapheme count, independent of BlockUnitCounter's memoization. */ +function refCount(text: string): number { + let n = 0; + for (const _segment of getSegmenter().segment(text)) n += 1; + return n; +} + +/** Pure Intl.Segmenter grapheme slice, independent of BlockUnitCounter's memoization. */ +function refSlice(text: string, units: number): string { + if (units <= 0) return ""; + let n = 0; + for (const { index, segment } of getSegmenter().segment(text)) { + n += 1; + if (n >= units) return text.slice(0, index + segment.length); + } + return text; +} + +describe("BlockUnitCounter.slice", () => { + it("matches a pure segmenter reference for fixed-text growing units", () => { + const counter = new BlockUnitCounter(); + const text = "café 👨‍👩‍👧‍👦 naïve 日本語 ❤️"; + const total = refCount(text); + for (let units = 0; units <= total; units++) { + expect(counter.slice(0, text, units)).toBe(refSlice(text, units)); + } + }); + + it("re-segments the boundary cluster when an append extends it (no stale slice)", () => { + const counter = new BlockUnitCounter(); + // "a" cached at 1 grapheme; appending a combining mark keeps it 1 cluster + // but changes the cluster's code units — the slice must not return stale "a". + expect(counter.slice(0, "a", 1)).toBe("a"); + expect(counter.slice(0, "a\u0301", 1)).toBe("a\u0301"); + // A ZWJ append merges the previous final cluster into a family emoji. + const merged = new BlockUnitCounter(); + expect(merged.slice(0, "ab👨", 3)).toBe("ab👨"); + expect(merged.slice(0, "ab👨\u200D👩x", 3)).toBe("ab👨\u200D👩"); + }); + + it("keeps separate block indices independent", () => { + const counter = new BlockUnitCounter(); + const a = "hello world"; + const b = "café résumé"; + const ta = refCount(a); + const tb = refCount(b); + for (let units = 0; units <= ta; units++) expect(counter.slice(0, a, units)).toBe(refSlice(a, units)); + for (let units = 0; units <= tb; units++) expect(counter.slice(1, b, units)).toBe(refSlice(b, units)); + // Re-slicing block 0 after touching block 1 still matches the reference. + expect(counter.slice(0, a, ta)).toBe(a); + }); + + it("matches the reference after a shrink and regrow", () => { + const counter = new BlockUnitCounter(); + const text = "the quick brown fox jumps over"; + const total = refCount(text); + expect(counter.slice(0, text, total)).toBe(text); + expect(counter.slice(0, text, 2)).toBe(refSlice(text, 2)); + expect(counter.slice(0, text, total - 1)).toBe(refSlice(text, total - 1)); + }); + + it("matches the reference when the text is fully replaced", () => { + const counter = new BlockUnitCounter(); + expect(counter.slice(0, "first block of text", 3)).toBe(refSlice("first block of text", 3)); + expect(counter.slice(0, "completely different café content", 5)).toBe( + refSlice("completely different café content", 5), + ); + }); + + it("matches the reference under seeded append + monotonic reveal (fuzz)", () => { + // Deterministic PRNG so the fuzz is reproducible across runs. + let state = 0x1234abcd; + const rand = (): number => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + return ((state >>> 0) % 100000) / 100000; + }; + // Appendable chunks include lone combining marks / ZWJ so appends randomly + // merge into the previous boundary cluster, stressing that invariant. + const chunks = ["a", "bc ", "e", "\u0301", "👨", "\u200D👩", "日", "本", "❤️", "xy", " ", "z"]; + const counter = new BlockUnitCounter(); + let text = ""; + let revealed = 0; + for (let step = 0; step < 400; step++) { + if (rand() < 0.6 || text.length === 0) { + text += chunks[Math.floor(rand() * chunks.length)]!; + } + const total = refCount(text); + // Monotonic reveal advance, with an occasional reset to a small value + // to exercise the full re-segment path. + revealed = rand() < 0.05 ? Math.floor(rand() * 3) : Math.min(total, revealed + 1 + Math.floor(rand() * 6)); + if (revealed < 0) revealed = 0; + expect(counter.slice(0, text, revealed)).toBe(refSlice(text, revealed)); + } + }); +});