Skip to content

Commit 0387243

Browse files
perf(benchmark): memoize the endurance comparator parse — blank-line-incremental, not full re-parse (#13032) (#13111)
Subject B (the main-thread comparator) re-parsed the full growing source each tick — O(n^2) over a session, conflating Neo's parser memoization with the worker-topology variable the benchmark exists to isolate. Resolve the naive-vs-best-practice fork to best practice: createIncrementalBlocks settles blocks before the last blank line once and re-parses only the open region per delta. A blank line is the only boundary a forward-only stream can never un-merge (a partial list item still merges with the next once it arrives), so block-granularity settling is unsafe — caught by the every-prefix equivalence test. parseMarkdownBlocks output stays byte-identical (back-compat). Verified: 9/9 unit green; benchmark e2e 4/4 green (comparator drives end-to-end; cross-subject delta runner intact).
1 parent 38df27a commit 0387243

3 files changed

Lines changed: 131 additions & 25 deletions

File tree

ai/examples/harnessEndurance/comparator/comparator.mjs

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import parseMarkdownBlocks from './markdownBlocks.mjs';
2-
import {LoadProfile} from '../shared/LoadProfile.mjs';
1+
import {createIncrementalBlocks} from './markdownBlocks.mjs';
2+
import {LoadProfile} from '../shared/LoadProfile.mjs';
33

44
/**
55
* @summary Subject B — the honest single-main-thread comparator for the Harness Endurance Benchmark.
@@ -12,12 +12,11 @@ import {LoadProfile} from '../shared/LoadProfile.mjs';
1212
* RENDER (best-practice shape): tail-incremental — settled blocks are written once then frozen, only
1313
* the open last block re-renders as it grows; NOT an O(n²) full-`innerHTML` rewrite.
1414
*
15-
* PARSE (honest caveat for the fairness fork): this re-parses the full growing source each tick — the
16-
* COMMON main-thread approach. Neo's parser memoizes settled blocks (re-parses only the open tail), so
17-
* the lag delta here includes Neo's memoization benefit ALONGSIDE the worker-topology effect; a
18-
* memoized comparator would isolate threading more tightly. Documented as the naive-vs-best-practice
19-
* refinement on the benchmark ticket — a defensible first comparator, biased if anything toward
20-
* realism (this is what typical main-thread streaming-markdown code does), reviewable.
15+
* PARSE (best-practice shape): incremental — settled blocks (everything before the last blank line) are
16+
* parsed ONCE and only the open region re-parses from each delta (`createIncrementalBlocks`), mirroring
17+
* the off-thread Neo parser's memoization. So the lag delta isolates the worker-topology variable (WHERE
18+
* the work runs) rather than conflating it with parse strategy — the benchmark's naive-vs-best-practice
19+
* fork, resolved to best-practice.
2120
*/
2221

2322
const
@@ -64,17 +63,16 @@ function applyBlocks(blocks) {
6463
}
6564

6665
/**
67-
* Drives the deterministic `LoadProfile` append stream, parsing + rendering each growing-source tick
68-
* synchronously on the main thread. A run token prevents overlapping runs.
66+
* Drives the deterministic `LoadProfile` append stream, parsing the open block + rendering each delta
67+
* synchronously on the main thread (settled blocks memoized). A run token prevents overlapping runs.
6968
* @param {Object} [config] forwarded to `LoadProfile` (seed, durationMs, cadences).
7069
* @returns {Promise<void>}
7170
*/
7271
async function start(config = {}) {
7372
const
7473
profile = new LoadProfile(config),
75-
token = ++runToken;
76-
77-
let accumulated = '';
74+
token = ++runToken,
75+
blocks = createIncrementalBlocks();
7876

7977
containers.forEach(container => container.remove());
8078
containers = [];
@@ -84,8 +82,7 @@ async function start(config = {}) {
8482
break
8583
}
8684

87-
accumulated += text;
88-
applyBlocks(parseMarkdownBlocks(accumulated));
85+
applyBlocks(blocks.push(text));
8986

9087
await new Promise(resolve => setTimeout(resolve, profile.appendCadenceMs))
9188
}

ai/examples/harnessEndurance/comparator/markdownBlocks.mjs

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,35 @@
11
/**
22
* @summary Competent single-main-thread markdown → block-HTML renderer for the benchmark comparator
3-
* (Subject B).
3+
* (Subject B), with blank-line-incremental parsing.
44
*
55
* The honest comparator does the SAME class of work as Subject A's off-thread Neo parser — block
66
* segmentation, inline marks, and HTML escaping (hostile input rendered inert) — but synchronously on
7-
* the MAIN thread. It returns per-block HTML so the app applies the stream tail-incrementally (append
8-
* new blocks, re-render only the open last block), never an O(n²) full-`innerHTML` rewrite.
7+
* the MAIN thread. {@link parseMarkdownBlocks} parses a full string; the streaming driver
8+
* {@link createIncrementalBlocks} MEMOIZES — it settles completed blocks once and re-parses only the
9+
* open region from the deltas it is fed.
910
*
10-
* Pure (string → string[]), dependency-free, unit-tested. Fairness posture (full rationale + the
11-
* naive-vs-best-practice fork on the benchmark ticket thread): this parser is competent but simpler than
12-
* Neo's full grammar, so it does LESS main-thread work — a CONSERVATIVE bias (a lighter parser makes
13-
* the comparator MORE responsive, shrinking any Neo advantage), which is the honest direction for a
14-
* falsifier. A real library (`marked`) is the documented alternative if reviewers want tighter
15-
* parse-work parity.
11+
* Why blank-line granularity (not per-block): a blank line is the ONLY block boundary a forward-only
12+
* token stream can never un-merge — a mid-stream `- a` followed by `-` parses as list + paragraph, yet
13+
* becomes ONE list once `- b` arrives, so settling per-block would corrupt the output. Blocks before
14+
* the last blank line are permanently separated; everything after it is the open region, re-parsed each
15+
* delta. Per-`push` work is therefore bounded by the blank-line cadence of the stream (the benchmark's
16+
* `LoadProfile` corpus emits `\n\n` separators regularly), NOT by the whole transcript — keeping the
17+
* comparator's parse cost off the O(n²) full-re-parse path.
18+
*
19+
* Why incremental matters for the falsifier (the benchmark's naive-vs-best-practice fork → best practice): a full
20+
* re-parse of the growing source each tick is O(n²) over a session AND conflates Neo's parser
21+
* memoization with the worker-topology variable under test. Memoizing here leaves the lag delta
22+
* measuring ONLY where the work runs (main thread vs worker) — the variable this benchmark isolates. No
23+
* `innerHTML` O(n²) rewrite on render either (the app applies tail-incrementally).
24+
*
25+
* Fairness posture: this parser is competent but simpler than Neo's full grammar, so it does LESS
26+
* main-thread work per block — a CONSERVATIVE bias (a lighter parser makes the comparator MORE
27+
* responsive, shrinking any Neo advantage), the honest direction for a falsifier. Residual: a multi-block
28+
* run with no blank line between (e.g. a heading immediately followed by a paragraph) re-parses together
29+
* until the next blank line — a bounded, negligible over-count. A real library (`marked`) is the
30+
* documented alternative if reviewers want tighter parse-work parity.
31+
*
32+
* Pure (no DOM / Neo / Playwright), dependency-free, unit-tested.
1633
*/
1734

1835
const ESCAPE = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'};
@@ -108,4 +125,63 @@ export function parseMarkdownBlocks(source) {
108125
return blocks
109126
}
110127

128+
/**
129+
* Index just after the LAST blank line in `source` — the highest offset before which every block is
130+
* permanently settled (a forward-only append can never merge a block across a blank line). Returns 0
131+
* when no blank line is present yet (nothing settled). A blank line is a newline, an optional run of
132+
* spaces/tabs, then a newline.
133+
* @param {String} source
134+
* @returns {Number}
135+
* @private
136+
*/
137+
function lastBlankBoundary(source) {
138+
const re = /\n[ \t]*\n/g;
139+
let boundary = 0, match;
140+
141+
while ((match = re.exec(source)) !== null) {
142+
boundary = match.index + match[0].length;
143+
re.lastIndex = match.index + 1; // step one char so consecutive blank-line runs keep advancing
144+
}
145+
146+
return boundary
147+
}
148+
149+
/**
150+
* @summary A stateful, delta-fed incremental block parser — the memoizing front-end for streaming.
151+
*
152+
* Feed it appended deltas via {@link push}; it keeps only the OPEN source after the last blank line,
153+
* settles the blocks before that boundary exactly once, and returns the current full block-HTML list
154+
* each call. Per-`push` work is bounded by the open region (since the last blank line), NOT the whole
155+
* transcript — the property that keeps the comparator's parse cost off the O(n²) full-re-parse path and
156+
* isolates the worker-topology variable the benchmark measures.
157+
*
158+
* @returns {{push: (function(String): String[])}}
159+
*/
160+
export function createIncrementalBlocks() {
161+
let settledHtml = [],
162+
openSource = '';
163+
164+
return {
165+
/**
166+
* Append a streamed delta and return the current block-HTML list (settled blocks + the open
167+
* region's blocks). Pass the DELTA, not the accumulated source.
168+
* @param {String} [textDelta='']
169+
* @returns {String[]}
170+
*/
171+
push(textDelta = '') {
172+
openSource += textDelta;
173+
174+
const boundary = lastBlankBoundary(openSource);
175+
176+
if (boundary > 0) {
177+
// Everything before the last blank line can never change → settle it once.
178+
settledHtml.push(...parseMarkdownBlocks(openSource.slice(0, boundary)));
179+
openSource = openSource.slice(boundary);
180+
}
181+
182+
return [...settledHtml, ...parseMarkdownBlocks(openSource)]
183+
}
184+
};
185+
}
186+
111187
export default parseMarkdownBlocks;

test/playwright/unit/ai/examples/harnessEndurance/comparator/markdownBlocks.spec.mjs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {test, expect} from '@playwright/test';
2-
import parseMarkdownBlocks, {parseMarkdownBlocks as named} from '../../../../../../../ai/examples/harnessEndurance/comparator/markdownBlocks.mjs';
2+
import parseMarkdownBlocks, {parseMarkdownBlocks as named, createIncrementalBlocks} from '../../../../../../../ai/examples/harnessEndurance/comparator/markdownBlocks.mjs';
33

44
/**
55
* @summary Coverage for ai/examples/harnessEndurance/comparator/markdownBlocks.mjs — the pure
@@ -53,4 +53,37 @@ test.describe('ai/examples/harnessEndurance/comparator/markdownBlocks', () => {
5353
test('default export equals the named export', () => {
5454
expect(parseMarkdownBlocks).toBe(named);
5555
});
56+
57+
test('createIncrementalBlocks streams to the SAME result as a full parse at every prefix', () => {
58+
// The list (`- a\n- b`) is the regression guard: a forward-only stream transiently parses
59+
// `- a\n-` as list+paragraph, so block-granularity settling would split it — blank-line
60+
// settling must keep it ONE list once `- b` arrives.
61+
const source = '# Title\n\npara one grows\n\n- a\n- b\n\n> quote here';
62+
const parser = createIncrementalBlocks();
63+
let acc = '', last = [];
64+
65+
for (let i = 0; i < source.length; i += 3) {
66+
const chunk = source.slice(i, i + 3);
67+
acc += chunk;
68+
last = parser.push(chunk);
69+
// The memoized incremental output must equal a full re-parse of the same prefix — always.
70+
expect(last).toEqual(parseMarkdownBlocks(acc));
71+
}
72+
73+
expect(last).toEqual(parseMarkdownBlocks(source));
74+
expect(last).toEqual(['<h1>Title</h1>', '<p>para one grows</p>', '<ul><li>a</li><li>b</li></ul>', '<blockquote>quote here</blockquote>']);
75+
});
76+
77+
test('createIncrementalBlocks settles blocks at blank-line boundaries; the open region re-parses', () => {
78+
const parser = createIncrementalBlocks();
79+
80+
expect(parser.push('a para')) .toEqual(['<p>a para</p>']); // open region
81+
expect(parser.push(' continues')) .toEqual(['<p>a para continues</p>']); // same open block grows
82+
expect(parser.push('\n\n# Head')) .toEqual(['<p>a para continues</p>', '<h1>Head</h1>']); // blank line settles the paragraph
83+
expect(parser.push('er')) .toEqual(['<p>a para continues</p>', '<h1>Header</h1>']); // settled stays byte-stable
84+
});
85+
86+
test('createIncrementalBlocks tolerates empty deltas', () => {
87+
expect(createIncrementalBlocks().push('')).toEqual([]);
88+
});
5689
});

0 commit comments

Comments
 (0)