Merge PR #3848: perf(coding-agent): memoize incremental grapheme slicing in streaming reveal (@oldschoola)

This commit is contained in:
can1357
2026-07-01 21:47:54 +02:00
5 changed files with 325 additions and 66 deletions
+3
View File
@@ -148,6 +148,9 @@
- Resolved status inconsistencies between `/extensions`, `/mcp list`, and the dashboard, ensuring MCP server states, allowlists/denylists, and configuration files (like `mcp.json`) stay fully synchronized.
- Improved branch-mode task merges to preserve the agent's original commit history (messages and authors) and fixed a bug where merges were rejected due to unrelated dirty changes in the parent checkout.
- Fixed an issue where the `Working...` loader spinner would prematurely disappear or fail to re-arm after a subagent (`task`) tool completed or during transient overlays (such as auto-compaction or auto-retry).
### 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
+22 -13
View File
@@ -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++;
}
@@ -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)`);
@@ -11,6 +11,7 @@ export const CATCHUP_FRAMES = 8;
type AssistantContentBlock = AssistantMessage["content"][number];
type DisplayThinkingContentBlock = Extract<AssistantContentBlock, { type: "thinking" }> & { rawThinking?: string };
type StreamingRevealComponent = Pick<AssistantMessageComponent, "updateContent">;
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<number, { text: string; count: number; tailStart: number }>();
#sliceEntries = new Map<number, { text: string; units: number; end: number; lastStart: number }>();
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<AssistantContentBlock, { type: "text" }>,
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<AssistantContentBlock, { type: "thinking" }>,
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();
@@ -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));
}
});
});