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}`,
],
};