diff --git a/.agents/guides/experiments.md b/.agents/guides/experiments.md new file mode 100644 index 0000000..fa29cda --- /dev/null +++ b/.agents/guides/experiments.md @@ -0,0 +1,1378 @@ +Make sure you understand the codebase first including the architecture, docs, and instructions. + +Our goal is to benchmark various approaches to representing wikitext events to see if we can improve perf. & memory through various approaches. We're looking for statistically significant results, and not just a little bit but a notable improvement, so being better 51% of the time is not enough, assume changes have to really improve things more than just 51%. + + + +# Wikitext event shape performance design note + +## Purpose + +This note captures the event-shape and data-oriented design options discussed for `@okikio/wikitext`. + +The goal is not to pick an optimization because it sounds clever. The goal is to design a controlled experiment that tells us whether changing event construction actually improves the parser in a way that is large enough, stable enough, and maintainable enough to keep. + +The current repo already has a strong performance contract: + +```text +Remove repeated work without changing the source ranges the parser reports. +``` + +That contract should remain the center of the work. Any event-shape change must preserve source fidelity, parser recovery behavior, public API expectations, and the range-first model. + +## Current baseline + +The current public event model has five event variants: + +```text +enter -> a node starts +exit -> a node ends +text -> source-backed literal text range +token -> raw tokenizer token surfaced in the event stream +error -> recovery information +``` + +The important baseline traits are: + +```text +Events are public plain objects. +Events carry eager `position` objects. +Text and token events carry source offsets. +Enter events carry `props`. +Tree building consumes public event objects. +Session and analyze paths can cache arrays of public events. +``` + +The current constructors are simple and predictable: + +```ts +export function textEvent( + start_offset: number, + end_offset: number, + position: Position, +): TextEvent { + return { kind: "text", start_offset, end_offset, position }; +} +``` + +That simplicity matters. It gives V8 a predictable object shape, keeps public behavior easy to understand, and keeps the API close to JSON-style records. + +The cost is that every event pays for the full public shape immediately, including nested positions: + +```text +position + start + line + column + offset + end + line + column + offset +``` + +The repo's own performance model already identifies eager positions as a meaningful cost to isolate. It does not prove positions are the dominant bottleneck, but it does make position construction the best first hypothesis. + +## Design principle: facts first, views only when earned + +The parser already behaves like a data-oriented pipeline: + +```text +source text + -> token ranges + -> block event ranges + -> inline event ranges + -> optional materialized tree +``` + +The question is how far to take that model for events. + +There are two broad paths: + +```text +Path A: public events remain the only event representation + make selected public fields lazy + keep the event API familiar + reduce eager allocation where possible + +Path B: add a separate internal event representation + parser works with compact internal facts + public events become a materialized view + larger architectural change +``` + +The conversation moved toward Path A first because it is smaller, easier to test, and keeps the current public event model intact. + +## Approach 1: Keep the current eager public event shape + +### Shape + +```ts +export function enterEvent( + node_type: string, + props: Readonly>, + position: Position, +): EnterEvent { + return { kind: "enter", node_type, props, position }; +} +``` + +### Reasoning + +This is the baseline and should remain the default unless another approach wins clearly. + +It has important benefits: + +```text +Simple object creation. +Plain public objects. +No getter surprises. +No descriptor transitions. +No hidden backing state. +Easy JSON/stringify/spread behavior. +Easy debugging. +``` + +### Risks + +```text +Every event pays for position allocation even if a consumer only reads offsets. +Nested Point and Position objects can create allocation pressure. +Small props objects may be repeatedly allocated. +Session and analyze caches can retain full public event arrays. +``` + +### Keep this if + +```text +Lazy variants do not show a statistically significant improvement. +Lazy variants improve one path but regress parse or diagnostics meaningfully. +The implementation becomes harder to reason about without a clear win. +``` + +## Approach 2: Shared empty props and interned tiny props + +### Shape + +```ts +const EMPTY_PROPS: Readonly> = Object.freeze({}); + +const HEADING_PROPS = Object.freeze([ + undefined, + Object.freeze({ level: 1 as const }), + Object.freeze({ level: 2 as const }), + Object.freeze({ level: 3 as const }), + Object.freeze({ level: 4 as const }), + Object.freeze({ level: 5 as const }), + Object.freeze({ level: 6 as const }), +] as const); + +function headingProps(level: 1 | 2 | 3 | 4 | 5 | 6) { + return HEADING_PROPS[level]!; +} +``` + +### Reasoning + +Some `props` values are tiny and repeated: + +```text +{} +{ level: 2 } +{ ordered: false } +{ tildes: 4 } +``` + +Allocating a new object for every empty or tiny props payload may not be worth it. Sharing stable immutable props can reduce allocation without changing how event objects behave. + +This is the lowest-risk optimization because `props` remains an eager data property. + +### Risks + +```text +Shared objects must be treated as immutable. +Tests should catch accidental mutation expectations. +The memory win may be small. +``` + +### Best use cases + +```text +No-prop nodes. +Heading levels. +Ordered/unordered list wrappers. +Signature tildes. +Other small finite props sets. +``` + +### Decision rule + +Keep this if it is behavior-neutral and shows a measurable allocation or memory improvement. Revert if it complicates code and the benchmark result is noise. + +## Approach 3: Own lazy memoized `position` + +### Shape + +Keep public event objects as the only event representation, but define `position` lazily. + +```ts +export interface PositionContext { + readonly line_starts: readonly number[]; +} + +export function defineLazyPosition( + event: T, + create_position: () => Position, +): T & { readonly position: Position } { + Object.defineProperty(event, "position", { + enumerable: true, + configurable: true, + + get() { + const position = create_position(); + + Object.defineProperty(event, "position", { + enumerable: true, + configurable: false, + value: position, + }); + + return position; + }, + }); + + return event as T & { readonly position: Position }; +} +``` + +Example usage: + +```ts +export function textEvent( + start_offset: number, + end_offset: number, + create_position: () => Position, +): TextEvent { + const event = { + kind: "text" as const, + start_offset, + end_offset, + }; + + return defineLazyPosition(event, create_position); +} +``` + +### Reasoning + +This targets the most obvious eager cost: nested position creation. + +If a consumer only reads `kind`, `start_offset`, and `end_offset`, it should not pay for line/column objects. + +The memoizing getter gives three useful behaviors: + +```text +If nobody reads `position`, no Position object is allocated. +If somebody reads `position` once, it is created once. +If somebody reads `position` repeatedly, later reads become normal property reads. +``` + +### Risks + +```text +The first `position` read changes the object from accessor property to data property. +That creates a shape transition for events whose position is read. +The getter may close over local variables, which can add closure allocation. +If tree building reads every position anyway, the cost may simply move later. +Object spread or JSON.stringify may invoke the getter and materialize position. +``` + +### Where it should win + +```text +Event consumers that only need offsets. +Filtering and analysis that reads `kind` and ranges only. +Public event iteration without tree materialization. +Potentially session event iteration where positions are rarely read. +``` + +### Where it may not win + +```text +parse(), if tree building reads every event position. +parseWithDiagnostics(), if diagnostics force position access. +Consumers that immediately serialize full events. +Consumers that read `position` on every event. +``` + +## Approach 4: Lazy memoized `position` plus shared/interned eager props + +### Shape + +```text +position: + lazy and memoized + +props: + eager data property + but shared/interned where common +``` + +Example: + +```ts +export function enterEvent( + node_type: string, + props: Readonly>, + create_position: () => Position, +): EnterEvent { + const event = { + kind: "enter" as const, + node_type, + props, + }; + + return defineLazyPosition(event, create_position); +} +``` + +### Reasoning + +This is the best first candidate. + +It attacks the eager position cost while keeping `props` simple. It also avoids the closure-heavy trap of making every props object lazy before we know props are a real cost. + +### Risks + +```text +Still has lazy getter complexity for position. +Still has eager props allocation for source-sliced props. +May not help tree-building paths if positions are always read. +``` + +### Why this is probably the first serious experiment + +It is focused. It answers one real question: + +```text +Does delaying Position allocation help common event consumers enough to justify lazy getters? +``` + +If this does not win clearly, more complex lazy-props designs probably do not deserve attention yet. + +## Approach 5: Lazy source-sliced props + +### Shape + +Only some props become lazy, especially props that require slicing or parsing source text. + +```ts +function defineLazyProps( + event: T, + create_props: () => Readonly>, +): T & { readonly props: Readonly> } { + Object.defineProperty(event, "props", { + enumerable: true, + configurable: true, + + get() { + const props = create_props(); + + Object.defineProperty(event, "props", { + enumerable: true, + configurable: false, + value: props, + }); + + return props; + }, + }); + + return event as T & { readonly props: Readonly> }; +} +``` + +Example: + +```ts +export function wikilinkEnterEvent( + source: TextSource, + target_start: number, + target_end: number, + create_position: () => Position, +): EnterEvent { + const event = { + kind: "enter" as const, + node_type: "wikilink", + }; + + defineLazyProps(event, function createProps() { + return { + target: source.slice(target_start, target_end), + }; + }); + + return defineLazyPosition(event, create_position); +} +``` + +### Reasoning + +Some props are cheap: + +```text +{ level: 2 } +{ ordered: false } +``` + +Some props are expensive because they require slicing or parsing: + +```text +{ target: source.slice(...) } +{ name: source.slice(...) } +{ value: source.slice(...) } +{ url: source.slice(...) } +{ attributes: parseAttributes(...) } +``` + +Lazy props should focus on the second category. + +### Risks + +```text +A lazy props getter per event can create closure pressure. +Mixing eager `props` and accessor `props` for the same event kind can split object shapes. +Repeated access must be memoized or it creates repeated strings/objects. +Some consumers expect `props` to be a plain data property. +``` + +### Shape consistency rule + +Do not mix this inside the same event kind: + +```ts +// Avoid this mix inside one variant. +{ kind: "enter", node_type, props, position } +{ kind: "enter", node_type, get props() { return props; }, position } +``` + +If `EnterEvent.props` is lazy in a variant, it should be lazy for all enter events in that variant. If it is eager, it should be eager for all enter events in that variant. + +### When to test this + +Only after measuring that props allocation or string slicing is meaningful. + +## Approach 6: Prototype or class-backed public event objects + +### Shape + +Instead of object-literal getters, use shared prototype getters. + +```ts +class TextEventObject { + readonly kind = "text" as const; + readonly start_offset: number; + readonly end_offset: number; + + #position?: Position; + readonly #create_position: () => Position; + + constructor( + start_offset: number, + end_offset: number, + create_position: () => Position, + ) { + this.start_offset = start_offset; + this.end_offset = end_offset; + this.#create_position = create_position; + } + + get position(): Position { + this.#position ??= this.#create_position(); + return this.#position; + } +} +``` + +### Reasoning + +Object-literal getters can create getter functions and closure environments per event. Prototype getters share the getter implementation across instances. + +This can be cleaner from a VM-shape perspective: + +```text +same class +same constructor +same own fields +shared getter functions +private backing state +``` + +### Risks + +```text +Events stop feeling like simple plain records. +Prototype getters are inherited, not own properties. +Object.keys() and JSON.stringify() behavior can differ. +Some user code may rely on spreading events. +This is a bigger public-behavior risk than own lazy getters. +``` + +### When to test this + +Only if own lazy getters show promise but appear to allocate too much or behave inconsistently. + +This is not the first candidate because public event compatibility matters. + +## Approach 7: Separate internal event shape or event tape + +### Shape + +This was the larger data-oriented idea discussed earlier. + +```ts +export interface InternalEventRecord { + readonly kind: "enter" | "exit" | "text" | "token" | "error"; + readonly type: string; + readonly start_offset: number; + readonly end_offset: number; + readonly props_ref: number; +} +``` + +Or a packed replay tape: + +```ts +export interface InternalEventTape { + readonly kind_ids: Uint8Array; + readonly type_ids: Uint16Array; + readonly start_offsets: Uint32Array; + readonly end_offsets: Uint32Array; + readonly props_ids: Int32Array; + readonly count: number; +} +``` + +### Reasoning + +The parser could work with compact facts internally, then materialize public `WikitextEvent` objects only at the boundary. + +This is strongest for replayable paths: + +```text +Session caches. +analyze() findings. +Repeated materialization. +Future editor or incremental parsing. +``` + +### Risks + +```text +It adds a second representation. +Memory can get worse if both internal tapes and public event arrays are retained. +Tree builder must consume internal records directly or public events are still allocated. +Adapter complexity increases. +Debugging gets harder. +``` + +### Current recommendation + +Do not start here. + +Keep it as a later option if public-event-only lazy variants do not produce enough benefit, or if session/analyze replay caches become the clear bottleneck. + +## Approach 8: Numeric IDs versus string discriminants + +### String shape + +```ts +export type InternalEventKind = + | "enter" + | "exit" + | "text" + | "token" + | "error"; +``` + +### Numeric shape + +```ts +export const InternalEventKind = Object.freeze({ + enter: 1, + exit: 2, + text: 3, + token: 4, + error: 5, +} as const); +``` + +### Reasoning + +String discriminants are easier to debug, match the public API, and avoid adapter tables. Numeric IDs become useful when events are packed into typed arrays or a compact internal tape. + +### Current recommendation + +Keep string `kind`, `node_type`, and `token_type` while testing public-event-only variants. + +Do not introduce numeric IDs unless we adopt an internal tape or benchmarks show string comparisons are a real cost. + +## Approach 9: Discontiguous text groups + +### Shape + +Current text events represent contiguous ranges: + +```text +text [start, end) +``` + +A possible future handoff could group multiple ranges: + +```text +text group + span [line 1 start, line 1 end) + span [line 2 start, line 2 end) + span [line 3 start, line 3 end) +``` + +### Reasoning + +Paragraph continuation lines may be logically part of one inline group, while the physical newline remains structural and is not emitted as ordinary text. + +A discontiguous handoff could reduce setup and repeated scanning for long paragraph groups. + +### Risks + +```text +This changes an internal contract. +It must not pretend omitted structural newlines are plain text. +Inline parsing across spans becomes more complex. +Correctness around links, templates, apostrophes, and bare URLs needs careful tests. +``` + +### Current recommendation + +Do not combine this with event-shape experiments. + +It is a separate parser-handoff experiment and should be benchmarked later. + +## Prototype and object-shape rules + +The point is not “use prototypes because prototypes are fast.” The point is to keep object shapes predictable. + +Rules: + +```text +Same event kind, same property order. +Same property name, same descriptor style. +Do not sometimes use data `props` and sometimes getter `props` in the same variant. +Do not sometimes include optional fields and sometimes omit them unless that is already the chosen event shape. +Do not return fresh empty objects from getters. +Memoize expensive lazy fields after first access. +Keep offsets eager because they are the cheap path. +Keep discriminants eager because switches need them. +``` + +For public compatibility, own lazy getters are easier to test than class-backed events. For implementation cleanliness, class-backed events may share getter code better. The benchmark should decide whether either path earns its cost. + +## Benchmark plan + +### Decision rule + +Keep a variant only if it meets all of these: + +```text +Improves the target benchmark by at least 5 to 10 percent. +Does not regress critical paths by more than 2 to 3 percent. +Shows allocation or retained-memory improvement in the expected path. +Repeats across multiple process runs. +Does not break public behavior tests. +``` + +If the result is not statistically meaningful, keep the current approach. + +### Variants to test + +```text +A. Current eager public events. +B. Eager positions plus shared empty/interned tiny props. +C. Own lazy memoized position. +D. Own lazy memoized position plus shared/interned eager props. +E. Own lazy memoized position plus lazy source-sliced props. +F. Class/prototype-backed event objects. +``` + +### Benchmark lanes + +Add drains that isolate access patterns: + +```ts +export function drainEventsNoPosition(input: string): number { + let count = 0; + + for (const event of events(input)) { + count += event.kind.length; + } + + return count; +} +``` + +```ts +export function drainEventsOffsetsOnly(input: string): number { + let checksum = 0; + + for (const event of events(input)) { + if (event.kind === "text") { + checksum += event.start_offset; + checksum += event.end_offset; + } + } + + return checksum; +} +``` + +```ts +export function drainEventsWithPosition(input: string): number { + let checksum = 0; + + for (const event of events(input)) { + checksum += event.position.start.offset; + checksum += event.position.end.offset; + } + + return checksum; +} +``` + +```ts +export function drainEnterProps(input: string): number { + let checksum = 0; + + for (const event of events(input)) { + if (event.kind === "enter") { + checksum += Object.keys(event.props).length; + } + } + + return checksum; +} +``` + +Also keep real workflow lanes: + +```text +parse(input) +parseWithDiagnostics(input) +outlineEvents(input) +events(input) +session cold events +session warm events +session cold parse +session warm parse +``` + +### Fixture coverage + +Use the existing fixture families: + +```text +plain prose +word-boundary/token-density stress +headings +tables +links +templates +mixed documents +pathological malformed input +inline-heavy documents +Unicode documents +synthetic articles +large streaming article +``` + +These fixtures already cover the cases where event count, inline density, source slicing, and malformed recovery can behave differently. + +### Memory measurement + +Timing is not enough. Add separate memory measurements outside hot benchmark loops. + +```ts +function forceGc(): void { + for (let index = 0; index < 3; index++) { + globalThis.gc?.(); + } +} + +function heapUsed(): number { + forceGc(); + return Deno.memoryUsage().heapUsed; +} + +export function measureRetainedEvents(input: string): number { + forceGc(); + const before = heapUsed(); + + const retained = Array.from(events(input)); + + forceGc(); + const after = heapUsed(); + + if (retained.length === 0) { + throw new Error("expected events"); + } + + return after - before; +} +``` + +Measure these cases separately: + +```text +Retain events without reading position. +Retain events then read every position. +Retain events then read every props. +Session warm event cache. +parse(input). +parseWithDiagnostics(input). +``` + +## Expected outcomes + +### If Variant D wins + +Use lazy memoized `position` and shared/interned eager props. + +This is the best likely outcome because it targets eager position allocation without making props too clever. + +### If only Variant B wins + +Keep shared empty props and interned tiny props. Leave positions eager. + +This would mean lazy position complexity did not earn its keep. + +### If lazy position wins only when position is never read + +Decide based on target consumers. + +If offset-only event consumers are important, it may still be worth it. If most real users call `parse()` or read positions anyway, keep the eager model. + +### If class-backed events win + +Only consider adopting them if public behavior remains acceptable. + +A performance win is not enough if it breaks plain-object expectations. + +### If no variant clearly wins + +Revert to the current event constructors. + +That is a valid result. It means the current simple shape is good enough, and the repo avoids unnecessary complexity. + +## Recommended experiment order + +```text +1. Add benchmark drains and memory measurement scripts. +2. Record baseline results from current main. +3. Test shared empty/interned props. +4. Test own lazy memoized position. +5. Test lazy position plus interned props. +6. Test lazy source-sliced props only if props show meaningful cost. +7. Test class-backed events only if own getters are promising but not ideal. +8. Keep the simplest variant that wins clearly. +9. Revert if results are not statistically meaningful. +``` + +## Recommended first bet + +The first serious candidate should be: + +```text +Lazy memoized position + shared/interned eager props. +``` + +This balances performance, public compatibility, and implementation risk. + +It also aligns with the repo's current performance model: remove repeated work, avoid eager position allocation where it is not needed, and preserve the exact source ranges reported by the parser. + +## Final framing + +The maintainable position is: + +```text +Public WikitextEvent objects remain the parser's event model. +Offsets and discriminants stay eager. +Positions can be lazy if benchmarks prove the win. +Props stay eager unless a specific props category proves expensive. +Internal tapes remain a later option for replay-heavy session/analyze paths. +``` + +The benchmark should decide whether the added complexity is justified. If not, the current eager event constructors are the right design. + + + + + + +Yes. I would approach this like a controlled parser experiment, not like a refactor. The goal is not to prove the lazy getter idea right. The goal is to find out whether it wins clearly enough to deserve the complexity. + +Your repo already has the right performance contract: optimize by removing repeated work without changing reported source ranges, keep range-first data intact, and isolate event creation, position calculation, and nested position-object allocation as measurable costs. The current event constructors are also simple and stable, which gives us a clean baseline: `enterEvent`, `exitEvent`, `textEvent`, `tokenEvent`, and `errorEvent` all create fresh public event objects today. + +## The decision rule + +I would set the rule before writing code: + +> Keep a variant only if it improves the target benchmark by at least 5 to 10 percent, does not regress any critical benchmark by more than 2 to 3 percent, and reduces allocation or memory in the expected path. Otherwise, keep the current approach. + +For this kind of micro-optimization, a tiny “win” is not enough. Lazy getters add conceptual cost, descriptor behavior, and potential shape transitions. If the benchmark result is noise, the current eager object model is better. + +Your previous repository test already pointed in this direction: soften certainty, test the exact claim, and revert if the evidence is not clear. + +## What we should test + +I would not jump straight from current eager events to “everything lazy.” Test narrow variants. + +### Variant A: current baseline + +This is the current implementation: + +```ts +return { kind: "text", start_offset, end_offset, position }; +``` + +This establishes the control. + +### Variant B: eager position, shared empty props, interned tiny props + +This keeps the current eager position behavior, but reduces avoidable props allocation. + +Examples: + +```ts +const EMPTY_PROPS: Readonly> = Object.freeze({}); + +const HEADING_PROPS = Object.freeze([ + undefined, + Object.freeze({ level: 1 as const }), + Object.freeze({ level: 2 as const }), + Object.freeze({ level: 3 as const }), + Object.freeze({ level: 4 as const }), + Object.freeze({ level: 5 as const }), + Object.freeze({ level: 6 as const }), +] as const); +``` + +This is the least risky optimization. It may give some memory benefit without changing `position`. + +### Variant C: own lazy memoized `position` + +This keeps plain objects, but makes `position` lazy. + +```ts +function defineLazyPosition( + event: T, + create_position: () => Position, +): T & { readonly position: Position } { + Object.defineProperty(event, "position", { + enumerable: true, + configurable: true, + + get() { + const position = create_position(); + + Object.defineProperty(event, "position", { + enumerable: true, + configurable: false, + value: position, + }); + + return position; + }, + }); + + return event as T & { readonly position: Position }; +} +``` + +This tests the core idea without touching props. + +### Variant D: own lazy memoized `position` plus shared/interned eager props + +This is probably the most realistic candidate. + +```text +position: + lazy and memoized + +props: + eager, but EMPTY_PROPS and tiny fixed props are shared +``` + +This avoids closure-heavy lazy props while attacking the position cost. + +### Variant E: own lazy memoized `position` plus lazy source-sliced props + +Only source-sliced or parsed props become lazy: + +```text +target +name +value +url +attributes +``` + +Tiny props stay shared/eager. + +This tests whether delaying string slicing helps enough. + +### Variant F: prototype/class-backed event objects + +This tests the “shared getter function” version. + +```ts +class TextEventObject { + readonly kind = "text" as const; + readonly start_offset: number; + readonly end_offset: number; + + #position?: Position; + readonly #create_position: () => Position; + + constructor( + start_offset: number, + end_offset: number, + create_position: () => Position, + ) { + this.start_offset = start_offset; + this.end_offset = end_offset; + this.#create_position = create_position; + } + + get position(): Position { + this.#position ??= this.#create_position(); + return this.#position; + } +} +``` + +I would test this, but I would expect more public API compatibility questions because class/prototype getters are less like plain JSON-ish event objects. + +## The hidden-class rule we should follow + +For each variant, keep one rule: + +> Same event kind, same property order, same descriptor style. + +So do not mix this inside the same variant: + +```ts +// Bad: some enter events use data props. +{ kind: "enter", node_type, props, position } + +// Bad: other enter events use accessor props. +{ kind: "enter", node_type, get props() { return props; }, position } +``` + +Pick one shape per variant and stick to it. The event union can have different shapes by kind, but all `TextEvent`s should look like all other `TextEvent`s, all `EnterEvent`s should look like all other `EnterEvent`s, and so on. + +For the first pass, I would keep this shape policy: + +```text +kind: + always eager string + +node_type/token_type/start_offset/end_offset/message: + always eager + +position: + either always eager or always lazy per variant + +props: + either always eager on enter events or always lazy on enter events per variant +``` + +## Benchmarks we need + +The existing fixtures are a good base. They already cover plain text, token-density stress, headings, tables, links, templates, mixed text, pathological input, inline-heavy text, Unicode fixtures, synthetic articles, and a large streaming article. + +I would add benchmark drains that isolate property access patterns. + +### 1. Event creation without reading position + +This is where lazy `position` should win. + +```ts +export function drainEventsNoPosition(input: string): number { + let count = 0; + + for (const event of events(input)) { + count += event.kind.length; + } + + return count; +} +``` + +### 2. Event creation while reading offsets only + +This matches range-first consumers. + +```ts +export function drainEventsOffsetsOnly(input: string): number { + let checksum = 0; + + for (const event of events(input)) { + if (event.kind === "text") { + checksum += event.start_offset; + checksum += event.end_offset; + } + } + + return checksum; +} +``` + +### 3. Event creation while reading every position + +This is where lazy `position` may lose or break even. + +```ts +export function drainEventsWithPosition(input: string): number { + let checksum = 0; + + for (const event of events(input)) { + checksum += event.position.start.offset; + checksum += event.position.end.offset; + } + + return checksum; +} +``` + +### 4. Enter props access + +This isolates `props`. + +```ts +export function drainEnterProps(input: string): number { + let checksum = 0; + + for (const event of events(input)) { + if (event.kind === "enter") { + checksum += Object.keys(event.props).length; + } + } + + return checksum; +} +``` + +### 5. Parse tree path + +This tells us whether the real public `parse()` path benefits. + +```ts +export function drainParseTree(input: string): number { + return parse(input).children.length; +} +``` + +### 6. Diagnostics path + +This matters because diagnostics heavily rely on positions. + +```ts +export function drainDiagnostics(input: string): number { + const result = parseWithDiagnostics(input); + return result.tree.children.length + result.diagnostics.length; +} +``` + +### 7. Session warm and cold + +The repo already has warm/cold session drains. Keep those because lazy fields may behave differently when events are cached and reused. + +## Statistical approach + +I would use two layers. + +### First pass: mitata benchmark comparison + +Use mitata for iteration speed. You already use it in benchmark files, and the tokenizer benchmark consumes results with `do_not_optimize()` and uses `.gc("inner")`, which is the right style for allocation-sensitive comparisons. + +Run each variant several times, preferably in separate processes: + +```bash +deno task bench:event-shapes +deno task bench:event-shapes +deno task bench:event-shapes +deno task bench:event-shapes +deno task bench:event-shapes +``` + +Do not compare one run to one run. + +### Second pass: saved JSON or CSV results + +For significance, save results from repeated process runs. Then compare distributions. + +For each benchmark and variant, collect: + +```text +mean +median +standard deviation +95% confidence interval +relative change versus baseline +peak heap or heap delta after GC +``` + +A result is convincing only if: + +```text +confidence intervals do not meaningfully overlap +effect size is large enough to matter +the same direction appears across repeated process runs +memory improves or stays neutral +``` + +For microbenchmarks, I would not accept a 1 to 2 percent improvement. That is too easy to lose to JIT, CPU frequency, GC timing, or machine noise. + +## Memory measurement + +Timing alone is not enough here because your concern is memory. + +Add a memory script separate from hot-loop timing. Do not mix heap measurement inside mitata loops. + +Example shape: + +```ts +function forceGc(): void { + for (let index = 0; index < 3; index++) { + globalThis.gc?.(); + } +} + +function heapUsed(): number { + forceGc(); + return Deno.memoryUsage().heapUsed; +} + +function measureRetainedEvents(input: string): number { + forceGc(); + const before = heapUsed(); + + const retained = Array.from(events(input)); + + forceGc(); + const after = heapUsed(); + + // Consume retained so it cannot be optimized away. + if (retained.length === 0) throw new Error("expected events"); + + return after - before; +} +``` + +Measure separate cases: + +```text +Array.from(events(input)) without reading position +Array.from(events(input)) then read every position +Array.from(events(input)) then read every props +session.events() warm cache +parse(input) +parseWithDiagnostics(input) +``` + +This matters because lazy getters can reduce initial retained memory but increase memory after all positions are read. + +## What results would mean + +### Lazy position wins clearly + +Keep it if: + +```text +events without position access improves meaningfully +offset-only consumers improve meaningfully +retained event memory drops meaningfully +parse() does not regress meaningfully +position-heavy consumers do not regress too much +``` + +This would support Variant D. + +### Lazy position only wins when nobody reads position + +That may still be worth it if your target users often consume offset-only events. But if `parse()` and common consumers always touch `position`, the win may not justify the complexity. + +### Lazy props does not clearly win + +Drop it. Keep shared `EMPTY_PROPS` and interned tiny props instead. + +### Class/prototype events win but change object behavior + +Be careful. A performance win may not be worth making events feel less like plain objects unless the win is large. + +### No statistically significant difference + +Revert. Keep current constructors. Maybe keep only `EMPTY_PROPS` and interned tiny props if they are behavior-neutral and measurable. + +## My recommended branch strategy + +Use separate branches or commits so each variant can be benchmarked cleanly: + +```text +baseline/current + +bench/event-shape-b-empty-props +bench/event-shape-c-lazy-position-own +bench/event-shape-d-lazy-position-interned-props +bench/event-shape-e-lazy-position-lazy-sliced-props +bench/event-shape-f-class-backed-events +``` + +Each branch should include: + +```text +implementation +same test suite +same benchmark harness +same benchmark fixtures +same result collection script +``` + +Do not stack variants on top of each other unless that variant is explicitly cumulative. + +## The approach I would personally bet on + +I would bet on this variant first: + +```text +D: lazy memoized position + shared/interned eager props +``` + +Because it targets the cost the repo already identifies, keeps the public event object model mostly intact, avoids over-lazying props, and should benefit offset-only event consumers. + +I would be skeptical of lazy props until measured. `props` can become a closure factory trap if we are not careful. + +## The final plan + +1. Add benchmark drains for no-position, offsets-only, all-position, and props access. +2. Add memory measurement scripts separate from timing benchmarks. +3. Run the current baseline and save results. +4. Test Variant B, because it is low risk. +5. Test Variant C/D, because lazy position is the main hypothesis. +6. Test Variant E only if props allocation shows up as meaningful. +7. Test Variant F only if own getters show overhead or memory pressure. +8. Keep the simplest variant that wins clearly. +9. Revert to current eager constructors if the results are not statistically meaningful. + +That gives you a disciplined path: try the idea seriously, protect the parser’s source-range contract, and avoid adopting complexity unless the numbers actually earn it. + + + \ No newline at end of file diff --git a/experiments/event-shape-study/README.md b/experiments/event-shape-study/README.md index bfd6d93..257ffb9 100644 --- a/experiments/event-shape-study/README.md +++ b/experiments/event-shape-study/README.md @@ -15,7 +15,10 @@ Current status: - `planned-flat-eager-event-shape` has now been tested and rejected. Every approach directory now also carries an approach-local `code/` snapshot and a checked-in -`artifacts/stress-mixed-16MiB.json` file for comparable large-input smoke coverage. +`artifacts/stress-mixed-16MiB.json` file for comparable large-input smoke coverage. The branch now +also has the full drafted experiment toolchain again: one-shot large-input stress, session-oriented +large-input stress, a budgeted `1 GiB` mitata lane, and a preflight inventory that confirms the +study surface is complete before new collection starts. The newest artifact paths both reject the candidate: @@ -27,9 +30,10 @@ important point: this candidate still does not clear the `+5%` acceptance bar an regresses retained memory overall. Important scope note: the current checked-in comparisons are statistically grounded for the -standard study sizes, not for `1 GiB`-class stress inputs. Large-input stress now has its -own study-local collection path documented in [methods.md](methods.md), and the archive keeps -a lighter `16 MiB` mixed-article stress artifact for each approach. +standard study sizes, not for `1 GiB`-class stress inputs. Large-input work now has three separate +study-local collection lanes documented in [methods.md](methods.md): one-shot stress artifacts, +session-oriented stress artifacts, and a budgeted `1 GiB` mitata lane. The current branch only keeps +the lighter `16 MiB` mixed-article smoke artifact per approach checked in so far. The study uses this decision rule: @@ -41,4 +45,5 @@ The study uses this decision rule: The protocol lives in [protocol.md](protocol.md). The practical collection steps and file layout live in [methods.md](methods.md). Each approach directory adds its own approach-specific notes and artifacts. The current checked-in outcome summary lives in -[results.md](results.md). \ No newline at end of file +[results.md](results.md). The scenario backlog and rollout order live in +[scenario-roadmap.md](scenario-roadmap.md). \ No newline at end of file diff --git a/experiments/event-shape-study/methods.md b/experiments/event-shape-study/methods.md index 3b4ab46..3c0c6fb 100644 --- a/experiments/event-shape-study/methods.md +++ b/experiments/event-shape-study/methods.md @@ -99,36 +99,257 @@ That command writes: The standard ledger is good for significance testing, but it does not represent the kind of large-input stress you called out, such as hundreds of MiB or about `1 GiB` of source text. -Those runs live in a separate stress-report path so they do not distort the standard report +Those runs live in separate stress-report paths so they do not distort the standard report budget or the acceptance gate. The checked-in archive currently keeps one cheaper large-input smoke artifact per approach at -`16 MiB` under `artifacts/stress-mixed-16MiB.json`. That gives every candidate one comparable -large-string result in-repo while leaving `1 GiB` collection as an explicit heavyweight follow-up. +`16 MiB` under `artifacts/stress-mixed-16MiB.json`. That proves the archive path end to end. +The broader scenario matrix is now driven by tooling rather than being implied by the current +checked-in artifact count. + +Use the batch runner when you want to fill a one-shot scenario matrix instead of one-off files: + +```bash +mise x deno@latest -- deno run --no-lock --allow-read --allow-write --allow-run --allow-sys \ + experiments/event-shape-study/tools/run_large_input_stress_matrix.ts \ + --approaches=current-baseline,shared-props \ + --scenarios=plain-paragraphs,mixed-article,pathological-recovery,table-heavy,template-heavy,inline-heavy,uri-heavy,unicode-heavy,synthetic-article,outline-heavy \ + --sizes-mib=16,1024 \ + --repeats=1 +``` + +For `16 MiB`, the runner uses the full profile with: + +- `events() streamed count` +- `parse() child count` +- `parseWithDiagnostics() tree+diagnostics count` + +For `1 GiB`, the runner defaults to a streaming-only profile so the standard GiB lane stays focused +on event-path survivability. When you want the real non-streaming workload instead, pass +`--gib-profile=full` to the matrix runner. + +The default streaming `1 GiB` artifacts keep the existing `stress--1GiB.json` naming. +When you override the GiB tier to full mode, the runner writes +`stress--1GiB-full.json` so the full-materialization probe does not overwrite the +default streaming artifact. Collect a `1 GiB` mixed-article stress report for one approach: ```bash mise x deno@latest -- deno run --no-lock --allow-read --allow-write --allow-sys \ - --v8-flags=--expose-gc \ + --v8-flags=--expose-gc,--max-old-space-size=8192 \ experiments/event-shape-study/tools/collect_large_input_stress_report.ts \ --approach-dir=current-baseline \ --variant=current-baseline \ --scenario=mixed-article \ + --profile=streaming-only \ --size-mib=1024 \ - --repeats=3 \ + --repeats=1 \ --out=experiments/event-shape-study/current-baseline/artifacts/stress-mixed-1GiB.json ``` -Available scenarios are: +Force the `1 GiB` matrix runner through the full non-streaming workload path: -- `plain-paragraphs` for low token density and long prose -- `mixed-article` for a more realistic mix of headings, links, templates, tables, and refs -- `pathological-recovery` for malformed input that stays in recovery-heavy paths +```bash +mise x deno@latest -- deno run --no-lock --allow-read --allow-write --allow-run --allow-sys \ + experiments/event-shape-study/tools/run_large_input_stress_matrix.ts \ + --approaches=current-baseline \ + --scenarios=mixed-article \ + --sizes-mib=1024 \ + --gib-profile=full \ + --repeats=1 +``` + +Available one-shot scenarios are: + +- `plain-paragraphs` +- `mixed-article` +- `pathological-recovery` +- `table-heavy` +- `template-heavy` +- `inline-heavy` +- `uri-heavy` +- `unicode-heavy` +- `synthetic-article` +- `outline-heavy` These reports are for scale behavior and survivability. The current acceptance rule still comes from the standard smaller-input study ledger. +The one-shot stress collector records per-case failures inside the JSON artifact instead of aborting +the whole file when one runtime exception hits a pathological input. That matters because a recorded +failure tells us more than a missing artifact would. + +## Large-input session stress runs + +One-shot large-input stress answers whether an approach survives large text and how much whole-run +work it does. Session stress answers a different question: whether the same event shape helps when +callers keep a session alive and ask for outline, events, parse, and diagnostics results more than +once. + +The session collector mirrors the case semantics in `session_bench.ts` for the first study-local +workload set: + +- `session.outline() cold` +- `session.outline() warm` +- `session.events() cold` +- `session.events() warm` +- `session.parse() cold` +- `session.parse() warm` +- `session.parseWithDiagnostics() cold` +- `session.parseWithDiagnostics() warm` +- `consumer workflow: session outline -> events -> parse cold` +- `consumer workflow: session outline -> events -> parse warm` + +The session lane now uses the same ten scenario families as the one-shot matrix. +That keeps `16 MiB` and `1 GiB` session coverage aligned with the broader large-input archive, +even though the checked-in session artifacts are still much sparser than the implemented surface. + +Collect one session-stress artifact for one approach: + +```bash +mise x deno@latest -- deno run --no-lock --allow-read --allow-write --allow-sys \ + --v8-flags=--expose-gc \ + experiments/event-shape-study/tools/collect_large_input_session_stress_report.ts \ + --approach-dir=current-baseline \ + --variant=current-baseline \ + --scenario=mixed-article \ + --size-mib=16 \ + --repeats=1 \ + --out=experiments/event-shape-study/current-baseline/artifacts/session-stress-mixed-16MiB.json +``` + +Use the session batch runner when you want the same session scenario set across several approaches: + +```bash +mise x deno@latest -- deno run --no-lock --allow-read --allow-write --allow-run --allow-sys \ + experiments/event-shape-study/tools/run_large_input_session_stress_matrix.ts \ + --approaches=current-baseline,shared-props \ + --scenarios=plain-paragraphs,mixed-article,pathological-recovery,table-heavy,template-heavy,inline-heavy,uri-heavy,unicode-heavy,synthetic-article,outline-heavy \ + --sizes-mib=16 \ + --repeats=1 +``` + +The session matrix runner now gives child collectors a larger V8 heap budget by default with +`--max-old-space-size-mib=8192`. Override that flag if you need a different ceiling for a +particular machine or scenario lane. + +The first full `16 MiB` matrix attempt on this branch exposed a practical budget issue: +`current-baseline` on `pathological-recovery` kept one CPU core busy for more than eleven +minutes before the run was stopped. Treat that scenario as a separately budgeted lane rather +than assuming it belongs in the same quick follow-up batch as `mixed-article` and `synthetic-article`. + +The current checked-in session archive covers only the first non-pathological `16 MiB` slice for all four +approaches: + +- `mixed-article` +- `synthetic-article` + +`pathological-recovery` remains intentionally incomplete on this branch until it is rerun with a +separate time budget. + +Warm session cases intentionally include the cache-priming step before the measured second access. +That matches the benchmark helper semantics in `session_bench.ts`, so the checked-in study artifacts +and the benchmark file describe the same workload shape. + +## Budgeted large-input mitata runs + +The one-shot stress collector is useful for survivability and scale diagnostics, but it does not use +mitata. For the `1 GiB` tier, the study now also has a budgeted mitata lane for streaming parser work. +Its purpose is narrower than the standard event-shape benchmark suite: + +- keep the command under about two minutes on the study machine +- stress real parser traversal on a `1 GiB` input +- stay on streaming parser paths instead of requiring full tree materialization + +The current mitata large-input collector measures two streaming cases: + +- `outlineEvents() streamed count` +- `events() streamed count` + +The collector now lowers mitata's minimum sample count to `1` by default for this lane and records +the checksum from the first measured execution instead of doing a separate full pre-pass. That keeps +the `1 GiB` collector much closer to its intended command budget on expensive scenarios. + +The mitata lane now uses the same ten scenario families as the one-shot and session lanes. The +implementation surface is no longer narrower than the rest of the study, and the checked-in `1 GiB` +archive now covers every non-pathological scenario across all four approaches. + +For limit-study sweeps, the matrix runner also supports `--continue-on-error` so one failing size or +approach does not hide the rest of the boundary. That mode still exits non-zero at the end, but it +keeps going and prints a structured failure summary for every failed job. + +Even with those mitigations, `pathological-recovery` at `1 GiB` currently exceeds the practical +survivability budget for `current-baseline` on this machine: the direct collector run was hard-killed +before it could write an artifact. Treat that scenario as a separate limit study rather than assuming +it belongs in the same normal archive pass as `plain-paragraphs`, `mixed-article`, and `synthetic-article`. + +The follow-up limit-study sweeps showed that the failure is not just a `1 GiB` budget miss. +On this branch and machine: + +- `256 MiB` baseline `pathological-recovery` overran a nominal `60s` budget for more than thirty minutes before it was stopped +- `64 MiB` baseline `pathological-recovery` failed with `RangeError: Maximum call stack size exceeded` +- a cross-approach `1/2/4 MiB` limit-study sweep failed for all four approaches with the same stack-overflow shape + +That makes `pathological-recovery` an explicit parser-limit lane, not merely an unfinished archive row. + +The current checked-in `1 GiB` mitata archive covers the non-pathological lane for all four approaches: + +- `plain-paragraphs` +- `mixed-article` +- `table-heavy` +- `template-heavy` +- `inline-heavy` +- `uri-heavy` +- `unicode-heavy` +- `synthetic-article` +- `outline-heavy` + +Collect one `1 GiB` mitata stress artifact for one approach: + +```bash +mise x deno@latest -- deno run --no-lock --allow-read --allow-write --allow-sys --allow-env=NODE_DISABLE_COLORS \ + --v8-flags=--expose-gc,--max-old-space-size=8192 \ + experiments/event-shape-study/tools/collect_large_input_mitata_report.ts \ + --approach-dir=current-baseline \ + --variant=current-baseline \ + --scenario=mixed-article \ + --size-mib=1024 \ + --total-budget-seconds=110 \ + --out=experiments/event-shape-study/current-baseline/artifacts/mitata-stress-mixed-1GiB.json +``` + +Use the matrix runner when you want the same `1 GiB` mitata stress lane across several approaches: + +```bash +mise x deno@latest -- deno run --no-lock --allow-read --allow-write --allow-run --allow-sys --allow-env=NODE_DISABLE_COLORS \ + experiments/event-shape-study/tools/run_large_input_mitata_matrix.ts \ + --approaches=current-baseline,shared-props \ + --scenarios=plain-paragraphs,mixed-article,pathological-recovery,table-heavy,template-heavy,inline-heavy,uri-heavy,unicode-heavy,synthetic-article,outline-heavy \ + --sizes-mib=1024 \ + --total-budget-seconds=110 +``` + +The matrix runner shells out through `mise`, so it needs `--allow-run`. The single-run collector does not. + +When you need a quick inventory before choosing the next batch, summarize the archive directly: + +```bash +mise x deno@latest -- deno run --no-lock --allow-read \ + experiments/event-shape-study/tools/summarize_large_input_archive.ts \ + --size-mib=1024 +``` + +That summary groups one-shot stress, session stress, and mitata stress coverage by approach so the +next run can target missing scenarios instead of rescanning artifact directories by hand. + +If you want to verify the study is fully built before collecting anything new, run the preflight inventory: + +```bash +mise x deno@latest -- deno run --no-lock --allow-read \ + experiments/event-shape-study/tools/validate_experiment_matrix.ts +``` + ## Decision rule The acceptance rule stays the same across all approaches: diff --git a/experiments/event-shape-study/results.md b/experiments/event-shape-study/results.md index d49f7a6..0ed94cf 100644 --- a/experiments/event-shape-study/results.md +++ b/experiments/event-shape-study/results.md @@ -17,12 +17,29 @@ Interpretation: - `planned-flat-eager-event-shape` keeps all changes inside its approach-local snapshot, but the refreshed direct comparison still lands below the timing bar and regresses retained memory. - the deterministic round-robin check for `planned-flat-eager-event-shape` shifts the target timing median to `+0.95%` with `2/9` significant target wins, but it still misses the acceptance bar and keeps the same memory regression. -Large-input smoke coverage: - -- `current-baseline/artifacts/stress-mixed-16MiB.json` -- `shared-props/artifacts/stress-mixed-16MiB.json` -- `lazy-position-shared-props/artifacts/stress-mixed-16MiB.json` -- `planned-flat-eager-event-shape/artifacts/stress-mixed-16MiB.json` +Archive completion summary: + +| Lane | Scenario set | Implemented | Collected across all four approaches | Current status | +|---|---|---:|---:|---| +| Standard comparison | event-shape timing + retained memory | yes | yes | complete | +| `16 MiB` one-shot stress | ten scenarios | yes | partial | only `mixed-article` is checked in across all four approaches | +| `16 MiB` session stress | ten scenarios | yes | partial | `mixed-article` and `synthetic-article` are checked in across all four approaches | +| `1 GiB` mitata stress | ten scenarios | yes | yes for all non-pathological scenarios | nine normal scenarios are complete across all four approaches; only `pathological-recovery` remains outside the normal archive | +| `1 GiB` one-shot stress | ten scenarios | yes | no | archive not started | +| `1 GiB` session stress | ten scenarios | yes | no | archive not started | +| Pathological limit lane | separate parser-limit study | yes | n/a | treat as limit study, not as a normal missing archive row | + +Large-input archive status: + +- comparable `16 MiB` mixed-article one-shot smoke artifacts exist for all four approaches +- full one-shot matrix tooling now exists for ten scenario families +- session-stress tooling now exists for the same ten scenario families as the one-shot lane +- budgeted `1 GiB` mitata tooling now exists for the same ten scenario families as the one-shot lane +- the non-pathological `1 GiB` mitata archive is now complete across all four approaches for `plain-paragraphs`, `mixed-article`, `table-heavy`, `template-heavy`, `inline-heavy`, `uri-heavy`, `unicode-heavy`, `synthetic-article`, and `outline-heavy` +- `pathological-recovery` is now clearly a separate parser-limit lane rather than an ordinary large-input benchmark row: baseline was hard-killed at `1 GiB`, baseline `256 MiB` overran a `60s` budget for more than thirty minutes before it was stopped, and a cross-approach `1/2/4 MiB` mitata sweep failed for all four approaches with `RangeError: Maximum call stack size exceeded` +- `16 MiB` session-stress artifacts for `mixed-article` and `synthetic-article` now exist for all four approaches +- the first `16 MiB` session-matrix attempt showed that `pathological-recovery` is not part of the quick lane on this branch: `current-baseline` was still consuming a full CPU core after more than eleven minutes before the run was stopped +- the broader one-shot matrix and the session matrix are still not filled yet on this branch, but the normal `1 GiB` mitata lane is now fully checked in Primary artifacts: diff --git a/experiments/event-shape-study/tools/collect_large_input_stress_report.ts b/experiments/event-shape-study/tools/collect_large_input_stress_report.ts index 2759c2b..49d5975 100644 --- a/experiments/event-shape-study/tools/collect_large_input_stress_report.ts +++ b/experiments/event-shape-study/tools/collect_large_input_stress_report.ts @@ -13,6 +13,13 @@ import { dirname, fromFileUrl, join, relative } from 'jsr:@std/path'; +type TextSource = { + length: number; + charCodeAt(index: number): number; + slice(start: number, end: number): string; + iterSlices?(start: number, end: number): Iterable; +}; + type NumericSummary = { samples: number[]; mean: number; @@ -24,23 +31,28 @@ type NumericSummary = { type StressSample = { index: number; + status: 'ok' | 'failed'; elapsed_ms: number; heap_delta_bytes: number; - checksum: number; + checksum: number | null; + error_message?: string; }; type StressCaseSummary = { name: string; samples: StressSample[]; - elapsed_ms: NumericSummary; - heap_delta_bytes: NumericSummary; + ok_sample_count: number; + failed_sample_count: number; + elapsed_ms: NumericSummary | null; + heap_delta_bytes: NumericSummary | null; }; type StressReport = { - schema_version: 1; + schema_version: 2; variant: string; approach_dir: string; generated_at: string; + profile: 'full' | 'streaming-only'; scenario: string; size_bytes: number; size_mib: number; @@ -54,9 +66,9 @@ type StressReport = { }; type ApproachModule = { - events(input: string): Iterable; - parse(input: string): { children: unknown[] }; - parseWithDiagnostics(input: string): { + events(input: TextSource): Iterable; + parse(input: TextSource): { children: unknown[] }; + parseWithDiagnostics(input: TextSource): { tree: { children: unknown[] }; diagnostics: unknown[]; }; @@ -64,13 +76,13 @@ type ApproachModule = { type StressCase = { name: string; - run: (approach: ApproachModule, input: string) => number; + run: (approach: ApproachModule, input: TextSource) => number; }; type ScenarioDefinition = { name: string; description: string; - build: (size_bytes: number) => string; + build: (size_bytes: number) => TextSource; }; type GcCapableGlobal = typeof globalThis & { @@ -80,6 +92,10 @@ type GcCapableGlobal = typeof globalThis & { const STUDY_ROOT = fromFileUrl(new URL('../', import.meta.url)); const REPO_ROOT = fromFileUrl(new URL('../../../', import.meta.url)); +function resolveOutputPath(raw_path: string): string { + return raw_path.startsWith('/') ? raw_path : join(REPO_ROOT, raw_path); +} + const PLAIN_UNIT = [ 'Observational archives preserve calibration notes, weather corrections, and provenance language across long paragraphs of ordinary prose.', 'This fixture keeps token density low so large-input runs represent the boring text-heavy documents that still cost a lot to scan and parse.', @@ -113,6 +129,101 @@ const PATHOLOGICAL_UNIT = [ '', ].join('\n'); +const TABLE_HEAVY_UNIT = [ + '{| class="wikitable sortable"', + '! Planet !! Radius !! Mass !! Notes', + '|-', + '| Mercury || 2,439.7 km || 3.30e23 kg || [[Inner planet]]', + '|-', + '| Venus || 6,051.8 km || 4.87e24 kg || {{Val|4.87|e=24|u=kg}}', + '|-', + '| Earth || 6,371.0 km || 5.97e24 kg || Reference', + '|-', + "| Mars || 3,389.5 km || 6.42e23 kg || ''Surveyed''", + '|}', + '', +].join('\n'); + +const TEMPLATE_HEAVY_UNIT = [ + '{{Infobox settlement', + '| name = Example City', + '| image = {{Map frame|lat=51.5|lon=-0.1|zoom=12}}', + '| region = {{Plainlist|* [[Region A]]|* [[Region B]]}}', + '| population = {{Formatnum:1234567}}', + '| leader = {{Person|name=Alex Example|title=Mayor}}', + '}}', + '{{Navbox|title=Transit|list1={{Flatlist|* Line A * Line B * Line C}}}}', + '{{Citation needed|date={{Start date|2024|05|17}}}}', + '', +].join('\n'); + +const INLINE_HEAVY_UNIT = [ + '== Inline ==', + "A [[Main Page|home]] link with ''italic'', '''bold''', and '''''both'''''.", + 'Example & entity with {{Citation|title=Doc}}', + 'inline tag and
break and [[literal]] {{literal}}.', + '[https://example.com Example] {{Card|name=value|body=ok}} __TOC__ ~~~~', + '', +].join('\n'); + +const URI_HEAVY_UNIT = [ + 'Visit https://example.com/path(test)?q=alpha,beta and then https://example.org/docs/path(testing).', + 'Open file:///Users/example/report.txt or contact mailto:editor@example.org next.', + 'Catalog urn:isbn:0451450523 and call tel:+12025550123 before loading data:text/plain,hello.', + 'Fetch magnet:?xt=urn:btih:abcdef, launch foo+bar://example.service/path, and compare [https://example.com Example].', + 'Reminder: check the corpus matrix, note:abc, chapter:one, longcustomscheme:alpha, abchttps://example.com, Visit https://', + '', +].join('\n'); + +const UNICODE_HEAVY_UNIT = [ + '== Unicode ==', + 'Cafe\u0301 nai\u0308ve Z\u0323a\u0301lgo alpha\u200Bbeta\u200Cgamma\u200Ddelta\u2060omega\uFEFFend', + 'раураl Αlpha Сode οrn 👩🏽‍🚀👨‍👩‍👧‍👦🏳️‍🌈☕️', + '日本語かな交じり文漢字テスト مرحبابالعالمكيفالحال 𓀀𓁐𓂀𓃀𓆣', + '(^ω^)人(^∀^)ノ abc\u200Fمرحبا\u200Exyz [[Main Page|home]] {{Card|name=Unicode}}', + '', +].join('\n'); + +const SYNTHETIC_ARTICLE_UNIT = [ + '{{Infobox settlement|name=Example City|population_total=123456|map={{Location map|World}}}}', + '== Lead ==', + "Example City is a ''fictional'' place with [[Main Page|notable links]], references, and templates.", + '== History ==', + '* Founded in 1901', + '* Expanded with {{Convert|25|km|mi}} of rail', + '== Geography ==', + '{| class="wikitable"', + '! District !! Population', + '|-', + '| North || 42000', + '|-', + '| South || 38000', + '|}', + '== Culture ==', + 'A cited note with & entity', + 'Inline tag with __TOC__ and
break.', + '', +].join('\n'); + +const OUTLINE_HEAVY_UNIT = [ + '== Astronomy ==', + '* Observation', + '** Calibration', + '*** Drift notes', + '# Sequence one', + '## Nested ordinal note', + '; Instrument', + ': Mirror-backed camera with long-form plain prose and careful terminology.', + '; Exposure', + ': Repeated block structure should stay outline-heavy even when prose fills each item.', + '=== Archive ===', + '* Preservation log', + '* Range tracking notes', + '; Recovery policy', + ': Keep structure stable and inline markup intentionally light.', + '', +].join('\n'); + const SCENARIOS: readonly ScenarioDefinition[] = [ { name: 'plain-paragraphs', @@ -135,6 +246,55 @@ const SCENARIOS: readonly ScenarioDefinition[] = [ return repeatToMinimumSize(`${PATHOLOGICAL_UNIT}\n`, size_bytes); }, }, + { + name: 'table-heavy', + description: 'Dense table markup with repeated rows, cells, refs, and attributes.', + build(size_bytes) { + return repeatToMinimumSize(`${TABLE_HEAVY_UNIT}\n`, size_bytes); + }, + }, + { + name: 'template-heavy', + description: 'Repeated nested templates and parser-like value formatting.', + build(size_bytes) { + return repeatToMinimumSize(`${TEMPLATE_HEAVY_UNIT}\n`, size_bytes); + }, + }, + { + name: 'inline-heavy', + description: 'Repeated inline markup with links, emphasis, refs, tags, and nowiki spans.', + build(size_bytes) { + return repeatToMinimumSize(`${INLINE_HEAVY_UNIT}\n`, size_bytes); + }, + }, + { + name: 'uri-heavy', + description: 'Dense URI acceptance and rejection cases embedded in surrounding prose.', + build(size_bytes) { + return repeatToMinimumSize(`${URI_HEAVY_UNIT}\n`, size_bytes); + }, + }, + { + name: 'unicode-heavy', + description: 'Unicode-heavy document text with combining marks, controls, emoji, RTL, and astral symbols.', + build(size_bytes) { + return repeatToMinimumSize(`${UNICODE_HEAVY_UNIT}\n`, size_bytes); + }, + }, + { + name: 'synthetic-article', + description: 'Larger article-shaped structure with repeated transitions across headings, prose, lists, tables, refs, tags, and templates.', + build(size_bytes) { + return repeatToMinimumSize(`${SYNTHETIC_ARTICLE_UNIT}\n`, size_bytes); + }, + }, + { + name: 'outline-heavy', + description: 'Heading, list, and definition-list structure with intentionally light inline markup.', + build(size_bytes) { + return repeatToMinimumSize(`${OUTLINE_HEAVY_UNIT}\n`, size_bytes); + }, + }, ] as const; const STRESS_CASES: readonly StressCase[] = [ @@ -164,6 +324,15 @@ const STRESS_CASES: readonly StressCase[] = [ }, ] as const; +function findStressCases(profile: 'full' | 'streaming-only'): readonly StressCase[] { + switch (profile) { + case 'full': + return STRESS_CASES; + case 'streaming-only': + return [STRESS_CASES[0]!]; + } +} + function getFlag(name: string): string | undefined { return Deno.args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); } @@ -191,9 +360,52 @@ function parsePositiveIntFlag(name: string, fallback: number): number { return value; } -function repeatToMinimumSize(unit: string, minimum_size: number): string { - const repeat = Math.ceil(minimum_size / unit.length); - return unit.repeat(repeat).slice(0, minimum_size); +class RepeatedTextSource implements TextSource { + readonly length: number; + + constructor( + private readonly unit: string, + minimum_size: number, + ) { + if (unit.length === 0) { + throw new Error('RepeatedTextSource requires a non-empty unit string'); + } + + this.length = minimum_size; + } + + charCodeAt(index: number): number { + if (index < 0 || index >= this.length) { + return Number.NaN; + } + + return this.unit.charCodeAt(index % this.unit.length); + } + + slice(start: number, end: number): string { + return Array.from(this.iterSlices(start, end)).join(''); + } + + *iterSlices(start: number, end: number): Iterable { + const safe_start = Math.max(0, Math.min(start, this.length)); + const safe_end = Math.max(safe_start, Math.min(end, this.length)); + let cursor = safe_start; + + while (cursor < safe_end) { + const unit_offset = cursor % this.unit.length; + const take = Math.min(safe_end - cursor, this.unit.length - unit_offset); + yield this.unit.slice(unit_offset, unit_offset + take); + cursor += take; + } + } +} + +function repeatToMinimumSize(unit: string, minimum_size: number): TextSource { + if (unit.length === 0) { + throw new Error('repeatToMinimumSize requires a non-empty unit string'); + } + + return new RepeatedTextSource(unit, minimum_size); } function mean(values: readonly number[]): number { @@ -263,54 +475,86 @@ function findScenario(name: string): ScenarioDefinition { return scenario; } -function measureCase(case_def: StressCase, approach: ApproachModule, input: string, index: number): StressSample { +function measureCase(case_def: StressCase, approach: ApproachModule, input: TextSource, index: number): StressSample { const before = heapUsed(); const start = performance.now(); - const checksum = case_def.run(approach, input); - const elapsed_ms = performance.now() - start; - const after = heapUsed(); - return { - index, - elapsed_ms, - heap_delta_bytes: after - before, - checksum, - }; + try { + const checksum = case_def.run(approach, input); + const elapsed_ms = performance.now() - start; + const after = heapUsed(); + + return { + index, + status: 'ok', + elapsed_ms, + heap_delta_bytes: after - before, + checksum, + }; + } catch (error) { + const elapsed_ms = performance.now() - start; + const after = heapUsed(); + + return { + index, + status: 'failed', + elapsed_ms, + heap_delta_bytes: after - before, + checksum: null, + error_message: error instanceof Error + ? `${error.name}: ${error.message}` + : String(error), + }; + } } const approach_dir = requireFlag('approach-dir'); const variant = getFlag('variant') ?? approach_dir.split('/').at(-1)!; const scenario_name = getFlag('scenario') ?? 'mixed-article'; +const profile = (getFlag('profile') ?? 'full') as 'full' | 'streaming-only'; const repeats = parsePositiveIntFlag('repeats', 3); const size_mib = parsePositiveIntFlag('size-mib', 1024); const out_raw = getFlag('out'); -const out_path = out_raw === undefined ? undefined : join(REPO_ROOT, out_raw); +const out_path = out_raw === undefined ? undefined : resolveOutputPath(out_raw); const scenario = findScenario(scenario_name); +const stress_cases = findStressCases(profile); const size_bytes = size_mib * 1024 * 1024; const input = scenario.build(size_bytes); const approach = await loadApproachModule(approach_dir); -const cases: StressCaseSummary[] = STRESS_CASES.map((case_def) => { +const cases: StressCaseSummary[] = stress_cases.map((case_def) => { const samples: StressSample[] = []; + const ok_samples: StressSample[] = []; for (let index = 0; index < repeats; index++) { - samples.push(measureCase(case_def, approach, input, index)); + const sample = measureCase(case_def, approach, input, index); + samples.push(sample); + if (sample.status === 'ok') { + ok_samples.push(sample); + } } return { name: case_def.name, samples, - elapsed_ms: summarizeSamples(samples.map((sample) => sample.elapsed_ms)), - heap_delta_bytes: summarizeSamples(samples.map((sample) => sample.heap_delta_bytes)), + ok_sample_count: ok_samples.length, + failed_sample_count: samples.length - ok_samples.length, + elapsed_ms: ok_samples.length > 0 + ? summarizeSamples(ok_samples.map((sample) => sample.elapsed_ms)) + : null, + heap_delta_bytes: ok_samples.length > 0 + ? summarizeSamples(ok_samples.map((sample) => sample.heap_delta_bytes)) + : null, }; }); const report: StressReport = { - schema_version: 1, + schema_version: 2, variant, approach_dir, generated_at: new Date().toISOString(), + profile, scenario: scenario.name, size_bytes, size_mib, @@ -323,6 +567,12 @@ const report: StressReport = { notes: [ 'Large-input stress runs are intentionally separate from the main acceptance ledger.', 'String sizes are generated on demand so the study can run MiB-scale and GiB-scale scenarios without checking giant fixtures into the repo.', + profile === 'streaming-only' + ? 'This artifact uses the streaming-only stress profile to keep 1 GiB runs focused on event-path survivability instead of full tree materialization.' + : 'This artifact uses the full stress profile, including tree materialization and diagnostics.', + cases.some((case_summary) => case_summary.failed_sample_count > 0) + ? 'One or more stress cases failed and were recorded in-place so the artifact still captures the scenario limit.' + : 'All requested stress cases completed successfully.', `Scenario description: ${scenario.description}`, ], };